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_noteandadd_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.

A server can expose exactly three kinds of thing. The difference between them is who decides when they get used:
| Primitive | Controlled by | What it is | Notes server example |
|---|---|---|---|
| Tools | The model | A function the model calls to take an action | search_notes, add_note |
| Resources | The application | Data the host loads into the model’s context | notes://home-lab-ideas |
| Prompts | The user | A reusable message template the user invokes by name | weekly_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
| Requirement | Why you need it | How to check |
|---|---|---|
| Python 3.11 or newer | The v2 SDK documentation lists Python 3.11+ as the requirement | python3 --version |
| uv | Creates the project, installs the SDK and launches the server for hosts | uv --version |
| Node.js 22.19 or newer | mcp dev starts the MCP Inspector through npx | node --version |
| An MCP host | Claude Desktop, Claude Code, Cursor or VS Code with GitHub Copilot | Open the app once so it creates its config folder |
| A text editor | You will edit one Python file and maybe one JSON file | Any 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 | shOn 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 notesOn 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 isfrom mcp.server import MCPServer, notfrom mcp import MCPServer.NOTES_DIRis the one folder the server is allowed to touch. It defaults to thenotesfolder next toserver.py, and you can point it elsewhere with aNOTES_DIRenvironment 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()turnsweekly_reviewinto a prompt. The string it returns becomes a user message.mcp.run()with no arguments starts a stdio server. It sits underif __name__ == "__main__":becausemcp dev,mcp run,mcp installand 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: strset 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 raise | What the model sees | When to use it |
|---|---|---|
ToolError("message") | A result marked is_error with your message, so it can retry or change course | The call ran but failed, such as a missing note |
MCPError | No tool result at all, only a protocol error | The request itself was invalid at the protocol level |
| Any other exception | Only “Error executing tool” with no details | Never 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.pyThis 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:
- On the Tools tab, pick
search_notes, enterpi-holeand run it. You should get back["home-lab-ideas"]. - Try
read_notewith a name that does not exist. The result should be an error containing yourToolErrormessage. - Try
read_notewith../secrets. It should fail with the “not a valid note name” message. - 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. Enterhome-lab-ideasto read it. - On the Prompts tab, get
weekly_reviewwith 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:

uv add --dev pytest anyioThen 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_errorRun 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.pymcp 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.

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:

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:
| Host | Where the config goes | Key detail |
|---|---|---|
| Claude Desktop | claude_desktop_config.json, or let mcp install write it | Servers live under mcpServers; fully quit and reopen after edits |
| Claude Code | No file; run claude mcp add notes -- uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py | Type /mcp in a session to confirm it is connected |
| Cursor | .cursor/mcp.json in the project root | Same mcpServers format as Claude Desktop |
| VS Code | .vscode/mcp.json in the project root | Wrapper key is servers, each entry needs "type": "stdio", and Copilot Chat must be in Agent mode |

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*.logWhen 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.
| Transport | How it works | Use it for |
|---|---|---|
| stdio | The host launches your file and talks over its standard input and output | Local servers; this is the default |
| Streamable HTTP | Your server listens on a port and clients connect to a URL | Anything you deploy or share |
| SSE | The older HTTP transport, replaced by Streamable HTTP in the 2025-03-26 protocol revision | Only 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
| Symptom | Likely cause | Fix |
|---|---|---|
| Server does not appear in the host | Relative path in the config, or the server itself crashes | Use absolute paths, and run the launch command in a terminal first: it should sit silently and wait |
| Config edits have no effect | The host is still running the old config | Fully quit the app, not just the window, and reopen it |
| Connection drops or garbled messages | Something wrote to standard output | Replace print() with logging and check wrapper scripts |
mcp install says Claude app not found | Claude Desktop has never been opened | Install Claude Desktop and run it once to create its config folder |
mcp dev says npx not found | Node.js is missing or not on PATH | Install Node.js 22.19 or newer |
ImportError for MCPServer | Old 1.x SDK installed, or the wrong import path | Upgrade with uv add "mcp[cli]" and import from mcp.server |
TypeError about an unexpected port argument | Transport 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_noterefuses to overwrite an existing file rather than replacing it. - Validate arguments. Use type hints and
Fieldbounds 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
envfield in the host config, or-vand-fwithmcp 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.
- SourceMCP: Build an MCP server
- SourceMCP Python SDK: Testing
- SourceMCP Python SDK: Tools
- SourceMCP Inspector on GitHub
- SourcePyPI: mcp












