TutorialAgents & automation
How to build an MCP server in Python with the official SDK
Build an MCP server in Python with the official mcp SDK: add a tool and a resource, run it over stdio or HTTP, and test it in the MCP Inspector.
To build an MCP server in Python, install the official mcp package, create an MCPServer object, and turn plain functions into tools and resources with decorators. Run the file over stdio for a local client or over Streamable HTTP for a deployed one, then open it in the MCP Inspector before any assistant touches it. The whole server below fits in about 40 lines.
The Python SDK changed shape in version 2.0. Many tutorials that rank for this topic still import FastMCP from mcp.server.fastmcp and serve over SSE. In the current SDK that import raises ModuleNotFoundError, and the docs tell you not to build anything new on SSE. Every snippet here uses the v2 API.
What does an MCP server expose?
An MCP server offers three kinds of thing to a client. The SDK documentation splits them by who decides to use them:
| Primitive | Who triggers it | Typical use |
|---|---|---|
| Tool | The model | Run a query, call an API, write a record |
| Resource | The host application | Load a file, a record or an API response into context |
| Prompt | The user | A saved message template, often shown as a slash command |
A tool is the closest thing to a POST endpoint: it does work and can have side effects. A resource is closer to a GET: it returns data and changes nothing. The server never talks to the model directly. The host (Claude Desktop, Claude Code, an IDE) runs an MCP client for each server it connects to.
Install the MCP Python SDK
The official MCP Python SDK (opens in a new tab) needs Python 3.10 or later. The package on PyPI is called mcp (opens in a new tab), and its current release is 2.3.0. The official build-server tutorial requires 2.0.0 or higher.
The project docs use uv, so the steps below do too:
uv init notes-server
cd notes-server
uv add "mcp[cli]"
The cli extra adds the mcp command (mcp dev, mcp run, mcp install, mcp version). With pip, the equivalent is pip install "mcp[cli]". If you never need the command-line tool, plain mcp is enough.
Build an MCP server in Python: a minimal example
The example below gives an assistant read and write access to a small set of notes. The notes live in a dictionary so the file runs as is. In a real server that dictionary becomes your database or API client.
Create server.py:
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
mcp = MCPServer("Notes")
# Illustrative sample data. Swap in your own database or API calls.
NOTES: dict[str, str] = {
"standup": "Ship the billing fix before Friday.",
"ideas": "Cache the search results for an hour.",
}
@mcp.tool()
def search_notes(query: str) -> list[str]:
"""Return the IDs of notes whose text contains the query."""
needle = query.lower()
return [note_id for note_id, text in NOTES.items() if needle in text.lower()]
@mcp.tool()
def add_note(note_id: str, text: str) -> str:
"""Save a new note under an ID that is not already taken."""
if note_id in NOTES:
raise ToolError(f"A note called {note_id!r} already exists.")
NOTES[note_id] = text
return f"Saved {note_id}."
@mcp.resource("notes://index")
def note_index() -> str:
"""Every note ID, one per line."""
return "\n".join(sorted(NOTES))
@mcp.resource("notes://by-id/{note_id}")
def read_note(note_id: str) -> str:
"""The full text of one note."""
return NOTES.get(note_id, f"No note with ID {note_id}.")
if __name__ == "__main__":
mcp.run()
How the SDK reads your functions
You never write a JSON Schema. The SDK takes the tool name from the function name, the description from the docstring and the input schema from the type hints. query: str is the whole schema for search_notes.
Why the tool raises ToolError
Raising ToolError returns a result marked as an error, with your message where the model can read it, so the model can retry with better arguments. The SDK docs warn against returning an error string instead: a returned string looks like a successful answer to the client.
Static resources and resource templates
notes://index is a fixed resource. notes://by-id/{note_id} is a resource template: the placeholder must match a function parameter by name, or the decorator fails at import time. Templates appear under resources/templates/list instead of the plain resource list. The function body runs only when a client reads that URI, never during listing.
A prompt works the same way with @mcp.prompt(). The string it returns becomes a user message.
Running the server over stdio or Streamable HTTP
mcp.run() with no arguments uses stdio. The host starts your file as a child process and exchanges messages over its stdin and stdout. Nothing listens on a port. Keep run() under the if __name__ == "__main__": guard, because mcp dev, mcp run and your tests all import the file.
To serve the same object over HTTP, name the transport:
if __name__ == "__main__":
mcp.run(transport="streamable-http", port=3001)
Clients then connect to http://127.0.0.1:3001/mcp. Without arguments, the HTTP transport listens on 127.0.0.1, port 8000, at the path /mcp. Transport options such as host and port go to run(), never to the MCPServer(...) constructor.
You can also switch transport from the command line without touching the file:
uv run mcp run server.py --transport streamable-http
mcp run skips your __main__ block and calls run() itself, forwarding only --transport. That command serves the default address, http://localhost:8000/mcp.
Choosing a transport
| Question | stdio | Streamable HTTP |
|---|---|---|
| Who starts the server | The host, as a subprocess | You, as a long-running process |
| Where it runs | Same machine as the host | Any machine the client can reach |
| Address | None | http://127.0.0.1:8000/mcp by default |
| What stdout is for | The protocol stream only | Free for normal logging |
| Good fit | A personal tool over local files or a private API | A server shared with other people or deployed |
SSE is the older HTTP transport. The SDK still runs it for clients that have not moved, and the docs say not to start new work on it.
How to test the server with the MCP Inspector
The MCP Inspector (opens in a new tab) is the official tool for poking at a server before a real client connects. It is a Node.js app, so you need npx on your PATH. The current Inspector (2.x) requires Node 22.19.0 or later.
Step 1: open the web UI
uv run mcp dev server.py
mcp dev launches the server over stdio under the Inspector. Open the URL it prints. The Inspector has a tab per primitive. Under Tools, call search_notes with query set to billing and you should get ["standup"] back. Under Resources, notes://index is in the list, and notes://by-id/{note_id} sits under Resource Templates.
If your server needs extra packages, add them with --with, for example uv run mcp dev server.py --with httpx.
Step 2: script the checks with the Inspector CLI
The Inspector also has a CLI mode that prints results and exits with a status code, which suits CI:
npx @modelcontextprotocol/inspector --cli uv run server.py --method tools/list
npx @modelcontextprotocol/inspector --cli uv run server.py \
--method tools/call --tool-name add_note \
--tool-arg note_id=todo --tool-arg text="Renew the TLS cert"
npx @modelcontextprotocol/inspector --cli uv run server.py \
--method resources/read --uri notes://index
Two details from the Inspector docs matter here. The server command must come first, straight after --cli, or the Inspector silently drops it and falls back to its saved catalog. A tools/call that returns an error exits with code 5, so a failing tool stops a shell && chain. The Inspector smoke-testing guide (opens in a new tab) covers JSON output and the full exit-code list.
Step 3: test the HTTP endpoint
Start the server with uv run mcp run server.py --transport streamable-http in one terminal, then point the Inspector at the URL from another:
npx @modelcontextprotocol/inspector --cli \
--transport http --server-url http://localhost:8000/mcp --method tools/list
For unit tests, the SDK's Client class accepts the server object itself (Client(mcp)) and connects in memory, without a subprocess or a port.
Connecting the server to a client
Every stdio host needs the same thing: the command that starts your server. The SDK docs suggest a launch command that works from any directory:
uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py
uv run mcp install server.py --name "Notes" writes that entry into Claude Desktop's config for you. For Claude Code, the SDK docs give claude mcp add notes -- uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py, and /mcp inside a session confirms the connection. The official build-server tutorial (opens in a new tab) walks through the Claude Desktop JSON by hand. Hosts start servers with a minimal PATH, so use the absolute path to uv if the host cannot find it.
Security: what your MCP server can reach
A stdio server is an ordinary local process started by the host. It can read any file, call any network address and use any credential that the process can reach. Nothing in the SDK limits what a tool function does, so the limits have to live in your code.
- Scope each tool narrowly. The model decides when to call a tool and fills in the arguments. Expose
search_notes, notrun_sql(query). Prefer read-only tools, and add write tools only where the assistant needs them. - Validate arguments inside the tool and raise
ToolErrorwith a clear message when they fail. - Pass secrets through environment variables in the host's config (
mcp installtakes-v KEY=VALUEor-f .env), never in tool output. - Never write to stdout in a stdio server. A stray
print()can corrupt the message stream. Use theloggingmodule, which writes to stderr. - Keep HTTP servers on localhost until you add authorization. By default the SDK binds
127.0.0.1and turns on DNS-rebinding protection, which accepts only localhostHostheaders. Passing a non-localhosthost=switches that check off unless you configuretransport_securitywith an explicit allowlist. - The HTTP transport rejects request bodies over 4 MiB by default (
max_request_body_size).
The Inspector needs the same care. Its web server binds 127.0.0.1 and guards its API with a random token per launch. The README warns that on machines without an OS keychain it stores OAuth tokens and stdio env values in ~/.mcp-inspector/secrets.json, unencrypted unless you supply a key.
Frequently asked questions
Is FastMCP still part of the MCP Python SDK?
The class was renamed to `MCPServer` in version 2.0, and its module moved from `mcp.server.fastmcp` to `mcp.server.mcpserver`. The old import path was removed rather than deprecated, so it raises `ModuleNotFoundError`. The decorators carry over unchanged, so porting a v1 server is mostly a change of imports.
Can an MCP server written in Python call an async API?
Yes. The official tutorial defines its tools as `async def` functions that await an HTTP client, and the SDK handles both sync and async functions with the same decorators. Use async when a tool waits on the network so one slow call does not block the others.
Do I need Node.js to build a Python MCP server?
The server itself needs only Python 3.10 or later and the `mcp` package. Node.js is needed only for the MCP Inspector, which `mcp dev` starts through `npx`. The current Inspector requires Node 22.19.0 or later.
How do I deploy a Python MCP server for other people to use?
Serve it over Streamable HTTP and give users a URL instead of a launch command. Behind a real hostname, configure `transport_security` with your allowed hosts, or every request is refused with a 421 error. The SDK's Authorization guide covers adding tokens before the server goes public.
Sources
- 1.What is the Model Context Protocol (MCP)? - Model Context ProtocolOfficial docsPrimarymodelcontextprotocol.io
- 2.Build an MCP server - Model Context ProtocolOfficial docsPrimarymodelcontextprotocol.io
- 3.GitHub - modelcontextprotocol/python-sdk: The official Python SDK for Model Context Protocol servers and clients · GitHubRepositoryPrimarygithub.com
- 4.GitHub - modelcontextprotocol/inspector: Visual testing tool for MCP servers · GitHubRepositoryPrimarygithub.com
- 5.pypi.org/project/mcpWeb
- 6.py.sdk.modelcontextprotocol.io/get-started/first-stepsWeb
- 7.py.sdk.modelcontextprotocol.io/servers/resourcesWeb
- 8.py.sdk.modelcontextprotocol.io/runWeb
- 9.py.sdk.modelcontextprotocol.io/servers/handling-errorsWeb
- 10.py.sdk.modelcontextprotocol.io/get-started/testingWeb
- 11.py.sdk.modelcontextprotocol.io/get-started/real-hostWeb
- 12.py.sdk.modelcontextprotocol.io/run/deployWeb
- 13.py.sdk.modelcontextprotocol.io/migrationWeb
- 14.github.com/modelcontextprotocol/inspector/blob/main/docs/cli-smoke-testing.mdRepositoryPrimary
- 15.github.com/modelcontextprotocol/inspector/blob/main/docs/environment-variables.mdRepositoryPrimary
- 16.raw.githubusercontent.com/modelcontextprotocol/inspector/main/package.jsonWeb
Spotted an error? See our corrections policy.
Enjoyed this? Get the weekly brief.
New guides, Project & Tool of the Week and the AI developments that matter. One email, no spam.
Read next
ExplainerAgents & automationAI agents found in US bank accounts: what banks are blocking and why
See which banks are restricting AI agents in bank accounts, how read-only access differs from credential sharing, and which claims remain unverified.
10 min read
- TutorialAgents & automation
How to test MCP server tools with the Inspector, unit tests and a client script
Test MCP server tools in three layers: explore them in the MCP Inspector, unit test the tool logic, and run a scripted client check in CI.
7 min read
- Weekly digest
The week on HuiaHub: sandboxed agents, RAG tooling and local models
41 new HuiaHub pages this week: sandboxing coding agents, RAG retrieval, local models on Macs and phones, Claude Haiku 5.5 and six how-to guides.
4 min read