Skip to main content

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, and mcp.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).

An infographic titled 'Hands-On: Build Your First MCP Server' showing how a few decorators become a running server. On the left is a server.py file using the official Python SDK: it imports FastMCP, creates a server named tasks, and decorates three plain Python functions — one with @mcp.tool() to add a task (an action), one with @mcp.resource() at the URI tasks://all to list tasks (read-only data), and one with @mcp.prompt() to plan the day (a template) — then calls mcp.run with the stdio transport. The center shows the running MCP SERVER named tasks exposing those three primitives: a TOOL add_task, a RESOURCE tasks://all, and a PROMPT plan-my-day, each color-coded. The FastMCP class turns the functions' type hints and docstrings into the tool name, description, and JSON-Schema input automatically, so you write ordinary Python and the protocol details are generated for you. On the right the server connects two ways: to the MCP Inspector, a web tool at localhost port 6274 that lets you test tools, resources, and prompts with no host, and to a HOST such as Claude Desktop, configured by adding the server to the mcpServers section of claude_desktop_config.json so its tool, resource, and prompt appear in the app. The three cards summarize the workflow: write three decorators, run the server over stdio, then test it in the Inspector and connect it to Claude Desktop. The takeaway: building an MCP server is decorating functions — @mcp.tool, @mcp.resource, @mcp.prompt — and calling mcp.run; the SDK handles the JSON-RPC, schemas, and lifecycle, so three small functions become a tool the model can call, a resource the app can read, and a prompt the user can invoke, usable from any MCP client. A concrete trace makes it click: when a host like Claude Desktop connects, it runs the initialize handshake, discovers the tools with tools/list (the add_task tool appears with the schema FastMCP generated from the function's type hints and docstring), and when the user asks to add a task the model issues a tools/call that runs the real add_task function and returns the result, while resources/read pulls the tasks://all resource in as context. Testing happens first in the Inspector with no host, then the server is connected to Claude Desktop through the config file. The same FastMCP server scales out: swap the stdio transport for Streamable HTTP to run it remotely behind a URL with OAuth, or use the TypeScript, Go, Java, or C# SDKs for other languages, and before building a new server reuse the thousands of reference and community servers that already exist. The core stays the same — decorate functions with tool, resource, and prompt, then call run.

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 command

That'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/stdout

Three decorators, three primitives — exactly the L139 (Tools, Resources & Prompts in MCP) control axis, now in code:

  • @mcp.tool()add_task is a tool (model-controlled action). FastMCP turns the signature (title: str, priority: str = "normal") into the inputSchema, the docstring into the description, and the Args: 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_tasks is a resource (app-controlled data) served at the URI tasks://all. The host can read it as context whenever it wants; it never acts.
  • @mcp.prompt()plan_my_day is 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:

The server you just wrote, handling a live session. Step through it: Claude Desktop launches your server over stdio and runs the initialize handshake; it discovers your primitives with tools/list (your @mcp.tool() add_task appears with its auto-generated JSON-Schema); when the user says “add buy milk,” the model calls tools/call → YOUR add_task() function runs → returns the result; then resources/read pulls your tasks://all resource as context. Three decorators became three live primitives — the model can act (tool), read (resource), and be guided (prompt). That's a working MCP server.

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

  1. You want the model to be able to mark a task done. Which decorator, and why that one (not a resource)?
  2. You want the model to always see the current tasks as context without calling a function. Tool or resource?
  3. After uv run mcp dev server.py, where do you go and what do you click to call add_task by hand?
  4. Claude Desktop shows no MCP tools after you edited the config. Name two likely causes.
  5. FastMCP generated add_task's inputSchema and 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 official FastMCP SDK (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; a tools/call runs your function and returns the result.
  • Test in isolation first with the MCP Inspector (uv run mcp dev server.pyhttp://127.0.0.1:6274) — Tools / Resources / Prompts tabs to exercise every primitive.
  • Connect to a host by adding a command + args entry under mcpServers in claude_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.