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:
| Published | Never published |
|---|---|
name, description | requiredCapability, resolveRequirement |
inputSchema, outputSchema | grants, 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:
| Participates | Does not |
|---|---|
name, description | executionId |
inputSchema, outputSchema | harness alias |
annotations | runtime 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
| Class | Example | SharedOS authorized? | Recommendation |
|---|---|---|---|
managed | files.search, brokered GitHub | Yes | The normal SharedOS path |
harness_local | patch tool, bounded shell | No | Allow only if the sandbox cannot bypass SharedOS |
external_direct | independently configured Jira | No | Explicit 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:
| Harness | Form | Shape |
|---|---|---|
| Codex | -c overrides | mcp_servers.sharedos.url, required=true |
| Claude Code | .mcp.json | {"type":"http","url":…} |
| DeepSeek | cordis.patch.yml | one @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-configdrops the machine's own MCP servers, so astrictpolicy 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__sharedosauto-approves the server: Claude separates prompting from authorization, and print mode has no human to prompt. - Codex.
mcp_servers.sharedos.required=truestops 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 defaultautomode asks a human before any tool that is not read-only,codex exechas no human, and the refusal would happen inside Codex with the kernel never consulted. - DeepSeek Harness. The plugin overlay sets
failOnStartupError: truefor the reason Codex's server isrequired: 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 variable | Meaning |
|---|---|
--full | Every case |
--limit N | The 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,b | Named cases; one that names no case stops the run |
--harness id | One 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 path | The 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:
| Variable | Meaning |
|---|---|
SHAREDOS_MODEL_API_KEY, else DEEPSEEK_API_KEY or DSH_API_KEY | The model column's key; without one the column does not run |
SHAREDOS_MODEL, SHAREDOS_MODEL_BASE_URL, SHAREDOS_MODEL_PROVIDER | The 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_CWD | DeepSeek Harness's JSON-RPC runtime (default dsh-jsonrpc-agent), its plugin composition, and its working directory |
DSH_PROVIDER, DSH_MODEL | What 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.