Skip to content
Robotadocs

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/client — one client for both protocol eras — 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.

There is no hand-written JSON-RPC path beside the SDK client, and the explicitly selected revision is the same SDK client pinned to its era, not a second stack: two client stacks cannot both be authoritative, so each guarantee below is stated, and held, once.

Non-goals / Boundaries

  • Allowed dependencies: @robota-sdk/agent-core (sole workspace peer; its ./node subpath, which holds the shared egress policy, is reached only from this package's own ./node) and the MCP client packages (@modelcontextprotocol/client, with @modelcontextprotocol/core for its schemas). Must not import agent-framework, agent-session, agent-cli, or any other agent-* package.
  • The main entry is platform-neutral and has no host default. It reaches no file, process, socket or node: module, and a capability that needs one — the stdio transport, credential files, the loopback listener, destination resolution — is a port a Node composition root fills from ./node. A host that fills none has none of them, and says so by refusing, as each port below states.
  • 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 or activate skill instructions.

Invariants and guarantees

  • URL admission: the HTTP transport asks the host's egress (IMCPHttpEgress) before anything connects, and the Node egress applies the shared destination policy to every actual request and reconnect under the owner policy and resolver captured when it was made. It connects only to that exchange's validated address set while retaining the admitted URL and its normal Host/TLS identity. Every transport, whatever its egress, refuses redirects rather than handing credentials to another endpoint, and sends to its admitted URL only. Each response owns its connection until body completion, cancellation or failure, with request cancellation preserving unrelated calls and delivering its own cancellation; closing the transport withdraws its requests and waits for cleanup. A trusted fetch given to the Node egress owns its own socket policy and lifetime rather than inheriting the pinning guarantee, though every request is still judged first. The egress is required: an admission or a construction without one is refused (egress-unavailable), so an omitted option can never become a transport that reaches any URL. The web egress, which admits any http: or https: URL and resolves and classifies nothing, is what a browser host passes on purpose. A redirect is refused whether the carrier reports a 3xx status or, as a browser's redirect: 'manual' does, an opaque redirect with status 0. This is an application HTTP boundary, not physical worker egress containment.
  • Precedence fails closed on the managed tier, not just per name: a malformed highest-precedence entry already resolves unresolved rather 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 and under the transport lifetime — never across a redirect, which stays refused. A closed transport never starts another authorization or dispatches its late result. 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 oauth the 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 https and PKCE S256 is 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 own state and, when present, its issuer's iss. 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. Every request goes through the host's OAuth network port, which holds these bounds, so a host that gives none cannot sign in, refresh or sign out; the loopback listener is a port too, and a sign-in that needs one without it fails with callback-unavailable before the user is shown anything.
  • Trace context stays on the call it belongs to: a tool call's trusted traceparent goes only on that call's own tools/call POST and the cancellation of it, and only to an exactly listed origin. No ambient context decides it: a list_changed refresh, a reply to a server request or a GET stream starts long after the call and carries nothing, and the admitted headers are never modified.
  • Cancelling a call withdraws that call's own request on either protocol era, and other calls stay live: on a legacy connection the client only notifies the server, which may ignore it, and a request left open would hold a connection for as long as the server cares to.
  • Stdio authority (a Node adapter, on ./node; a host without it connects no stdio server): 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 cwd means 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 with shell: false; every DEFAULT_INHERITED_ENV_VARS key 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, and requiresTrustedWorkspace is 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_OPTIONS value 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/header KEYS 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 transient failures retry, under bounded backoff; other classes surface for the caller to act on rather than looping silently. A stale catalog from a failed list_changed refresh is kept (marked stale) 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. Runtime tools retain the catalog's source attribution so an observation can be traced to its contributing definition and declared version without treating that declaration as authority or verification of bytes.
  • Protocol selection is explicit and never grants authority: the default retains legacy negotiation, while the explicit revision pins the same client to the stateless era — never falling back to a handshake — and sends self-contained requests through the same admitted carrier. Unsupported results cannot become completed observations or trigger a mutation replay. The only client input offered is form-mode elicitation, and only on a session whose host can put a question to a person: a request that arrives during a tool call is asked through that call's ask port, titled with the identity the operator configured for the server and never masked, with the server's own words kept to one bounded line beneath that title, and the answer continues that same call (the re-sent tools/call that carries it is the protocol's continuation, not a replay) for as long as that call lives — a prompt is withdrawn when its call ends or, while other calls to that server are in flight and the request named none of them, when the last of them ends. A request outside an in-flight call is declined, and no other request for client input is ever answered: an embedded request of a kind this surface does not answer closes the carrier, while one the client refuses on its face (URL mode, a malformed form) fails the call, and a legacy server's request for anything but form elicitation is refused by method and leaves the call to the server; none is replayed. Connection-local catalog identity prevents private disclosures carrying into a later carrier. Optional extensions are declared only behind a host opt-in, and a server's use of one authorizes nothing: host-selected skill disclosures remain bound to their server and current verified manifest, and receiving metadata or supporting files cannot authorize instructions or tool permissions.
  • A tool's app view is untrusted display data, never model instructions or tool authority: only a host that can isolate the view opts in. Negotiation permits declared display resources; it grants no additional tool authority. A missing or invalid view must not change the completed model-facing tool result. Valid audience restrictions remain binding even when a view reference is invalid. App callbacks stay bound to the source and authority of tools the runtime accepted; a naming collision must not redirect a callback to another server. Model-hidden tools are refused until the host supports a model-hidden registration path.

Design decision: one client, strict about completion on the pinned era

Both eras run on one SDK client because every guarantee here lives in the carrier and the session around it, not in the wire framing — a second framing could only drift from them. On the pinned era a result that does not say it is complete is refused rather than assumed complete: the assumption served peers that predate the discriminator, and legacy negotiation serves those. An invalid freshness hint on the discovery probe is read as its safest value, as the official client reads it, because the probe precedes every cached entry and refusing it would turn away servers every other client accepts; a page or read that carries one is still refused, because that hint governs a cache entry.

Design decision: elicitation is an adapter onto the host's ask port

A server's elicitation becomes the host's existing ask prompt rather than a prompt type of its own, so there is one prompt mechanism, one registry and one set of renderers to trust; the form's own model was rejected as the internal one because the host's prompts need masked secret entry and a free-text "other", which a form may not express. The host decides per session whether to declare the capability, because it is negotiated when the carrier opens while the ask port arrives with each call; a session that declared it but whose call has no port declines at once, so a server is never left waiting on a prompt nobody can answer. Decline is offered as a choice of the first question rather than as a new answer kind in the foundation's ask contract, because only this adapter needs it and a new kind would oblige every wire decoder and renderer to grow a control for it; a second answer the field rejects cancels, because a value the schema refuses is worse than no answer. The prompt names the server by the identity the operator configured, never by the name it declares for itself, since a declared name is a claim; the warning on a credential-shaped field is a guess from its name, so attribution, not the warning, is what the person relies on. An elicitation is attributed to the oldest in-flight call of its session, since neither era names the call it belongs to and every in-flight call speaks to the same server through the same port; for the same reason a prompt is withdrawn only when the last such call ends, because a sibling ending earlier proves nothing about whose prompt it is, and a live prompt cancelled by mistake costs more than one that waits.

Design decision: what needs a host is a port, and a forgotten port refuses

The egress is one object that judges a destination and opens a carrier, not a bare fetch, because the Node guarantee is per endpoint: resolve once, connect only to what was checked, and own each response until its body ends or is cancelled. A function cannot be closed, so it could not withdraw what it left open. The OAuth network is a port of its own with the byte and redirect bounds in its contract, since a POST's body is a credential. Both are required, and neither has a default: the egress is what stops a definition from pointing the client at a cloud-metadata address or a private service, and a default would turn a forgotten option into a transport with no such stop that still compiles. A browser, which has no name resolution to consult and whose own sandbox decides what it may reach, passes the web egress explicitly, so permissive is a choice written at a call site rather than what an omission produces. The CLI cannot forget it either: its composition builds the Node egress itself rather than accepting transport deps from a caller. Tracing and cancellation need no port because they need no ambient context: the JSON-RPC id and the SDK's per-request headers and signal identify the call.

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; agent runtime 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 a schema is narrowed once, at catalog registration, instead of being refused: inexpressible property subtrees and union branches 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, and a union branch emptied in place matches nothing and fails registration); 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. A narrowed node carries only the values IParameterSchema admits, whatever the server wrote, because the session record's strict decoder treats anything else as a corrupt record: a value outside the contract is dropped, and reported only if it was a constraint. An annotation such as a non-scalar default leaves nothing unchecked, and the contract is not widened to hold it. A root that named no properties stays open once properties is filled in, since filling a member must not start refusing arguments the root accepted. A name required holds but properties does not declare is declared as an accepts-anything property, because registration refuses such a root, which would stop the whole agent over one server's schema, and a closed node could never be satisfied.

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.