Contents
Figure 1: Ninety lines, four tools, no API keys
You want Claude, or any MCP-capable app, to read and write your own data. That means building a server, and the Python SDK makes it a short job. A notes server with four tools, two resources, and a prompt fits in about 90 lines.
This tutorial builds that server step by step. Every piece ran on SDK version 2.2.0: in-process tests, a stdio subprocess (how hosts launch servers), and Streamable HTTP. The outputs below are real. For the protocol concepts behind all of this, start with the MCP explained guide in this series.
What You'll Build
A Notes server that stores short notes in memory. It exposes:
- Four tools:
add,list,get, anddeletenotes. - Two resources: a plain-text index and one URI per note.
- One prompt: a ready-made "summarize my notes" request.
Why notes? The domain is simple enough to ignore, so you can focus on the MCP parts. Ever tried learning a framework through a project that needs three API keys? This one needs none.
Setup
You need Python 3.10 or later. Install the SDK:
pip install "mcp[cli]"
The cli extra adds the mcp command. In a plain sandbox, the base package worked for everything below, and the mcp run command failed until the extra was present, so install it if you want the command.
One naming note: this tutorial uses the v2 API (MCPServer, Client). The SDK docs describe v2 as the current stable line and tell v1 users to pin mcp>=1.28,<2. Older tutorials use FastMCP, which isn't in v2.
Step 1: Create the Server
Start with the server object and a data model:
from pydantic import BaseModel
from mcp.server import MCPServer
mcp = MCPServer(
"Notes",
instructions="Store, search and delete short text notes. Notes are identified by integer IDs.",
)
class Note(BaseModel):
id: int
title: str
body: str
tags: list[str] = []
_notes: dict[int, Note] = {}
_next_id = 1
The instructions string is guidance for the model about how to use your server. Keep the object as a module-level variable named mcp, because the CLI looks for that name by default.
Step 2: Add Your First Tool
A tool is a decorated Python function. The SDK builds everything else from it:
from typing import Annotated
from pydantic import Field
@mcp.tool()
def add_note(
title: Annotated[str, Field(min_length=1, max_length=100, description="Short title for the note.")],
body: Annotated[str, Field(description="The note text.")],
tags: Annotated[list[str] | None, Field(description="Optional tags for filtering.")] = None,
) -> Note:
"""Create a note and return it, including its new ID."""
global _next_id
note = Note(id=_next_id, title=title, body=body, tags=tags or [])
_notes[note.id] = note
_next_id += 1
return note
Here's what the SDK derives, per its docs:
- Name: the function name.
- Description: the docstring.
- Input schema: the type hints, with
Fieldconstraints likemin_lengthbecoming schema rules. - Output: a returned Pydantic model becomes structured content.
Write the docstring for the model. It decides when to call your tool, so "Create a note and return it, including its new ID" beats "Adds note."
Step 3: Add Read-Only Tools With Hints
Next come the tools that read data. Mark them as read-only so hosts can skip confirmation prompts if they choose:
from mcp.types import ToolAnnotations
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False))
def list_notes(
tag: Annotated[str | None, Field(description="Only return notes with this tag.")] = None,
limit: Annotated[int, Field(ge=1, le=50, description="Maximum number of notes.")] = 10,
) -> list[Note]:
"""List notes, optionally filtered by tag, newest first."""
notes = sorted(_notes.values(), key=lambda n: n.id, reverse=True)
if tag:
notes = [n for n in notes if tag in n.tags]
return notes[:limit]
The SDK docs call annotations hints, and they say clients may ignore them. They aren't a security mechanism. Never rely on a hint to protect anything.
Step 4: Raise Errors the Model Can Fix
This step separates good servers from frustrating ones. The SDK gives you two error paths:
ToolError: An execution failure the model could plausibly avoid. The result hasis_error=True, and your message reaches the model.MCPError: A protocol failure. The whole call fails with a JSON-RPC error, and the model never sees a result.
The docs give a simple test: ask whether a smarter model could have avoided the failure. If yes, raise ToolError.
from mcp.server.mcpserver.exceptions import ToolError
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False))
def get_note(note_id: int) -> Note:
"""Fetch one note by ID."""
if note_id not in _notes:
raise ToolError(f"No note with id {note_id}. Call list_notes to see valid IDs.")
return _notes[note_id]
The message tells the model what went wrong and what to try next. Don't return an error string instead, because a returned string has is_error=False, and clients treat it as success.
Two other behaviors are worth knowing. The SDK validates arguments against the schema before your function runs, and it rejects bad input as a tool error. Any other exception, like a crash, produces a generic message for the model, and the traceback stays in your server log.
Step 5: Mark Destructive Tools
Deletion deserves a loud warning label:
@mcp.tool(annotations=ToolAnnotations(destructive_hint=True, idempotent_hint=False))
def delete_note(note_id: int) -> str:
"""Permanently delete a note by ID."""
if note_id not in _notes:
raise ToolError(f"No note with id {note_id}. Call list_notes to see valid IDs.")
del _notes[note_id]
return f"Deleted note {note_id}."
Hosts can use destructive_hint to ask the user before running the tool. A destructive tool without a hint is a bug waiting for a bad prompt.
Step 6: Add Resources and a Prompt
Tools are for actions. Resources are for data the user or model can read, and each has a URI. A URI with a parameter becomes a template:
from mcp.server.mcpserver.exceptions import ResourceNotFoundError
@mcp.resource("notes://index")
def notes_index() -> str:
"""A plain-text index of every note."""
if not _notes:
return "No notes yet."
return "\n".join(f"{n.id}: {n.title}" for n in sorted(_notes.values(), key=lambda n: n.id))
@mcp.resource("notes://{note_id}")
def note_resource(note_id: int) -> str:
"""The full text of one note."""
if note_id not in _notes:
raise ResourceNotFoundError(f"notes://{note_id}")
n = _notes[note_id]
return f"# {n.title}\n\n{n.body}"
A prompt is a reusable message template that users pick, usually from a menu:
@mcp.prompt()
def summarize_notes(tag: str = "") -> str:
"""Ask the model to summarize notes, optionally only those with one tag."""
scope = f"the notes tagged {tag!r}" if tag else "all of my notes"
return f"Use the list_notes tool to read {scope}, then write a five-bullet summary."
Prompt arguments are a flat list of named strings, and the client shows them as a form. A missing resource returns JSON-RPC error -32602.
Step 7: Run It
Add an entry point at the bottom of the file:
import sys
if __name__ == "__main__":
transport = sys.argv[1] if len(sys.argv) > 1 else "stdio"
mcp.run(transport=transport)
Run python server.py and it waits silently for input, which is exactly what a stdio server should do. Run python server.py streamable-http and it serves on 127.0.0.1:8000, with the endpoint at /mcp.
One rule matters above all: never print() to stdout in a stdio server. Stdout carries the protocol, and a stray line corrupts it. A check confirmed the server wrote 0 bytes to stdout when launched with empty input. Logs go to stderr by default.
Step 8: Test It in Process
The SDK lets a Client talk to your server object directly, with no subprocess and no network. The messages still pass through the real protocol layer:
from mcp import Client
async with Client(srv.mcp, raise_exceptions=True) as c:
r = await c.call_tool("get_note", {"note_id": 99})
assert r.is_error is True
assert "No note with id 99" in r.content[0].text
Five tests cover the surface: add and list, tool errors, schema validation, resources and prompts, and tool annotations. All five passed. The schema test confirmed that limit=500, an empty title, and note_id="ten" all came back as errors, and that nothing reached the store.
The SDK docs show the same pattern with pytest and a fixture. Without pytest installed, these tests run as a standalone script. Adapt them to pytest using the docs' pattern, and keep raise_exceptions=True in tests only.
Step 9: Run It the Way a Host Does
Hosts launch your server as a subprocess and talk over stdin and stdout. The SDK client can do the same. This is the closest test to the real thing:
from mcp import Client, StdioServerParameters
params = StdioServerParameters(command=sys.executable, args=["server.py"])
async with Client(params) as c:
...
A real run printed this (trimmed):
tools: ['add_note', 'list_notes', 'get_note', 'delete_note']
add_note -> {'id': 1, 'title': 'Over stdio', 'body': 'launched as a subprocess', 'tags': ['demo']}
get_note(42) is_error: True | Error executing tool get_note: No note with id 42. Call list_notes to see valid IDs.
read_resource(notes://42) -> MCPError code -32602
delete_note -> Deleted note 1.
Over HTTP, a client connected to http://127.0.0.1:8000/mcp, added a note, and listed it back with ['Over HTTP'].
The child process doesn't inherit your whole environment. It receives a small allow-list of variables, so pass anything it needs, like an API key, through env=.
Step 10: Connect a Real Host
The SDK docs give the launch command for hosts. Use absolute paths, because hosts start your server from their own working directory:
uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py
To register it with Claude Code, per the docs:
claude mcp add notes -- uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py
For Claude Desktop, the docs offer uv run mcp install server.py, which writes the config for you, and then you fully quit and reopen the app. Cursor and VS Code use small JSON files in your project.
The stdio test launches python server.py through the SDK client, which proves the server speaks stdio correctly, and the docs' uv commands remain untested here. If your host can't find the server, the docs name the usual cause: a relative path.
Design Tips
- Keep tools small. One job per tool beats a Swiss Army knife.
- Name things for the model. Clear verbs, specific docstrings, described arguments.
- Constrain inputs.
Field(ge=1, le=50)stops silly values before your code runs. - Return actionable errors. Say what failed and what to call next.
- Avoid hidden state. The spec advises explicit handles, like the note ID here, because a connection isn't a conversation.
- Persist for real. The in-memory store vanishes when the process restarts, so use a database for anything you keep.
Common Pitfalls
- Printing to stdout. It breaks stdio servers.
- Returning error strings. Raise
ToolErrorinstead. - Trusting annotations. They're hints, not guards.
- Using relative paths in host config. Use absolute ones.
- Mixing SDK versions. Match your tutorial to v1 or v2.
- Skipping tests. In-process tests run in milliseconds, so there's no excuse.
Recommended Books
| Cover | Book | Description | Get it |
|---|---|---|---|
![]() |
AI Agents in Action | - practical agent builds where servers like this one plug into the tool loop. | View on Amazon |
![]() |
Build a Large Language Model (From Scratch) | - the model-side counterpart: how the LLM that calls your tools actually works. | View on Amazon |
![]() |
AI Engineering | - production patterns for the integrations you build on top of MCP. | View on Amazon |
Unlock AI That Actually Works
Get lifetime access to GPT-6 Astra, Claude Fable 5.1, Gemini 3.5, Grok 4.5, and more — all in one platform. Build websites, apps, videos, content, and digital products from a single command. No monthly fees. No tool-hopping.
Click here to get GPTAstra Max now — one-time payment, lifetime access.
Frequently Asked Questions
What does this MCP server tutorial build?
A Notes server on MCP SDK v2.2.0 that stores short notes in memory and exposes four tools (add, list, get, delete), two resources (a plain-text index and one URI per note), and one prompt (a ready-made summarize-my-notes request). It runs three ways: in-process tests, a stdio subprocess the way hosts launch servers, and Streamable HTTP.
What is the difference between ToolError and MCPError?
ToolError is an execution failure the model could plausibly avoid — the result has is_error=True and your message reaches the model, so it can correct itself and retry. MCPError is a protocol failure: the whole call fails with a JSON-RPC error and the model never sees a result. The SDK docs give a simple test: if a smarter model could have avoided the failure, raise ToolError.
Why does a stdio MCP server never print to stdout?
Stdout carries the protocol — each message is one JSON line. A stray print() corrupts the stream and breaks the client. Logs go to stderr by default. A real check confirmed the notes server wrote 0 bytes to stdout when launched with empty input.
How do I register the server with a real host like Claude Desktop?
Use absolute paths — hosts start your server from their own working directory, so relative paths fail. Per the SDK docs: uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py for a generic host, claude mcp add notes -- ... for Claude Code, and uv run mcp install server.py for Claude Desktop (then fully quit and reopen the app). Cursor and VS Code use small JSON files in your project.
Wrapping This Up
An MCP server is decorated functions plus clear docstrings: tools for actions, resources for data, prompts for templates, and ToolError for failures the model can fix. The SDK handles schemas, validation, and transports, and an in-process Client makes it all testable.
Copy the notes server this week and swap the in-memory dict for something you actually use, like a folder of Markdown files or a small database. Then write a one-line docstring for each tool as if the reader has never seen your code — the discipline every tool definition in the frameworks comparison demands.


