agent-mcp Specification
Purpose
The MCP (Model Context Protocol) client-side owner for Robota SDK. It keeps three concerns
deliberately separate: definitions (what an MCP server IS — decoding, precedence, disable
overlays, redacted projections, identity/fingerprints; pure, nothing here connects or spawns),
activation (whether a definition may be used — admission, exact identity matching, a
replaceable approval/audit store), and client, catalog and supervision (the official
@modelcontextprotocol/sdk client behind an admit-then-construct transport seam, the canonical
tools/prompts/resources catalog, and the connection/catalog lifecycle supervisor). Discovered
tools enter the runtime through the existing generic tool slot (IToolWithEventService); no
MCP-only runtime path exists.
The hand-written JSON-RPC path that preceded the SDK client was removed rather than wrapped: it had no discovery, and two client stacks cannot both be authoritative.
Non-goals / Boundaries
- Allowed dependencies:
@robota-sdk/agent-core(sole workspace peer; shared egress policy comes from its./nodesubpath) and@modelcontextprotocol/sdk. Must not importagent-framework,agent-session,agent-cli, or any otheragent-*package. - Does not own a tool registry, factory, or product client identity — the consumer (composition root or CLI) selects its protocol identity and wires tools at construction time.
- Transport set is Streamable HTTP and stdio only, behind an admit-then-construct seam. Deprecated HTTP+SSE and custom WebSocket are refusals surfaced in the catalog's rejected bucket, never adapters and never silent.
- MCP activation policy is transport-neutral and host-injected: this package owns the admission port, identity matching, and the approval/audit store, but does not decide workspace trust or read project/plugin files.
Invariants and guarantees
- URL admission: the shared egress policy runs BEFORE any connection attempt;
http:outside loopback, private ranges and cloud-metadata addresses are refused, and a redirect is refused rather than followed. There is no second admission path, so definition headers never reach a host the policy did not admit. - Precedence fails closed on the managed tier, not just per name: a malformed highest-precedence
entry already resolves
unresolvedrather than falling through to a lower source; when the managed tier cannot be read AT ALL (its configuration root,mcpServers, or its whole document is unusable), every name that would otherwise resolve from a LOWER tier is blocked the same way, because a name the managed policy would have defined is indistinguishable from one it never mentioned — a name already resolved from a different, readable managed origin is unaffected. A source-level problem in any other tier is informational only — reported beside the servers that still resolve normally. - Authentication is bound to one server and never optional once asked for: a host registers an
authenticator for one server identity, and the HTTP transport asks it for headers on every request
to that server only, after admission — never across a redirect, which stays refused. Its headers
override a static header of the same name and never enter a projection, log, audit record or
error. A refused credential is retried at most once, with fresh authorization, and only when the
authenticator allows it; a failure is a typed, content-free refusal, and there is never an
unauthenticated attempt. A definition that declares authentication this version cannot perform, a
header helper the host does not allow, or
oauththe host offers no sign-in for, stays listed and is refused by name, rather than connected with its static headers alone. A header helper is an exact argv the host runs, never a shell line or a template, so the host can allow one command line rather than a program; its output is parsed strictly, may not set a header the transport or protocol owns, and is obtained once per connection and once more after the server refuses them — however many requests were refused together. - OAuth trusts only what it has checked: discovery follows no link it has not checked against
the one before it — the resource metadata must describe this server, the authorization server's
metadata must name the issuer it was fetched for, every endpoint is
httpsand PKCES256is advertised — because the SDK's discovery guesses when a link is missing. A POST never follows a redirect, since its body is a code, a token or a secret, and a sign-in's redirect — caught on loopback or pasted by the user — is accepted only once, as its own redirect URI with its ownstateand, when present, its issuer'siss. Tokens are kept by server identity and canonical URL together, so a shadowing definition cannot use another's sign-in; a refresh goes only to the endpoint the tokens came from and runs once per credential across processes, because a rotating refresh token spent twice signs the user out — and a refused refresh never clears a token someone else rotated in meanwhile. Signing out deletes the credential before asking the stored issuer to revoke it, so an authorization server that is down or refuses cannot keep the user signed in. A sign-in state shown to the user is a fixed word, never derived from a token. Storage is a get/set/delete port with that coordination on the caller's side, so a keychain can replace files without anything else changing. A client secret never lives in a definition. - Trace context stays on the call it belongs to: a tool call's trusted
traceparentgoes only on that call's owntools/callPOST and the cancellation of it, and only to an exactly listed origin. The decision is made from each request's body, not from the async context, because the SDK runs a call's response stream — and anylist_changedrefresh or reply it triggers — inside that context; the admitted headers are never modified, so nothing carries over to another request. - Stdio authority: definitions cannot grant execution authority — only a host-owned authority
can, and it is consulted before reading environment values, constructing the transport, or
spawning. Absent
cwdmeans the authority's allowed root, never the ambient process cwd; lexical.., NUL, non-directory paths and canonical paths outside the allowed root (including symlink escapes) are rejected, and both activation and cwd are rechecked immediately before spawn — this limits but cannot eliminate concurrent filesystem replacement. The child spawns withshell: false; everyDEFAULT_INHERITED_ENV_VARSkey is explicitly shadowed rather than left to the SDK's default merge, though an empty baseline key remains present in the child. Stderr is drained without publishing raw bytes, and cleanup observes direct-child close within a bound but makes no process-tree termination guarantee. - Activation matching is exact: server id, source/provenance, definition fingerprint, security
identity and (for every source outside
managed/user) repository identity and workspace generation must all match an approval; project/plugin sources cannot self-approve, andrequiresTrustedWorkspaceis deny-by-default. - One principle decides what is secret: a value is secret because of what it is — a stretch a credential-shaped variable produced (its default included), or a value under a credential-shaped env or header key — not because of which field carries it. Materialization records which stretches came from which variable, and every consumer reads that record.
- Secrets are never hashed, and everything else is: the definition fingerprint covers every
value that decides what runs or where it connects, env and header values included, with each
secret replaced by a marker naming its source. A changed
NODE_OPTIONSvalue or a changed host invalidates an approval on every transport; rotating a credential does not. - Nothing printed carries a secret: the activation endpoint and a projected command line, cwd
and URL stay readable, because they are how an operator tells servers apart, with only their
secret stretches replaced — what a credential-shaped variable expanded, and any literal a
credential's shape gives away. That shape test is a guess, so it serves display only and never
the fingerprint: two different literal tokens mask alike, and a fingerprint blind to a changed
token would carry an old approval over to it. Transport errors name origins only. Projections
carry
env/headerKEYS but never VALUES: the key alone tells servers apart, "configured but redacted" and "no header" must remain distinguishable answers, and a value there is too often a credential of no recognisable shape. - A session is stateless about liveness by contract. The SDK has no cancellation acknowledgment, so an abort or timeout of an active stdio request closes the direct child rather than pretending the in-flight call can be cancelled cleanly; a failed tool call is never replayed by the supervisor.
- An MCP server is never a source of session turns. No server notification is delivered as an event for a host to turn into a turn, whatever experimental capability the server declares: a server connection proves nothing about who wrote a message, so a sender named inside one would be a claim, not an identity. An external event reaches a session only through ingress that verifies the sender's own credential.
- Discovery is bounded and honest: an unsupported capability's list method is never called; a declared one is drained page by page until pagination ends, bounded by a page count and a per-request timeout; a partial catalog is never reported as complete.
- Result-size metadata is a bounded, adapter-validated request, not a server-controlled policy override. Only one vendor metadata key is interpreted, only within a fixed numeric range; a malformed or out-of-range value is ignored with a diagnostic rather than treated as permission to widen a result. An independent, transport-level receive cap (applied before SDK parsing) is a separate, smaller concern than the character-based admission cap: the receive cap limits local materialization, the admission cap limits model context.
- Connection state carries its own failure classification. Only
transientfailures retry, under bounded backoff; other classes surface for the caller to act on rather than looping silently. A stale catalog from a failedlist_changedrefresh is kept (markedstale) rather than emptied. A retained catalog is bound to an explicit identity (server id, negotiated protocol version, server version); a reconnect whose identity differs invalidates it. - The legacy protocol era is a recorded limit: the pinned SDK generation speaks the pre-2026-07-28 protocol; a server that refuses the negotiated version is disconnected, not used.
Design decision: definition precedence, with plugins last
A server name resolves to one whole entry from the highest source that defines it: managed,
then local, project, user and plugin. Entries are never field-merged, and a malformed
winner still shadows the name rather than handing it down. Plugins rank last because a plugin is
the least-trusted source and plugin server names are not namespaced: ranking it above user
would let an installed plugin silently replace a server the user configured under the same name,
while ranking it last still lets a plugin add servers under names of its own. Claude Code ranks
plugin-provided servers above user scope; Robota deliberately does not.
Design decision: narrowing, not refusing, third-party schemas
An MCP tool's inputSchema is authored by a third-party server. Handing an expressive-but-partial
schema to a strict validator unchanged would refuse every payload for that tool once the schema
used a construct the universal subset cannot express — breaking a working tool over a limitation
that is this repo's, not the server's. So the validator narrows instead of refusing:
inexpressible property subtrees are replaced with an accepts-anything node rather than deleted
(deleting a key from a closed object schema would turn the server's own declared parameter into an
"unexpected additional property" and refuse the payload for the opposite reason); required is
carried through untouched, because a narrowed property is one whose value cannot be checked, not
one that stopped being required; everything expressible is still enforced completely, including
nested objects and array items; and the dropped paths are reported once per tool (narrowing is a
pure function of a schema that does not change), because silence here would be a downgrade nobody
could see.
Error semantics
Refusals and failures are typed and secret-free: transport admission, stdio authority, and session errors carry a reason without raw command values, argv, or stderr text. Errors are never silently swallowed — a discovery failure, a rejected catalog entry, and a connection failure are all surfaced as classified results a caller can branch on, not thrown as opaque exceptions or dropped.