Skip to content

Agent MCP Governance

A running agent can call SynthOrg's own MCP tools through its ordinary tool invoker (the self-consumer bridge) and can reach external MCP servers through the MCP bridge tool. This page covers how that surface is scoped per agent and hardened against supply-chain and blast-radius risk.

Per-agent MCP visibility (progressive capabilities)

ToolAccessLevel is an agent's earned trust tier (SANDBOXED < RESTRICTED < STANDARD < ELEVATED). ELEVATED is the top: the surface an agent can drive without a per-call approval. The problem this design closes: previously every ELEVATED agent saw the whole ~243-tool MCP surface (fire staff, deploy, delete, mutate the org), so a prompt-injection hijacking any ELEVATED agent could reach the highest blast-radius tools.

Ambient versus sensitive

Each MCP tool carries a domain:action capability tag. The action marks its tier:

  • Ambient (domain:read, domain:write): usable out of the box by any ELEVATED agent, with zero per-agent configuration. Reading and ordinary writing must work immediately or the surface is unusable.
  • Sensitive (domain:admin): the high-blast-radius tools that carry the require_admin_guardrails confirm+reason gate. Visible to an ELEVATED agent only when explicitly granted.

The bridge

engine/mcp_self_consumer.py::_provide computes an ELEVATED agent's visible tools as:

ambient(all non-admin tools)  UNION  granted(agent's own mcp_capabilities)  UNION  operator_broadening(elevated_capabilities)
  • ambient is always visible (the default surface).
  • granted is the agent's own ToolPermissions.mcp_capabilities (e.g. ("agents:admin",)): the sensitive families it earned. Empty by default, so an unconfigured agent gets exactly the ambient surface.
  • elevated_capabilities (default ()) is an operator-set org-wide broadening; setting it to ("*",) restores the whole surface.
  • denied_tools is the highest-priority denylist, applied last.

Sub-ELEVATED agents keep the explicit operator read_tool_allowlist path (empty by default: no MCP for low-trust agents).

Retrieval after scoping

Scoping answers what an agent MAY reach and is the security question. What is put in front of the model for one unit of work is a different question, asked afterwards of the survivors alone: the scoped surface is a couple of hundred tools, and past a few dozen offered tools a model's selection accuracy over the whole surface collapses. engine/mcp_tool_retrieval.py ranks the scoped tools against the text of the unit of work (a task's title and description; for a chat action, the most recent human instruction) by term overlap weighted by rarity across the surface, name matches counting double, and keeps security.mcp_self_consumer_retrieval_top_k of them (default 40, 0 keeps every scoped tool), in the order scoping listed them. It is lexical on purpose: the vocabulary of a tool name and a task brief is the same, and an embedding model would add a network call and a second (provider, model) binding for nothing.

Retrieval runs strictly after visible_tools(...) and over its result alone, so it can only drop from what the agent may reach and never add to it: a tool it drops was admissible and is merely not offered this time, and a tool scoping refused is never offered whatever its relevance. A caller with no text to rank against passes retrieval_query=None and is offered the scoped surface whole, which is the honest answer rather than a guess. Every narrowing logs MCP_SELF_CONSUMER_RETRIEVAL_NARROWED with the scoped and offered counts. The ceiling lives on McpSelfConsumerConfig.retrieval_top_k beside the mode and reaches a running agent the same way the mode does: the security bridge subscriber rebuilds the live config on a write and the runtime reload subscriber rebuilds the engine on it.

Because sensitive tools are hidden until granted, a prompt-injected marketing agent cannot see or call deploy / org_fire / delete tools: the surface is not there to attack. And because ambient tools are always visible, the surface works with no per-agent setup.

check_mcp_self_consumer_scoped.py guards that the bridge keeps reading identity.tools.mcp_capabilities; if it regressed to a single global grant, every ELEVATED agent would again see everything.

Where a stdio MCP server actually runs

A stdio MCP server is arbitrary third-party code, so it runs in a container, never as a child of the backend. Getting that wrong is not theoretical: it is what made the shipped catalog unlaunchable on every shipped stack.

The backend image is hardened and ships no shell, no node and no npx, so a direct spawn raises FileNotFoundError. The wrapper that existed to solve that rewrote the launch to docker run -i ..., and the image ships no docker binary either, so it raised the same error from one line further along. A live boot logged mcp.client.credentials_injected (the operator's install was correct), then connection_failed error='FileNotFoundError', then mcp.factory.complete tool_count=0, and moved on. Install-time validation checked credentials thoroughly and never asked whether this process could launch the thing at all.

The transport

tools/mcp/container_stdio.py reaches the daemon the way the rest of the product does, over the API. It creates the container, attaches to its stdin and stdout before starting it (so no output frame is lost and the session's first request has somewhere to go), and yields the same (read, write) memory-stream pair the SDK's stdio_client yields: line-delimited JSON-RPC in both directions, a parse failure delivered as a value rather than an exception, and stderr logged and never parsed.

Attaching before the start is a step, not a call. The client's attach builds a stream object and performs no I/O; the connection opens inside the first read or write. Left to happen on its own that first call comes from a pump, after the start, so a server that greets on startup or dies immediately has that output dropped by the daemon with nothing attached, and logs=False means there is no replay to recover it from. The transport therefore enters the stream itself before starting the container. The same step decides which task performs that lazy setup: both pumps would otherwise reach it together, its guard is unlocked, and each would open a connection, one of which would be leaked while the other overwrote the shared queue.

Isolation is the same policy the CLI wrapper asked for, expressed as HostConfig: every capability dropped, no new privileges, a read-only root with one writable tmpfs, and the operator's memory / pids / cpu / network limits (tools.mcp_sandbox_*, converted to daemon units by tools/sandbox/_container_limits.py). The container keeps the image's own uid, as the agent sandbox does, because naming a user here would bind the transport to one image's accounts. It also carries the operator's configured container runtime, so a deployment hardened with gVisor gets gVisor here. That is not symmetry for its own sake: with every capability dropped and no host path writable, a kernel or runtime bug is the only escape left, and this is the one path in the product that runs code nobody reviewed, so honouring the setting for our own agents while ignoring it here would give the weaker isolation to the stronger threat.

The runtime is read on its own, under its own guard, and that separation is load-bearing rather than incidental. Boot derives two independent facts here, the runtime and the deployment identity the reconciliation pass reclaims containers by, and a guard shared between them orders them: whichever is derived first can cost the other. A lost identity leaves a container nobody reclaims, which the next boot pass can still be taught to find; a lost runtime leaves one carrying weaker isolation than the operator asked for, and nothing downstream can tell it apart from a deployment that never configured any. So neither is paid for with the other, and an unresolvable runtime degrades to the daemon's default with sandboxing otherwise unchanged, reported under sandbox.gvisor.fallback: the same event the runtime resolver raises when a configured runtime goes unavailable at dispatch, because losing gVisor is one fact whichever side of boot notices it.

What the container can still reach (residual)

Egress is not restricted. tools.mcp_sandbox_network offers bridge, none and host, and a server exists to call an upstream API, so none is not a setting an operator can use. A bound connection's credential is injected into the container's environment, which means the package's own runtime code holds a live secret and can open a connection to any host the network allows, including the host gateway and, on a cloud host, the instance metadata endpoint. Version pinning and NPM_CONFIG_IGNORE_SCRIPTS do not touch this: both constrain what happens at INSTALL, and this is the code doing exactly what it was installed to do.

Closing it properly means a per-server egress allowlist on the sidecar the agent sandbox already uses. The obstacle is that a catalog entry declares its package and its credential mapping but not the host it talks to, so the allowlist has no source to derive from today and would have to be operator-set per server. Until then this is a stated gap rather than an implied assurance: an operator installing a credentialed catalog server is trusting that package's runtime behaviour with that credential.

Three narrower residuals belong with it, none of them closed here:

  • The pin is a version, not a hash. The package is fetched at every connect. _validate_npm_pin stops a dist-tag re-resolving to something un-reviewed, but nothing checks that the tarball for a given version is the one that was reviewed. The image's signature is verified, and that check stops at the image boundary: the package is fetched into it afterwards.
  • An orphan keeps its credential until the next boot of the same deployment. AutoRemove is deliberately off so the reconciliation pass can find a container a hard kill left behind, and AutoRemove would only fire on exit anyway. The consequence is that a third-party process keeps its network access and its environment (readable via docker inspect) for as long as the host stays up. The old CLI wrapper died with its parent.
  • Server stderr is logged verbatim (400 characters, DEBUG). Many command-line tools dump their resolved configuration on failure. scrub_event_fields masks the known credential shapes on every record, so this is defence in depth, but the redaction is pattern-based: a token in a bespoke format would pass.

Why these containers carry no tracking row

Every other managed container class has a TrackedContainerRepository row, and the boot reconciliation pass uses it to tell a live peer's container from an orphan: a container with a row is kept (adopted back into the in-memory tracking dict), and only one that is ours, predates this boot, and has no row is removed.

An MCP container cannot be adopted. Its whole value is an attached stdio stream, which belongs to the process that opened it, so a container whose backend is gone has nothing that can talk to it again: it is a credentialed process holding a socket nobody owns. Giving it a row would move it from the removed set into the kept set, which is precisely the wrong answer, so the absence is deliberate and the labels alone carry it.

The cost is real and worth naming: in the one arrangement deployment_identity.py explicitly blesses, two backends sharing a workspace root and a daemon, the second one's boot sweep will destroy the first one's live MCP servers, because from the daemon they are indistinguishable from orphans. That is the same window the reconciliation module already documents for its row-race, one step wider. Closing it needs an ownership signal that survives the process without implying adoption (a liveness lease rather than a row), which is a change to the reconciler rather than to this transport.

Three properties beyond the isolation are load-bearing:

  • Trusted controls win by construction. HOME, NPM_CONFIG_CACHE and NPM_CONFIG_IGNORE_SCRIPTS are merged last, so a configured environment cannot re-enable install scripts (the npm RCE vector) or redirect writes off the one writable mount. A collision is logged, not silently dropped.
  • The container is attributable. It carries the managed label and this deployment's label (both owned by tools/sandbox/deployment_identity.py, derived from the agent workspace root). Without them the boot reconciliation pass leaves an orphan alone for ever, and a hard kill of the backend leaves a credentialed server running with nothing attached to it.
  • A failure keeps its type. A task group re-raises what escapes its body as an ExceptionGroup. The client's reconnect handler retries an MCPConnectionError and nothing else, so the transport carries a session-time failure out of the group and re-raises it unchanged.

One image runs untrusted code

The runtime image is the resolved tools.sandbox_image: it carries Node, npm and Python, and the CLI verifies its signature. tools.mcp_sandbox_image is deleted. A second knob naming a second image is a second answer to a question the operator already answered by hardening and verifying one image, and its default named a third-party image the deployment had never pulled.

Refusing what cannot be launched

installation_to_server_config is the single owner of "can this entry become a runnable server". CatalogService.install calls it before persisting a row, so an install refuses exactly what a boot would refuse, at the one moment an operator is present to be told; a boot skips a row it refuses rather than failing, so one bad row does not cost an operator every other server.

RUNTIME_PROGRAMS in tools/mcp/runtime_provision.py declares each launchable program together with the apko package that installs it. check_mcp_catalog_launchable.py holds that declaration to docker/sandbox/apko.yaml in both directions: a declared program no package provides fails the build, and so does a bundled entry naming an undeclared program. It fails closed on an empty declaration, because a gate looking at nothing must not report success.

Supply-chain hardening: npm version pinning

The MCP catalog installer pins every npm package to @<version>, but a hand-authored MCPServerConfig (in a config file or built programmatically) bypasses that path. An npx-launched stdio server with an unpinned (or @latest) package resolves whatever is newest on every reconnect, so an un-reviewed version could start running under an agent's tools with no config change.

MCPServerConfig._validate_npm_pin rejects an unpinned npm package at the model boundary: an npx / pnpm dlx / bunx command must run a package spec ending in an exact @<version>. Only MAJOR.MINOR.PATCH (with optional pre-release/build metadata) names one immutable artifact, so a dist-tag (latest / next / canary), a range (^1.2.3 / ~1.2.3 / >=1.2.3), a partial version (1 / 1.2), and a wildcard (1.x / *) are all refused: each still re-resolves at spawn time. npx reads its own options only up to the first positional, so a --package after it is an argument forwarded to the spawned binary, not a second install. CatalogEntry.npm_version applies the same rule through the shared core.npm_version.is_exact_npm_version, so the curated and hand-authored paths cannot drift apart. Non-npx commands (node, python, docker) are exempt. The stdio sandbox's NPM_CONFIG_IGNORE_SCRIPTS=true blocks the install-script RCE vector independently, but does not stop an unpinned package resolving a newer version, so the pin is a distinct control.

check_mcp_server_config_pinned.py guards the validator against removal.

Catalog credential binding

A catalog entry's credential_env_map maps a bound connection's credential field to the environment variable its MCP server reads. Injection is an exact field-name lookup at connect time with no aliasing, so an entry naming a field the required connection type never stores injects nothing: the server launches unauthenticated, the only signal is a warning nobody is watching for, and the failure resurfaces much later as an opaque upstream auth error.

Two checks close that gap from both sides. CatalogService.install refuses a bound connection missing any mapped field, naming the field, rather than recording an installation that can only fail. check_catalog_credential_fields.py compares every bundled entry against the field registry, because the entry and the fields live in different files and nothing else notices them drift.

Destructive external-MCP auto-escalation

Every MCP call already flows through the same ToolInvoker security interceptor as a native tool (there is no MCP bypass), but the built-in DestructiveOpDetector only recognises shell/SQL command syntax (rm -rf, DROP TABLE) embedded in string arguments. A third-party MCP call never carries that: its intent lives in the tool name (mcp_github_delete_repository) or a structured dispatch argument ({"action": "delete_channel"}), so a destructive third-party operation sailed through as a plain comms:external ALLOW.

MCPDestructiveOpDetector (a SecurityRule, registered right after the shell detector, gated by RuleEngineConfig.mcp_destructive_op_detection_enabled) closes that gap. It fires only for ToolCategory.MCP, tokenises the tool name and string argument values, and escalates any call whose operation reads as destructive (delete / purge / revoke / terminate ...): HIGH, or CRITICAL for mass-destruction verbs. It only ever escalates, never auto-denies: a human, not a regex, makes the final call on a third-party operation, and escalation is the safe direction (an over-broad match costs a confirmation, never data). A rule-matched verdict is authoritative and bypasses the LLM fallback, which stays reserved for the low-confidence minority of unclassified MCP calls.

A HIGH/CRITICAL ESCALATE verdict routes to the approval gate through the existing _handle_escalation -> pending_escalations -> should_park chain. That chain had a latent no-op: ToolInvoker._check_security skipped the escalation when a verdict reached it with no approval_id (an interceptor that never actually parked the call), yet still returned an "approval required" result, so the destructive call was blocked but the escalation was silently dropped instead of reaching a human. The invoker now fails closed on that combination (a loud error log plus a blocked result), so an unattributable escalation can never slip through without review.

Residual scope (tracked)

  • Grant on demand. The design intent is that a sensitive tool an agent is not yet granted can be requested at first use, approved once, and then persisted onto the agent's mcp_capabilities so future use is seamless. The visibility layer above ships first; the request-then- persist flow layers on top of the existing approval gate.