Published on July 16, 2026
Build an MCP server from scratch: Python guide with full code (2026)
Build a working MCP server in Python with two real tools, test it three ways, and connect it to Claude Desktop and Cursor. Full tested code, no API keys.
You build an MCP server in Python with the official mcp SDK (1.28.1 as I write this): create a FastMCP instance, decorate plain Python functions with @mcp.tool(), and call mcp.run(). That's the whole skeleton. The server talks to Claude Desktop, Cursor, Claude Code, and any other Model Context Protocol client over stdio, and the client's LLM decides when to call your functions based on nothing but their names, docstrings, and type hints.
In this guide we build a real one: a devsearch server with two tools that need no API keys, one that searches Hacker News and one that fetches a web page with proper input validation. You'll test it three ways (the MCP Inspector, a scripted client, and a live LLM), register it in both Claude Desktop and Cursor, and run a short security pass before anyone else touches it. Every line of code below ran on my machine against SDK 1.28.1 before it went into this article. Python 3.10+ is the only prerequisite.
why everyone suddenly wants your tools
The Model Context Protocol shipped in November 2024 as an Anthropic project with a modest pitch: one standard way to plug tools and data into an LLM, instead of a custom integration per app. As of July 2026, the PulseMCP directory lists over 22,000 servers. OpenAI, Google, and Microsoft adopted the protocol, and even Safari ships an MCP server for web developers now. Whatever client your users prefer, an MCP server is the one integration you write once.
Here's the practical translation. Before MCP, giving Claude access to your database meant writing against Anthropic's tool-use API, then rewriting the whole thing for ChatGPT, then again for Cursor. Now you write one server, and every client that speaks the protocol can use it. The current spec revision is 2025-11-25; the SDK negotiates versions for you, so you mostly never think about it.
A server can expose three kinds of things. Tools are functions the model may call ("search this", "create that"). Resources are read-only data identified by URI (a file, a config, a log). Prompts are reusable templates the user can invoke. Tools are where beginners should start, and they're what we build today. One more piece of vocabulary and we can go: a transport is how client and server exchange JSON-RPC messages. stdio runs your server as a subprocess of the client, ideal for local tools. Streamable HTTP serves remote clients over the network. We use stdio; it needs zero network setup and it's what Claude Desktop and Cursor expect from a local server.
five minutes of setup (uv or plain venv, your call)
Create a project directory and install the SDK. I'll show plain venv since it works everywhere; if you use uv, uv init && uv add "mcp[cli]" httpx does the same job faster.
# Run from wherever you keep projects:
mkdir devsearch-mcp && cd devsearch-mcp
python3 -m venv .venv
source .venv/bin/activate
pip install "mcp[cli]" httpxThe [cli] extra matters. It gives you the mcp command with three subcommands you'll use constantly: mcp dev (run your server under the visual Inspector), mcp run (run it plain), and mcp install (register it in Claude Desktop automatically). httpx is the HTTP client our tools use.
Check the install:
mcp version
# MCP version 1.28.1If you see an older major version, the code below may not match. The SDK moves fast; pip install --upgrade "mcp[cli]" fixes it.
the server, top to bottom (60 lines, two real tools)
Most MCP tutorials give you an add(a, b) calculator that no LLM would ever need. We're building tools an assistant actually calls in daily use: search Hacker News for what practitioners say about a topic, and fetch a page to read it. Both run on free public APIs, no keys, so you can paste and run.
Create the file:
# devsearch-mcp/server.py
"""devsearch — an MCP server exposing two research tools over stdio."""
from urllib.parse import urlparse
import httpx
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("devsearch")
HN_API = "https://hn.algolia.com/api/v1/search"
BLOCKED_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0", "169.254.169.254", "[::1]"}
MAX_CHARS = 8000
@mcp.tool()
def search_hackernews(query: str, limit: int = 5) -> str:
"""Search Hacker News stories by keyword, ranked by relevance.
Args:
query: What to search for (e.g. "MCP server").
limit: How many stories to return (1-20).
"""
query = query.strip()
if not query:
raise ValueError("query must not be empty")
limit = max(1, min(limit, 20))
r = httpx.get(
HN_API,
params={"query": query, "tags": "story", "hitsPerPage": limit},
timeout=10,
)
r.raise_for_status()
hits = r.json()["hits"]
if not hits:
return f"No Hacker News stories found for {query!r}."
lines = []
for h in hits:
title = h.get("title") or "(untitled)"
url = h.get("url") or f"https://news.ycombinator.com/item?id={h['objectID']}"
lines.append(
f"- {title} ({h.get('points', 0)} points, "
f"{h.get('num_comments', 0)} comments)\n {url}"
)
return "\n".join(lines)
@mcp.tool()
def fetch_page(url: str) -> str:
"""Fetch a public web page and return its readable text content.
Args:
url: Full http(s) URL of the page to fetch.
"""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError("only http/https URLs are allowed")
if parsed.hostname in BLOCKED_HOSTS or (parsed.hostname or "").endswith(".internal"):
raise ValueError("refusing to fetch internal/private hosts")
r = httpx.get(
url,
timeout=15,
follow_redirects=True,
headers={"User-Agent": "devsearch-mcp/1.0"},
)
r.raise_for_status()
content_type = r.headers.get("content-type", "")
if "text" not in content_type and "json" not in content_type:
raise ValueError(f"unsupported content type: {content_type}")
text = r.text
if "html" in content_type:
import re
text = re.sub(r"(?is)<(script|style|nav|footer)[^>]*>.*?</\1>", " ", text)
text = re.sub(r"(?s)<[^>]+>", " ", text)
text = re.sub(r"\s+", " ", text).strip()
if len(text) > MAX_CHARS:
text = text[:MAX_CHARS] + f"\n\n[truncated at {MAX_CHARS} chars]"
return text
if __name__ == "__main__":
mcp.run() # stdio transport by defaultThat's the entire server. Now the parts that deserve a closer look.
the decorator does the boring 80%
@mcp.tool() reads the function signature and docstring and generates the JSON Schema the client sees. query: str becomes a required string parameter; limit: int = 5 becomes an optional integer with a default. The docstring becomes the tool description, and the Args: section becomes per-parameter descriptions. Before MCP, you wrote that schema by hand for every provider. Here you write a typed Python function, which you were doing anyway.
This has a consequence beginners miss: the docstring is your UI. The model picks tools by reading their descriptions, nothing else. "Search Hacker News stories by keyword, ranked by relevance" gets called at the right moments; a docstring that says "searches stuff" gets called at random or never. Write descriptions for a reader who has no idea how the function works inside, because that's exactly what the model is.
validate like the caller is drunk
Look at what the tools reject. Empty queries. A limit of 5,000 (clamped to 20). URLs with file:// or ftp:// schemes. Requests to localhost, 127.0.0.1, and 169.254.169.254, which is the cloud metadata endpoint that turns a friendly page-fetcher into a credential thief on any AWS/GCP box. The LLM composes your tool arguments from conversation context, and conversation context can contain anything, including a malicious page the user pasted. Treat every argument as untrusted input, because it is.
When validation fails, raise ValueError with a clear message. The SDK converts the exception into a proper JSON-RPC error result, the model reads it and usually self-corrects on the next call. Returning error text as a normal string also works, but an exception keeps your happy path clean, and the model sees an explicit error flag instead of parsing prose.
one design decision per tool
Both tools return a formatted string instead of raw JSON. That's deliberate. Tool output lands in the model's context window, and the model pays tokens to read it. The raw Algolia response for five stories is about 30 KB of JSON; my formatted list is under 1 KB and contains all five titles, scores, and URLs. Return what the model needs to continue the conversation, not what the upstream API happened to send you.
The same reasoning caps fetch_page at 8,000 characters and strips scripts, styles, and navigation from HTML before returning it. Microsoft's Playwright MCP team made the same call at larger scale: their server reads the browser's accessibility tree instead of screenshots, because structured, compact output beats raw dumps for reliability and cost.
test it three ways before a client touches it
The debugging story for MCP has one ugly truth: when a server misbehaves inside Claude Desktop, all you get is a gray "disconnected" state and a log file. Test outside the client first, always.
1. the Inspector: your new favorite command
# Run from devsearch-mcp/ with the venv active:
mcp dev server.pyThis starts your server and opens the MCP Inspector, a web UI on localhost where you see the tool list exactly as clients see it, call tools with hand-typed arguments, and read raw request/response JSON-RPC frames. Call search_hackernews with query: "MCP server", then try to break fetch_page with url: "http://127.0.0.1/admin" and watch the validation error come back as a structured error result. Two minutes here saves an hour of staring at Claude Desktop logs.
2. a scripted smoke test (the one CI can run)
The SDK ships a client too, so you can drive your server over real stdio, the same way Claude Desktop will:
# devsearch-mcp/test_client.py
"""Smoke-test the devsearch server over real stdio, like Claude Desktop would."""
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
async def main():
params = StdioServerParameters(command="python", args=["server.py"])
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
names = [t.name for t in tools.tools]
print("TOOLS:", names)
assert names == ["search_hackernews", "fetch_page"]
res = await session.call_tool(
"search_hackernews", {"query": "MCP server", "limit": 3}
)
assert "https://" in res.content[0].text
res = await session.call_tool("fetch_page", {"url": "https://example.com"})
assert "Example Domain" in res.content[0].text
# validation must reject internal hosts
res = await session.call_tool("fetch_page", {"url": "http://127.0.0.1/admin"})
assert res.isError, "expected internal host to be rejected"
print("ALL CHECKS PASSED")
asyncio.run(main())python test_client.py
# TOOLS: ['search_hackernews', 'fetch_page']
# ALL CHECKS PASSEDNote the last assertion: the test proves the SSRF guard rejects internal hosts, not just that the happy path works. When I ran this against SDK 1.28.1, the HN search returned live stories ("MCP server that reduces Claude Code context consumption by 98%", 570 points, was the top hit that day, which felt fitting). This script is 30 lines and becomes your regression test every time you touch the server.
3. a live client, where it gets fun
Testing done, time to register it where an actual model can call it.
plug it into Claude Desktop and Cursor (same server, two clients)
The point of MCP is write once, run in every client, so let's prove it with two.
Claude Desktop. The lazy path is one command, which uses uv under the hood:
mcp install server.py --name devsearchOr edit the config yourself. Open claude_desktop_config.json (macOS: ~/Library/Application Support/Claude/, Windows: %APPDATA%\Claude\) and add your server, with absolute paths, to mcpServers:
{
"mcpServers": {
"devsearch": {
"command": "/home/you/devsearch-mcp/.venv/bin/python",
"args": ["/home/you/devsearch-mcp/server.py"]
}
}
}Restart Claude Desktop (fully quit, not just close the window), and the tools appear under the sliders icon in the chat input. Ask "what are HN's top stories about MCP servers this year?" and watch it call your function.
Cursor. Same JSON shape, different file: ~/.cursor/mcp.json globally, or .cursor/mcp.json inside a project for a per-repo server. Paste the same mcpServers block, enable the server in Cursor's MCP settings, and the agent picks up both tools.
Claude Code, if that's your daily driver, takes it in one line: claude mcp add devsearch -- /path/to/.venv/bin/python /path/to/server.py.
Two mistakes cause 90% of "my server doesn't show up" reports. First, relative paths: the client launches your server from its working directory, not yours, so python server.py fails while the absolute-path version works. Second, printing to stdout: on stdio transport, stdout carries the protocol, so a stray print() corrupts the JSON-RPC stream and the client drops the connection. Log to stderr or use the Context logging the SDK provides; never print() in a stdio server.
don't ship these mistakes (the 15% club)
A community audit posted to r/mcp scanned the top 500 servers on the Smithery registry and reported security findings in 76 of them, 15.3%, including a dozen with exploitable "toxic flows." These are the top listed servers, written by people confident enough to publish. Before your server leaves your machine, walk this list:
- No shell, no
eval, ever. If a tool must run a system command, pass an argument list (subprocess.run([...])), never a string built from tool arguments. One f-string into a shell is remote code execution for anyone who can influence the conversation. - Validate every argument at the top of every tool. Type hints constrain shape, not content. Clamp numbers, allowlist URL schemes, block private hosts (our
fetch_pageshows the pattern). - Scope filesystem access to one directory. If a tool reads files, resolve the path and verify it stays inside your sandbox root:
Path(root, name).resolve().is_relative_to(root). Otherwise../../.ssh/id_rsais a tool call away. - Keep secrets out of tool descriptions and error messages. Descriptions are sent to every client and pasted into every context window. An API key in a docstring or a traceback is public the moment someone connects.
None of this is advanced. That's the uncomfortable part of the 15.3% number: the bar for the top-500 list was apparently lower than these four bullets.
where to go from here (and where MCP is going)
You now have the shape of every MCP server you'll ever write: FastMCP, decorated functions, validation at the top, compact strings out, tested in the Inspector before any client sees it. From here, three natural next steps. Add a resource (@mcp.resource("notes://recent")) to expose read-only data without making the model call a tool. Swap the transport to streamable HTTP (mcp.run(transport="streamable-http")) when you need a remote server that many clients share, and read up on OAuth in the spec before you do, because auth is where remote servers get hard. And watch WebMCP, the Google and Microsoft push to let websites expose MCP-style tools straight from the browser tab; the protocol is spreading from desktops into the web platform itself. If you'd rather flip to the other side of the connection and build the agent that calls these tools, I wrote a full guide to building your first AI agent.
Everything above is the full devsearch code, nothing omitted, tested against mcp 1.28.1 and spec revision 2025-11-25 on July 16, 2026. If an SDK update breaks something, tell me and I'll refresh the article.
Sources
- MCP Python SDK (official repo) — FastMCP API, CLI commands, client session used in the smoke test. Verified against v1.28.1. https://github.com/modelcontextprotocol/python-sdk
- Model Context Protocol specification — protocol concepts (tools/resources/prompts), transports, current revision 2025-11-25. https://modelcontextprotocol.io/specification/2025-11-25/
- PulseMCP server directory — ecosystem scale figure (22,311 servers). Retrieved July 16, 2026. https://www.pulsemcp.com/servers
- HN Algolia Search API — the free search endpoint behind
search_hackernews. https://hn.algolia.com/api - Smithery top-500 security scan (r/mcp) — the 76/500 (15.3%) security-findings figure behind the checklist. https://www.reddit.com/r/mcp/comments/1to9gei/
- Introducing the Safari MCP server (WebKit blog) — browser-vendor adoption example. https://webkit.org/blog/18136/introducing-the-safari-mcp-server-for-web-developers/
- Playwright MCP (Microsoft) — the accessibility-tree-over-screenshots design pattern cited in the output-size discussion. https://github.com/microsoft/playwright-mcp
Need an MCP server built for your API, database, or internal tools? Start a project — I build MCP servers, AI agents, and agentic payment systems that run on your infrastructure.