The MCP proxy¶
qed mcp sits between an agent and an MCP server. It speaks MCP to the agent
on one side and MCP to the upstream on the other, and asks the kernel about
every tools/call in between.
qed keys init --dir .qed
qed mcp --human you@example.com --env prod \
--target-template 'db:{env}/{args.table}' \
-- npx -y @some/mcp-server
Then point the agent at qed mcp ... instead of at the server. For a remote
server, --upstream-url https://host/mcp replaces everything after --.
Nothing about the agent changes. initialize reports the upstream's own
serverInfo, and the proxy adds one line to instructions rather than renaming
the server — an agent whose tool list suddenly came from qed-proxy would have
to be reconfigured to be governed, and the point is that it does not.
Shadow first¶
The default mode is shadow. Every call is decided, scored and written to the
evidence chain, and then forwarded regardless of the verdict. Nothing is
blocked. That is deliberate and it is the whole onboarding story:
- Run in shadow for a week. The agent behaves exactly as before.
- Read what would have happened:
qed export --format oscal, orverdict.would_decisionin the chain. - Tune the policy against real traffic instead of against a guess.
- Switch
mode: enforceinqed.yamlwhen the answer stops surprising you.
Only step 4 can break anything, and by then you have the evidence to argue about, which is why shadow is the default and enforcing is a separate, deliberate change.
A shadow record carries mode: shadow. It proves the decision would have been
made; it does not prove the action was prevented. Both live in the same chain,
distinguished by that one field, so "we have been observing since this date" is
demonstrable rather than claimed.
Why --human or --mandate is required¶
The proxy refuses to start without one. This looks over-strict and is not.
evaluate short-circuits to a synthetic AG-IDN-007 BLOCK when a proposal
has neither a human principal nor a mandate — an agent acting on nobody's
authority. That is the right verdict, but it is reached before any rule in the
bundle is evaluated. A proxy that left identity unset would write a chain of
identical identity blocks in which no policy rule had ever run — and in shadow
mode nothing would be stopped and nothing would look wrong. You would discover
it in week 10, when enforcement began blocking everything.
So identity is explicit:
--human you@example.com— attended. Hashed withqed.core.evidence.hashing.principal_hash, the same function week 3's token verification will use, so a chain written across both attributes to one person rather than two.--issuernames the identity provider.--mandate signed.json— unattended. Passed to the kernel as-is.
--human is the adapter's own assertion. Nothing has verified it. It is
recorded in principal.client_fingerprint as adapter-asserted:--human:<issuer>
— a field SPEC §3.1 documents as advisory and spoofable, and which no rule is
allowed to enforce on. Verified identity is available with JWKS and the
token exchange. The proxy says this on stderr every time it starts.
What the adapter derives, and what the kernel decides¶
The protocol hands the proxy a tool name and a JSON object. Neither says "this writes" or "this deletes", so the adapter derives the three hints SPEC §3.1 asks for and the kernel does the rest.
Derived by the adapter (qed/adapters/mcp/intent.py) |
Decided by the kernel |
|---|---|
writes, deletes from the verb, in the name and in the arguments |
the 0–100 blast-radius score |
scope: single_resource, multi_resource, account |
which rules match |
action.target from the configured template |
ALLOW / BLOCK / REQUIRE_APPROVAL |
The adapter reads the arguments, not just the tool name. This is the specific
trap the week 1 review carried forward: Valqore's MCP gateway scored
execute_sql carrying DROP TABLE orders as low risk, because execute is a
mild verb and the destructive part was in the parameters. Both are deterministic
text the proxy already has, so both are read.
scope never returns global. A proxy cannot tell from one JSON object that a
call covers an entire estate, and AG-BLR-001 blocks account-or-global scope in
production at CRITICAL — a hint that guessed would make every wildcard call a
critical block on the adapter's say-so. account is the ceiling.
Target templates¶
action.target is what target_globs in a rule matches on. A tool name is not
a target — execute_sql says nothing about which database — so you supply a
template per upstream:
db:{env}/{args.table} -> db:prod/orders
aws:iam:roles/{args.role} -> aws:iam:roles/deploy
k8s:{args.filter.namespace} -> k8s:kube-system
{server}:{tool} -> orders:execute_sql (the default)
{args.<path>} walks dotted paths and list indexes. An argument the call did
not supply becomes -absent- rather than raising: one missing argument must not
take the call out of the chain. Substituted values are sanitised, so a tool
argument — which is attacker-influenced input — cannot reshape the target into
something a policy glob was not written for.
A bad template is a startup error, not a surprise halfway through a session with half the chain written against the wrong target.
What the agent sees when a call is refused¶
In enforce mode, a refused call comes back as an MCP tool error, which is what a client shows to its model:
[qed BLOCK] AG-DAT-001: The parameters of this action contain secret material.
Fix: Reference the secret from the credential broker instead of passing it inline.
[qed APPROVAL PENDING] AG-BLR-003: Destructive change to a production resource.
Fix: Run it against staging first, or request approval with an explicit intent.
Approval apr_01J8… is open; `qed approvals` lists it. Retry once agreed.
The id is in the message because the kernel files the request before it
answers: check_proposal writes an approval_requested record with the id, the
approvers and the expiry, whether or not a Slack channel is configured. So
there is always something for the agent — or the person reading its output — to
wait for, and something for qed approve to resolve.
SPEC §12.1 also wants the expiry in this message, and it is not here. It lives
on the stored request and on the record, and a record held in the HA outbox has
no chained copy to read it from, so putting it in this string means either a
store lookup on the decision path or a new field on Verdict. qed approvals
shows it. Until that is decided, this message names what it can prove.
Resources and prompts¶
resources/read and prompts/get are forwarded, recorded, and never
refused — SPEC §12.1 promises passthrough for them in v1. The verdict is still
computed and written, so you can see what a rule would do to resource reads
before v2 starts enforcing them, but it is not acted on even in enforce mode. A
block that silently started applying because someone flipped mode: enforce
would break that promise.
They appear in the chain as orders.resources/read and orders.prompts/get.
Fail mode¶
--fail-mode governs what happens when the kernel fails — a locked database,
a full disk, a bug. Not an upstream failure, which is simply reported.
closed(default): the call is refused.open: the call is forwarded, and anungated_windowrecord is written with the reason and the tool name.
Either way the gap is a record, not silence. An auditor reading the chain sees a
window with a reason and a count rather than a quiet day. That is why
ungated_window exists (ADR-003), and why
tests/adapters/test_import_boundary.py fails the build if any broad except
in the proxy neither logs, records nor refuses.
What is not here yet¶
- Tool-list filtering by token scope. SPEC §12.1 filters
tools/listagainst the token'smcp:<server>:<tool>scopes. Not implemented: the list is forwarded whole, so a tool the token may not use is visible and refused when called rather than hidden. Hiding it would be better, because a model that cannot see a tool does not try to use it. - ~~Credential injection.~~ Built. On ALLOW the proxy forwards with the
broker's credential when one is configured (
qed/core/broker/), andinjected_envon the record says which name was used rather than which value. upstreams.yaml. Target templates come from--target-templatefor now. The file arrives with the broker, which is what needs the rest of it.