Model Context Protocol Series — Presentation 03

Building an MCP Server

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

Python SDK TypeScript SDK Tools Resources Prompts Sampling Elicitation
SDK → Tools → Resources → Prompts → Sampling → Elicitation → Errors → Inspector
00

Topics We'll Cover

01

SDK Landscape & the Minimum Viable Server

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.

Python — FastMCP idiom (mcp 1.x) before 2026-07-28

from 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

TypeScript — the official SDK (v1, @modelcontextprotocol/sdk) before 2026-07-28

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: "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.

What “done” looks like

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.

The SDKs for 2026-07-28 2026-07-28

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

02

Tools — Schema, Structured Output, Annotations

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.

Anatomy of a Tool descriptor

tools/list result item — abridged
{
  "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
  }
}

The five annotation hints

HintMeaningWhat hosts do with it
readOnlyHintTool only reads stateAuto-approve, batch with other reads, no confirmation prompt
destructiveHintSide effect is hard to reverse (e.g. DROP TABLE, git push --force)Always confirm, often with explicit typed acknowledgement
idempotentHintRepeating the same call has no extra effectSafe to retry on transport-level failures
openWorldHintTool reaches outside the host's process (network, external services)Show a network indicator; route through a proxy if policy requires
titleHuman-readable display nameUI rendering — the only annotation a user sees directly

Returning a result

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.

A successful create_issue call — result shape
{
  "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
}
Resource links are new in 2025-06-18

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.

03

Resources — URIs, Content Types, Subscriptions

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.

Two flavours: static and templated

Static resources

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

Templated resources

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}

Reading a resource

resources/read — request and response
// → 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.

Subscriptions — pushing changes before 2026-07-28

If the server declared resources.subscribe: true at handshake, the client can ask to be told when a specific URI changes:

Subscribe & receive notifications
// → 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"}}
Subscription is stateful

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.

Subscriptions in 2026-07-28 2026-07-28

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

04

Prompts — Reusable Argumented Templates

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.

A prompt with arguments — FastMCP
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’."),
    ]

The wire view

prompts/list (truncated)
{"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}
    ]
  }]
}}
prompts/get — what the user actually invokes
// → 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. ..."}
  }]
}}
Argument autocompletion

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.

05

Sampling — Borrowing the Host's Model before 2026-07-28

Deprecated in 2026-07-28

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.

Server-initiated sampling request (2025-06-18 shape)
{"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"
}}

How the host gates this

Why this exists

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.

Why hosts gate it

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

06

Elicitation — Asking the User a Typed Question before 2026-07-28

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.

elicitation/create — server → client (as a server-initiated request, before 2026-07-28)
{"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"]
  }
}}
Response (user accepted)
{"jsonrpc":"2.0","id":21,"result":{
  "action":"accept",
  "content":{
    "name":"Ada Lovelace",
    "street":"1 Analytical Way",
    "city":"London",
    "zip":"10001",
    "country":"UK"
  }
}}

The three actions

accept

User filled the form. content validated against requestedSchema.

decline

User explicitly refused. The server should fail gracefully — do not retry.

cancel

User dismissed without choosing. Treat as “unknown”; the server may try a different path.

When to elicit vs sample vs ask via prompt

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 in 2026-07-28 2026-07-28

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

06b

Multi Round-Trip Requests — How a Server Asks in 2026-07-28 2026-07-28

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.

1. Server answers the call with input_required
// → 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>"
}}
2. Client retries the same call with the answers
{"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}}

Rules worth remembering

  • Only 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.
  • The retry is a new request (new id); the server may ask again.

Why it is built this way

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.

See it run

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

07

Errors, Progress, Cancellation, Logging

Tool errors vs JSON-RPC errors

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:

Right — tool failure as a result, not an error
{"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.

Progress notifications

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.

Progress wire shape
{"jsonrpc":"2.0","method":"notifications/progress","params":{
  "progressToken":"abc-7",
  "progress":0.45,
  "total":1.0,
  "message":"Indexing 45 of 100 files"
}}

Cancellation

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.

notifications/cancelled
{"jsonrpc":"2.0","method":"notifications/cancelled","params":{
  "requestId":42,
  "reason":"User pressed Esc"
}}

Logging before 2026-07-28

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:

A log record
{"jsonrpc":"2.0","method":"notifications/message","params":{
  "level":"warning",
  "logger":"db.pool",
  "data":{"event":"slow_query","durationMs":1840,"sql":"SELECT ..."}
}}
Don't log secrets

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

08

Testing — The MCP Inspector

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.

Run Inspector against a stdio server
# 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

What to exercise

handshake / discover
initialize capabilities returned notifications/initialized (2026-07-28: server/discover, version error -32022)
tools
tools/list tools/call (happy) tools/call (bad params → -32602) tools/call (tool failure → isError:true)
resources
resources/list resources/read subscribe + change → updated notification (2026-07-28: subscriptions/listen)
prompts
prompts/list prompts/get with required + optional args
edge
unknown method → -32601 cancellation mid-flight progress notifications
CI tip

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.

09

What to Take Away

Where to next

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.