MCP API reference

SharedOS presents its permission-filtered tool catalogue as an MCP server, so a harness that already speaks MCP — Codex, Claude Code, DeepSeek Harness, Pi — runs natively against it without a shim. This page is the wire reference: transports, methods, status codes, and headers. Why this boundary exists, what crosses it, and what is deliberately never published are in MCP toolshare and ADR 0014.

This is the second network surface. The first is the HTTP API, which serves your own code. They are not alternatives: the kernel behind them is the same, and a tools/call here becomes exactly the ToolCall that POST /v1/tools/invoke would have made.

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

const bridge = await createStreamableHttpMcpServer({
  server: new McpToolServer({ invoker, serverInfo: { name: "sharedos", version: "1" } }),
  authorize: (token) => verifySessionToken(token),
});
// bridge.url  ->  http://127.0.0.1:53124/mcp

A bridge lives for one turn

The catalogue is resolved once per turn and cannot change underneath a running harness. Everything else about this surface follows from that:

  • initialize advertises tools: { listChanged: false } — stated, not omitted. A client polling for catalogue changes would wait on a notification this server will never have cause to send.
  • tools/list is served whole. There is no pagination, because a harness that acted on a prefix would be acting on a partially discovered catalogue — exactly the stale-discovery failure catalogHash exists to catch.
  • There is no server-initiated stream. GET is refused rather than left open.
  • A session id from an earlier bridge is answered with 404, not silently adopted, so a client re-initializes instead of calling into a turn that ended.

Transports

TransportEntry pointLifetime
stdioserveMcpOverStdioThe harness spawns SharedOS; the process ends with the invocation
Streamable HTTPcreateStreamableHttpMcpServerLoopback listener, closed with the turn

Both carry the same McpToolServer. Choose stdio when SharedOS is the subprocess — a turn-scoped bridge and a process lifetime that already match need nothing to keep them in step. Choose HTTP when the harness is sandboxed or remote and cannot be handed a stream pair at all; it is also the transport every emitted harness configuration points at.

stdio

Newline-delimited JSON on a Readable/Writable pair. Messages are answered in arrival order — MCP permits interleaving, but a bridge that answered out of order would let a harness observe two calls resolving against one turn in an order the audit trail does not show. Resolves when the input ends or the signal aborts.

Streamable HTTP

One route, on loopback by default.

OptionDefaultMeaning
path/mcpThe path every emitted harness configuration expects
host127.0.0.1Loopback unless a host deliberately widens it
port0Ask the OS for a free port, which is what a turn-scoped bridge wants
authorizeabsentValidates the bearer token on every request

The returned server carries url, port, sessionId, and close().

RequestStatusResponse
POST with one message or a batch200The JSON-RPC answer, or an array for a batch
POST whose answers are all notifications202Empty. Accepted, with nothing to say back
POST with accept: text/event-stream200The same answers, framed as SSE
DELETE200Empty. The client is done with the session
Any other method405{"error":"method_not_allowed"}, with allow: POST, DELETE
Any other path404Empty
authorize returned false401{"error":"unauthorized"}, with www-authenticate: Bearer realm="sharedos"
mcp-session-id from another bridge404{"error":"unknown_session"}
Body is not JSON400JSON-RPC -32700
Empty JSON-RPC batch400JSON-RPC -32600
Unhandled failure500{"error":"internal_error"}

Every response carries mcp-session-id.

authorize receives the bearer token, or undefined when none was sent. Leave it absent for a loopback bridge whose port is known only to the subprocess it was opened for. Supply it for a sandboxed or remote harness, where the execution token identifies the broker session — mintExecutionToken and verifyExecutionToken are in the same package. The token identifies a session and never carries authority. Authorization is still the kernel's, per call, against a grant.

Methods

MethodParamsResult
initializeprotocolVersion?protocolVersion, capabilities, serverInfo, instructions?
ping{}
tools/listtools[], _meta
tools/callname, arguments?content[], structuredContent?, isError
notifications/initializedNo response

Anything else is -32601.

initialize

{
  "protocolVersion": "2025-06-18",
  "capabilities": { "tools": { "listChanged": false } },
  "serverInfo": { "name": "sharedos", "version": "1" },
  "instructions": "…"
}

Supported protocol versions are 2025-06-18 (latest), 2025-03-26, and 2024-11-05. A version outside that set — including none at all — negotiates to the latest rather than failing, so an older client is never refused at the door for a version string.

tools/list

{
  "tools": [
    {
      "name": "files.search",
      "description": "Semantically search files inside an explicitly granted path.",
      "inputSchema": { "type": "object", "required": ["path", "query"], "properties": {} },
      "outputSchema": { "type": "object", "properties": {} },
      "annotations": { "readOnlyHint": true },
      "_meta": { "sharedos/namespace": "files", "sharedos/source": "sharedos" }
    }
  ],
  "_meta": {
    "sharedos/catalogHash": "…",
    "sharedos/executionId": "execution-1"
  }
}

This is the same effective catalogue GET /v1/tools returns for the same context: registered and namespace enabled and allowed by some grant. A tool the caller may not use does not appear, so a model driven by this list never learns it exists.

name is the canonical SharedOS tool ID, unchanged. A harness may rewrite it for its own display — Claude Code, Codex, and DeepSeek all use mcp__<server>__<tool> — and that alias is recorded on the bridge for diagnosis, never on the ToolCall, so it cannot reach an authorization decision even by mistake.

Two fields change name on the way out. SharedOS annotates a tool readOnly and destructive; what is published are MCP's own readOnlyHint and destructiveHint. Namespace and source are not MCP fields at all, so they travel under _meta as sharedos/namespace and sharedos/source. What is never published — the required capability, the grants behind it, the issuing authority, namespace settings, credentials, handler references — is listed in MCP toolshare.

catalogHash is 64 hex characters over the tools and nothing else, so two harnesses handed the same semantic tool set produce the same hash even though their execution ids, transports, and aliases differ. A catalogue holds at most 512 tools.

tools/call

{
  "name": "files.search",
  "arguments": { "path": ["Work", "Projects", "atlas"], "query": "ship date" }
}

The exposed name is mapped back to the canonical ID, the call is given a fresh SharedOS call id, and the invoker hands it to the kernel — where the arguments are parsed, the exact resource is re-derived from them, and that resource and action are authorized again. A name that was never published is passed through to be refused tool_unavailable rather than rejected at the bridge, so an unpublished tool and a misspelled one fail the same way.

{
  "content": [{ "type": "text", "text": "{\"hits\":[…]}" }],
  "structuredContent": { "hits": [] },
  "isError": false
}

structuredContent is present only when the published tool declares an outputSchema and the output is a JSON object.

A denied call is a successful JSON-RPC response. Refusals and failures come back as a tool result, not a JSON-RPC error:

{
  "content": [
    {
      "type": "text",
      "text": "{\"status\":\"denied\",\"code\":\"no_matching_grant\",\"message\":\"…\"}"
    }
  ],
  "isError": true,
  "_meta": { "sharedos/status": "denied", "sharedos/code": "no_matching_grant" }
}

Read _meta["sharedos/status"] to separate denied from failed, and sharedos/code for the reason code. A client that treats every isError alike will report a permission decision as an outage.

JSON-RPC errors

Reserved for messages that never reached a tool at all.

CodeMeaning
-32700Body is not JSON
-32600Not a JSON-RPC message, or an empty batch
-32601Unknown method
-32602tools/call without a tool name
-32603Internal failure

Harness configuration

@aicoo/sharedos-mcp emits the connection file each harness expects, so a bridge URL becomes a working configuration without hand-editing.

McpHarnessIdFileFunction
codexconfig.tomlcodexMcpConfig
claude-code.mcp.jsonclaudeCodeMcpConfig
deepseekcordis.patch.ymldeepseekMcpConfig
piJSON optionspiMcpConfig

harnessMcpConfigFile(harness, connection) returns the filename and contents for any of them; claudeAgentSdkMcpOptions reuses the Claude Code server block for the Agent SDK; harnessToolAlias reproduces the display name a harness will show.

Escalation over MCP

A turn driven over MCP can end by asking a human to decide, the same as one driven in-process. The affordance is published as an ordinary tool, sharedos.escalate, and is permission-filtered like any other — an agent with no grant over it does not see it in the catalogue at all.

It is never invoked. A driver that recognizes the name ends the turn with an escalate outcome instead of making a tool call, so nothing reaches the kernel. See ADR 0018 and ADR 0011.

Calling it with curl

BRIDGE=http://127.0.0.1:53124/mcp
AUTH="authorization: Bearer $EXECUTION_TOKEN"
JSON='content-type: application/json'

curl -s "$BRIDGE" -H "$AUTH" -H "$JSON" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}'

curl -s "$BRIDGE" -H "$AUTH" -H "$JSON" \
  -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'

curl -s "$BRIDGE" -H "$AUTH" -H "$JSON" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

curl -s "$BRIDGE" -H "$AUTH" -H "$JSON" \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"files.search","arguments":{"path":["Work"],"query":"ship date"}}}'