Appendix · Claude
MCP Cheat Sheet
Model Context Protocol architecture, primitives, transports, lifecycle, security, server authoring in Python and TypeScript, and when to use MCP versus alternatives.
Architecture
┌──────────────── Host (Claude Desktop / Claude Code / your app) ────────────────┐│ ││ ┌─────────────┐ 1:1 ┌─────────────┐ ┌─────────────┐ ││ │ MCP Client │◄──────────►│ MCP Server │ │ MCP Server │ ││ │ (per server)│ JSON-RPC │ github │ │ postgres │ ││ └─────────────┘ └─────────────┘ └─────────────┘ ││ ▲ transport: stdio transport: Streamable HTTP ││ │ (local subprocess) (remote, OAuth 2.1) ││ Claude model ── sees tools/resources/prompts exposed by all servers │└────────────────────────────────────────────────────────────────────────────────┘- Host embeds one client per server; clients speak JSON-RPC 2.0 to servers.
- Servers expose capabilities; the host decides what the model sees and enforces permissions.
Primitives
| Primitive | Controlled by | What it is | Example |
|---|---|---|---|
| Tools | Model | Callable functions with JSON Schema inputs | create_issue, query_db |
| Resources | Application | Read-only data addressed by URI | file:///docs/spec.md, db://schema |
| Prompts | User | Reusable prompt templates with arguments | /summarise-pr |
| Sampling | Server → client | Server asks the host’s model to complete something | Server-side summarisation |
| Roots | Client → server | Filesystem/URI boundaries the server may operate in | Project directory |
| Logging / progress | Server → client | Diagnostics and long-task progress | Indexing progress |
Lifecycle
client ──initialize (protocolVersion, capabilities, clientInfo)──► serverclient ◄──result (capabilities, serverInfo)────────────────────── serverclient ──notifications/initialized──────────────────────────────► serverclient ──tools/list ──► server client ──resources/list ──► serverclient ──tools/call {name, arguments} ──► server ──► result {content[], isError}Capability negotiation at initialize tells each side which primitives (tools, resources, prompts, sampling, logging) are supported.
Transports
| Transport | Where | Auth | Notes |
|---|---|---|---|
stdio | Local subprocess | Process/user permissions | Simplest; Claude Desktop/Code default for local servers |
| Streamable HTTP | Remote server | OAuth 2.1 (PKCE), bearer tokens | Current remote standard; supports streaming responses |
| HTTP + SSE | Remote (legacy) | OAuth / tokens | Superseded by Streamable HTTP; still seen |
Authoring a server
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("orders")
@mcp.tool()def get_order(order_id: str) -> dict: """Look up one order by ID. Use when the user references an order number. Returns status, eta and items. Read-only.""" order = db.fetch_order(order_id) if order is None: raise ValueError(f"not_found: no order {order_id}") # surfaces as isError with message return order
@mcp.resource("orders://schema")def schema() -> str: """Orders table schema (read-only reference).""" return open("schema.sql").read()
@mcp.prompt()def triage(order_id: str) -> str: return f"Triage order {order_id}: check status, delays and next action."
if __name__ == "__main__": mcp.run(transport="stdio") # or transport="streamable-http"import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';import { z } from 'zod';
const server = new McpServer({ name: 'orders', version: '1.0.0' });
server.tool( 'get_order', 'Look up one order by ID. Use when the user references an order number. Returns status, eta and items. Read-only.', { order_id: z.string().describe('Order ID, e.g. ORD-12345') }, async ({ order_id }) => { const order = await db.fetchOrder(order_id); if (!order) { return { isError: true, content: [{ type: 'text', text: JSON.stringify({ category: 'not_found', retryable: false, message: `No order ${order_id}` }) }] }; } return { content: [{ type: 'text', text: JSON.stringify(order) }] }; },);
server.resource('schema', 'orders://schema', async () => ({ contents: [{ uri: 'orders://schema', text: await fs.readFile('schema.sql', 'utf8') }],}));
await server.connect(new StdioServerTransport());Server design checklist
- One narrow purpose per tool; 4–8 tools per server is typical. Beyond ~10 exposed to one agent, rely on tool search /
defer_loading. - Descriptions state what, when, when-not, and return shape.
- Structured errors (
isError: truewith category, retryable, message) – never empty success. - Idempotency for anything that writes; accept an idempotency key.
- Pagination for list operations; cap page sizes.
- Least privilege: expose read tools by default; put destructive tools behind separate servers or confirmation.
- Identity propagation: for multi-user apps, tools must act as the end user (OAuth on behalf of), not a shared super-user.
- Treat tool output as untrusted downstream (indirect injection).
- Version the server; add tools rather than changing semantics.
Connecting from Claude
claude mcp add orders -- python orders_server.py # stdio, local scopeclaude mcp add --scope project orders -- python orders_server.py # .mcp.json, sharedclaude mcp add --transport http linear https://mcp.linear.app/mcp # remote/mcp # inspect, authenticate{ "mcpServers": { "orders": { "command": "python", "args": ["/abs/path/orders_server.py"] }} }r = client.messages.create( model="claude-opus-5", max_tokens=1024, mcp_servers=[{"type": "url", "url": "https://mcp.example.com/mcp", "name": "orders", "authorization_token": token}], messages=[{"role": "user", "content": "Where is ORD-12345?"}],)No client harness needed – Anthropic’s servers call the remote MCP server for you.
MCP vs alternatives
| Need | Prefer | Reason |
|---|---|---|
| Reusable connector to an external system used by several agents/hosts | MCP server | Standard interface, discoverable, host-managed permissions |
| One-off function inside a single app | Custom tool in the API request | Less infrastructure |
| A procedure/knowledge Claude should follow | Skill | Progressive disclosure, no runtime |
| Deterministic scripted step with no model judgment | Plain API/CLI call in code | Cheaper, testable |
| Two autonomous systems negotiating | Agent-to-agent protocol / orchestration layer | MCP is model↔tool, not agent↔agent |
Security quick list
| Risk | Control |
|---|---|
| Over-broad tools (delete/refund exposed) | Remove them; separate servers; hooks |
| Indirect injection via tool results | Boundaries (XML), treat as data, output validation |
| Shared credentials | OAuth per user; short-lived tokens; scopes |
| Secret leakage | Env/secret manager; never in prompts, CLAUDE.md or logs |
| Untrusted servers | Allowlist servers; pin versions; review source |
| Excessive agency | Human approval for irreversible actions; permissions.ask |
Capability negotiation (the JSON)
At initialize each side advertises what it supports. The host only exposes primitives both sides negotiated.
// client → server{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": { "roots": { "listChanged": true }, "sampling": {} }, "clientInfo": { "name": "claude-code", "version": "2.1.0" } } }
// server → client{ "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2025-06-18", "capabilities": { "tools": { "listChanged": true }, "resources": { "subscribe": true, "listChanged": true }, "prompts": { "listChanged": true }, "logging": {} }, "serverInfo": { "name": "orders", "version": "1.4.0" } } }
// client → server (handshake complete){ "jsonrpc": "2.0", "method": "notifications/initialized" }If the server does not advertise sampling, the host will not route sampling requests to it — and vice-versa for client roots.
Resources, prompts and sampling – worked examples
Resources (application-controlled, read-only)
// list{ "jsonrpc": "2.0", "id": 2, "method": "resources/list" }{ "jsonrpc": "2.0", "id": 2, "result": { "resources": [ { "uri": "orders://schema", "name": "Orders schema", "mimeType": "text/sql" }, { "uri": "orders://policy/refunds", "name": "Refund policy", "mimeType": "text/markdown" } ] } }
// read{ "jsonrpc": "2.0", "id": 3, "method": "resources/read", "params": { "uri": "orders://schema" } }{ "jsonrpc": "2.0", "id": 3, "result": { "contents": [ { "uri": "orders://schema", "mimeType": "text/sql", "text": "CREATE TABLE orders (…)" } ] } }Resources are data, not actions: no side effects, addressed by URI, chosen by the application (not the model). Use them for schemas, policies and reference docs.
Prompts (user-controlled templates)
{ "jsonrpc": "2.0", "id": 4, "method": "prompts/get", "params": { "name": "triage", "arguments": { "order_id": "ORD-12345" } } }{ "jsonrpc": "2.0", "id": 4, "result": { "messages": [ { "role": "user", "content": { "type": "text", "text": "Triage order ORD-12345: check status, delays and next action." } } ] } }Prompts surface as slash commands (/triage) — the user invokes them, unlike tools (model-invoked) or resources (app-selected).
Sampling (server asks the host’s model)
The server can request a completion from the host’s model — e.g. to summarise before returning. The host stays in control and can deny, redact or rate-limit.
// server → client{ "jsonrpc": "2.0", "id": 5, "method": "sampling/createMessage", "params": { "messages": [{ "role": "user", "content": { "type": "text", "text": "Summarise: <500 rows>" } }], "maxTokens": 300, "modelPreferences": { "intelligencePriority": 0.3, "speedPriority": 0.8 } } }
// client → server (after the host runs its model, with user approval){ "jsonrpc": "2.0", "id": 5, "result": { "role": "assistant", "content": { "type": "text", "text": "12 orders delayed, avg 3 days…" }, "model": "claude-haiku-4-5", "stopReason": "endTurn" } }Sampling is a trust boundary
Sampling lets a server spend the host’s tokens and see model output. The host must gate it (approval, rate limits) and never auto-approve for untrusted servers.
Streamable HTTP server example
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("orders")
@mcp.tool()def get_order(order_id: str) -> dict: """Look up one order by ID. Read-only.""" return db.fetch_order(order_id)
if __name__ == "__main__": # Serves POST /mcp with streaming responses; put OAuth 2.1 in front (reverse proxy / gateway) mcp.run(transport="streamable-http", host="0.0.0.0", port=8080, path="/mcp")Client Reverse proxy / gateway MCP server │ POST /mcp (Bearer token) │ │ ├────────────────────────────────► validate token, scopes │ │ ├────────────────────────────────► initialize / tools/call │ │ │ │ ◄─────── streamed JSON-RPC responses (chunked) ─────────────────┤Streamable HTTP is a single endpoint that supports request/response and server-streamed messages. It supersedes the older HTTP+SSE two-endpoint transport.
OAuth 2.1 flow (remote servers)
┌── Host (MCP client) ──┐ ┌── Authorization server ──┐ ┌── MCP server ──┐│ 1. discover metadata │────────►│ /.well-known/oauth-* │ │ (resource) ││ 2. PKCE: code_verifier│ │ │ │ ││ + code_challenge │ │ │ │ ││ 3. authorize (browser)│────────►│ user logs in, consents │ │ ││ 4. redirect w/ code │◄────────│ auth code │ │ ││ 5. token (code + │────────►│ /token │ │ ││ code_verifier) │◄────────│ access + refresh token │ │ ││ 6. call with Bearer │───────────────────────────────────┼─────►│ validate scope ││ 7. refresh on expiry │────────►│ /token (refresh_token) │ │ │└───────────────────────┘ └──────────────────────────┘ └────────────────┘- PKCE is mandatory in OAuth 2.1 (no implicit flow).
- Tokens are short-lived; refresh silently.
- Scopes map to tool permissions; the token carries the end user’s identity so tools act as that user.
- The MCP server is a resource server; a separate authorization server issues tokens.
Error shapes
MCP distinguishes protocol errors (JSON-RPC level) from tool execution errors (a successful call whose result says it failed).
// Protocol / JSON-RPC error (method missing, bad params){ "jsonrpc": "2.0", "id": 7, "error": { "code": -32602, "message": "Invalid params: order_id required" } }
// Tool execution error (call succeeded, tool failed) — isError on the result{ "jsonrpc": "2.0", "id": 8, "result": { "isError": true, "content": [{ "type": "text", "text": "{\"category\":\"not_found\",\"retryable\":false,\"message\":\"No order ORD-999\"}" }] } }| JSON-RPC code | Meaning |
|---|---|
-32700 | Parse error |
-32600 | Invalid request |
-32601 | Method not found |
-32602 | Invalid params |
-32603 | Internal error |
Rule: business failures use isError: true on the result (the model can see and react), not a JSON-RPC error. Reserve JSON-RPC errors for genuinely malformed calls.
Testing with the MCP Inspector
# Launch the inspector against a local stdio servernpx @modelcontextprotocol/inspector python orders_server.py
# Against a remote Streamable HTTP server (walks the OAuth flow)npx @modelcontextprotocol/inspector --transport http https://mcp.example.com/mcpChecklist in the inspector: initialize returns the expected capabilities; tools/list shows correct names, descriptions and schemas; each tool call returns structured content; error paths set isError; resources read cleanly; prompts render with arguments. Test before wiring the server into Claude — most “the model won’t call my tool” bugs are description/schema bugs the inspector surfaces immediately.
Versioning
| Change | Compatibility | Do |
|---|---|---|
| Add a new tool | Backward-compatible | Ship freely; bump minor version |
| Add an optional field | Backward-compatible | Ship; document |
| Rename/remove a tool or required field | Breaking | New tool name; keep old one deprecated for a window |
| Change a tool’s semantics silently | Dangerous | Never — agents encoded the old behaviour; version and communicate |
| Protocol version | Negotiated at initialize | Support a range; advertise the highest you speak |
Prefer additive evolution. Because agents and prompts encode tool names and behaviours, a silent semantic change breaks callers with no error — versioning and deprecation windows are the exam-correct approach.
Common misconceptions
| Misconception | Reality | Why it matters on the exam |
|---|---|---|
| “Tools, resources and prompts are interchangeable” | Tools = model-invoked actions; resources = app-selected data; prompts = user-invoked templates | Primitive-confusion distractor |
| “MCP is agent-to-agent” | MCP is model↔tool; use an orchestration/A2A layer for agent↔agent | Wrong-protocol distractor |
“A tool that finds nothing should return {}” | Return isError with a category; never empty success | Silent-failure anti-pattern |
| “Expose everything the server can do” | Least privilege; separate destructive tools; remove unneeded ones | Over-broad-tools distractor |
| “OAuth is optional for remote servers” | Streamable HTTP remote servers need OAuth 2.1 + PKCE; propagate user identity | Shared-super-user authz gap |
| “20 tools on one server is fine” | 4–8 typical; beyond ~10 use tool search + defer_loading | Too-many-tools anti-pattern |
| “Business errors should be JSON-RPC errors” | Use isError on the result; JSON-RPC errors are for malformed calls | Error-shape distractor |
Scenario walkthrough
A SaaS company wants Claude, embedded in several internal apps, to read and act on customers’ CRM records. Multi-tenant: each end user may only see their own accounts. The CRM already has an OAuth provider. How should the MCP integration be designed?
- MCP server, not per-app custom tools — the connector is reused across several hosts/apps; a standard server is discoverable and host-permission-managed.
- Streamable HTTP transport — remote, multi-user; stdio is for local subprocesses only.
- OAuth 2.1 + PKCE, per-user tokens — the token carries the end user’s identity so tools enforce that user’s CRM permissions. A shared super-user credential is the authz-gap distractor.
- Narrow tools —
search_accounts,get_account,create_note(4–8). Destructive operations (delete_account) live behind a separate server or a confirmation, or are omitted (least privilege). - Structured errors —
isErrorwith category/retryable; never empty success. - Treat tool output as untrusted — CRM notes could carry indirect injection; keep boundaries and validate downstream.
- Version additively — add tools over time; never silently change semantics.
Rejected alternatives: stdio (not remote/multi-user), a shared API key (breaks per-user authz), exposing every CRM verb (over-broad), and returning {} on “no accounts found” (silent failure).
Last updated Sep 18, 2026