Skip to content

Model Context Protocol (MCP)

The Model Context Protocol (MCP) lets AI agents call tools, read resources, and use prompts hosted by MCP servers. By default, MCP has no authorization model — any client that can reach the server can invoke any exposed tool. Ratify adds the missing layer: prove that a specific human authorized this agent to use this tool, with these scopes, for this long.

┌─────────────────────────────────┐
│ MCP Host (Claude Desktop, │
│ Cursor, etc.) — the AI app │
└────────────────┬────────────────┘
│ runs MCP client
┌─────────────────────────────────┐
│ AI Agent │
│ Holds: bundle{ │
│ delegations: [cert], │
│ challenge_sig: ... │
│ } │
└────────────────┬────────────────┘
│ MCP tools/call
│ + Ratify proof bundle
┌─────────────────────────────────┐
│ MCP Server (your code) │
│ │
│ Before invoking the tool: │
│ - issue challenge │
│ - verify_bundle(...) │
│ - check required scope │
│ On valid → run the tool │
│ On invalid → return MCP error │
└─────────────────────────────────┘
  1. MCP server advertises a required scope per tool. When the client lists tools, the server includes a _meta.ratify_scope field on each tool definition.
  2. Client sends a Ratify proof bundle along with the tool call. Embedded in the request’s _meta field (MCP-spec extension).
  3. Server verifies before executing. Standard verifier algorithm; reject with an MCP error on any failure.

When the MCP server lists tools, it adds _meta.ratify_scope:

{
"tools": [
{
"name": "send_email",
"description": "Send an email on the user's behalf",
"inputSchema": { /* ... */ },
"_meta": {
"ratify_scope": "email:send",
"ratify_sensitive": true
}
},
{
"name": "list_inbox",
"description": "Read the user's inbox",
"inputSchema": { /* ... */ },
"_meta": {
"ratify_scope": "email:read"
}
}
]
}

The client can decide upfront: “the user’s proof bundle covers email:read but not email:send. Don’t even expose send_email to the model.”

The client wraps the proof in the MCP request’s _meta:

{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "send_email",
"arguments": { /* ... */ },
"_meta": {
"ratify_proof": {
"agent_id": "b4a4c71795d676b6",
"agent_pub_key": { "ed25519": "...", "ml_dsa_65": "..." },
"delegations": [ /* cert chain */ ],
"challenge": "Zx8t4vQrM2...",
"challenge_at": 1800000000,
"challenge_sig": { "ed25519": "...", "ml_dsa_65": "..." }
}
}
}
}
from mcp.server import Server
from ratify_protocol import verify_bundle, IdentityStatus, ProofBundle
server = Server("my-mcp-server")
# Each tool has an associated required scope
TOOL_SCOPES = {
"send_email": "email:send",
"list_inbox": "email:read",
}
@server.list_tools()
async def list_tools():
return [
{
"name": name,
"description": "...",
"inputSchema": {},
"_meta": {"ratify_scope": scope},
}
for name, scope in TOOL_SCOPES.items()
]
@server.call_tool()
async def call_tool(name: str, arguments: dict, meta: dict | None = None):
# 1. Extract the proof bundle
proof_data = (meta or {}).get("ratify_proof")
if not proof_data:
raise McpError("missing_ratify_proof", "All tools require a Ratify proof")
bundle = ProofBundle.from_dict(proof_data)
# 2. Verify against the tool's required scope
required = TOOL_SCOPES.get(name)
if not required:
raise McpError("unknown_tool", name)
result = verify_bundle(bundle, required_scope=required)
if result.identity_status != IdentityStatus.VALID:
raise McpError(
f"ratify_{result.identity_status.value}",
result.error_reason or "authorization failed",
)
# 3. Execute the actual tool
return await execute_tool(name, arguments, principal_id=result.principal_id)

MCP is stateless from the server’s perspective — the client could come and go between calls. There’s no natural place to put a long-lived challenge protocol. Two solutions:

Option A: Client supplies its own challenge (simpler)

Section titled “Option A: Client supplies its own challenge (simpler)”

The client generates a fresh challenge per request, signs it, and sends both. The server checks that the challenge timestamp is fresh (within ~30 seconds for high-assurance tools) and the signature verifies. No server-side challenge state.

This is acceptable for most cases because:

  • The agent can’t replay (challenge_at is timestamped and the freshness window is tight)
  • The server doesn’t need to track per-client challenge state
  • Network round-trips stay at 1 (request + response)

Option B: Server issues challenge first (stricter)

Section titled “Option B: Server issues challenge first (stricter)”

For sensitive tools (email:send, payment:initiate, etc.), the server can require an explicit challenge round:

1. Client: tools/call request WITHOUT proof
2. Server: rejects with `ratify_challenge_required`, includes a fresh nonce
in the error's _meta.ratify_challenge field
3. Client: re-issues tools/call WITH a proof bundle whose challenge matches the
server's nonce
4. Server: verifies; on success executes the tool

This doubles the round-trip cost but gives the server tight control: it knows the challenge is fresh because it issued it 50ms ago. Use Option B for ratify_sensitive: true tools, Option A for everything else.

The MCP host (Claude Desktop, Cursor, etc.) can read the _meta.ratify_scope from the tool list and refuse to even surface tools the agent’s bundle can’t cover. This is a UX win:

User: "Send an email to my team about the meeting"
Host: (checks bundle's effective scope, sees [email:read] but not [email:send])
Host: "I have your delegation for reading email but not sending. Want to
re-authorize with email:send scope?"

This is much better than letting the model attempt the call and discover via an error response.

PitfallFix
Server verifies once at MCP handshake, then trusts subsequent requestsVerify on every tools/call — delegations can expire mid-session
Tool scope hardcoded in server logic, not advertised in _meta.ratify_scopeAdvertise the scope so the client/host can pre-filter
Returning generic “unauthorized” on Ratify failureReturn the specific identity_status so the client can ask the user to re-authorize for the right scope
Tool’s required scope drifts from the actual action it performsKeep TOOL_SCOPES next to the tool implementation; CI test that every tool has a registered scope
  • Agent-to-Agent (A2A) — when one MCP agent delegates to another
  • API Gateway — the same pattern applied to general REST APIs
  • Scopes — the canonical scope vocabulary (mcp:tool, mcp:resource, mcp:prompt are also defined)