MCP tool gate
cloakpipe mcp-proxy sits between an agent and one upstream MCP server. It always masks PII in tool-call arguments and rehydrates pseudonym tokens in results. With --manifest, it also enforces certification where the agent acts: its tool calls.
| Capability | Status |
|---|---|
mcp-proxy --upstream (masking interceptor) |
Main |
cloakpipe mcp (MCP server with pseudonymize, rehydrate, detect, vault_stats, configure, session_context tools) |
Main |
Release gate (--manifest, --certification, …) and release-bound evidence |
Main |
| Action boundary: tool contracts, signed approval receipts, operation outcomes and reconciliation | Main |
Usage
cloakpipe mcp-proxy --upstream "npx -y @acme/crm-mcp" \
--manifest release.yaml --certification release.cert.dsse.json \
--trust-key "$KEYID=$PUBHEX" --environment production
| Flag | Meaning |
|---|---|
--upstream CMD |
required. Upstream MCP server command and args, e.g. --upstream "npx -y @modelcontextprotocol/server-filesystem /data" |
--manifest FILE |
the Agent Release manifest the agent runs as. Enables the tool gate: tools/call must name a tool the manifest declares |
--certification FILE |
DSSE certification envelope of the release. Without one, every tools/call is refused (enforce) or reported (warn) |
--trust FILE |
trusted signer key file (from release keygen; public part only); repeatable |
--trust-key KEYID=PUBHEX |
trusted signer inline; repeatable |
--revoked-statement HEX |
SHA-256 hex of a revoked certification statement; repeatable |
--revoked-key KEYID |
key id of a revoked signer; repeatable |
--environment ENV |
the environment the agent runs in; the certification must cover it (default production) |
--gate enforce|warn |
enforce (default) refuses calls that fail the gate; warn forwards them and reports |
How a call is decided
With --manifest, each tools/call passes only if:
- the tool is declared in the manifest’s
spec.tools(tool:refund@4declaresrefund), and --certificationverifies offline at the moment of the call: trusted signer (--trust/--trust-key), not revoked (--revoked-statement,--revoked-key), inside its validity window, about this manifest’s release, acertifieddecision, for--environment.
Because verification happens per call, a certification that expires while the proxy runs stops the next call.
Refusals
With --gate enforce, a refused call never reaches the upstream tool. The agent receives JSON-RPC error -32001 with data: {reason, tool, release} (notifications get no reply). Reasons:
reason |
Why |
|---|---|
undeclared_tool |
the manifest does not declare the tool |
uncertified |
no --certification was given |
wrong_environment |
certified, but for another environment |
blocked |
the certification is a verified BLOCKED decision |
expired, revoked, invalid, … |
the certification’s verification status |
The refusal is recorded as a release-bound mcp_tool_call hop with action block and gate_denial=<reason>. With --gate warn, the call is forwarded, the violation is logged, and the hop is marked gate_violation=<reason>.
Action boundary
Main The release gate answers “may this release call this tool at all”. The action boundary answers a narrower question: may this exact call run, decided only from trusted inputs. The model proposes a tool call; the gate checks its arguments against a signed-in contract and the orchestrator’s task context, requires a signed approval for this exact payload when the contract says so, and tracks the outcome of every side-effecting call so an uncertain one is reconciled instead of retried blindly.
Fail closed everywhere: anything unparseable, unknown, expired or ambiguous is a denial. A release with no tool contract, and a gate started without the new flags, behaves exactly as described above.
Tool contracts
A ToolContract declares, per tool, which arguments are allowed and what values they may take:
apiVersion: cloakpipe.co/v1alpha1
kind: ToolContract
metadata:
name: crm-tools
spec:
tools:
crm.patch:
sideEffect: true # track operation outcomes
requiresApproval: true # needs a signed approval receipt
arguments: # closed by default: unknown argument -> denial
tenant: { bindTo: task.tenant }
record: { bindTo: task.record }
patch:
type: object
minProperties: 1
additionalProperties: false
properties:
stage: { enum: [qualified, review] }
note: { type: string, maxLength: 280 }
required: [tenant, record, patch]
The release binds the contract through its existing spec.policies list as policy:<name>@sha256:<hex>, where the hex is SHA-256 over the domain string cloakpipe.co/tool-contract/v1, a newline, and the RFC 8785 (JCS) bytes of the contract. The contract is therefore part of the certified release hash, with no manifest schema change.
The constraint language is a small, deterministic subset of JSON Schema: type (string, integer, number, boolean, object, array, null), enum, const, minLength / maxLength (counted in Unicode scalar values), pattern (anchored full match), minimum / maximum, properties, required, additionalProperties (default false), minProperties / maxProperties, items, minItems / maxItems, plus one extension:
bindTo: task.<key>: the value must equal (JSON equality) the trusted task context’s value for that key. A missing task key is a denial.
Unknown keywords in a contract are a parse error, never ignored. Tools declared in the manifest’s spec.tools but absent from every contract keep the release-gate behaviour.
Trusted task context
The orchestrator writes a JSON object (flat or nested) describing the task the agent is working on, for example:
{"taskId": "t-981", "tenant": "demo-co", "record": "lead-42"}
Pass it with --task-context <file.json> or CLOAKPIPE_TASK_CONTEXT=<path>. It never comes from MCP messages: _meta, tool arguments and anything else the agent sends cannot change it.
Signed approval receipts
For a tool with requiresApproval: true, the gate needs a valid approval receipt for this exact call. The call is identified by its argument fingerprint: SHA-256 over cloakpipe.co/tool-call/v1, a newline, and the JCS bytes of {"release": "<sha256:…>", "tool": "<name>", "arguments": <raw args>}.
cloakpipe action fingerprint --manifest release.yaml --tool crm.patch --args call.json
cloakpipe release keygen --out approver.key
cloakpipe approve --manifest release.yaml --tool crm.patch --args call.json \
--task-context task.json --key approver.key \
[--expires 15m] [--operation-id <id>] --out approvals/
approve writes approvals/<fingerprint-hex>.approval.json: a DSSE envelope (Ed25519, payload type application/vnd.in-toto+json) holding an in-toto statement with predicate type https://cloakpipe.co/attestations/approval/v1alpha1, the fingerprint as subject, and the predicate { release, tool, taskId, operationId, approver, approvedAt, notAfter }. --operation-id defaults to a fresh UUID v4.
The gate looks up <fingerprint-hex>.approval.json in --approvals and checks: a signature by a trusted --approver-key, notAfter not passed (and approvedAt not in the future beyond the usual clock-skew allowance), the release and tool equal the gate’s, the subject equals the recomputed fingerprint, and taskId equals the task context’s taskId. Any change to the arguments gives a different fingerprint, so no receipt is found. A receipt is not single-use on its own; replaying an approved call is handled by its operation ID (below).
Operation identity and outcomes
For a tool with sideEffect: true, the gate keeps a durable record of each operation in a SQLite store (--operations-db <path> or CLOAKPIPE_OPERATIONS_DB). The store is required when any contract tool has sideEffect: true.
The operation ID comes from the approval receipt’s operationId when there is one; otherwise it is op- followed by the first 32 hex characters of SHA-256 over cloakpipe.co/operation/v1, a newline, the taskId, a newline, and the fingerprint hex. The model never supplies it.
Before forwarding a call, the gate checks the stored operation in one transaction:
| Stored state | What the gate does |
|---|---|
| none | records in_flight and forwards the call |
| any, with a different call fingerprint | denies operation_conflict |
done |
does not forward; answers with the stored result, marked _meta.cloakpipe.replayed = true |
in_flight or unknown |
denies reconcile_before_retry |
failed or not_applied |
allows a retry and sets it back to in_flight |
A result without isError: true settles the operation as done; isError: true or a JSON-RPC error settles it as failed. If the upstream exits or the pipe breaks while operations are in_flight, they become unknown, and so does any in_flight row left by a previous process at startup.
MCP has no standard way to ask a server whether a call took effect, so reconciliation is an explicit operator step:
cloakpipe operations list --state unknown --operations-db ops.db
cloakpipe operations reconcile <operation-id> --outcome done --operations-db ops.db --ledger-db ledger.db
--outcome is done or not-applied. reconcile refuses unless the operation is unknown, or in_flight with --force (an in_flight call may still be running, so check the real system first).
Only one gate can use an operations database at a time: a second gate on the same file refuses to start (exit 2). list and reconcile work while a gate runs. Every request gets an answer: if the upstream exits before replying, the agent receives error -32002 with reason operation_unknown, or operation_reconciled with the recorded state when an operator already settled it. The database keeps the masked upstream result for replay, so it is created owner-only (0600); set a retention policy for it.
Flags
| Flag | Meaning |
|---|---|
--tool-contract FILE |
a ToolContract; repeatable. Each one’s digest must appear in the release’s spec.policies, and every tool-contract policy the release references must be supplied, or the gate refuses to start (exit 2) |
--task-context FILE |
the trusted task context (or CLOAKPIPE_TASK_CONTEXT) |
--approvals DIR |
directory of approval receipts named <fingerprint-hex>.approval.json |
--approver-key KEY |
a trusted approver public key; repeatable |
--operations-db PATH |
the operations store (or CLOAKPIPE_OPERATIONS_DB); required when a contract tool has sideEffect: true |
Denials
Action boundary denials use the same JSON-RPC error -32001 and block hop as the release gate, except reconcile_before_retry, which is error -32002 with data.operationId. Codes are stable:
| Code | Why |
|---|---|
argument_missing |
a required argument is absent |
argument_not_allowed |
an argument the contract does not declare (its name is not echoed) |
argument_invalid |
a value breaks a rule; carries the contract path, such as /patch/stage, never the value |
scope_mismatch |
a bindTo value differs from the task context |
task_context_missing |
no task context, or it lacks a bound key |
approval_required |
no receipt for this exact call |
approval_invalid |
bad signature, or wrong release, tool, task or subject |
approval_expired |
notAfter has passed |
operation_conflict |
the operation ID is already used by a different call |
reconcile_before_retry |
the previous attempt is in_flight or unknown |
Evidence
The ledger stays free of PII: it never holds raw argument values, or a plain hash of them. A denial hop carries gate_denial=<code> and, where it applies, gate_path=<contract path>. An approved call’s mcp_tool_call hop carries approval=<sha256 of the receipt envelope> and operation_id; the receipt stays with the operator, and an auditor matches it to the ledger by digest. The mcp_tool_result hop carries operation_id and operation_state (done or failed; unknown when written by the process-exit sweep). Reconciliation appends an operation_reconciled hop with operation_id and operation_state (done or not_applied).
To check that a release neither under-blocks nor over-refuses at this boundary, certify it with a boundary rule.
Fail-closed behaviour
- With a gate, only a message it can read in full is forwarded, re-serialized (never the raw line). In both modes it refuses, with a JSON-RPC error (
id: null) and ablockhop:- lines that do not parse:
unreadable,-32700(out-of-range numbers, lone surrogates, deep nesting that laxer upstream parsers accept), - batches:
batch, - non-objects:
not_an_object, - case variants of JSON-RPC member names such as
"Method"or"Name":noncanonical(some decoders match keys case-insensitively).
- lines that do not parse:
--manifestwithout a certification refuses every call.- These refuse to start: any gate flag without
--manifest, a malformed--revoked-statement, an uncertifiable manifest, an unreadable envelope, or aCLOAKPIPE_RELEASEnaming another release.
Evidence
The manifest also binds every evidence hop to its release: each ledger hop carries release_hash inside its signed, hash-chained bytes. Without --manifest, set CLOAKPIPE_RELEASE=sha256:… for the same binding; a malformed value stops the interceptor from starting. See Evidence & verification.
Limits
MCP server identity (spec.mcpServers) is not checked: the gate fronts the one upstream it was started with. Run one mcp-proxy per upstream server.
Source
The interceptor and MCP server are in crates/cloakpipe-mcp on main, including the gate (gate.rs).