# MCP Cheat Sheet

Model Context Protocol architecture, primitives, transports, lifecycle, security, server authoring in Python and TypeScript, and when to use MCP versus alternatives.

import { Tabs, TabItem, Steps } from '@prosefly/astro-components';

## Architecture

```text
┌──────────────── 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

```text
client ──initialize (protocolVersion, capabilities, clientInfo)──► server
client ◄──result (capabilities, serverInfo)────────────────────── server
client ──notifications/initialized──────────────────────────────► server
client ──tools/list ──► server      client ──resources/list ──► server
client ──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

<Tabs>
  <TabItem label="Python (FastMCP)">
```python
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"
```
  </TabItem>
  <TabItem label="TypeScript">
```typescript
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());
```
  </TabItem>
</Tabs>

### 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: true` with 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

<Tabs>
  <TabItem label="Claude Code">
```bash
claude mcp add orders -- python orders_server.py                     # stdio, local scope
claude mcp add --scope project orders -- python orders_server.py     # .mcp.json, shared
claude mcp add --transport http linear https://mcp.linear.app/mcp    # remote
/mcp                                                                 # inspect, authenticate
```
  </TabItem>
  <TabItem label="Claude Desktop">
```json
// claude_desktop_config.json
{ "mcpServers": {
  "orders": { "command": "python", "args": ["/abs/path/orders_server.py"] }
} }
```
  </TabItem>
  <TabItem label="Messages API (MCP connector)">
```python
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.
  </TabItem>
</Tabs>

## 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.

```json
// 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)

```json
// 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)

```json
{ "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.

```json
// 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" } }
```

:::caution[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

```python
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")
```

```text
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)

```text
┌── 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).

```json
// 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

```bash
# Launch the inspector against a local stdio server
npx @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/mcp
```

Checklist 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?

<Steps>
1. **MCP server, not per-app custom tools** — the connector is reused across several hosts/apps; a standard server is discoverable and host-permission-managed.
2. **Streamable HTTP transport** — remote, multi-user; stdio is for local subprocesses only.
3. **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.
4. **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).
5. **Structured errors** — `isError` with category/retryable; never empty success.
6. **Treat tool output as untrusted** — CRM notes could carry indirect injection; keep boundaries and validate downstream.
7. **Version additively** — add tools over time; never silently change semantics.
</Steps>

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).
