TAI BUI
Back to curriculum
BuildPython

Building an MCP Server — Python + TypeScript SDKs

Most MCP tutorials stop at a stdio hello-world. A real server exposes tools, resources, and prompts, negotiates capabilities, returns structured errors, and behaves the same under any SDK. This lesson builds a notes server from the ground up with a stdlib stdio transport, JSON-RPC dispatch, and all three server primitives, written as plain functions so they move into FastMCP or the TypeScript SDK when you outgrow the stdlib.

TIME: ~75 minutesPREREQUISITES: Phase 13 · 06 (MCP fundamentals)

Learning Objectives

  • Implement `initialize`, `tools/list`, `tools/call`, `resources/list`, `resources/read`, `prompts/list`, and `prompts/get` methods.
  • Write a dispatch loop that reads JSON-RPC messages from stdin and writes responses to stdout.
  • Emit structured error responses per the JSON-RPC 2.0 spec and MCP's additional codes.
  • Graduate a stdlib implementation to FastMCP (Python SDK) or the TypeScript SDK without rewriting tool logic.

The Problem

Before you add a remote transport (Phase 13 · 09) or an auth layer (Phase 13 · 16), you want a clean local server. Local means stdio: the client spawns the server as a child process and the two talk over stdin and stdout, one message per line.

The 2025-11-25 spec encodes stdio messages as JSON objects separated by an explicit \n. SSE was the old remote mode and is being removed in mid-2026 (Atlassian's Rovo MCP server deprecated it on June 30, 2026, and Keboola on April 1, 2026), so it has no place here. For stdio, one JSON object per line is the entire wire format.

A notes server is a good shape because it touches all three server primitives. Tools mutate state (notes_create). Resources expose data (notes://{id}). Prompts ship templates (review_note). Swap notes for tickets or files and the structure stays the same.

Warm-up0/3
1. Which kind of JSON-RPC 2.0 message carries no `id` and expects no response?Warm-up
Six JSON-RPC 2.0 field names drawn as chips: jsonrpc 2.0, method, params, id, result and error.
Field names from the JSON-RPC 2.0 spec that MCP messages use.
2. Of the three server primitives, which one exposes read-only content addressed by a URI?Warm-up
A client box with an arrow to a notes server box that holds three items named notes_create, notes://note-1 and review_note.
Three of the things the lesson's notes server exposes, by name.
3. A server's `initialize` response does not declare `resources.subscribe`. What must the client do?Warm-up
A capabilities JSON object with tools, resources and prompts blocks that each hold only listChanged false; the resources block has no subscribe key.
Typed to match the stem. main.py itself writes subscribe false, which differs from leaving the key out.
Check: The Problem0/3
1. What is an MCP server?Define
A host app box connected by an arrow labelled MCP to a card for main.py, 279 lines, with server name notes-lesson-07 and version 1.0.0.
Name and version come from SERVER_INFO in main.py.
2. How does a stdio MCP server talk to its client?Define
A client box and a server box on one machine joined by a dashed line with a question mark between them.
Local setup from the lesson's Problem section.
3. A tickets server must let the model file tickets, let the user attach a chosen ticket to the chat, and offer a `/triage` command in the host. Which split fits?Design
Three needs of a tickets server listed as rows: the model files tickets, the user attaches a chosen ticket, and the host offers a /triage command.
Needs restate the stem.

The Concept

Dispatch loop

loop:
  line = stdin.readline()
  msg = json.loads(line)
  if has id:
    handle request -> write response
  else:
    handle notification -> no response

Three rules:

  • Keep stdout for JSON-RPC envelopes only. Debug logs go to stderr.
  • A request gets exactly one response, carrying the same id.
  • A notification gets no response.

The first rule is the one people trip over. A stray print corrupts the stream, and the client sees a parse error on a line you never meant to send.

Implementing initialize

python
def initialize(params):
    return {
        "protocolVersion": "2025-11-25",
        "capabilities": {
            "tools": {"listChanged": True},
            "resources": {"listChanged": True, "subscribe": False},
            "prompts": {"listChanged": False},
        },
        "serverInfo": {"name": "notes", "version": "1.0.0"},
    }

Declare only what you support, because the client uses the capability set to decide which features to turn on.

Implementing tools/list and tools/call

tools/list returns {tools: [...]} with each entry having name, description, inputSchema. tools/call takes {name, arguments} and returns {content: [blocks], isError: bool}.

Content blocks are typed. The most common:

json
{"type": "text", "text": "Found 2 notes"}
{"type": "resource", "resource": {"uri": "notes://14", "text": "..."}}
{"type": "image", "data": "<base64>", "mimeType": "image/png"}

Tool errors come in two shapes. Protocol-level errors (unknown method, bad params) are JSON-RPC errors. Tool-level errors (valid call but the tool failed) are returned as {content: [...], isError: true}. The model can then read the failure in its own context and decide what to do next.

Implementing resources

Resources are read-only by design. resources/list returns a manifest; resources/read returns the content. URIs can be file://..., http://..., or a custom scheme like notes://.

Exposing data as a resource instead of a tool changes who is in control:

  • The model does not "call" it. The client can inject it into context when the user asks.
  • Subscriptions let the server push updates when the resource changes (Phase 13 · 10).
  • Phase 13 · 14 extends the idea with ui:// for interactive resources.

Implementing prompts

Prompts are templates with named arguments, and the host surfaces them as slash-commands. A review_note prompt takes a note_id argument and returns messages the client feeds to its model.

Stdio transport subtleties

  • Messages are newline-delimited JSON, with no length-prefixed framing.
  • Don't buffer: call sys.stdout.flush() after each write.
  • The client owns the lifetime. When stdin closes (EOF), exit cleanly.
  • If a write hits SIGPIPE, log it and exit instead of swallowing it.

Annotations

A tool can carry annotations that describe its safety properties:

  • readOnlyHint: true: pure read, safe to retry.
  • destructiveHint: true: irreversible side effects, so the client should confirm.
  • idempotentHint: true: same inputs produce same outputs.
  • openWorldHint: true: interacts with external systems.

Clients use them to decide UX (confirmation dialogs, status indicators) and routing (Phase 13 · 17).

Graduation path

The stdlib server in code/main.py spends most of its lines on registries, dispatch, and framing. FastMCP (Python) collapses the same logic into decorators:

python
from fastmcp import FastMCP
app = FastMCP("notes")
 
@app.tool()
def notes_search(query: str, limit: int = 10) -> list[dict]:
    ...

The TypeScript SDK has an equivalent shape. Moving over is a port of the wiring, not the logic, because capabilities, dispatch, and content blocks mean the same thing in every SDK.

Check: The Concept0/24
1. What is a content block in a tool result?Define
A tools/call result as JSON with a content list holding one entry, type text and text Found 2 notes, highlighted, and isError false.
Result shape from the lesson's tools/call description.
2. What does `isError: true` in a `tools/call` result signal?Define
A JSON-RPC response with a result holding content and a highlighted isError true line.
Shape typed by hand; content elided.
3. Which annotation marks a tool that interacts with external systems?Define
A made-up tool named fetch_url sends a request to a website or API outside the server, and its annotations field is hidden behind a question mark.
Illustrative tool, not in main.py.
4. What is FastMCP?Define
The first two lines of the lesson's FastMCP snippet, an import and app = FastMCP notes, followed by an ellipsis.
Snippet from the lesson's Graduation path; the rest is left out.
5. What does a resource URI such as `notes://note-1` do?Define
A table of three notes with their ids, titles and notes:// URIs, the URI column highlighted.
Ids and titles from NOTES in main.py; each URI is notes:// plus the id.
6. What is a prompt in MCP, and what does `review_note` take?Define
A prompt entry named review_note with its description and a hidden arguments list, and a question mark box about how a host calls it.
Name and description are real from main.py; the arguments list is elided.
7. What is a capability declaration?Define
Three lines from a snippet in the lesson's Concept section: tools listChanged true, resources listChanged true and subscribe false, prompts listChanged false.
Copied from the lesson's Concept section.
8. Which pair describes `resources/list` and `resources/read` correctly?Define
Two request cards, resources/list and resources/read, each above a dashed box that hides its result.
Both are methods of the notes server in main.py.
9. Which fields does the `initialize` result in the Implementing `initialize` snippet contain?Define
An initialize request, with the server's result hidden behind a dashed question mark box.
Request is the one printed in the lesson's Use It section.
10. What does the `destructiveHint` annotation tell a client?Define
An excerpt of the notes_create tool descriptor from main.py with annotations destructiveHint false and idempotentHint false.
Real excerpt from the TOOLS list in main.py.
11. What may a stdio server write to stdout, and where do debug logs go?Explain
A server box next to two lines it wants to print, a JSON-RPC envelope and a debug line, and two output streams named stdout and stderr, not yet connected.
The debug line is made up for illustration.
12. How do protocol-level errors differ from tool-level errors in `tools/call`?Explain
Two requests in separate panels, an unknown method tools/frobnicate and a notes_search call with empty arguments, each followed by a hidden reply box.
Requests typed to exercise main.py; replies hidden.
13. A stdio server gets SIGPIPE on a write. What should it do?Explain
Three boxes in a row: the client closes the pipe, the server writes a response, and the write raises SIGPIPE.
Schematic of the stem, typed.
14. What changes when the stdlib notes server graduates to FastMCP?Explain
A bar chart of main.py's 279 lines split into six sections: header and data, registries, tool executors, handlers, dispatch loop and demo.
Counted from the comment markers in main.py.
15. Why should `initialize` declare only the capabilities the server really supports?Explain
Two panels: the seven methods main.py handles, and the capability flags its initialize result declares for tools, resources and prompts.
Both lists are real, from main.py.
16. Why does the dispatch loop write nothing back when a message has no `id`?Explain
Three JSON-RPC lines in order: initialize with id 1, a notifications/initialized line, and tools/list with id 2.
Typed sequence of three lines.
17. Why does a failed tool return `isError: true` instead of a JSON-RPC error?Explain
A model, a client and a server box in a row, a note that a tool fails, and two JSON reply shapes: A with an error object and B with a result carrying isError true.
Reply shapes typed from the lesson's description of both error kinds.
18. Why can a stdio server get by without a length prefix on each message?Explain
Two JSON responses with their contents elided, run together as one wrapped block of text with no visible separator between them.
Typed, contents elided as [...]. The characters between the responses are not shown.
19. Your notes server prints `loaded 3 notes` with `print()` at startup, and the client reports a parse error on its first read. What is the fix?Analyze
A notes server line print of loaded 3 notes at startup, and a client log line reporting a parse error on its first read.
Both lines restate the question.
20. An agent keeps repeating the same bad `notes_search` call and never changes its arguments. The tool lets its exception escape, so the client gets a JSON-RPC error. Which fix helps?Analyze
Three calls to notes_search with the same arguments, each answered by a JSON-RPC error box.
Schematic of the stem, typed.
21. You add a `resources/subscribe` handler, but the client never subscribes. `initialize` still returns `"subscribe": False`. What is wrong?Analyze
An initialize result that still says subscribe False, and a client log line saying the client never sent resources/subscribe.
Client log line typed from the stem.
22. A stdio server computes correct responses, but the real client hangs waiting for them. The code calls `sys.stdout.write` and never flushes. What fixes it?Analyze
A server loop that calls sys.stdout.write without a flush, beside a server box whose response is computed and a client box still waiting.
Loop typed to match the stem; main.py's real loop also flushes.
23. You want the user to pick a note and have the client inject its body into context, without the model deciding to fetch it. Which primitive fits?Design
A host UI listing the three note titles with MCP overview picked, an arrow to a conversation context box with an empty slot for the note body.
Titles are from main.py's NOTES.
24. You add `notes_delete`, which permanently removes a note. How should the server expose it?Design
Three note boxes, note-1 MCP overview, note-2 Function calling and note-3 Tool schemas, with note-2 highlighted as the target of a new notes_delete call.
Note-2 is picked for illustration.

Use It

code/main.py is a complete notes MCP server over stdio, stdlib only. It handles initialize, tools/list, and tools/call for three tools (notes_list, notes_search, notes_create), resources/list and resources/read for each note, and a review_note prompt. Drive it by piping JSON-RPC messages:

echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | python main.py

Run python main.py --demo to skip the pipe and watch a scripted session instead.

What to look at:

  • The dispatcher is a dict[str, Callable] keyed by method name.
  • Every tool executor returns a list of content blocks, not a bare string.
  • isError: true is set when the executor raises.
Check: Use It0/11
1. How is the dispatcher in `code/main.py` structured, and what does every tool executor return?Define
A message with method tools/list enters a dispatch box next to the seven handler function names in main.py; below, the three executor names and a hidden box.
Function names are from main.py.
2. Why does every tool executor in `code/main.py` return a list of content blocks, not a bare string?Explain
Excerpt of exec_notes_search in main.py: its def line, an ellipsis and a return line that builds a list holding one text dict.
Real lines from main.py.
3. In `main.py`, `notes_search` runs with `{"query": "mcp"}`. What text does the content block hold?Apply
The three notes in main.py as a table of id, title, body and tag, a notes_search request with query mcp, and a hidden result box.
Notes and request come from the stem and main.py.
4. A client pipes `{"jsonrpc":"2.0","method":"notifications/initialized"}` into `python main.py`. What does the server write to stdout?Apply
A terminal command that pipes a notifications/initialized JSON line into python main.py, and a hidden stdout box.
Command from the stem.
5. `main.py` receives `resources/read` with `uri` `notes://note-9`, which does not exist. What comes back?Apply
A resources/read request for notes://note-9, a line saying main.py holds note-1, note-2 and note-3, and a hidden reply box.
The id 5 is typed; the URI is from the stem.
6. In `main.ts`, a client calls `tools/call` with the tool name `no_such_tool`. What does the server return?Apply
A tools/call request for no_such_tool sent to main.ts, the three keys of its TOOL_EXECUTORS table, and a hidden stdout box.
Request matches scenario 8 of the lesson's demo.
7. In `main.ts`, the client sends the line `{oops`, which is not valid JSON. What does the server write to stdout?Apply
A single line {oops sent to main.ts and a hidden stdout box.
Line taken from the stem.
8. In `main.ts`, `execNotesList` receives `{"tag": "api"}`. Which notes does it return?Apply
The three notes as a table, a notes_list request with tag api, and a hidden result box.
Notes are from main.ts; request is from the stem.
9. `main.py` runs `notes_create` with `{"title": "T", "body": "B"}`. What does `content` hold?Apply
A notes_create request with title T and body B, and a hidden content box.
Request from the stem.
10. A `tools/call` for `notes_create` arrives in `main.py` with `{"body": "B"}` and no `title`. What does the server return?Apply
A notes_create request with only a body B and no title, and a hidden reply box.
Request from the stem.
11. `main.py` receives `prompts/get` for `review_note` with `note_id` `note-9`, which does not exist. What happens?Apply
The three notes as a table, a prompts/get request for review_note with note_id note-9, and a hidden reply box.
Notes are from main.py; request is from the stem.

Ship It

This lesson produces outputs/skill-mcp-server-scaffolder.md. Given a domain (notes, tickets, files, database), the skill scaffolds an MCP server with the right tools / resources / prompts split and SDK graduation path.

Check: Ship It0/1
1. Given a domain such as tickets or files, what does the `mcp-server-scaffolder` skill produce?Define
A flow from the input domain tickets into the mcp-server-scaffolder skill and then a dashed box for its output.
Skill name and path from the lesson's Ship It section.
Author's Note

I'd run python main.py --demo first. It pushes eight scripted requests through dispatch with no transport at all, including a call to a tool that doesn't exist, so you see the success path and the isError path before you touch a pipe. Then pipe a single line into stdin and watch the same logic come out as one JSON object per line.

The detail worth rereading is the split between the two kinds of failure. An unknown tool name comes back as a normal result with isError: true, so the model sees it. A bad resource URI raises inside resources/read, and dispatch turns that into a JSON-RPC error with code -32603. Same server, two different channels, and the choice matters for what the model gets to read.

Two places where the code is simpler than the prose. The initialize snippet above declares listChanged: True for tools and resources, while main.py declares False everywhere, since nothing here ever sends a list-changed notification. And the notes live in an in-memory dict, so notes_create ids (a short random suffix after note-) vanish when the process exits. I'd leave the capabilities honest like that and graduate to FastMCP only after the three primitives feel boring. main.ts is there if you'd rather read the same server in TypeScript.

Exercises

  1. Run code/main.py and drive it with hand-built JSON-RPC messages. Exercise notes_create, then resources/read to retrieve the new note.

  2. Add a notes_delete tool with annotations: {destructiveHint: true}. Verify the client would surface a confirmation dialog (this requires a real host; Claude Desktop works).

  3. Implement resources/subscribe so the server pushes notifications/resources/updated whenever a note is modified. Add a keepalive task.

  4. Port the server to FastMCP. The Python file should shrink to under 80 lines. The wire behavior must be identical; verify with the same JSON-RPC test harness.

  5. Read the spec's server/tools section and identify one field of a tool definition not implemented in this lesson's server. (Hint: there are several; pick one and add it.)

Key Terms

TermWhat people sayWhat it actually means
MCP server"The thing that exposes tools"Process that speaks MCP JSON-RPC over stdio or HTTP
stdio transport"Child process model"Server is spawned by client; communicates via stdin/stdout
Dispatcher"Method router"Map of JSON-RPC method name to handler function
Content block"Tool result chunk"Typed element in the content array of a tool response
isError"Tool-level failure"Signals the tool failed; distinguishes from JSON-RPC error
Annotations"Safety hints"readOnly / destructive / idempotent / openWorld flags
FastMCP"Python SDK"Decorator-based higher-level framework on top of the MCP protocol
Resource URI"Addressable data"file://, db://, or custom scheme identifying a resource
Prompt template"Slash-command brief"Server-supplied template with argument slots for host UIs
Capability declaration"Feature toggle"Per-primitive flags declared in initialize

Further Reading

Code

Ships With