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
~247-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 out-of-the-box 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).
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.
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.
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.