Status: discussion draft
Author: Interlock
Goal: shared evidence envelope for MCP runtime security
MCP servers, hosts, and gateways are starting to split security responsibility across different layers:
- The MCP spec defines identity, OAuth/scopes, consent, sampling, and elicitation.
- Hosts can provide user approval and local allow/deny controls.
- Servers can expose scoped tools, coarse safety modes, and tool-call evidence.
- Gateways can enforce cross-server policy, inspect arguments and responses, run shadow mode, aggregate audit logs, and send events to SIEM or review workflows.
This draft proposes a minimal receipt envelope that lets MCP servers emit structured evidence and lets gateways like Interlock reference that evidence when making runtime decisions.
The goal is not to make every MCP server a policy engine. The goal is to make server-side evidence stable enough that a gateway can consume, correlate, enforce, and audit it without re-deriving everything from tool names and raw payloads.
Server emits evidence. Gateway makes cross-server decisions. Audit log links both.
MCP Server Receipt
|
v
Interlock Gateway Decision
|
v
Audit Log / SIEM / Shadow Mode / Review Queue
The key lineage should be:
server_receipt_id -> gateway_decision_id -> audit_event_id
This draft does not try to define:
- A full policy language.
- A UI format.
- A replacement for OAuth scopes.
- A replacement for host-level approval.
- A server-specific authorization model.
- A guarantee that server-provided metadata is trusted policy truth.
Gateway policy should still treat receipts as evidence, not as final authority.
This draft separates two related records:
McpSecurityReceipt: emitted by an MCP server or host boundary.GatewayDecisionReceipt: emitted by a gateway such as Interlock.
The gateway receipt references the server receipt instead of duplicating or rewriting the server's evidence.
{
"receipt_id": "mcp_rcpt_01HV...",
"schema_version": "0.1",
"issued_at": "2026-05-19T08:15:30Z",
"server_id": "roam-code-local",
"tool_name": "apply_patch",
"tool_call_id": "call_01HV...",
"client_id": "claude-code",
"actor_ref_id": "user_123",
"run_id": "run_01HV...",
"run_event_id": "event_42"
}These fields let a gateway connect one tool call to a user, client, run, and server.
| Field | Type | Required | Description |
|---|---|---|---|
receipt_id |
string | yes | Unique receipt identifier. |
schema_version |
string | yes | Receipt schema version. |
issued_at |
string | yes | RFC 3339 timestamp. |
server_id |
string | yes | Stable MCP server identifier. |
tool_name |
string | yes | MCP tool name. |
tool_call_id |
string | yes | Unique tool-call identifier if available. |
client_id |
string | recommended | MCP client or host identifier. |
actor_ref_id |
string | recommended | User, service account, or actor reference. |
session_id |
string | optional | Session identifier. |
run_id |
string | recommended | Agent run identifier. |
run_event_id |
string | recommended | Event identifier within a run. |
These fields describe what the server believes the tool is capable of doing.
| Field | Type | Required | Description |
|---|---|---|---|
declared_side_effects |
string[] | recommended | Effects such as read, write, delete, execute, share, export, message. |
side_effect |
string | recommended | Summary severity, such as read_only, mutating, destructive. |
data_classes |
string[] | recommended | Data classes such as internal, pii, secrets, financial, source_code. |
externality |
string | recommended | internal, external, or third_party. |
required_scopes |
string[] | optional | Server or OAuth scopes required by the tool. |
mode |
string | optional | Runtime mode, such as read_only, safe_edit, migration, autonomous_pr. |
tool_schema_hash |
string | recommended | Hash of canonical tool input schema. |
tool_description_hash |
string | optional | Hash of canonical tool description. |
These fields allow audit and verification without requiring raw sensitive payloads to be copied into every system.
| Field | Type | Required | Description |
|---|---|---|---|
input_hash |
string | recommended | SHA-256 hash of canonical JSON input. |
argument_keys |
string[] | recommended | Top-level argument keys used in the call. |
input_ref |
string | optional | Reference to stored input evidence, if available. |
output_hash |
string | optional | SHA-256 hash of canonical output. |
output_ref |
string | optional | Reference to stored output evidence, if available. |
redactions |
object[] | recommended | Redaction records applied to input or output. |
Example redaction record:
{
"field": "output.text",
"class": "secret",
"method": "pattern",
"replacement": "[REDACTED_SECRET]"
}These fields describe any decision the server or host already made before the gateway sees the event.
| Field | Type | Required | Description |
|---|---|---|---|
policy_decision |
string | optional | allow, deny, monitor, redact, quarantine, or unknown. |
policy_reason |
string | optional | Human-readable reason. |
matched_rule |
string | optional | Server-side rule or mode that matched. |
policy_version |
string | optional | Server policy version. |
override_used |
boolean | optional | Whether a local override was used. |
shadow_mode |
boolean | optional | Whether decision was observed but not enforced. |
These fields let a gateway and later auditor check whether the receipt is trustworthy and whether the run ledger was tampered with.
| Field | Type | Required | Description |
|---|---|---|---|
receipt_signature |
string | optional | Signature or HMAC over canonical receipt content. |
signature_alg |
string | optional | Algorithm identifier. |
ledger_id |
string | optional | Run ledger identifier. |
ledger_seq |
integer | optional | Receipt sequence number in ledger. |
previous_hash |
string | optional | Previous ledger entry hash. |
verification_status |
string | optional | ok, unsigned, tampered, empty, or unknown. |
The gateway creates its own receipt after applying cross-server policy, argument inspection, response scanning, drift checks, and audit rules.
{
"gateway_decision_id": "gw_dec_01HV...",
"schema_version": "0.1",
"issued_at": "2026-05-19T08:15:31Z",
"server_receipt_id": "mcp_rcpt_01HV...",
"gateway_id": "interlock-prod",
"server_id": "roam-code-local",
"tool_name": "apply_patch",
"actor_ref_id": "user_123",
"role": "readonly_agent",
"action": "deny",
"matched_rule": "readonly_agent_no_mutating_tools",
"reason": "readonly_agent cannot call tools with declared write side effects",
"shadow_mode": false,
"audit_event_id": "audit_01HV..."
}| Field | Type | Required | Description |
|---|---|---|---|
gateway_decision_id |
string | yes | Unique gateway decision identifier. |
schema_version |
string | yes | Gateway receipt schema version. |
issued_at |
string | yes | RFC 3339 timestamp. |
server_receipt_id |
string | recommended | Referenced MCP server receipt. |
gateway_id |
string | recommended | Gateway instance identifier. |
server_id |
string | yes | MCP server identifier. |
tool_name |
string | yes | MCP tool name. |
actor_ref_id |
string | recommended | Actor reference used by gateway policy. |
role |
string | recommended | Gateway role or persona, such as readonly_agent. |
action |
string | yes | allow, deny, monitor, quarantine, or redact. |
matched_rule |
string | optional | Gateway policy rule that matched. |
reason |
string | recommended | Human-readable reason. |
policy_version |
string | optional | Gateway policy version. |
metadata_confidence |
number | optional | Gateway confidence in normalized metadata. |
warnings |
string[] | optional | Metadata, drift, receipt, or scan warnings. |
argument_scan_result |
object | optional | Summary of argument inspection. |
response_scan_result |
object | optional | Summary of response scanning. |
drift_result |
object | optional | Baseline drift result, if applicable. |
shadow_mode |
boolean | optional | Whether the decision was observed only. |
audit_event_id |
string | recommended | Linked audit event. |
An MCP tool was previously read-only. A later version adds external sharing capability. The server emits a receipt that declares the tool's side effects and evidence hashes. Interlock compares the tool against its stored baseline, detects risky drift, quarantines the tool, and writes a gateway decision that references the original server receipt.
{
"receipt_id": "mcp_rcpt_roam_0001",
"schema_version": "0.1",
"issued_at": "2026-05-19T08:15:30Z",
"server_id": "roam-code-local",
"tool_name": "publish_artifact",
"tool_call_id": "call_0001",
"client_id": "claude-code",
"actor_ref_id": "user_maaz",
"run_id": "run_20260519_001",
"run_event_id": "event_0042",
"declared_side_effects": ["read", "export", "share"],
"side_effect": "mutating",
"data_classes": ["source_code", "internal"],
"externality": "external",
"required_scopes": ["repo.read", "artifact.write", "external.share"],
"mode": "safe_edit",
"tool_schema_hash": "sha256:9d3c...",
"input_hash": "sha256:1a7b...",
"argument_keys": ["path", "destination", "visibility"],
"output_hash": "sha256:49cc...",
"redactions": [],
"policy_decision": "allow",
"policy_reason": "safe_edit mode permits artifact publishing",
"matched_rule": "safe_edit_publish_artifact",
"policy_version": "roam-policy-2026-05-19",
"override_used": false,
"shadow_mode": false,
"ledger_id": "roam_run_20260519_001",
"ledger_seq": 42,
"previous_hash": "sha256:abc1...",
"verification_status": "ok"
}{
"gateway_decision_id": "gw_dec_interlock_0001",
"schema_version": "0.1",
"issued_at": "2026-05-19T08:15:31Z",
"server_receipt_id": "mcp_rcpt_roam_0001",
"gateway_id": "interlock-local",
"server_id": "roam-code-local",
"tool_name": "publish_artifact",
"actor_ref_id": "user_maaz",
"role": "developer_agent",
"action": "quarantine",
"matched_rule": "high_risk_externality_drift",
"reason": "Tool added external share/export capability compared with stored baseline",
"policy_version": "interlock-policy-0.1",
"metadata_confidence": 0.91,
"warnings": ["tool capability drift detected", "external sharing added"],
"argument_scan_result": {
"status": "pass",
"argument_keys": ["path", "destination", "visibility"]
},
"response_scan_result": {
"status": "not_run",
"reason": "gateway-mediated tool call held before upstream forwarding"
},
"drift_result": {
"severity": "critical",
"action": "quarantine",
"changes": ["externality_changed_to_external", "effect_added_share", "scope_added_external_share"]
},
"shadow_mode": false,
"audit_event_id": "audit_interlock_0001"
}With this receipt model, a gateway can:
- Avoid guessing tool intent only from names and descriptions.
- Correlate tool calls across multiple MCP servers.
- Link server-side evidence to gateway-side enforcement.
- Preserve input/output evidence without always storing raw payloads.
- Run shadow mode and compare what would have been blocked.
- Detect drift between discovered baselines and runtime behavior.
- Produce auditable allow, deny, monitor, redact, and quarantine events.
Response scanning is a strong gateway-owned use case because correct identity and scopes do not prevent malicious or sensitive data from crossing the boundary in tool output.
Example:
1. Server emits McpSecurityReceipt with output_hash/output_ref.
2. Gateway retrieves or observes output.
3. Gateway response scanner detects token-like data or prompt-injection content.
4. Gateway redacts, denies, monitors, or quarantines the response.
5. GatewayDecisionReceipt references the original server receipt.
6. Audit log shows both the server evidence and gateway decision.
This keeps the server responsible for emitting evidence and keeps the gateway responsible for cross-server output policy and audit aggregation.
- Should
McpSecurityReceiptbe emitted before upstream forwarding, after an observed response, or both? - Should receipt signing be required, recommended, or left to high-assurance servers?
- Should
declared_side_effectsuse a fixed vocabulary? - Should
data_classesuse a fixed vocabulary or allow vendor-specific extensions? - How should gateways handle missing, unsigned, or conflicting server receipts?
- Should shadow-mode decisions live in the server receipt, gateway receipt, or both?
- Should output references be pull-based, push-based, or implementation-specific?
allowdenymonitorredactquarantine
readwritedeleteexecuteshareexportmessagenetwork
read_onlymutatingdestructive
internalexternalthird_party
okunsignedtamperedemptyunknown
Compare this draft with existing McpDecisionReceipt implementations and reduce it to the smallest stable envelope that servers and gateways can both support.
The immediate integration target for Interlock would be:
Consume server receipt -> normalize metadata -> apply gateway policy -> scan response -> write gateway decision -> link audit event