
This lesson is part of Learn MCP, our free course on the Model Context Protocol, within The MCP Guide.
In this lesson you will build a small MCP server in Python with two tools. One searches a book catalog, and one looks up an author. You will test it three ways: in memory, with the MCP Inspector CLI, and inside a real host.
A tool is a function the AI model can choose to call. In the Python SDK, you write a normal function and add one decorator. The SDK reads the name, description and input schema from the function itself.
The code adapts the Python SDK README and the SDK's Tools guide. It ran on 28 October 2026 with mcp 2.3.0 on Python 3.12.
Key Takeaways
MCPServerplus@mcp.tool()is the whole API for a basic tool; the function name becomes the tool name and the docstring becomes its description.- Type hints are the input schema.
Annotated[..., Field(...)]adds descriptions and limits that the SDK enforces before your code runs. - Raise
ToolErrorwhen the model made a fixable mistake, so it reads your message and tries again. Client(mcp)tests the server in memory with no subprocess and no port.- Never
print()in a stdio server; stdout carries the protocol.
Step 1: Start from the project you made in lesson 13
You need the bookshop project from lesson 13, with mcp[cli] installed. Check it:
cd bookshop
uv run mcp version
Step 2: Write the server
Replace the contents of main.py or create server.py with this:
from typing import Annotated
from pydantic import Field
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
mcp = MCPServer("Bookshop")
CATALOG = {
"Dune": "Frank Herbert",
"Neuromancer": "William Gibson",
"The Left Hand of Darkness": "Ursula K. Le Guin",
}
@mcp.tool()
def search_books(
query: Annotated[str, Field(description="Part of a title or author name.")],
limit: Annotated[int, Field(ge=1, le=20, description="Maximum results.")] = 5,
) -> list[str]:
"""Search the catalog by title or author. Returns matching titles."""
needle = query.lower()
hits = [t for t, a in CATALOG.items() if needle in t.lower() or needle in a.lower()]
return hits[:limit]
@mcp.tool()
def get_author(title: str) -> str:
"""Look up the author of a book. Use an exact title from search_books."""
if title not in CATALOG:
raise ToolError(f"No book titled {title!r}. Call search_books first.")
return CATALOG[title]
if __name__ == "__main__":
mcp.run()
What each part does:
MCPServer("Bookshop")creates the server. The name is what clients see.@mcp.tool()registers a function as a tool. Per the Tools guide, the function name is the tool name and the docstring is the description the model reads.query: strandlimit: int = 5become a JSON Schema. A parameter with a default is optional.Field(ge=1, le=20)adds a minimum and maximum. The SDK rejectslimit=99before your function runs.ToolErrorsends a readable message back to the model as a failed tool result.mcp.run()with no arguments starts a stdio server. It sits underif __name__ == "__main__":because themcpcommand line tools import your file, and the guard stops an import from starting a server.
Step 3: Test it in memory
The SDK's Testing guide shows that Client accepts the server object directly. Nothing runs on a wire. Create check.py:
import anyio
from mcp import Client
from server import mcp
async def main() -> None:
async with Client(mcp) as client:
tools = await client.list_tools()
for tool in tools.tools:
print(tool.name, "->", tool.input_schema["properties"].keys())
result = await client.call_tool("search_books", {"query": "gibson"})
print(result.structured_content)
bad = await client.call_tool("search_books", {"query": "x", "limit": 99})
print(bad.is_error, bad.content[0].text.splitlines()[0])
missing = await client.call_tool("get_author", {"title": "Dun"})
print(missing.is_error, missing.content[0].text)
anyio.run(main)
Run it:
uv run python check.py
On 28 October 2026 the output was:
search_books -> dict_keys(['query', 'limit'])
get_author -> dict_keys(['title'])
{'result': ['Neuromancer']}
True Error executing tool search_books: 1 validation error for search_booksArguments
True Error executing tool get_author: No book titled 'Dun'. Call search_books first.
Look at the last two lines. Neither call crashed the server. Both came back as results with is_error set to True, which is how the model learns it should try again. Lesson 20 covers this in depth.
The structured_content field holds typed data for the client app, alongside the plain content the model reads. Our spec reference on structured tool output explains the two.
Step 4: Test it with the MCP Inspector
The Python SDK has a shortcut that opens the web Inspector: uv run mcp dev server.py. For a scriptable check, use the Inspector CLI from the Inspector docs:
npx @modelcontextprotocol/inspector --cli \
uv --directory /ABSOLUTE/PATH/TO/bookshop run server.py \
--protocol-era modern \
--method tools/call --tool-name search_books \
--tool-args-json '{"query":"dune"}' --format json
The result on 28 October 2026:
{"result":{"content":[{"type":"text","text":"Dune"}],"structuredContent":{"result":["Dune"]},"isError":false}}
(The real output also includes a _meta block naming the server. It is trimmed here.)
Run the same command with --protocol-era legacy. You should get the same answer. That proves your server works with clients on the 2026-07-28 spec and with older ones.
Now call get_author with a bad title. The Inspector prints the error result and exits with code 5, which its smoke-testing guide defines as "tool error." That exit code is useful in CI.
Step 5: Connect it to a host
The Python SDK's host guide says connecting to a host is one act: you tell the host the command that starts your server. For Claude Desktop the SDK can write the config for you:
uv run mcp install server.py
For Claude Code, the guide's pattern is:
claude mcp add bookshop -- uv run --with "mcp[cli]" mcp run /ABSOLUTE/PATH/TO/server.py
These two host commands come from the SDK guide; unlike the code above, they were not run for this lesson. Fully quit and reopen Claude Desktop after a config change. Then ask, "Who wrote Neuromancer?" The model should call search_books or get_author, and the host should show the tool call before or after it runs.
Step 6: Avoid the three classic failures
The host guide lists the usual reasons a server does not show up:
- A relative path. Hosts start your server from their own working directory. Use absolute paths everywhere.
- An old config. Hosts read config at launch. Restart the host fully.
- Output on stdout. On stdio, stdout is the protocol. The official build tutorial says never write to stdout. Use the
loggingmodule, which writes to stderr.
Before blaming the host, run the launch command yourself. A healthy stdio server prints nothing and waits. A traceback is your real bug.
Try it
Add a third tool called count_books that takes an author name and returns how many titles in CATALOG match. Then:
- Give it a docstring that says what it returns.
- Make the author argument required and describe it with
Field. - Add a call to
check.pyand run it. - Run the Inspector CLI with
--method tools/listand confirm your description appears.
If the model would call it with "Le Guin" and get 0, decide whether that should be a result of 0 or a ToolError with a hint. There is no single right answer, but you should choose on purpose.
Recap
- A Python MCP tool is a decorated function. Name, description and schema come from the function.
- Use
AnnotatedandFieldto describe and constrain arguments. Client(mcp)is your test harness; the Inspector CLI is your end-to-end check.- Test both protocol eras, connect a host with an absolute path, and keep stdout clean.
Next step
Next, lesson 15 builds the same server in TypeScript. For the protocol view of what you just made, read MCP tools explained and tool input and output schemas.
Course navigation
Previous lesson: Lesson 13: Set Up Your Development Environment.
Next lesson: Lesson 15: Build Your First Tool in TypeScript.
Back to the Learn MCP course outline.
Frequently asked questions
Why use MCPServer instead of FastMCP?
MCPServer is the v2 name. The SDK's What's New page says FastMCP was renamed and the old mcp.server.fastmcp import path was removed. Code written for v1 fails on import until you update it.
Should my tools be async?
Use async def when the tool does I/O, such as calling an API or a database. The Tools guide says a plain def tool also works, because the SDK runs it in a thread so it does not block the server.
Do I have to write JSON Schema by hand?
No. The SDK generates it from your type hints and Field settings. You only write schemas by hand if you drop down to the SDK's low-level Server class.
Why did the bad limit value come back as an error result instead of an exception?
Validation failures are tool errors in MCP, because the model chose the arguments and can fix them. The tools spec lists "input validation errors" under tool execution errors reported with isError: true.
Can I run this server over HTTP instead of stdio?
Yes. uv run mcp run server.py --transport streamable-http serves the same file at http://localhost:8000/mcp. Lesson 21 covers what changes when a server leaves your laptop.











