{"schemaVersion":"1.0","type":"TechArticle","types":["Article","TechArticle"],"slug":"build-your-first-mcp-server-in-30-minutes-2j3wm","url":"https://zyvop.com/build-your-first-mcp-server-in-30-minutes-2j3wm","title":"Build Your First MCP Server in 30 Minutes","subtitle":"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.","tldr":"MCP lets AI apps use your own code as a tool. Here's how to build a small Python MCP server that finds TODO comments in a project, test it with pytest, and connect it to Claude Desktop, Claude Code, Cursor or VS Code.","keywords":["MCP","Python","tutorial","Claude","AI agents","Tutorial"],"entities":["Samod Alex","MCP","Python","tutorial","Claude","AI agents","Tutorial","ZyVOP"],"keyTakeaways":["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.","You write a small program that exposes a few functions, and any MCP-aware app can call them."],"headings":["The 30-minute plan","MCP in two minutes","Step 1: Install the SDK","Step 2: A tiny server in the Inspector","Step 3: The real tool","Your function is the schema","The folder fence","Errors the model can fix","Hints for the host","One prompt","Try it","Step 4: Test it without a host","Step 5: Plug it into a real AI app","Claude Desktop","Claude Code","Cursor","VS Code","Step 6 (bonus): Serve it over HTTP","What the 2026 spec changed","When it doesn't work","Keep it safe","Where to go next","Recap"],"outboundLinks":["https://docs.astral.sh/uv/","https://modelcontextprotocol.io/specification/latest","https://py.sdk.modelcontextprotocol.io/","https://github.com/modelcontextprotocol/python-sdk/releases/tag/v2.0.0","https://code.claude.com/docs/en/mcp","https://scalar.com/learn/mcp/what-is-mcp"],"contentText":"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]) --&gt; Host[\"Host&lt;br/&gt;Claude, Cursor, VS Code\"] Host &lt;--&gt; Model[\"Language model\"] Host --&gt; Client[\"MCP client\"] Client &lt;--&gt;|\"stdio or HTTP\"| Server[\"Your MCP server&lt;br/&gt;server.py\"] Server --&gt; 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&gt;=1.28,&lt;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) -&gt; 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 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() -&gt; 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, ) -&gt; 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) &gt;= limit: return results return results @mcp.prompt() def triage_todos(folder: str) -&gt; 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.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. 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.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_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/projectIt 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.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. 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-&gt;&gt;Server: tools/list (when it connects) Server--&gt;&gt;Host: project_root, find_todos You-&gt;&gt;Host: \"What TODOs are left?\" Host-&gt;&gt;Host: The model picks find_todos Host-&gt;&gt;You: Allow this tool call? You-&gt;&gt;Host: Yes Host-&gt;&gt;Server: tools/call find_todos Server--&gt;&gt;Host: Matching lines Host-&gt;&gt;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 &amp; 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-&lt;NAME&gt;.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 Model Context Protocol specification MCP Python SDK documentation MCP Python SDK v2.0.0 release notes Claude Code: connect to tools via MCP What is MCP? (Scalar guide)","contentHash":"sha256:aefa5d6923b61205a6812029815e6bba3c9144d9d0eaeef732999324c2617a4f","authorName":"Samod Alex","authorUrl":"https://zyvop.com/author/samod","authorSameAs":[],"category":"Tutorial","tags":["MCP","Python","tutorial","Claude","AI agents"],"audience":"Developers, software engineers, and students learning Tutorial","tone":"Instructional, practical, code-first","readingTimeMinutes":12,"wordCount":3101,"faqs":null,"primaryTopic":"Tutorial","publishedAt":"2026-10-24T05:26:47.314Z","updatedAt":"2026-10-24T05:26:47.314Z","canonicalUrl":"https://zyvop.com/build-your-first-mcp-server-in-30-minutes-2j3wm"}