Hands-On: Build Your First MCP Server
Introduction
You know what a server exposes (L139) and how the protocol is wired (L138). Time to build one. And here's the good news that surprises everyone: an MCP server is just decorated Python functions. The SDK turns your functions into a real protocol server — JSON-RPC, schemas, the lifecycle — so you write ordinary code and get a server any MCP client can use.
You're about to write a ~25-line server that exposes a tool, a resource, and a prompt — then test it in the Inspector and plug it into Claude Desktop. Three decorators, one
run(), done.
We'll build a tiny tasks server (one of each primitive, mapped straight to L139 — Tools, Resources & Prompts in MCP), using the official Python SDK (FastMCP). By the end you'll be able to wrap any function, API, or data source as an MCP server.
In this lesson:
- Setup — the SDK and project
- Build the server —
@mcp.tool,@mcp.resource,@mcp.prompt, andmcp.run() - See it run (hands-on), test it with the MCP Inspector, and connect it to Claude Desktop
- Going further — remote servers, the TypeScript SDK, the reference servers
Scope: this is the server side — exposing capabilities. Writing the client that connects an agent to servers is the next lesson (L141 — Connecting an Agent to MCP Servers); the ecosystem & security close the section (L142 — The MCP Ecosystem & Security Considerations).

Setup — The SDK & Project
MCP has official SDKs for Python, TypeScript, and more; we'll use Python. The package is mcp (the [cli] extra adds the mcp dev Inspector command), and the easiest way to manage the project is uv. Two commands:
# Install the official Python SDK (with the dev CLI / Inspector) using uv:
uv init tasks && cd tasks
uv add "mcp[cli]" # `mcp` = the SDK + the `mcp dev` Inspector commandThat's the whole toolchain. The class you'll use is FastMCP — the high-level, decorator-based API from mcp.server.fastmcp. (There's also a lower-level Server API if you ever need full control, but you rarely do.) FastMCP reads your functions' type hints and docstrings to auto-generate the tool/resource/prompt definitions — so you almost never touch raw JSON-RPC or JSON Schema yourself.
Build the Server — Tool, Resource, Prompt
Here's the entire server — one file. Notice it's just three decorated functions plus mcp.run(). Read it once, then we'll walk each decorator:
# server.py — an MCP server exposing ONE of each primitive (L139).
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("tasks") # name the server; FastMCP is the high-level SDK
TASKS: list[dict] = [] # a toy in-memory store
# TOOL — an ACTION the model can call (model-controlled). Type hints + the docstring
# auto-generate the tool name, description, and JSON-Schema inputSchema for you.
@mcp.tool()
def add_task(title: str, priority: str = "normal") -> str:
"""Add a task to the list.
Args:
title: what needs doing
priority: low | normal | high
"""
TASKS.append({"title": title, "priority": priority})
return f"Added: {title} ({priority})"
# RESOURCE — read-only DATA the app loads as context (app-controlled), at a URI.
@mcp.resource("tasks://all")
def list_tasks() -> str:
"""The current task list."""
return "\n".join(f"- {t['title']} [{t['priority']}]" for t in TASKS) or "(no tasks)"
# PROMPT — a reusable TEMPLATE the user invokes (user-controlled), e.g. a /command.
@mcp.prompt()
def plan_my_day(focus: str) -> str:
"""Plan the day around a focus area."""
return f"My tasks are in the `tasks://all` resource. Plan my day around: {focus}."
if __name__ == "__main__":
mcp.run(transport="stdio") # local server — speaks JSON-RPC over stdin/stdoutThree decorators, three primitives — exactly the L139 (Tools, Resources & Prompts in MCP) control axis, now in code:
@mcp.tool()→add_taskis a tool (model-controlled action). FastMCP turns the signature(title: str, priority: str = "normal")into theinputSchema, the docstring into the description, and theArgs:lines into per-parameter docs — the whole tool definition, generated from your function. It returns a result the model reads back.@mcp.resource("tasks://all")→list_tasksis a resource (app-controlled data) served at the URItasks://all. The host can read it as context whenever it wants; it never acts.@mcp.prompt()→plan_my_dayis a prompt (user-controlled template). It returns a filled-in instruction (referencing the resource), surfaced to the user as a slash command.
And mcp.run(transport="stdio") starts it as a local server speaking JSON-RPC over stdin/stdout (the stdio transport from L138 — MCP Architecture). That's it — you've written a complete, spec-compliant MCP server without ever hand-writing a protocol message.
Run It & See the Flow
Run uv run server.py and your server is live, waiting for a host. But what actually happens when a client connects? Step through a real session — watch your decorators become protocol messages, and your add_task function get called:

That's the whole point made concrete: you wrote add_task, and a tools/call from the model ran your function and fed the result back. The SDK did the handshake, the tools/list schema, and the JSON-RPC plumbing; you just wrote Python. The same flow works for any client — Claude Desktop, Cursor, your own agent (L141).
Test It — The MCP Inspector
Before wiring it into a host, test the server in isolation with the official MCP Inspector — a local web UI that connects to your server and lets you exercise every primitive by hand. It's the single best debugging tool in MCP:
# Test it with NO host — the official MCP Inspector (a local web UI):
uv run mcp dev server.py
# → opens http://127.0.0.1:6274 — click Connect, then the Tools / Resources /
# Prompts tabs to run add_task, read tasks://all, and render plan_my_day.The Inspector opens at http://127.0.0.1:6274. Click Connect, and you'll see three tabs — Tools, Resources, Prompts — populated from your server. Call add_task with arguments and watch the result; read tasks://all; render plan_my_day. If it works in the Inspector, it'll work in any host — so always test here first. (You can also watch the raw JSON-RPC messages, which makes L138 — MCP Architecture's lifecycle tangible.)
Connect It — Claude Desktop
Now plug it into a real host. Claude Desktop discovers MCP servers from a config file — claude_desktop_config.json — where each entry tells it how to launch your server. Add yours under mcpServers:
// ~/Library/Application Support/Claude/claude_desktop_config.json
{
"mcpServers": {
"tasks": {
"command": "uv",
"args": ["--directory", "/ABSOLUTE/PATH/TO/tasks", "run", "server.py"]
}
}
}
// Restart Claude Desktop → your tool, resource, and prompt now appear in the app.The command + args are literally how to start your server (here, uv --directory <path> run server.py) — Claude Desktop launches it as a subprocess and talks over stdio (which is why local servers use stdio: the host owns the process). Use an absolute path. Restart Claude Desktop, and your server's capabilities appear: the tool behind the 🔧 menu (the model can call add_task, asking your approval), the resource attachable as context, and the prompt in the slash-command menu (/plan_my_day). You just extended Claude with your own code.
Going Further
You've built a local, stdio server with the Python SDK. The same FastMCP server scales out in a few directions:
- Remote (Streamable HTTP). Swap
mcp.run(transport="stdio")for the HTTP transport and your server runs behind a URL, reachable by many hosts at once (add OAuth for auth — L138 — MCP Architecture). That's how hosted servers like GitHub's or Sentry's work. - Other languages. The TypeScript SDK mirrors this exactly (
server.tool(...),server.resource(...),server.prompt(...)), and there are SDKs for Go, Java, C#, Rust, and more — same protocol, your language. - Don't reinvent — reuse. Before writing a server, check the reference & community servers (
modelcontextprotocol/servers— filesystem, GitHub, Postgres, Slack, and thousands more). Often the server you need already exists; you just connect to it (next lesson). - 2026 tooling. Modern SDKs add auth scopes, OpenTelemetry tracing, and one-command deploy — but the core you just learned (
@tool/@resource/@prompt+run) is unchanged.
The mental shift: wrapping anything as an MCP server is now trivial. Any API, database, or script becomes a few decorated functions — and instantly usable by every MCP client.
🧪 Try It Yourself
Work these through (extending the tasks server):
- You want the model to be able to mark a task done. Which decorator, and why that one (not a resource)?
- You want the model to always see the current tasks as context without calling a function. Tool or resource?
- After
uv run mcp dev server.py, where do you go and what do you click to calladd_taskby hand? - Claude Desktop shows no MCP tools after you edited the config. Name two likely causes.
- FastMCP generated
add_task'sinputSchemaand description from what in your code?
→ (1) @mcp.tool() — marking done is an action with a side effect (it mutates state), which is model-controlled; a resource is read-only. (2) A resource (@mcp.resource("tasks://all")) — read-only context the app loads, no function call/decision needed; a tool would force the model to choose to fetch it. (3) The MCP Inspector at http://127.0.0.1:6274 — click Connect, open the Tools tab, fill add_task's args, and run it. (4) Likely: a JSON syntax error in claude_desktop_config.json, a non-absolute path in args, the wrong command (e.g., uv not on PATH), or you didn't restart Claude Desktop. (5) From your function's type hints (→ inputSchema) and its docstring (→ description + per-arg docs). Write good signatures and docstrings and the tool spec writes itself.
Key Takeaways
- An MCP server is decorated functions +
mcp.run(). Use the officialFastMCPSDK (uv add "mcp[cli]"):@mcp.tool()(action),@mcp.resource("uri")(data),@mcp.prompt()(template) — the L139 (Tools, Resources & Prompts in MCP) primitives, one decorator each. - The SDK does the hard part: it reads your type hints + docstrings to auto-generate the tool name, description, and JSON-Schema
inputSchema— you never hand-write protocol messages. mcp.run(transport="stdio")starts a local server speaking JSON-RPC over stdin/stdout; atools/callruns your function and returns the result.- Test in isolation first with the MCP Inspector (
uv run mcp dev server.py→http://127.0.0.1:6274) — Tools / Resources / Prompts tabs to exercise every primitive. - Connect to a host by adding a
command+argsentry undermcpServersinclaude_desktop_config.json(absolute path, then restart) — your tool/resource/prompt appear in the app. - Scale out: swap to Streamable HTTP for a remote server, use the TS/Go/Java SDKs for other languages, and reuse the thousands of reference/community servers before building your own.
- Next — L141: Connecting an Agent to MCP Servers — the other side: writing the client so your agent can use any MCP server.