Tutorial

Build Your First MCP Server in 30 Minutes

A hands-on Python walkthrough: build a small MCP server, test it, and plug it into Claude, Cursor or VS Code, using the 2026 spec and the new SDK v2.

·
12 min read
·
AI-assisted

AI tooling disclosure

Generative AI materially contributed to this article.

Human review confirmed by the author.

Build Your First MCP Server in 30 Minutes

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 FastMCP renamed to MCPServer. 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

find_todos, create_invoice

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 + b

Now run it:

uv run mcp dev server.py

This 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 FastMCP is the v1 spelling. In v2 you write from 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

raise ToolError("...")

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

Run the Inspector again from the dev-notes folder:

uv run mcp dev server.py

Call 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-coded

Try 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 pytest

You should see three passing tests. A few notes on how it works:

  • Client(server.mcp) talks to the server object in memory.

  • monkeypatch points ROOT at a temporary folder, so the tests never touch your real files.

  • A ToolError comes back as a result with is_error set, not as a Python exception. So you assert on the result.

  • raise_exceptions=True is 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.py

Two rules save most of the pain:

  1. Use absolute paths everywhere. The host starts your server from its own working directory, not yours.

  2. Pass PROJECT_ROOT explicitly. 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/project

It adds an entry to claude_desktop_config.json:

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json

  • Windows: %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.py

Claude 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. Swap uv for its absolute path, which which uv (macOS and Linux) or where 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 summary

Step 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-http

That 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/mcp

Careful: 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

from mcp.server.fastmcp import FastMCP

from mcp.server import MCPServer

An initialize handshake and a session ID

Gone. Every request carries its own protocol version and capabilities.

The old HTTP+SSE transport (/sse)

Streamable HTTP at /mcp

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 logging, which writes to stderr, instead of print().

The host can't start the server

Relative path, or uv isn't on the host's PATH. Use absolute paths for both.

It scans the wrong folder

Call project_root to see what it's reading, then set PROJECT_ROOT.

cannot import name 'MCPServer'

You have v1 installed. Run uv run mcp version to check.

mcp dev fails to open the Inspector

It needs npx, so install Node.js.

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.1 unless 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 def and await an 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 ToolError for 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!

Samod Alex

Passionate developer sharing knowledge about modern web technologies and best practices.

Subscribe to Samod Alex's Newsletter

Direct email dispatches when new stories are published. Zero algorithms.