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./nodesubpath, which holds the shared egress policy, is reached only from this package's own./node) and the MCP client packages (@modelcontextprotocol/client, with@modelcontextprotocol/corefor its schemas). Must not importagent-framework,agent-session,agent-cli, or any otheragent-*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 anyhttp:orhttps: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'sredirect: '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
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 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
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. 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 withcallback-unavailablebefore the user is shown anything. - 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. No ambient context decides it: alist_changedrefresh, 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. Absentcwdmeans 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. 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/callthat 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.