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 therequire_admin_guardrailsconfirm+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)
ambientis always visible (the default surface).grantedis the agent's ownToolPermissions.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_toolsis 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_pinstops 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.
AutoRemoveis deliberately off so the reconciliation pass can find a container a hard kill left behind, andAutoRemovewould only fire on exit anyway. The consequence is that a third-party process keeps its network access and its environment (readable viadocker inspect) for as long as the host stays up. The old CLI wrapper died with its parent. - Server
stderris logged verbatim (400 characters, DEBUG). Many command-line tools dump their resolved configuration on failure.scrub_event_fieldsmasks 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_CACHEandNPM_CONFIG_IGNORE_SCRIPTSare 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 anMCPConnectionErrorand 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_capabilitiesso future use is seamless. The visibility layer above ships first; the request-then- persist flow layers on top of the existing approval gate.