
Ask an AI assistant "what's left to fix in my project?" and it has to guess. It can't see your files unless you paste them in.
MCP fixes that. You write a small program that exposes a few functions, and any MCP-aware app can call them.
In this post you'll build one. It's a Python server that scans a project for TODO, FIXME and HACK comments and hands them to the model on request. By the end it'll be running inside a real AI app.
I picked a TODO finder because it's useful, the tool itself needs nothing beyond the standard library, and you can check the output by eye.
Good to know: MCP changed a lot in 2026. The 2026-07-28 spec made the protocol stateless, and the official Python SDK shipped v2 on July 28, with
FastMCPrenamed toMCPServer. If an older tutorial's imports fail on you, that's why. I'll flag the differences as we go.
The 30-minute plan
Step | What you do | Rough time |
|---|---|---|
1 | Install the SDK | 3 min |
2 | Run a tiny server in the Inspector | 5 min |
3 | Write the real TODO-finder tool | 10 min |
4 | Test it with pytest | 5 min |
5 | Connect it to a real AI app | 7 min |
You'll need:
Python 3.11 or newer, which is what the SDK requires
uv, to manage the project
Node.js, only for the Inspector (a debugging UI)
One MCP host: Claude Desktop, Claude Code, Cursor or VS Code
MCP in two minutes
MCP stands for Model Context Protocol. Anthropic introduced it in November 2024 as an open standard.
In December 2025 it moved to the Agentic AI Foundation, a fund under the Linux Foundation. So it's no longer one company's project.
There are three roles:
Host: the AI app you talk to (Claude, Cursor, VS Code).
Client: a connector inside the host. It runs one per server.
Server: the thing you're about to write.
flowchart LR
You([You]) --> Host["Host<br/>Claude, Cursor, VS Code"]
Host <--> Model["Language model"]
Host --> Client["MCP client"]
Client <-->|"stdio or HTTP"| Server["Your MCP server<br/>server.py"]
Server --> Files[("Your project files")]The model never talks to your server directly. The host sits in the middle and stays in control of what gets called.
A server can offer three kinds of things. What separates them is who decides to use them:
Primitive | Who decides | What it is | Example |
|---|---|---|---|
Tools | The model | A function it can call |
|
Resources | The app | Read-only data loaded into context | A file, a database record |
Prompts | The user | A reusable template you pick by name | "Summarize this incident" |
Tools are what you'll use most. This post builds two tools and one prompt.
One more thing: how do messages travel? There are two standard transports.
stdio runs your server as a local subprocess. Streamable HTTP runs it as a web service. We'll start with stdio and try HTTP at the end.
Step 1: Install the SDK
uv init dev-notes
cd dev-notes
uv add "mcp[cli]"uv init creates the project. uv add installs the SDK. The [cli] extra gives you the mcp command, which we'll use for debugging.
Check your Python version with uv run python --version. It should say 3.11 or higher.
Staying on v1 for now? The 1.x line is in maintenance mode and only gets critical fixes and security patches. If you can't migrate yet, pin it with
mcp>=1.28,<2. Everything in this post is v2.
Step 2: A tiny server in the Inspector
Create a file called server.py:
from mcp.server import MCPServer
mcp = MCPServer("Demo")
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + bNow run it:
uv run mcp dev server.pyThis starts your server and opens the MCP Inspector, a web UI for poking at it. It needs npx on your PATH, because the Inspector is a Node app.
Open the URL it prints, go to the Tools tab, and call add with a = 1 and b = 2. You get 3 back.
Look at what you didn't write. There's no JSON Schema, no request parsing and no protocol code.
The type hints a: int, b: int are the schema. The docstring is the description the model reads.
Coming from an older tutorial?
from mcp.server.fastmcp import FastMCPis the v1 spelling. In v2 you writefrom mcp.server import MCPServer. The@mcp.tool()decorator works the same way.
Step 3: The real tool
Replace everything in server.py with this:
import os
import re
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
mcp = MCPServer("Dev Notes")
# The only folder this server is allowed to read.
ROOT = Path(os.environ.get("PROJECT_ROOT", ".")).resolve()
MARKER = re.compile(r"\b(TODO|FIXME|HACK)\b[:\s-]*(.*)")
SKIP_DIRS = {".git", "node_modules", ".venv", "__pycache__", "dist", "build"}
SOURCE_SUFFIXES = {".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs", ".java", ".md", ".sh", ".yml", ".yaml", ".toml"}
# Hints for the host: these tools only read, and only inside one folder.
READ_ONLY = ToolAnnotations(read_only_hint=True, open_world_hint=False)
@mcp.tool(annotations=READ_ONLY)
def project_root() -> str:
"""Show which folder this server is allowed to read."""
return str(ROOT)
@mcp.tool(annotations=READ_ONLY)
def find_todos(
folder: Annotated[str, Field(description="Folder to scan, relative to the project root. Use '.' for everything.")] = ".",
limit: Annotated[int, Field(ge=1, le=200, description="Maximum number of results.")] = 50,
) -> list[str]:
"""Find TODO, FIXME and HACK comments in a project's source files."""
start = (ROOT / folder).resolve()
if not start.is_relative_to(ROOT):
raise ToolError("That folder is outside the project root.")
if not start.is_dir():
raise ToolError(f"{folder!r} is not a folder inside the project.")
results: list[str] = []
for dirpath, dirnames, filenames in os.walk(start):
dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS)
for name in sorted(filenames):
path = Path(dirpath) / name
if path.suffix not in SOURCE_SUFFIXES:
continue
if not path.resolve().is_relative_to(ROOT):
continue # a symlink pointing outside the project
try:
lines = path.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeDecodeError):
continue
for number, line in enumerate(lines, start=1):
match = MARKER.search(line)
if match is None:
continue
where = path.relative_to(ROOT).as_posix()
results.append(f"{where}:{number} {match.group(1)}: {match.group(2).strip()}")
if len(results) >= limit:
return results
return results
@mcp.prompt()
def triage_todos(folder: str) -> str:
"""Ask the model to sort a folder's TODOs by urgency."""
return (
f"Use the find_todos tool on the folder {folder!r}. "
"Then group what you find into 'fix now', 'fix soon' and 'someday', "
"and give a one-line reason for each group."
)
if __name__ == "__main__":
mcp.run()It's about 80 lines. Most of the length is the folder fence and the error handling. Here's what matters.
Your function is the schema
The SDK builds the tool's input schema from your type hints and Field(...) metadata.
Field(description=...) is extra text the model reads. ge=1, le=200 become minimum and maximum values in the schema.
If the model sends limit=999, the SDK rejects the call before your function runs. The model gets the error and retries with a valid number. You wrote the bound once and got that for free.
The folder fence
A tool that reads files will be asked, sooner or later, to read the wrong ones. So the server can only see one folder, ROOT.
The key lines are (ROOT / folder).resolve() followed by is_relative_to(ROOT). Resolving first means ../.. can't sneak out.
The loop also skips folders like node_modules and .git, and skips symlinks that point outside the project.
Errors the model can fix
There are two kinds of failure, and they behave differently:
What you do | What the model sees |
|---|---|
| Your message, in a normal result flagged as an error. It can read it and retry. |
Any other exception | Only "Error executing tool find_todos". The details stay in your server log. |
The SDK docs give a good rule of thumb. Ask yourself: could a smarter model have avoided this error? If yes, raise ToolError.
And never return an error string. A returned string looks like success to the model, so it'll treat your error message as the answer.
Hints for the host
ToolAnnotations(read_only_hint=True, open_world_hint=False) tells the host that these tools only read, and only from a closed set of things. A host can use that to decide whether to ask you first.
They're hints, not security. Don't rely on a client honouring them. The folder fence is your real protection.
One prompt
triage_todos is the third primitive. It's a function that returns text, and the user picks it by name. It's handy for workflows you repeat.
Try it
Make a small folder with a couple of TODOs to find. Inside dev-notes, create demo/sample.py:
def checkout(cart):
# TODO: handle empty carts
total = sum(item.price for item in cart)
# FIXME: tax is hard-coded
return total * 1.2Run the Inspector again from the dev-notes folder:
uv run mcp dev server.pyCall project_root first. It should print your dev-notes folder, which is where you ran the command. That's the only place the server is allowed to read.
If it prints a different folder, set PROJECT_ROOT to the right one before you launch. The demo below assumes it's dev-notes.
Then call find_todos with folder set to demo. You should get:
demo/sample.py:2 TODO: handle empty carts
demo/sample.py:4 FIXME: tax is hard-codedTry folder set to ../.. too. You'll get the "outside the project root" error, which is exactly what we want.
Step 4: Test it without a host
You don't need Claude open to test a server. The SDK's Client can connect to your server object directly, in memory. No subprocess, no port.
It's the same idea as FastAPI's TestClient. Create test_server.py next to server.py:
import pytest
from mcp import Client
import server
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.fixture
def project(tmp_path, monkeypatch):
(tmp_path / "app.py").write_text("x = 1\n# TODO: add retries\n")
(tmp_path / "node_modules").mkdir()
(tmp_path / "node_modules" / "lib.js").write_text("// TODO: ignore me\n")
monkeypatch.setattr(server, "ROOT", tmp_path.resolve())
return tmp_path
@pytest.fixture
async def client(project):
async with Client(server.mcp, raise_exceptions=True) as c:
yield c
@pytest.mark.anyio
async def test_finds_todo_and_skips_node_modules(client):
result = await client.call_tool("find_todos", {})
assert result.structured_content == {"result": ["app.py:2 TODO: add retries"]}
@pytest.mark.anyio
async def test_refuses_to_leave_the_project(client):
result = await client.call_tool("find_todos", {"folder": "../.."})
assert result.is_error
assert "outside the project root" in result.content[0].text
@pytest.mark.anyio
async def test_project_root(client, project):
result = await client.call_tool("project_root", {})
assert result.structured_content == {"result": str(project.resolve())}Install pytest and run it:
uv add --dev pytest
uv run pytestYou should see three passing tests. A few notes on how it works:
Client(server.mcp)talks to the server object in memory.monkeypatchpointsROOTat a temporary folder, so the tests never touch your real files.A
ToolErrorcomes back as a result withis_errorset, not as a Python exception. So you assert on the result.raise_exceptions=Trueis for failures outside your tools. Without it, the server hides the details. It's meant for tests only.
Step 5: Plug it into a real AI app
A host launches your server as a child process and talks to it over stdin and stdout. So connecting is just telling the host which command starts your server.
That command is:
uv run --with "mcp[cli]" mcp run /absolute/path/to/server.pyTwo rules save most of the pain:
Use absolute paths everywhere. The host starts your server from its own working directory, not yours.
Pass
PROJECT_ROOTexplicitly. Your shell's environment variables aren't passed along. Without it, the server scans whatever folder the host started in.
Point PROJECT_ROOT at a real project you want to scan.
Claude Desktop
The SDK can write the config for you:
uv run mcp install server.py --name "Dev Notes" -v PROJECT_ROOT=/absolute/path/to/your/projectIt adds an entry to claude_desktop_config.json:
macOS:
~/Library/Application Support/Claude/claude_desktop_config.jsonWindows:
%APPDATA%\Claude\claude_desktop_config.json
Then fully quit Claude Desktop (not just the window) and reopen it.
If you see Claude app not found, open Claude Desktop once so it creates its config folder, then run the command again.
Claude Code
claude mcp add devnotes --transport stdio \
--env PROJECT_ROOT=/absolute/path/to/your/project \
-- uv run --with "mcp[cli]" mcp run /absolute/path/to/server.pyClaude Code's own flags go before the --, and everything after it is your launch command. Run /mcp inside a session to check that devnotes is connected.
Cursor
Create .cursor/mcp.json in your project:
{
"mcpServers": {
"devnotes": {
"command": "uv",
"args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"],
"env": { "PROJECT_ROOT": "/absolute/path/to/your/project" }
}
}
}VS Code
Create .vscode/mcp.json in your project. The wrapper key is servers here, and each entry needs a type:
{
"servers": {
"devnotes": {
"type": "stdio",
"command": "uv",
"args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"],
"env": { "PROJECT_ROOT": "/absolute/path/to/your/project" }
}
}
}You need VS Code 1.99 or later with GitHub Copilot signed in, and Copilot Chat has to be in Agent mode. Other modes don't call tools.
If a host can't find
uv: hosts start your server with a minimal PATH. Swapuvfor its absolute path, whichwhich uv(macOS and Linux) orwhere uv(Windows) will show you.
Try it
Ask your host something like:
What TODOs are left in my project? Which one should I fix first?
Most hosts ask before running a tool. Say yes and watch it work.
Here's what just happened:
sequenceDiagram
participant You
participant Host as Host (e.g. Claude)
participant Server as Your server
Host->>Server: tools/list (when it connects)
Server-->>Host: project_root, find_todos
You->>Host: "What TODOs are left?"
Host->>Host: The model picks find_todos
Host->>You: Allow this tool call?
You->>Host: Yes
Host->>Server: tools/call find_todos
Server-->>Host: Matching lines
Host->>You: The model's summaryStep 6 (bonus): Serve it over HTTP
Stdio is right for a tool that lives on your machine. If you want other people or other apps to reach the same server, you serve it over Streamable HTTP.
You don't have to change any code:
uv run mcp run server.py --transport streamable-httpThat starts a web server on port 8000, and clients connect to http://localhost:8000/mcp. In Claude Code:
claude mcp add --transport http devnotes http://localhost:8000/mcpCareful: this server reads files. Keep it on localhost. Don't put it on the public internet without authentication. The SDK docs have a "Deploy & scale" guide for doing that properly.
What the 2026 spec changed
If you've read older MCP tutorials, a few things will look different. Here's the short translation:
Older tutorials | The 2026-07-28 spec and SDK v2 |
|---|---|
|
|
An | Gone. Every request carries its own protocol version and capabilities. |
The old HTTP+SSE transport ( | Streamable HTTP at |
The server calls back into the client mid-request | A tool returns the question, and the client retries with the answer |
State lives in the connection | State lives in explicit handles you return and accept |
The good news is that SDK v2 still serves clients on the older spec from the same server. There's nothing to configure.
Our server keeps no state between calls, so it needed no special treatment.
If you ever build a tool that has to remember something (a cart, a transaction), return a handle from one call and take it as an argument in the next.
When it doesn't work
Symptom | Likely cause and fix |
|---|---|
The server never shows up in Claude Desktop | The host reads its config at launch. Fully quit it and reopen. |
The connection drops straight away, or the host reports garbage | Something wrote to stdout. On stdio, stdout is the protocol. Use |
The host can't start the server | Relative path, or |
It scans the wrong folder | Call |
| You have v1 installed. Run |
| It needs |
Before touching any host config, run the launch command yourself. A healthy stdio server prints nothing and just waits. A traceback is the real bug, and it's much easier to read there.
If a host keeps logs, they help. Claude Desktop writes one per server, named mcp-server-<NAME>.log, under ~/Library/Logs/Claude on macOS and %APPDATA%\Claude\logs on Windows.
Keep it safe
An MCP tool can do anything the code behind it can do. A few habits keep that under control:
Start read-only. Add tools that change things later, and think about them harder.
Fence file access to one folder, like we did.
Treat tool output as untrusted. A comment inside someone else's repo can contain text aimed at the model, like "ignore your instructions". That's prompt injection. Keep your host's confirmation prompts switched on.
Don't trust hints. Annotations help hosts make good choices, but they aren't a security boundary.
Keep local servers local. Bind to
127.0.0.1unless you've set up proper authentication.
Where to go next
You've got a working server. Here are good next steps, in rough order of effort:
Return structured data. A Pydantic model as the return type gives the host typed output as well as text.
Add a resource. Expose a read-only view of something, like a project summary.
Call an API. Make a tool
async defandawaitan HTTP call inside it.Go remote. Deploy over Streamable HTTP and add OAuth.
The official Python SDK docs cover each of these with complete, runnable examples.
Recap
MCP lets AI apps call your code through one standard interface.
A tool is a plain function with
@mcp.tool(). Type hints are the schema.Raise
ToolErrorfor mistakes the model can fix.Test with the in-memory
Client, no host needed.Connecting a host means giving it one launch command, with absolute paths.
Fence what your tools can touch.
That's the whole loop. Write a function, check it, plug it in.
Details checked against the MCP spec revision 2026-07-28 and Python SDK v2.0.0 on October 24, 2026. Tooling changes quickly, so if a flag or config key behaves differently on your version, the official docs win.
Sources
Comments(0)
Join the discussion by logging into your account.
No comments yet. Be the first to comment!