How to Build an MCP Server in Python With the Official SDK

19 min read

,
MCP Inspector web interface showing a server's tools list, a tool result and the raw protocol messages, used to test an MCP server built in Python

You can build a small MCP server in Python without writing protocol messages or JSON Schema by hand. The official v2 SDK turns typed functions into tools. We will use it to make a local notes server, test the tools, then connect it to an AI app.

The Model Context Protocol (MCP) is the open standard that lets AI apps such as Claude Desktop, Claude Code, Cursor and VS Code call tools and read data you provide. Connecting other people’s servers is the easy part, and we have a separate guide on setting up MCP servers in Claude, ChatGPT and Cursor. Writing your own is where MCP gets useful, because you decide exactly what the model can see and do.

The official MCP Python SDK now documents v2 as its stable release line. If you are following an older v1 example, check the import path and server class before copying code: this version uses MCPServer.

What you will build

The official MCP tutorial builds a weather server on the US National Weather Service API, which only returns data for US locations. That is fine for learning but less useful if you live anywhere else. This guide builds something that works the same in any country: a notes server that lets an AI app list, search, read and add Markdown notes stored in one folder on your computer.

By the end, the server will expose:

  • Four tools: list_notes, search_notes, read_note and add_note
  • One resource template, notes://{name}, so an app can attach a note to a chat
  • One prompt, weekly_review, a reusable instruction a user can pick by name
  • A pytest test file that checks the tools without starting a separate process

Everything runs locally over the stdio transport, which is what desktop AI apps use for servers on the same machine. Later sections cover when and how to move the same code to HTTP.

How an MCP server fits in

The SDK documentation uses three words that are worth getting straight before writing any code. A host is the AI application the user talks to, such as Claude Desktop or an IDE. A client lives inside the host and speaks MCP, with one client per connected server. A server is what you build. It exposes capabilities to clients and never talks to the model directly.

Diagram showing AI applications such as Claude Desktop and IDEs connecting through MCP to data sources, development tools and productivity tools
Image: Model Context Protocol

A server can expose exactly three kinds of thing. The difference between them is who decides when they get used:

PrimitiveControlled byWhat it isNotes server example
ToolsThe modelA function the model calls to take an actionsearch_notes, add_note
ResourcesThe applicationData the host loads into the model’s contextnotes://home-lab-ideas
PromptsThe userA reusable message template the user invokes by nameweekly_review

When a client connects, your server declares its capabilities, meaning which kinds of requests it will answer. MCPServer writes that declaration for you. You also never write the JSON-RPC framing or version negotiation. In the 2026-07-28 protocol revision, servers dropped the older initialize handshake, and the SDK handles both the new style and the older one for clients that still need it.

What you need before you start

RequirementWhy you need itHow to check
Python 3.11 or newerThe v2 SDK documentation lists Python 3.11+ as the requirementpython3 --version
uvCreates the project, installs the SDK and launches the server for hostsuv --version
Node.js 22.19 or newermcp dev starts the MCP Inspector through npxnode --version
An MCP hostClaude Desktop, Claude Code, Cursor or VS Code with GitHub CopilotOpen the app once so it creates its config folder
A text editorYou will edit one Python file and maybe one JSON fileAny editor works

Step 1: Install uv

uv is the Python package and project manager the official tutorial uses. On macOS or Linux, run:

curl -LsSf https://astral.sh/uv/install.sh | sh

On Windows, run this in PowerShell:

powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"

Close and reopen your terminal afterwards so the uv command is picked up. If you prefer pip, pip install "mcp[cli]" works too, but the rest of this guide uses uv because the host launch commands depend on it.

Step 2: Create the project

Create a folder for the server, set up a virtual environment and add the SDK with its command-line extra:

uv init notes-mcp
cd notes-mcp
uv venv
source .venv/bin/activate
uv add "mcp[cli]"
mkdir notes

On Windows, activate the environment with .venv\Scripts\activate instead of the source line.

The cli extra adds the mcp command with three subcommands you will use: mcp dev to open the Inspector, mcp run to start a server, and mcp install to register a server in Claude Desktop.

Step 3: Write the server

Create server.py in the project folder and paste in the full server below. It is about 90 lines, and each part is explained after the code.

import logging
import os
from datetime import date
from pathlib import Path
from typing import Annotated

from pydantic import Field

from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations

logger = logging.getLogger(__name__)

NOTES_DIR = Path(os.environ.get("NOTES_DIR", Path(__file__).parent / "notes")).resolve()
NOTES_DIR.mkdir(parents=True, exist_ok=True)

mcp = MCPServer("Notes", instructions="Search, read and add Markdown notes in one folder.")


def note_path(name: str) -> Path:
    """Turn a note name into a safe path inside NOTES_DIR."""
    path = (NOTES_DIR / f"{name}.md").resolve()
    if path.parent != NOTES_DIR:
        raise ToolError(f"{name!r} is not a valid note name.")
    return path


@mcp.tool(annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False))
def list_notes() -> list[str]:
    """List the names of all notes, newest first."""
    files = sorted(NOTES_DIR.glob("*.md"), key=lambda p: p.stat().st_mtime, reverse=True)
    return [p.stem for p in files]


@mcp.tool(annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False))
def search_notes(
    query: Annotated[str, Field(description="Word or phrase to look for.")],
    limit: Annotated[int, Field(ge=1, le=20, description="Maximum number of notes to return.")] = 5,
) -> list[str]:
    """Find notes whose name or text contains the query."""
    needle = query.lower()
    hits = []
    for p in sorted(NOTES_DIR.glob("*.md")):
        if needle in p.stem.lower() or needle in p.read_text(encoding="utf-8").lower():
            hits.append(p.stem)
    logger.info("search %r matched %d notes", query, len(hits))
    return hits[:limit]


@mcp.tool(annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False))
def read_note(name: str) -> str:
    """Return the full text of one note."""
    path = note_path(name)
    if not path.exists():
        raise ToolError(f"No note called {name!r}. Use list_notes to see what exists.")
    return path.read_text(encoding="utf-8")


@mcp.tool(annotations=ToolAnnotations(destructive_hint=False, idempotent_hint=False, open_world_hint=False))
def add_note(
    name: Annotated[str, Field(description="Short note name, letters, numbers and dashes.")],
    text: str,
) -> str:
    """Create a new note. Fails if a note with that name already exists."""
    path = note_path(name)
    if path.exists():
        raise ToolError(f"A note called {name!r} already exists.")
    path.write_text(f"# {name}\n\n_Added {date.today().isoformat()}_\n\n{text}\n", encoding="utf-8")
    return f"Saved {name}."


@mcp.resource("notes://{name}")
def note_resource(name: str) -> str:
    """One note as a resource the app can attach to the chat."""
    return read_note(name)


@mcp.prompt()
def weekly_review(topic: str) -> str:
    """Ask the model to review every note about a topic."""
    return (
        f"Use search_notes to find my notes about {topic}. "
        "Read each one with read_note, then give me a short summary, "
        "open questions and next steps."
    )


if __name__ == "__main__":
    mcp.run()

Here is what each part does:

  • MCPServer("Notes", instructions=...) creates the server. The name is what hosts show, and the instructions describe the server to clients. Note the import path: it is from mcp.server import MCPServer, not from mcp import MCPServer.
  • NOTES_DIR is the one folder the server is allowed to touch. It defaults to the notes folder next to server.py, and you can point it elsewhere with a NOTES_DIR environment variable.
  • note_path() resolves the full path and refuses any name that would escape the folder, such as ../secrets. The model chooses the arguments, so treat every argument as untrusted input.
  • Each @mcp.tool() function becomes a tool. The function name becomes the tool name and the docstring becomes the description the model reads when deciding what to call.
  • @mcp.resource("notes://{name}") makes a resource template. The {name} in the URI maps to the function parameter.
  • @mcp.prompt() turns weekly_review into a prompt. The string it returns becomes a user message.
  • mcp.run() with no arguments starts a stdio server. It sits under if __name__ == "__main__": because mcp dev, mcp run, mcp install and your tests all import the file, and the guard stops an import from starting a server.

Add a sample note so there is something to search. Create notes/home-lab-ideas.md with a couple of lines, such as a reminder to move Pi-hole to a NAS. Any Markdown file in that folder counts as a note.

Step 4: Understand how functions become tools

The SDK builds each tool’s input schema from your type hints, so you never write JSON Schema. For search_notes, a client sees a required string query and an optional integer limit with a default of 5, a minimum of 1 and a maximum of 20.

Three details produce that schema:

  • Plain type hints such as query: str set the type.
  • A default value, such as limit = 5, makes an argument optional. Arguments without defaults are required.
  • Annotated[int, Field(ge=1, le=20, description=...)] adds a per-argument description and numeric bounds. If the model sends a value outside the bounds, the SDK rejects it before your function runs and returns an error the model can read and correct.

The ToolAnnotations on each decorator are hints for the client. read_only_hint=True says a tool changes nothing, open_world_hint=False says it works on a closed set of data rather than the open web, and destructive_hint and idempotent_hint describe tools that write. A well-behaved client may use these to decide whether to ask the user before running a tool. The SDK docs are clear that they are hints, not security, so never rely on a client honouring them.

Step 5: Return errors the model can use

Tools fail all the time: a note does not exist, a name is invalid, a file is already there. How you raise the error decides who sees the message.

What you raiseWhat the model seesWhen to use it
ToolError("message")A result marked is_error with your message, so it can retry or change courseThe call ran but failed, such as a missing note
MCPErrorNo tool result at all, only a protocol errorThe request itself was invalid at the protocol level
Any other exceptionOnly “Error executing tool” with no detailsNever on purpose; it is treated as a crash and logged with a traceback

The SDK docs suggest one question to decide: could a smarter model have avoided this error? If yes, raise ToolError. That is why read_note tells the model to use list_notes when a name is wrong. Also, never return an error message as a normal string. A returned string is not flagged as an error, so the model may treat it as a successful result.

Step 6: Test it in the MCP Inspector

Before connecting any AI app, open the server in the MCP Inspector, the official testing tool:

uv run mcp dev server.py

This launches the Inspector through npx and connects it to your server over stdio, exactly as a host would. Open the URL it prints. The Inspector has tabs for tools, resources and prompts:

  1. On the Tools tab, pick search_notes, enter pi-hole and run it. You should get back ["home-lab-ideas"].
  2. Try read_note with a name that does not exist. The result should be an error containing your ToolError message.
  3. Try read_note with ../secrets. It should fail with the “not a valid note name” message.
  4. On the Resources tab, the list of concrete resources will be empty. notes://{name} appears under Resource Templates, because it needs a name before it can be read. Enter home-lab-ideas to read it.
  5. On the Prompts tab, get weekly_review with a topic and check the generated message.

The Inspector also has a protocol view that shows the raw requests and responses, which helps when a host behaves differently from what you expect.

If you run python server.py directly, nothing prints and the command does not return. That is correct. A stdio server waits for a host to send the first message on standard input. Press Ctrl+C to stop it.

Step 7: Add an automated test

Clicking through the Inspector gets tedious once you have more than a few tools. The SDK’s Client class can connect to your server object in memory, with no subprocess and no port, which makes it a test harness. Add the test dependencies:

Terminal running a Python MCP client with uv, calling the get-alerts tool on a weather MCP server and printing California weather alerts
Image: Model Context Protocol
uv add --dev pytest anyio

Then create test_server.py:

import pytest

from mcp import Client

import server


@pytest.fixture
def anyio_backend():
    return "asyncio"


@pytest.fixture(autouse=True)
def temp_notes(tmp_path, monkeypatch):
    monkeypatch.setattr(server, "NOTES_DIR", tmp_path)


@pytest.mark.anyio
async def test_add_then_search():
    async with Client(server.mcp, raise_exceptions=True) as client:
        await client.call_tool("add_note", {"name": "router-setup", "text": "Changed Wi-Fi channel to 36."})
        result = await client.call_tool("search_notes", {"query": "wi-fi"})
        assert result.structured_content == {"result": ["router-setup"]}


@pytest.mark.anyio
async def test_bad_name_is_rejected():
    async with Client(server.mcp, raise_exceptions=True) as client:
        result = await client.call_tool("read_note", {"name": "../secrets"})
        assert result.is_error

Run it with uv run pytest. The temp_notes fixture points the server at a temporary folder so tests never touch your real notes. raise_exceptions=True only affects failures outside your tool code: it shows the real error message in the test instead of the sanitised “Internal server error” a remote caller would see. A ToolError still comes back as an is_error result either way, which is what the second test checks. The SDK docs recommend leaving the flag on in tests and never relying on it in production code.

Step 8: Connect the server to Claude Desktop

Claude Desktop is the one host the SDK can configure for you. Install and open Claude Desktop once so it creates its config folder, then run this from the project folder:

uv run mcp install server.py

mcp install imports your file to read the server name, finds Claude Desktop’s config file, and writes a launch command with absolute paths. If your server needs settings from the environment, add them with -v NAME=value or -f .env, because Claude Desktop starts the server in its own process without your shell variables. For example, uv run mcp install server.py -v NOTES_DIR=/Users/you/Documents/notes points the server at a different folder.

If you prefer to edit the config by hand, the file is claude_desktop_config.json in these locations:

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: %APPDATA%\Claude\claude_desktop_config.json
  • Linux: ~/.config/Claude/claude_desktop_config.json

Add an entry like this under mcpServers, using the absolute path to your server.py:

{
  "mcpServers": {
    "notes": {
      "command": "uv",
      "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/notes-mcp/server.py"]
    }
  }
}

On Windows, write the path with forward slashes, such as C:/Users/you/notes-mcp/server.py, or escape each backslash by doubling it. Hosts start servers with a minimal PATH, so if Claude Desktop cannot find uv, replace "uv" with the full path from which uv on macOS and Linux or where uv on Windows. That is what mcp install writes for you.

Fully quit Claude Desktop and reopen it, because it only reads the config at launch. Closing the window is not enough. On Windows and Linux, quit from the system tray icon. On macOS, press Cmd+Q. Then open the “Add files, connectors, and more” menu next to the message box and look under Connectors for your server.

Claude Desktop Connectors menu listing a local MCP server with its toggle switched on
Image: Model Context Protocol

Now ask something like “What did I write about Pi-hole?” Claude should decide to call search_notes, then read_note, and answer using the results. Claude Desktop asks for your approval before running a tool, and the reply shows which tool and which server produced the result, as in this example from the official weather tutorial:

Claude Desktop answering a question using a result from a local MCP server tool
Image: Model Context Protocol

Step 9: Use the same server in Claude Code, Cursor or VS Code

Every host gets the same launch command. Only the place you put it changes:

HostWhere the config goesKey detail
Claude Desktopclaude_desktop_config.json, or let mcp install write itServers live under mcpServers; fully quit and reopen after edits
Claude CodeNo file; run claude mcp add notes -- uv run --with "mcp[cli]" mcp run /absolute/path/to/server.pyType /mcp in a session to confirm it is connected
Cursor.cursor/mcp.json in the project rootSame mcpServers format as Claude Desktop
VS Code.vscode/mcp.json in the project rootWrapper key is servers, each entry needs "type": "stdio", and Copilot Chat must be in Agent mode
Claude Available MCP Tools list showing create_directory, get_file_info and list_allowed_directories from a filesystem MCP server
Image: Model Context Protocol

VS Code needs version 1.99 or later with the GitHub Copilot extension signed in, and the free Copilot tier is enough. After you accept the trust prompt, run MCP: List Servers from the Command Palette to check it is running.

Logging without breaking the connection

This catches nearly everyone once. On the stdio transport, standard output carries the protocol messages. Anything else written there can corrupt the JSON-RPC stream and break the connection. The official tutorial’s rule is simple: in a stdio server, do not use print() for debugging. Use the logging module, which writes to standard error by default:

import logging

logger = logging.getLogger(__name__)
logger.info("search %r matched %d notes", query, len(hits))

Version 2 of the SDK is more forgiving than older versions. While serving, it moves the protocol to a private descriptor and diverts flushed stray output to standard error. Output written before the server starts, such as a wrapper script that echoes text or a print() at import time, can still break things, so the logging habit is worth keeping. Servers that use the HTTP transport are not affected, because their protocol does not travel over standard output.

To get more detail, create the server with MCPServer("Notes", log_level="DEBUG"). Claude Desktop also writes a log per server: mcp-server-notes.log holds your server’s standard error and mcp.log covers connections. On macOS they are in ~/Library/Logs/Claude, and on Windows in %APPDATA%\Claude\logs. To follow them live on a Mac:

tail -n 20 -f ~/Library/Logs/Claude/mcp*.log

When to switch to Streamable HTTP

Stdio is right for a personal tool on one machine, because the host launches your file directly. To share a server with people who do not have your file, you give them a URL instead, and the same mcp object runs over Streamable HTTP.

TransportHow it worksUse it for
stdioThe host launches your file and talks over its standard input and outputLocal servers; this is the default
Streamable HTTPYour server listens on a port and clients connect to a URLAnything you deploy or share
SSEThe older HTTP transport, replaced by Streamable HTTP in the 2025-03-26 protocol revisionOnly for old clients; do not build new servers on it

Switching is one line. Transport options go to run(), not to the MCPServer constructor:

if __name__ == "__main__":
    mcp.run(transport="streamable-http", port=3001)

Clients then connect to http://127.0.0.1:3001/mcp. The host defaults to 127.0.0.1, the port to 8000 and the endpoint path to /mcp. You can also start the HTTP version without editing the file, using uv run mcp run server.py --transport streamable-http.

Two things change once a server leaves localhost. First, the app the SDK builds turns on DNS-rebinding protection and accepts only requests addressed to localhost until you list your real hostname in transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...]). Passing a public host= does not allowlist it. Second, a public server needs authorization in front of it. The SDK has OAuth support for that, and its deployment docs cover both steps. Do not expose a notes server like this one to the internet without both.

Troubleshooting common problems

SymptomLikely causeFix
Server does not appear in the hostRelative path in the config, or the server itself crashesUse absolute paths, and run the launch command in a terminal first: it should sit silently and wait
Config edits have no effectThe host is still running the old configFully quit the app, not just the window, and reopen it
Connection drops or garbled messagesSomething wrote to standard outputReplace print() with logging and check wrapper scripts
mcp install says Claude app not foundClaude Desktop has never been openedInstall Claude Desktop and run it once to create its config folder
mcp dev says npx not foundNode.js is missing or not on PATHInstall Node.js 22.19 or newer
ImportError for MCPServerOld 1.x SDK installed, or the wrong import pathUpgrade with uv add "mcp[cli]" and import from mcp.server
TypeError about an unexpected port argumentTransport options passed to MCPServer()Pass port and host to mcp.run() instead

Keep your first server safe

An MCP server runs with your user account’s permissions, and the model decides which tools to call and with what arguments. A few habits keep that manageable:

  • Limit scope. The notes server only touches one folder, and note_path() enforces it. Give every file or database tool the same kind of boundary.
  • Prefer read-only tools. Start with tools that read, and add write tools only when you need them. add_note refuses to overwrite an existing file rather than replacing it.
  • Validate arguments. Use type hints and Field bounds so bad input is rejected before your code runs, then check anything the types cannot express.
  • Keep secrets out of code. Pass API keys through the env field in the host config, or -v and -f with mcp install, rather than hard-coding them.

If you want the model side to stay on your machine as well, local models are worth a look. Our guide on running AI models locally with Ollama covers that setup. A local notes server and a local model together keep the data and the reasoning on your own hardware, provided the host app you pair them with supports both.

Frequently asked questions

What is the difference between FastMCP and MCPServer?

Older tutorials import a high-level class called FastMCP from the 1.x SDK. In version 2 of the official SDK, the high-level server class is MCPServer, imported with from mcp.server import MCPServer. The SDK publishes a migration guide covering every breaking change between the two versions.

Does an MCP server work with ChatGPT or other AI apps?

Any app that acts as an MCP host can use your server, as long as it supports the transport you run. This guide covers Claude Desktop, Claude Code, Cursor and VS Code because the SDK documents their setup. A host that cannot launch a local file needs the Streamable HTTP version running at a URL it can reach, with authorization in front of it.

How do I give my server an API key?

Read it from an environment variable with os.environ, then supply the value in the host config. With Claude Desktop, uv run mcp install server.py -v API_KEY=your-key records it in the entry’s env field. Never write the key into server.py itself.

When should I use a resource instead of a tool?

Use a tool when the model should decide to fetch or change something. Use a resource when the application should choose data to load into the conversation. If you are unsure, start with a tool, because the model can call it on its own when it decides it needs the data.

About the author