From a 30-line skeleton to production-shaped servers — tools, resources, prompts, sampling, elicitation, errors, progress, cancellation and logging. Python and TypeScript SDKs side-by-side. Updated for revision 2026-07-28, where a server no longer sends requests to the client but returns resultType: "input_required" and the client retries; handshake-era slides are tagged “before 2026-07-28”.
Anthropic publishes first-party SDKs in Python, TypeScript, Java, C#, Kotlin, Swift, Go and Ruby. They're all generated from one schema, so feature parity is good. The two you'll meet most often are Python (FastMCP) and TypeScript — we'll show both in parallel.
mcp 1.x) before 2026-07-28from mcp.server.fastmcp import FastMCP
mcp = FastMCP("weather")
@mcp.tool()
def forecast(region: str) -> dict:
"""24h forecast for an ISO 3166-2 region."""
return {"tempC": 12.0,
"summary": "light rain"}
if __name__ == "__main__":
mcp.run() # stdio by default
@modelcontextprotocol/sdk) before 2026-07-28import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport }
from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({ name: "weather", version: "1.0" });
server.registerTool("forecast", {
inputSchema: { region: z.string() },
outputSchema: { tempC: z.number(), summary: z.string() }
}, async ({ region }) => ({
structuredContent: { tempC: 12, summary: "light rain" },
content: [{ type: "text", text: "London 12 C, light rain" }]
}));
await server.connect(new StdioServerTransport());
FastMCP autogenerates the input schema from Python type hints; the TS SDK uses Zod and converts to JSON Schema at registration time. Either way the wire-format is identical — pick by language preference, not by feature.
The 30-line server above — if you save it, point Claude Desktop or Inspector at it, and call forecast — is a complete MCP server. Everything we cover for the rest of this deck adds polish around that core.
Python: mcp 2.x renamed FastMCP to MCPServer; the only change to the snippet is the first lines: from mcp.server import MCPServer and mcp = MCPServer("weather"). (mcp.server.fastmcp no longer exists in 2.x.) That version of the snippet was run here against mcp 2.3.0 and served tools/list and tools/call to the SDK client in both mode="2026-07-28" and mode="legacy": the 2.x SDK is dual-era. TypeScript: v2, which implements 2026-07-28, ships as split packages (@modelcontextprotocol/server, 2.3.1 on 2026-10-05) replacing @modelcontextprotocol/sdk; see its migration guide. SDK versions move fast; check the docs.
Sources: PyPI mcp, npm @modelcontextprotocol/server (accessed 2026-10-08).
A tool exposes one function the model can call. Three pieces matter: the input schema, the output schema (since 2025-06-18) and the annotations that classify the tool's effects.
{
"name": "create_issue",
"title": "Create GitHub Issue",
"description": "Open a new issue on a GitHub repository.",
"inputSchema": {
"type": "object",
"properties": {
"repo": { "type": "string", "pattern": "^[^/]+/[^/]+$" },
"title": { "type": "string", "minLength": 1, "maxLength": 256 },
"body": { "type": "string" },
"labels":{ "type": "array", "items": { "type": "string" } }
},
"required": ["repo","title"]
},
"outputSchema": {
"type": "object",
"properties": {
"number": { "type": "integer" },
"url": { "type": "string", "format": "uri" }
},
"required": ["number","url"]
},
"annotations": {
"title": "Create GitHub Issue",
"readOnlyHint": false,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true
}
}
| Hint | Meaning | What hosts do with it |
|---|---|---|
readOnlyHint | Tool only reads state | Auto-approve, batch with other reads, no confirmation prompt |
destructiveHint | Side effect is hard to reverse (e.g. DROP TABLE, git push --force) | Always confirm, often with explicit typed acknowledgement |
idempotentHint | Repeating the same call has no extra effect | Safe to retry on transport-level failures |
openWorldHint | Tool reaches outside the host's process (network, external services) | Show a network indicator; route through a proxy if policy requires |
title | Human-readable display name | UI rendering — the only annotation a user sees directly |
A successful tools/call returns three things: an array of content blocks (text/image/audio/resource_link) for the model, optional structuredContent for downstream code, and isError: false. A tool-level failure flips isError to true and puts the explanation in content.
{
"content": [
{ "type": "text",
"text": "Created issue #42: 'Bug in login flow'." },
{ "type": "resource_link",
"uri": "https://github.com/me/repo/issues/42",
"name": "Issue #42" }
],
"structuredContent": { "number": 42,
"url": "https://github.com/me/repo/issues/42" },
"isError": false
}
Embedding a resource_link in a tool result tells the host “this tool produced a resource at this URI; you can resources/read it later.” This is how a long-running tool returns “here's the report I generated” without inlining megabytes into the call's response.
Resources are the read side of MCP. The model can pull a file's contents, a database row, a Confluence page — addressed by URI — without you having to write a tool wrapper for every read pattern.
Enumerated in resources/list. URI is exact. Examples: a configuration file, a fixed help document, a schema dump.
file:///etc/server.conf
config://server/limits
Declared as RFC 6570 URI templates. The client substitutes parameters at resources/read time. Lets you expose a parameterised surface without enumerating every value.
db://users/{user_id}/profile
git://repo/{ref}/{path}
// → client
{"jsonrpc":"2.0","id":7,"method":"resources/read",
"params":{"uri":"db://users/42/profile"}}
// ← server
{"jsonrpc":"2.0","id":7,"result":{
"contents":[{
"uri":"db://users/42/profile",
"mimeType":"application/json",
"text":"{\"id\":42,\"name\":\"Ada\",\"role\":\"admin\"}"
}]
}}
Each entry in contents carries either a UTF-8 text field or a base64 blob for binary types — PDFs, images, audio. The mimeType is used by the host to decide rendering and by the model to disambiguate parsing.
If the server declared resources.subscribe: true at handshake, the client can ask to be told when a specific URI changes:
// → client
{"jsonrpc":"2.0","id":8,"method":"resources/subscribe",
"params":{"uri":"file:///project/README.md"}}
// ← server, later when the file changes
{"jsonrpc":"2.0","method":"notifications/resources/updated",
"params":{"uri":"file:///project/README.md"}}
The server tracks per-session subscriptions. If your server is horizontally scaled, sticky-route by Mcp-Session-Id or replicate the subscription set in shared state. Free-flow tip: bound the number of subscriptions per session and reject excess with -32602 Invalid params.
resources/subscribe and resources/unsubscribe are gone. A client sends one long-lived subscriptions/listen request whose filter names the change types it wants (toolsListChanged, promptsListChanged, resourcesListChanged, resourceSubscriptions: [uri, …]); the server acknowledges, then streams notifications/resources/updated tagged with io.modelcontextprotocol/subscriptionId. The subscription is scoped to that request, not to a session, so there is nothing to sticky-route; the server holds no subscription state across reconnects. Also new: a missing resource is -32602, not -32002.
Source: MCP specification 2026-07-28 — Subscriptions; Resources (accessed 2026-10-08).
Prompts are the third server primitive: parameterised text templates the user can invoke explicitly — typically as slash commands in the host UI. They differ from tools in two ways: they're user-driven, not model-driven, and they produce messages to insert, not results to act on.
from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.prompts import base
mcp = FastMCP("reviewer")
@mcp.prompt()
def review_pr(repo: str, pr_number: int, focus: str = "correctness"):
"""Run a structured PR review."""
return [
base.UserMessage(
f"Review PR #{pr_number} on {repo}. "
f"Focus on {focus}. Use the ‘diff’ tool first, "
f"then comment via ‘create_review’."),
]
{"jsonrpc":"2.0","id":11,"result":{
"prompts":[{
"name":"review_pr",
"description":"Run a structured PR review.",
"arguments":[
{"name":"repo","required":true},
{"name":"pr_number","required":true},
{"name":"focus","required":false}
]
}]
}}
// → client
{"jsonrpc":"2.0","id":12,"method":"prompts/get","params":{
"name":"review_pr",
"arguments":{"repo":"acme/api","pr_number":"341","focus":"security"}}}
// ← server
{"jsonrpc":"2.0","id":12,"result":{
"messages":[{
"role":"user",
"content":{"type":"text",
"text":"Review PR #454a29 on acme/api. Focus on security. ..."}
}]
}}
If a server declares the completions capability, the client can call completion/complete as the user is typing a prompt argument and get suggestions back. Practical example: a repo argument suggests the repos the OAuth principal has access to, fetched live.
Sampling is deprecated in 2026-07-28 (still functional; earliest removal in the first revision on or after 2027-07-28; suggested migration: call an LLM provider API directly). Where it is still used, the request below is no longer sent by the server as its own JSON-RPC request: it travels as an entry in inputRequests of an input_required result (slide 06b). includeContext: "thisServer"/"allServers" are deprecated too.
Sampling inverts the usual direction. The server calls sampling/createMessage, asking the client's LLM to produce a completion. Lets a server build agentic behaviour without bringing its own model — and lets the user keep control of which model is used and how much is spent.
{"jsonrpc":"2.0","id":99,"method":"sampling/createMessage","params":{
"messages":[
{"role":"user","content":{"type":"text",
"text":"Summarise this issue thread in three bullets."}}
],
"modelPreferences":{
"hints":[{"name":"claude-sonnet-4"}],
"intelligencePriority":0.6,
"speedPriority":0.3,
"costPriority":0.1
},
"systemPrompt":"You are a precise issue triager.",
"maxTokens":400,
"temperature":0.0,
"includeContext":"thisServer"
}}
includeContext can be none, thisServer, or allServers — controls whether messages from other connected servers' contexts get folded in.A code-review server can ask the host to generate the actual review prose; a research server can ask for a summary; an agent server can run an inner loop using the host's model. None of them needs an API key.
Without consent a malicious server could exfiltrate context (“summarise everything you know about this user”) or grind up billing. The user-approval gate is non-negotiable.
Source: MCP specification 2026-07-28 — Sampling; Deprecated features (accessed 2026-10-08).
Elicitation, standardised in 2025-06-18, lets a server pause and ask the user a structured question mid-flow. The server sends a JSON Schema describing the requested input; the host renders it as a form; the user accepts, declines, or cancels.
{"jsonrpc":"2.0","id":21,"method":"elicitation/create","params":{
"message":"Confirm shipping address for order #4a452e11.",
"requestedSchema":{
"type":"object",
"properties":{
"name": { "type":"string" },
"street": { "type":"string" },
"city": { "type":"string" },
"zip": { "type":"string", "pattern":"^[0-9]{5}$" },
"country":{ "type":"string", "enum":["US","UK","DE","FR"] }
},
"required":["name","street","city","zip","country"]
}
}}
{"jsonrpc":"2.0","id":21,"result":{
"action":"accept",
"content":{
"name":"Ada Lovelace",
"street":"1 Analytical Way",
"city":"London",
"zip":"10001",
"country":"UK"
}
}}
User filled the form. content validated against requestedSchema.
User explicitly refused. The server should fail gracefully — do not retry.
User dismissed without choosing. Treat as “unknown”; the server may try a different path.
Sample when you want text generated by the model. Elicit when you want a typed answer from the user. Prompt when the user is initiating the action and you want to seed the conversation.
Elicitation is not deprecated. The form (message + requestedSchema) and the three actions are unchanged, but the request rides inside an input_required result and the answer comes back in inputResponses on a retry (next slide). URL-mode elicitation (2025-11-25) remains for sensitive out-of-band steps; its elicitationId and the notifications/elicitation/complete notification were removed, since the client learns the outcome by retrying.
Source: MCP specification 2026-07-28 — Elicitation; Key changes (accessed 2026-10-08).
In 2026-07-28 a server never sends a request to the client. When tools/call, prompts/get or resources/read needs more input, the server finishes that request with an interim result of resultType: "input_required". The client gathers the answers (from the user, or its model) and retries the original request with a new ID, carrying inputResponses and echoing requestState.
// → client: tools/call, id 1 (_meta omitted for brevity)
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{
"name":"ship_order","arguments":{"order":"4a452e11"}}}
// ← server
{"jsonrpc":"2.0","id":1,"result":{
"resultType":"input_required",
"inputRequests":{
"address":{
"method":"elicitation/create",
"params":{"mode":"form",
"message":"Confirm shipping address for order #4a452e11.",
"requestedSchema":{"type":"object",
"properties":{"city":{"type":"string"},"zip":{"type":"string"}},
"required":["city","zip"]}}}},
"requestState":"<opaque, integrity-protected by the server>"
}}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{
"name":"ship_order","arguments":{"order":"4a452e11"},
"inputResponses":{
"address":{"action":"accept","content":{"city":"London","zip":"10001"}}},
"requestState":"<echoed unchanged>"}}
// ← server
{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete",
"content":[{"type":"text","text":"Order 4a452e11 shipped."}],"isError":false}}
tools/call, prompts/get and resources/read may return input_required.inputRequests values are elicitation/create, sampling/createMessage or roots/list, and only for capabilities the client declared.id); the server may ask again.The server holds no state between the two calls: everything it needs comes back in the retry, so any replica can finish the job. requestState passes through the client, so the server MUST treat it as attacker-controlled: protect it (HMAC/AEAD) when it matters, and bind it to the principal, a short expiry and the original request.
Chapter 5 of Agent Protocols Explained plays the same elicitation and sampling exchanges in both eras, side by side, from a simulator checked against the official SDK. The tool name and order data here are illustrative.
Source: MCP specification 2026-07-28 — Multi round-trip requests; Elicitation (accessed 2026-10-08).
The single most common server-side mistake. A tool that ran but failed (“the API returned 401”) returns a normal JSON-RPC result with isError: true:
{"jsonrpc":"2.0","id":3,"result":{
"content":[{"type":"text","text":"Auth failed: 401 Unauthorized. Refresh your token and retry."}],
"isError":true
}}
JSON-RPC errors are reserved for protocol-layer problems — bad params, unknown method, malformed JSON. The reason: the model needs to see tool failures to course-correct; protocol errors short-circuit before the model gets a turn.
Any long-running request (tools/call, resources/read) can include a progressToken in params._meta. The server then emits notifications/progress with the same token at intervals.
{"jsonrpc":"2.0","method":"notifications/progress","params":{
"progressToken":"abc-7",
"progress":0.45,
"total":1.0,
"message":"Indexing 45 of 100 files"
}}
before 2026-07-28 Either side can cancel an in-flight request by sending a notification with the request ID. The receiver SHOULD stop work and SHOULD NOT send the response. Initialize cannot be cancelled.
2026-07-28 Only the client cancels requests. On Streamable HTTP, closing the request's SSE stream is the cancellation; on stdio the client sends notifications/cancelled. A server sends notifications/cancelled only to end a subscriptions/listen stream.
{"jsonrpc":"2.0","method":"notifications/cancelled","params":{
"requestId":42,
"reason":"User pressed Esc"
}}
In 2026-07-28 Logging is deprecated and logging/setLevel is removed: a client puts io.modelcontextprotocol/logLevel in a request's _meta, and the server MUST NOT emit notifications/message for requests without it. Suggested migration: log to stderr (stdio) or OpenTelemetry. Before 2026-07-28: if the server declared logging capability, the client can call logging/setLevel to set a syslog-style level (debug, info, notice, warning, error, critical, alert, emergency). The server emits structured records via notifications/message:
{"jsonrpc":"2.0","method":"notifications/message","params":{
"level":"warning",
"logger":"db.pool",
"data":{"event":"slow_query","durationMs":1840,"sql":"SELECT ..."}
}}
Log records flow into the host. The host MAY display them, persist them, or include them in error reports. Never log the Authorization header, OAuth tokens, refresh tokens, or row contents you wouldn't show the user. Deck 04 returns to this from the security side.
Source: MCP specification 2026-07-28 — Cancellation; Logging; Deprecated features (accessed 2026-10-08).
The @modelcontextprotocol/inspector Node tool is the official conformance harness. It launches your server (stdio or HTTP), exercises every primitive interactively, and shows the raw JSON-RPC traffic. If your server doesn't pass Inspector smoke-tests, no host will be able to use it.
# with no arguments — opens a UI on http://localhost:6274
npx @modelcontextprotocol/inspector
# launching your server directly
npx @modelcontextprotocol/inspector \
uvx mcp-server-sqlite --db-path /tmp/test.db
# a Streamable HTTP server
npx @modelcontextprotocol/inspector \
--transport http http://localhost:3000/mcp
Inspector ships a non-interactive mode (--cli) that emits a JUnit-style report. Bake it into your server's CI: list tools, call each one with a known input, assert on the response. You'll catch schema regressions before users do.
@mcp.tool is a complete, useful MCP server.result.isError. JSON-RPC errors are for protocol problems only.input_required and the client retries; Sampling (and Roots, Logging) are deprecated.Deck 04 covers everything you need to make a remote server safe to expose: the threat model, OAuth 2.1, audience binding, prompt-injection through tool output, sandboxing. Deck 05 surveys the ecosystem — which clients exist, the registry, and how to think about deployment.