MCP toolshare

SharedOS is the canonical tool catalogue and authorization broker. MCP is the default interoperability boundary presented to external harnesses.

Harness configuration declares a CONNECTION.
SharedOS declares the TOOLS.
SharedOS capabilities declare the AUTHORITY.
RuntimeHost is the only EXECUTION path.

Everything below follows from those four lines. The design decisions and the alternatives rejected along the way are in ADR 0014.

Why this exists

SharedOS could already drive a vendor harness through @aicoo/sharedos-adapters: the adapter speaks the vendor's API-layer tool-call shape and SharedOS owns the turn loop. That path is exact, and it cannot be pointed at an installed CLI. No coding-agent CLI exposes its API layer, and none has a wire frame meaning here is your tool catalogue — so a live CLI told to call files.read reaches for its own Read instead, and every kernel row goes unexercised.

MCP is the one interface Codex, Claude Code, and DeepSeek Harness all accept a host-supplied tool set on. Over it, the CLI runs natively — its own loop, on whatever model it is configured with — and still cannot take an action SharedOS did not authorize.

One name, three places

ToolDefinition.name  =  SharedOS canonical tool ID  =  raw MCP Tool.name

ToolNameSchema enforces it at registration: 1–128 characters of [A-Za-z0-9_.-], deliberately narrower than IdentifierSchema. A tool name goes on a wire, so the character set has to be one every harness carries unchanged.

Names are globally unique across namespaces. Two brokered providers exposing the same underlying operation publish two names:

files.search        github.search        notion.search

never search three times.

A harness may rewrite the name for its own display — Claude Code, Codex, and DeepSeek all use mcp__<server>__<tool>, and DeepSeek appends a hash when normalisation would collide. That alias is presentation. It is recorded on the bridge for diagnosis, never on the ToolCall, so it cannot reach an authorization decision even by mistake.

What crosses the boundary

PublishedToolDefinition is defined by what it omits:

PublishedNever published
name, descriptionrequiredCapability, resolveRequirement
inputSchema, outputSchemagrants, issuing authority, AccessContext
annotations (MCP hints)namespace settings, credentials, handler references
metadata.namespace, metadata.source

The omission is load-bearing rather than tidy. Authorization is resource + action + context, resolved from the arguments at call time — not grant → files.read — so two calls to one published tool routinely need different authority:

files.read(path="/public/a.txt")   → allowed
files.read(path="/private/a.txt")  → denied

The canonical tool ID identifies the operation implementation, not the authority. Publishing a capability requirement would therefore be both a leak and a lie.

The same projection applies wherever a model is shown a catalogue, not only over MCP. GET /v1/tools and AgentTurnRequest.tools carry full ToolDefinitions for the host's benefit; a client or driver that feeds a model from either applies publishToolCatalog first, as ModelDriver does. See the HTTP reference.

Per turn, never global

1. SharedOS resolves a trusted AccessContext.
2. kernel.listTools(context) computes the effective catalogue.
3. A turn-scoped MCP bridge exposes only those tools.
4. The harness runs.
5. Every tools/call becomes a SharedOS ToolCall.
6. RuntimeHost.invokeTool() re-authorizes the exact call.
7. The bridge closes when the turn closes.

ContextToolProvider exists so one user's MCP catalogue never mutates a registry shared with concurrent users, and that invariant now runs all the way through the harness. There is no long-lived SharedOS MCP server holding a union of every user's tools: it would have to re-derive who is asking on every call, and would be wrong once.

Step 7 is not housekeeping. Harness processes outlive their turns — on cancellation, on timeout — and after the bridge closes such a process finds a shut door and a released port rather than a catalogue resolved for a turn that has ended.

Refusals are results

SharedOS succeeded → MCP successful content/result
SharedOS denied    → MCP tool error/result, never transport error
SharedOS failed    → MCP tool error/result
cancellation       → aborted MCP request/session

A JSON-RPC error means the request could not be processed. A harness receiving one learns nothing about its authority; most retry, some abandon the turn. A denial is a processed request whose answer is "no", and returning it as isError: true is what lets a harness report the refusal and carry on.

denied and failed stay distinguishable in the payload and in _meta["sharedos/status"]. They mean different things — policy refused this, versus the tool broke — and collapsing them makes a denial rate uncountable.

Catalogue hashes

catalogHash = SHA-256(canonical JSON(tools sorted by canonical name))

Field participation is the contract, not an implementation detail. It is fixed by CATALOG_HASH_FIELDS and asserted by the projection, which cannot emit a key outside it:

ParticipatesDoes not
name, descriptionexecutionId
inputSchema, outputSchemaharness alias
annotationsruntime name
metadata (namespace, source)transport

Without fixed participation, two implementations can both claim to compute catalogHash and agree on nothing.

The hash lands in SystemIdentity on every execution record. That turns cross-harness comparison into a check: two columns whose hashes differ were not given the same tool set, and their refusal behaviour cannot be compared until that is explained. It also catches schema drift, a missing tool, a renamed tool, and a stale discovery cache — failures that otherwise look like a harness behaving differently.

Tool classes

ClassExampleSharedOS authorized?Recommendation
managedfiles.search, brokered GitHubYesThe normal SharedOS path
harness_localpatch tool, bounded shellNoAllow only if the sandbox cannot bypass SharedOS
external_directindependently configured JiraNoExplicit opt-in hybrid mode

ToolPolicy declares which a run had:

{
  "mode": "strict",
  "managedMcp": ["sharedos"],
  "harnessLocal": ["apply_patch"],
  "externalDirect": []
}

versus

{
  "mode": "hybrid",
  "managedMcp": ["sharedos"],
  "harnessLocal": ["shell", "apply_patch"],
  "externalDirect": ["github", "browser"]
}

A strict policy that also lists externalDirect entries is rejected by the schema, rather than producing a run whose headline claim its own manifest contradicts. harnessLocal stays permitted under strict — most CLIs keep some tool of their own — but the entries must be named, and a launch that leaves the harness none of its own, as Pi's does with --no-builtin-tools, declares []: the extension's mcp proxy is the catalogue's conduit, not a local tool.

This is what makes a result readable. "The kernel refused every violation" means one thing when the managed catalogue was the only way to have an effect, and almost nothing when the harness also had a shell.

Using it

Inside a turn

import { McpToolServer, openToolBridge } from "@aicoo/sharedos-mcp";
import { createStreamableHttpMcpServer } from "@aicoo/sharedos-mcp/node";

// Inside RuntimePlugin.run(request, host, signal):
const bridge = openToolBridge({
  executionId: request.executionId,
  context: { traceId: request.context.traceId, now: request.context.now },
  tools: request.tools, // already permission-filtered by the envelope
  host, // RuntimeHost: every call is re-authorized
});
const http = await createStreamableHttpMcpServer({
  server: new McpToolServer({ invoker: bridge }),
});
try {
  // ... point the harness at http.url and run it ...
} finally {
  bridge.close();
  await http.close();
}

Outside a turn

import { kernelToolBridge } from "@aicoo/sharedos-mcp";

const invoker = kernelToolBridge({ kernel, context, executionId });

Every kernel guarantee still holds — filtered discovery, per-call re-authorization. What is absent is the envelope: no step or tool-call budget and no execution event stream, so a turn's evidence cannot be assembled from a session served this way.

The installed CLIs

import { CLAUDE_CODE_MCP_HARNESS, createMcpHarnessRuntime } from "@aicoo/sharedos-adapters/node";

const runtime = createMcpHarnessRuntime(CLAUDE_CODE_MCP_HARNESS);

CODEX_MCP_HARNESS, DEEPSEEK_MCP_HARNESS, and PI_MCP_HARNESS are the others.

Each spec hands its CLI the connection in the one form that CLI takes, and nothing else:

HarnessFormShape
Codex-c overridesmcp_servers.sharedos.url, required=true
Claude Code.mcp.json{"type":"http","url":…}
DeepSeekcordis.patch.ymlone @deepseek-ai/dsh-mcp-client plugin entry
Pi.mcp.json{"url":…,"lifecycle":"eager"}

The DeepSeek overlay must use dsh's insert: form. A bare id: entry overrides a plugin already in the tree and does not add one; dsh warns patch: entry "..." not found on stderr and boots without the client, which downstream reads as a harness that declined the catalogue rather than as a misconfigured one. The plugin also has to be installed into the profile first — a patch activates a plugin, it does not fetch one:

dsh plugin --profile headless add @deepseek-ai/dsh-mcp-client

What each launch turns off

Every spec launches its CLI with the flags that keep a measurement honest. Each is a permission-prompt or tool-set decision rather than an authorization one: what secures the run is that every call is re-authorized by the kernel.

  • Claude Code. --strict-mcp-config drops the machine's own MCP servers, so a strict policy is checkable rather than merely declared, and the disallowed-tools list removes the harness's own file and shell tools — a probe that can edit files on the machine it is measuring is answering a different question. --allowedTools mcp__sharedos auto-approves the server: Claude separates prompting from authorization, and print mode has no human to prompt.
  • Codex. mcp_servers.sharedos.required=true stops a run whose bridge failed to start rather than continuing with Codex's own tools, which would look like a harness that declined the catalogue. default_tools_approval_mode="approve", scoped to this one server, is the same decision as Claude's --allowedTools: Codex's default auto mode asks a human before any tool that is not read-only, codex exec has no human, and the refusal would happen inside Codex with the kernel never consulted.
  • DeepSeek Harness. The plugin overlay sets failOnStartupError: true for the reason Codex's server is required: a run that quietly continued with only the harness's own tools would be a different finding.
  • Pi. --mode rpc --no-session --no-builtin-tools: a session-less RPC run with Pi's own tools off, so what the model reaches is the extension's proxy tool and nothing else.

Pi needs an extension, and which one is your choice

Pi is the one harness here that ships no MCP client. Some extension is therefore required before Pi can reach an MCP server at all — but which extension is a choice the host makes, not something SharedOS mandates. pi-mcp-adapter is what this repository is exercised against:

pi install npm:pi-mcp-adapter

Anything with the same job would serve, and piMcpConfig emits that adapter's file shape. A different extension may want a different one.

The effect is not identical to a native client, and it is worth knowing when reading a Pi column. The adapter registers a single mcp proxy tool and discovers the catalogue behind it on demand, so Pi's model calls mcp({tool: "files.read", …}) rather than files.read, and the harness-facing surface is one tool wide. None of that reaches SharedOS: what arrives at the bridge is an ordinary tools/call naming the canonical tool, authorized like any other. The manifest stamps mcpSupport: "extension" and names the extension, so a record says how the catalogue reached the harness rather than implying Pi did it itself.

Sandboxed or remote harnesses

Loopback plus an ephemeral port is enough when the port is known only to the subprocess it was opened for. Otherwise mint a short-lived execution token:

const token = await mintExecutionToken(
  { executionId, namespaceId, actor: canonicalActor(agent), catalogHash, expiresAt },
  hostSecret,
);
const http = await createStreamableHttpMcpServer({
  server,
  authorize: async (presented) =>
    presented !== undefined &&
    (await verifyExecutionToken(presented, hostSecret, { now, expect: { executionId } })).valid,
});

canonicalActor renders an Address as <kind>:<id>agent:agent-bob — which is the one string form a token's actor takes; it is compared by equality, never parsed back. The token identifies the broker session. It carries no grants and no capabilities: whoever presents it still receives exactly the catalogue that turn's AccessContext resolved, and every call is still authorized from the trusted grant source at the moment of the call. catalogHash is bound in so a stale sandbox cannot reconnect and call tools it was never shown.

Running the conformance column

pnpm build
pnpm conformance:mcp                                    # one case, every installed harness
pnpm conformance:mcp -- --config ./run.json --full      # every case, one pinned model
pnpm conformance:mcp -- --harness codex --case broker-ungranted,broker-out-of-scope

Live runs cost model tokens, so the default is one case; --full runs the whole set and is what a published result should come from. Output lands in artifacts/conformance/mcp-conformance.json.

Flag or variableMeaning
--fullEvery case
--limit NThe first N cases (default 1): a smoke test, not a way to reach a row added at position eighteen; 0 probes the harnesses and runs none
--case a,bNamed cases; one that names no case stops the run
--harness idOne column — claude-code, codex, deepseek, or pi; an id that names no column stops the run, and a named harness that is not installed is reported not available
--config pathThe operator's model and per-harness configuration (below); SHAREDOS_MCP_CONFIG is its default

pnpm conformance:native (scripts/native-conformance.mjs) is the other live script: it drives each installed CLI as a harness over its own stdio, and runs the model column when a key is present. It shares --case, --harness (which also takes model, for the model column alone), and --config (default SHAREDOS_NATIVE_CONFIG), has no --limit — it runs every case — and reads:

VariableMeaning
SHAREDOS_MODEL_API_KEY, else DEEPSEEK_API_KEY or DSH_API_KEYThe model column's key; without one the column does not run
SHAREDOS_MODEL, SHAREDOS_MODEL_BASE_URL, SHAREDOS_MODEL_PROVIDERThe model column's model (default DSH_MODEL, else the config's model.id, else deepseek-v4-flash), chat-completions root (default https://api.deepseek.com), and provider label (default the config's model.provider, else deepseek)
DSH_RUNTIME_COMMAND, DSH_RUNTIME_CONFIG, DSH_RUNTIME_CWDDeepSeek Harness's JSON-RPC runtime (default dsh-jsonrpc-agent), its plugin composition, and its working directory
DSH_PROVIDER, DSH_MODELWhat that runtime is told at initialize (default deepseek-official, deepseek-v4-flash)

The scripted suite, pnpm conformance (scripts/conformance.mjs), takes --check and --strict — what conformance:check passes — and --no-build, which skips the package build when dist is already current.

Holding the model constant

Comparing harnesses only means something if the model is the same on both sides; otherwise a column that refused fewer violations may simply have had a weaker model. --config takes a file the operator supplies — provider names, base URLs, credentials, and per-vendor flags are not SharedOS's to know:

{
  "model": { "id": "deepseek-v4-flash", "provider": "deepseek" },
  "harnesses": {
    "claude-code": {
      "credentialVariables": ["DEEPSEEK_API_KEY"],
      "env": { "ANTHROPIC_BASE_URL": "…", "ANTHROPIC_AUTH_TOKEN": "${DEEPSEEK_API_KEY}" },
      "args": ["--model", "deepseek-v4-flash"]
    }
  }
}

env and args are passed to the harness process untouched; ${VAR} is read from the ambient environment so a shared file can name a credential without containing one. Declaring credentialVariables also makes the credential required for that column — every harness here can otherwise fall back to a stored session login, and a stored session authenticates to the provider the harness normally uses, not the one the run pinned.

SharedOS's only interest in the model is the model string, recorded on every execution record. It does not select the model and cannot confirm the provider served it — DeepSeek, for instance, silently maps an unrecognised model name to deepseek-v4-flash rather than rejecting it. Two columns whose recorded models differ are not comparable, and the run says so.

The column is reported separately from the committed manifest. Its correlation is weaker than a scripted column's — a live harness mints its own call ids, so attempts are matched on tool and resource — and its result depends on a model's choices. A harness that is absent, unauthenticated, or that declines a declared call leaves no operation in the record and is graded not exercised: never a pass, and never a kernel failure.