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 │ └─────────────────────────────────┘The pattern
Section titled “The pattern”- MCP server advertises a required scope per tool. When the client lists tools, the server
includes a
_meta.ratify_scopefield on each tool definition. - Client sends a Ratify proof bundle along with the tool call. Embedded in the request’s
_metafield (MCP-spec extension). - Server verifies before executing. Standard verifier algorithm; reject with an MCP error on any failure.
Tool advertisement
Section titled “Tool advertisement”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.”
Tool call with proof
Section titled “Tool call with proof”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": "..." } } } }}Server-side verification
Section titled “Server-side verification”from mcp.server import Serverfrom ratify_protocol import verify_bundle, IdentityStatus, ProofBundle
server = Server("my-mcp-server")
# Each tool has an associated required scopeTOOL_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)import { Server } from "@modelcontextprotocol/sdk/server";import { verifyBundle, IDENTITY_STATUS_VALID, type ProofBundle } from "@identities-ai/ratify-protocol";
const TOOL_SCOPES: Record<string, string> = { send_email: "email:send", list_inbox: "email:read",};
const server = new Server("my-mcp-server");
server.setRequestHandler("tools/list", async () => ({ tools: Object.entries(TOOL_SCOPES).map(([name, scope]) => ({ name, description: "...", inputSchema: {}, _meta: { ratify_scope: scope }, })),}));
server.setRequestHandler("tools/call", async (request) => { const { name, arguments: args, _meta } = request.params; const proof = _meta?.ratify_proof as ProofBundle | undefined;
if (!proof) { throw new Error("missing_ratify_proof"); }
const required = TOOL_SCOPES[name]; if (!required) throw new Error("unknown_tool");
const result = await verifyBundle(proof, { required_scope: required });
if (result.identity_status !== IDENTITY_STATUS_VALID) { throw new Error(`ratify_${result.identity_status}`); }
return await executeTool(name, args, { principal_id: result.principal_id });});The challenge problem
Section titled “The challenge problem”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_atis 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 toolThis 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.
Scope-aware tool exposure
Section titled “Scope-aware tool exposure”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.
Common pitfalls
Section titled “Common pitfalls”| Pitfall | Fix |
|---|---|
| Server verifies once at MCP handshake, then trusts subsequent requests | Verify on every tools/call — delegations can expire mid-session |
Tool scope hardcoded in server logic, not advertised in _meta.ratify_scope | Advertise the scope so the client/host can pre-filter |
| Returning generic “unauthorized” on Ratify failure | Return 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 performs | Keep TOOL_SCOPES next to the tool implementation; CI test that every tool has a registered scope |
Where to next
Section titled “Where to next”- 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:promptare also defined)