MCP standardized the boring part of giving an agent tools. One streamable HTTP
endpoint, one list_tools call, and the tools show up in the model's callable
list. The first time we wired one up internally it took an afternoon.
Then we tried to put it in production, and someone from security asked five
questions. I could not answer any of them.
This post is those five questions, and how we ended up answering them in SOIT —
an open-source agent runtime with governance in the middle of it. Everything
below points at a file in the repo, because posts like this are unusually easy
to write as a slide deck instead of as software.
1. Who is allowed to call this tool?
MCP does not have an opinion here. Whatever list_tools returns is what the
model can call. Visibility is capability.
In a multi-tenant, multi-workspace deployment that is not enough. SOIT installs
an MCP server as a plugin artifact rather than as a config entry. Tool
references are namespaced — mcp_tool:{server}:{tool}, parsed by
parse_mcp_tool_ref in server/app/adapters/tools/mcp.py — and every
resolution carries a RequestContext holding tenant_id, workspace_id and
user_id.
Two things follow from that:
- From the agent's point of view, a tool from a plugin, a tool from an MCP server, and a built-in adapter all look identical. Bindings are typed and versioned.
- Permission checks, secret injection, egress limits, audit, cost attribution, trace and replay apply to MCP tools automatically. Nobody writes the governance path twice.
Each agent version also carries a capability allowlist covering models,
knowledge bases, workflows, tools, plugins and MCP servers. So "which MCP tools
can v3 of this agent call" is something you can diff and roll back, rather than
a runtime toggle somebody flipped.
2. Where do the credentials live?
Most MCP integration examples look like this:
{
"auth": { "type": "bearer", "token": "sk-xxxxxxxx" }
}
A plaintext token in a config file. It ends up in git. It ends up in logs. It
ends up in the config backup somebody exported to a laptop.
SOIT rejects this outright. _build_auth_headers checks for a token or
value field in the auth config and raises:
MCP credentials must use secret_id
Only secret_id is accepted, resolved through SecretsPort at call time. Same
for API keys — and they are only supported in headers, never in a query string,
because query strings leak through logs and referrers.
The real value exists in memory for the duration of the call and nowhere else.
What gets persisted — to the database, to audit records, to traces — is a
redacted copy: ToolPolicyGateway._resolve_secrets builds it in the same
pass that resolves the secret, keeping only secret_id and the signing policy
reference (server/app/kernel/ports/tools/policy.py).
Three auth types are supported: bearer, api_key, oauth2. OAuth follows
2.1 with authorization-server discovery (RFC 9728, RFC 8414 / OpenID Connect)
and resource-bound tokens (RFC 8707), using the client_credentials grant.
One limitation worth stating plainly: the browser-based authorization_code
flow is not implemented. SOIT calls MCP servers on its own behalf, not on
behalf of a user sitting in front of a browser. If you need "call a protected
MCP server as the end user," this does not cover you.
3. Where can it connect to?
This is the one that should worry you most.
You deployed the MCP server, but it is a thing that makes network requests on
your behalf. Put a URL in the tool arguments and it will fetch it. The classic
shape of this is asking it for http://169.254.169.254/ — the cloud metadata
service, holding temporary credentials.
SOIT's egress policy is deny-by-default, in three layers.
Layer one: domain policy. check_egress_policy matches the target domain
against tenant-scoped and workspace-scoped allowlists and blocklists, with the
blocklist winning. The defaults are enable_egress_policy: bool = True and
egress_allowlist: list[str] = [] — an empty allowlist means nothing is
permitted until you say so. And if the policy lookup itself throws, the answer
is deny, not allow:
except Exception as exc:
raise ForbiddenError(
"Egress policy lookup failed; request denied",
{"resource_ref": resource_ref},
) from exc
Fail-closed is not a slogan. It is whatever you actually wrote in each except
branch.
Layer two: per-address validation after resolution. Passing the domain check
is not enough — DNS rebinding lets an allowlisted hostname resolve to
127.0.0.1 or 10.0.0.x. So after the domain is allowed, GovernedEgressGuard
actually resolves the hostname and checks
ipaddress.ip_address(address).is_global for every address returned. One
non-public address and the whole request is refused
(server/app/kernel/security/egress.py).
Closed off in the same pass: non-http/https schemes are denied by default, URLs
carrying userinfo (https://user:pass@host/) are denied, and a DNS failure is a
denial rather than a retry.
Layer three: authorization per hop. A URL that cleared both layers returns a
302 pointing at your internal network. Now what? So the outbound HTTPX client is
built like this (server/app/adapters/http/governed_client.py):
async def authorize_request(request: httpx.Request) -> None:
await guard.authorize(ctx, resource_ref, str(request.url))
event_hooks["request"] = [authorize_request, *request_hooks]
kwargs.setdefault("follow_redirects", False)
Authorization hangs off the HTTPX request event hook, so every request that
actually goes out is checked, redirect hops included — not just the URL you
handed in at the entry point. And redirects are not followed by default.
The MCP adapter builds its sessions with that client, so the whole MCP path —
initialization, list_tools, every call_tool — sits inside these constraints.
4. Can you find out what happened afterwards?
Tool calls are the only place an agent produces real side effects. A model
saying something wrong can be asked again. A tool that changed a row in the
production database changed it.
SOIT persists each tool call as a step of a run, and writes two pieces of
evidence per call:
-
Gateway audit.
log_gateway_requestwithgateway_type="tool". The request side records thetool_ref, redacted parameters, and the egress decision (allow / deny plus the target URL). The response side records success, result type, metadata, and error. The failure path writes one too — the first thing theexceptbranch does is emit the audit record. That is the one people forget, and the one you need when something has gone wrong. - Step metrics. Latency, success flag, summarized arguments and result, error code and error details.
The same call writes a cost entry with billing_basis="requests", the provider,
and source_port="tools" — so "what did this agent's MCP tools cost this month"
is a query you can drill into by agent, workflow, tool, and source
(source_kind=plugin | mcp | builtin).
On top of that, an OpenTelemetry span soit.tool.invoke carrying tenant,
workspace, run and step ids, for whatever APM you already run.
5. Can you replay it?
The most frustrating property of agent debugging is that it does not reproduce.
Same input, different reasoning.
At the tool layer you can at least be deterministic. Every tool call in SOIT
carries an idempotency key, defaulting to tool:{run_id}:{tool_call_id}, and
claims a leased execution record. If the claim lands on a record that already
completed, the cached response comes straight back and the external tool is
not called again.
The retry policy changes accordingly. The comment says it better than I can:
max_retries=1 if kwargs.get("idempotency_key") else self.max_retries,
Durable Agent calls are at-most-once at this boundary.
Not every downstream adapter can honor an idempotency key.
At-most-once at this boundary, because you cannot assume the MCP server on the
other end honors your idempotency key. Better to call once too few than once too
many — for writes, that trade is not really a choice.
Rate limits and daily quotas come along with it, keyed by tool_ref plus
tenant, workspace and user, so one runaway agent does not burn a whole tenant's
third-party API budget.
What this does not do
The usual honest list:
- MCP transport is streamable HTTP only, targeting the MCP SDK v1 line. The stateless 2026-07-28 protocol revision is not supported yet.
- OAuth is
client_credentialsonly, no authorization_code (see question 2). - A marketplace for one-click MCP tool installation is on the roadmap; today you install plugin artifacts by hand.
- The default egress allowlist is empty, which means your first MCP server will be refused until you add its domain explicitly. That is deliberate, but it does add a step to the quickstart.
Why none of this belongs in the agent framework
A question that comes up constantly: how does this relate to LangChain and
friends?
They are not the same layer. A framework answers "how do I orchestrate this
call." A runtime answers "under whose identity did this call run, with whose
credentials, what could it reach, what evidence did it leave, and can I replay
it." The first is a concern while you write the code. The second is a concern
after the code ships and someone else asks.
You can certainly put permission checks inside a framework, but then every new
tool integration reimplements the governance logic. Push it down into the
runtime's port layer and MCP tools, plugin tools and built-in tools all travel
the same path — which is the reason question 1 could say "nobody writes it
twice."
Try it
SOIT is Apache-2.0 and the code is all on GitHub:
- Repository: https://github.com/soit-ai/soit
- Quickstart:
docs/quickstart.mdin the repo - Governance demo:
docs/governance-demo.md— a 20-minute local script that walks through permissions, secrets, call audit, cost attribution, replay and regression, and writes a machine-readable report at the end
If you are pushing MCP toward production right now, I would genuinely like to
hear which of the five questions is blocking you. In our experience the hardest
one is not technical — it is "who gets to decide what goes on the allowlist."
Disclosure: I maintain SOIT.
Top comments (0)