If you have built an agent before, you probably wrote its tools straight into your code: a JSON schema here, a Python function there, all wired to one model and one app. It works until you want the same tools in a second place. The Model Context Protocol (MCP) fixes that: you write a tool once as a small server, and any MCP-capable app can use it, including Claude Code, Claude Desktop, and a growing list of editors and agent frameworks.
This guide builds a real MCP server in Python, tests it without spending a single API call, and connects it to Claude. It is written against the current spec, revision 2026-07-28, and the Python SDK 2.x. That matters more than usual: the July 2026 revision changed enough that a large share of the MCP tutorials still online will give you code that fails to import or relies on features now marked deprecated.
What changed in July 2026, and why older tutorials break
MCP's previous revision was dated 2025-11-25. The 2026-07-28 revision is the biggest change since the protocol launched. Five changes matter if you are building a server today:
- The Python class was renamed. In SDK 2.x,
FastMCPis nowMCPServer, imported withfrom mcp.server import MCPServer. The decorators (@mcp.tool()and friends) work the same way. If a tutorial starts withfrom mcp.server.fastmcp import FastMCP, it was written for SDK 1.x. - MCP is now stateless. The old
initializehandshake is gone. Every request carries its own protocol version and client capabilities, and protocol-level sessions (theMcp-Session-Idheader) were removed from the HTTP transport. If your server needs to remember something between calls, the spec's answer is to hand the client an ID and have it passed back as an ordinary tool argument. - Sampling, Roots and Logging are deprecated. They still work during a deprecation window of at least twelve months, but new servers should not depend on them. The spec's suggested replacements: pass folders and files as tool parameters instead of Roots, call your LLM provider's API directly instead of Sampling, and log to stderr or OpenTelemetry instead of Logging.
- Servers can ask for more input mid-request. Instead of the server firing its own requests at the client, it now returns an
input_requiredresult, and the client retries the original call with the answers. The SDK's client handles this loop for you. - The old HTTP+SSE transport is formally deprecated. For remote servers, use Streamable HTTP. For servers that run on your own machine, stdio is still the simplest choice, and it is what we use below.
The good news: SDK 2.x servers still answer older 2025-era clients, so you are not forced to choose between new and old apps.
Is MCP worth it for your project?
MCP adds a process boundary and a protocol between your model and your tools. That is overhead, so be honest about whether you need it.
- Use MCP when the same tools should work in more than one app (your IDE assistant and your own agent, for example), when you want to share tools with a team through a config file, or when you are wrapping a system (a database, an internal API, a file store) that several agents will touch.
- Skip MCP for a single script with two tools that only one program will ever call. Plain tool use through your model provider's API is less code and easier to debug. Our first-agent guide covers that path.
The three things a server can offer
An MCP server exposes some mix of three primitives, and choosing the right one is most of the design work:
- Tools are functions the model decides to call: "add a task", "search invoices". They can change things, so the spec requires hosts to get user consent before running them.
- Resources are read-only data the app can pull into context, addressed by a URI such as
tasks://overdue. Use them for information, not actions. - Prompts are reusable instructions a user picks deliberately, like a slash command. They are how you package "the right way to do this job" alongside the tools.
A common beginner mistake is making everything a tool. If the model only needs to read something, a resource keeps the tool list short, and a short tool list means the model picks the right tool more often.
Build it: a task-list server in about 80 lines
We will build a small to-do server with three tools, one resource and one prompt. It stores tasks in a local JSON file, so there is no database or API key to set up. You need Python 3.10 or newer.
Set up the project with uv, the package manager the official MCP docs use:
# macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh
# Windows (PowerShell)
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
# then, in a new terminal
uv init tasks
cd tasks
uv add "mcp[cli]"
Create tasks_server.py:
import json
import logging
from datetime import date
from pathlib import Path
from typing import Annotated
from pydantic import Field
from mcp.server import MCPServer
logger = logging.getLogger(__name__) # logs go to stderr, never stdout
DB = Path(__file__).parent / "tasks.json"
mcp = MCPServer("tasks")
def load() -> list[dict]:
return json.loads(DB.read_text(encoding="utf-8")) if DB.exists() else []
def save(tasks: list[dict]) -> None:
DB.write_text(json.dumps(tasks, ensure_ascii=False, indent=2), encoding="utf-8")
@mcp.tool()
def add_task(
title: Annotated[str, Field(description="What needs to be done, in one short sentence.")],
due: Annotated[str | None, Field(description="Optional due date, YYYY-MM-DD.")] = None,
) -> str:
"""Add a task to the to-do list."""
tasks = load()
task_id = max((t["id"] for t in tasks), default=0) + 1
tasks.append({"id": task_id, "title": title, "due": due, "done": False})
save(tasks)
logger.info("added task %s", task_id)
return f"Added task #{task_id}: {title}"
@mcp.tool()
def list_tasks(include_done: bool = False) -> str:
"""List tasks. Open tasks only unless include_done is true."""
tasks = [t for t in load() if include_done or not t["done"]]
if not tasks:
return "No tasks."
return "\n".join(
f"#{t['id']} [{'x' if t['done'] else ' '}] {t['title']}"
+ (f" (due {t['due']})" if t["due"] else "")
for t in tasks
)
@mcp.tool()
def complete_task(
task_id: Annotated[int, Field(description="The number shown next to the task in list_tasks.")],
) -> str:
"""Mark a task as done."""
tasks = load()
for t in tasks:
if t["id"] == task_id:
t["done"] = True
save(tasks)
return f"Task #{task_id} marked done."
return f"No task with id {task_id}."
@mcp.resource("tasks://overdue")
def overdue() -> str:
"""Open tasks whose due date has passed."""
today = date.today().isoformat()
late = [t for t in load() if not t["done"] and t["due"] and t["due"] < today]
return json.dumps(late, ensure_ascii=False)
@mcp.prompt()
def weekly_review() -> str:
"""Review the week's tasks and plan the next one."""
return (
"Call list_tasks with include_done=true. Group the tasks into done, "
"still open, and overdue. Then suggest the three most important open "
"tasks for next week and explain why in one line each."
)
if __name__ == "__main__":
mcp.run(transport="stdio")
Three details in this file are deliberate, and each one fixes a problem you would otherwise hit later:
- Parameter descriptions use
AnnotatedandField. When we tested SDK 2.3, anArgs:section in the docstring stayed in the tool's description text but did not appear in the parameter schema.Field(description=...)puts the description on the parameter itself, which is where a model looks when deciding what to pass. - Logging goes through
logging, neverprint. A stdio server talks to the client over standard output. One strayprint()injects text into that channel. The next section shows what that does. - State lives in the server, and the model gets an ID.
complete_tasktakes atask_idthe model saw inlist_tasks. This is exactly the pattern the stateless 2026 spec recommends: no hidden session, just a handle passed back as an argument.
Test it before any AI touches it
Most guides jump straight to "restart Claude and hope the tool appears". When it doesn't, you can't tell whether the bug is in your server, your config, or the app. Test the server on its own first with the SDK's built-in client. Create test_client.py next to the server:
import asyncio
import sys
from mcp import Client
from mcp.client.stdio import StdioServerParameters
async def main():
server = StdioServerParameters(command=sys.executable, args=["tasks_server.py"])
async with Client(server) as client:
print("protocol:", client.protocol_version)
tools = await client.list_tools()
print("tools:", [t.name for t in tools.tools])
print(tools.tools[0].input_schema["properties"]["title"])
r = await client.call_tool("add_task", {"title": "Write the MCP article", "due": "2026-01-01"})
print(r.content[0].text)
r = await client.call_tool("add_task", {"title": "Review it"})
print(r.content[0].text)
await client.call_tool("complete_task", {"task_id": 2})
r = await client.call_tool("list_tasks", {"include_done": True})
print(r.content[0].text)
res = await client.read_resource("tasks://overdue")
print("overdue:", res.contents[0].text)
p = await client.get_prompt("weekly_review")
print("prompt:", p.messages[0].content.text[:60])
asyncio.run(main())
Run it with uv run test_client.py. This is the output we got with SDK 2.3.0:
protocol: 2026-07-28
tools: ['add_task', 'list_tasks', 'complete_task']
{'description': 'What needs to be done, in one short sentence.', 'title': 'Title', 'type': 'string'}
Added task #1: Write the MCP article
Added task #2: Review it
#1 [ ] Write the MCP article (due 2026-01-01)
#2 [x] Review it
overdue: [{"id": 1, "title": "Write the MCP article", "due": "2026-01-01", "done": false}]
prompt: Call list_tasks with include_done=true. Group the tasks into
The first line confirms the client and server agreed on the 2026-07-28 protocol. If any call fails here, the problem is in your server, and you found it in seconds instead of guessing inside a chat window.
To see why the print rule matters, we swapped the logger line for print("added task", task_id) and ran the same test. It crashed on the first add_task call with Invalid JSON: expected value at line 1 column 1 ... input_value='added task 1'. The client expected a protocol message and received a plain sentence. In a desktop app, that same bug usually shows up as a vague "server disconnected" message.
Connect it to Claude
Claude Code. From your project folder, register the server (use the full path to the tasks folder):
claude mcp add tasks -- uv --directory /ABSOLUTE/PATH/TO/tasks run tasks_server.py
Everything after -- is the command that starts your server. The default scope, local, enables it for the current project only. --scope project writes it to a .mcp.json file you can commit for your team, and --scope user makes it available in all your projects. Check it with claude mcp list, or type /mcp inside Claude Code.
Claude Desktop. Edit the config file (on Windows it is %APPDATA%\Claude\claude_desktop_config.json; on macOS, ~/Library/Application Support/Claude/claude_desktop_config.json) and add:
{
"mcpServers": {
"tasks": {
"command": "uv",
"args": ["--directory", "C:\\ABSOLUTE\\PATH\\TO\\tasks", "run", "tasks_server.py"]
}
}
}
Restart the app completely. On Windows, note the double backslashes in the JSON path. If the app can't find uv, put its full path in command (run where uv on Windows or which uv on macOS to find it).
Now ask: "Add a task to send the invoice by Friday, then show me my open tasks." You will see the app ask permission before each tool runs. That approval step is not a nuisance to switch off; the spec treats tools as arbitrary code execution, and the prompt is your last check.
Four mistakes that make agents misuse your tools
A server that runs is not the same as a server a model uses well. These are the problems that show up after the code works:
- Vague descriptions. The model chooses tools from their names and descriptions alone. "Process data" gets called at random; "Mark a task as done" gets called correctly. Write descriptions for a reader who can't see your code.
- Too many tools. Every tool you expose takes up space in the model's context and adds one more option to choose between. If you have twenty tools, ask which are really reads (make them resources) and which could merge.
- Destructive tools with no guard. We deliberately did not add
delete_all_tasks. If you need destructive actions, make them narrow (one ID at a time), and return what changed so the user can see it. - Trusting what comes back. Text a tool returns, such as an email body or a web page, goes straight into the model's context. If that text contains instructions, the model may follow them. This is prompt injection, and it is why tools that read outside content should return data, clearly labeled, rather than act on it.
Where to go next
Swap the JSON file for something you actually use: a SQLite database, a folder of notes, or your company's internal API. Keep the same shape: a few well-described tools for actions, resources for reading, and one prompt that captures how the job should be done. Test with the SDK client after every change, and only then plug it into Claude. When you are ready to run it for other people over the network, move to the Streamable HTTP transport with mcp.run(transport="streamable-http") and read the spec's authorization section before you expose anything.
Sources: MCP specification 2026-07-28 and its changelog (modelcontextprotocol.io); the official "Build an MCP server" guide; the MCP Python SDK release notes; Claude Code's MCP documentation (code.claude.com). Code tested on 2026-10-04 with mcp 2.3.0 and Python 3.13.