ap.Abhishek Patel

Search

Search notes, essays, projects, the library and the AI course. Use arrow keys to choose and Enter to open.

Lab 3 of 5 60 min

Build a tiny MCP server

Build an MCP server with two tools, a resource and a prompt using FastMCP, test it in MCP Inspector, connect it to Claude Desktop and Claude Code, and call it from Python.

Words in this lessonFastMCP · MCP server · MCP resource · MCP prompt · MCP Inspector · stdio transport
FastMCP
The easy way to build MCP servers in Python, where decorators turn functions into tools, resources and prompts. Like a label maker that prints a proper product label from the details you type in.
MCP server
A small program that wraps one system, like an order database, and offers its tools and data to AI apps through MCP. Like a device with a standard USB-C port: build the port once and any compatible cable works.
MCP resource
Read-only data an MCP server offers at an address, like crumb://menu/today, for an app to load into context. Like a printed menu on the counter: you can read it, but reading it does not place an order.
MCP prompt
A reusable message template an MCP server offers, which a user picks and fills in. Like a fill-in-the-blanks form letter the shop keeps for common replies.
MCP Inspector
The official browser-based tool for connecting to an MCP server and trying its tools, resources and prompts by hand. Like a test bench where you plug in a new appliance and try every button before installing it in the kitchen.
stdio transport
Running an MCP server as a local child process and exchanging messages over its standard input and output. Like a private intercom between two rooms in the same house.
On this page
  1. Step 1: Create the project and install the MCP SDK
  2. Step 2: Write your first tool and run the server
  3. Step 3: Add an allergen tool, a resource and a prompt
  4. Step 4: Test everything in MCP Inspector
  5. Step 5: Connect it to Claude Desktop and Claude Code
  6. Claude Desktop
  7. Claude Code
  8. Step 6: Call it from your own Python client
  9. What could go wrong, and where to look
  10. Recap
  11. Go deeper

Meera’s request had become a little list, stuck to Arjun’s monitor:

  1. Ask Claude where a customer’s order is.
  2. Ask Claude whether a product is safe for someone with an allergy.
  3. See today’s menu without opening the website.

“All three from my Claude,” she had said. “Not a new app.”

The community Google Sheets server had taught Arjun that MCP was not magic. Today he would build Crumb’s own server from scratch: two tools, a resource and a prompt, tested properly, then connected to Meera’s Claude Desktop and to the team’s Claude Code. Priya blocked out the afternoon. “Expect at least two things to go wrong,” she said. “That’s how you’ll learn where to look.”

Important

The idea in one sentence: An MCP server is an ordinary Python program in which decorated functions become tools, resources and prompts; you test it in the MCP Inspector, connect it to any host with one line of configuration, and from then on every MCP-capable app can use it.

In this lab you will build crumb-mcp, a small MCP server with:

  • a tool order_status(order_id),
  • a tool check_allergens(product),
  • a resource crumb://menu/today,
  • a prompt allergy_reply(customer_name, product).

You need Python 3.12, uv (from the Developer Toolkit module) and, for the Inspector, Node.js. Real Crumb data would come from the order database; to keep the lab self-contained, it uses small dictionaries you can swap out later.

Step 1: Create the project and install the MCP SDK

uv init crumb-mcp
cd crumb-mcp
uv add "mcp[cli]<2"

uv init creates a project folder with a pyproject.toml (the project’s settings and dependency list). uv add installs the official MCP Python SDK; the [cli] part adds the mcp command-line helper you will use later. If you use pip instead: pip install "mcp[cli]<2".

Why <2? Arjun found out the hard way. His first uv add "mcp[cli]" installed version 2 of the SDK, and the line from mcp.server.fastmcp import FastMCP, copied from the docs he had bookmarked, failed with an error saying FastMCP had been renamed. Version 2 calls the class MCPServer and imports it with from mcp.server.mcpserver import MCPServer; the decorators you will use below (@mcp.tool(), @mcp.resource(...), @mcp.prompt()) work the same way. Version 2 also speaks the newer, stateless 2026-07-28 revision of the protocol from the last lesson, while version 1 speaks 2025-11-25, with the handshake you traced by hand. This lab pins version 1 so that every line matches the official examples you will find most often, and the messages match the last lesson’s trace. If you prefer version 2, change those two names and follow its migration guide.

Check the install:

uv run mcp version

You should now see a version number starting with 1..

Step 2: Write your first tool and run the server

Create server.py:

"""A tiny MCP server that lets any AI app look things up at Crumb & Co."""
import sys

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("crumb")

ORDERS = {
    "A1001": {"customer": "Sunita Kulkarni", "status": "Out for delivery", "eta": "6:30 pm"},
    "A1002": {"customer": "Rohan Mehta", "status": "Being baked", "eta": "tomorrow, 11 am"},
    "A1003": {"customer": "Asha Iyer", "status": "Delivered", "eta": "-"},
}


@mcp.tool()
def order_status(order_id: str) -> str:
    """Look up the delivery status of a Crumb order.

    Args:
        order_id: The order ID printed on the receipt, like "A1001".
    """
    order = ORDERS.get(order_id.strip().upper())
    if order is None:
        return f"No order found with ID {order_id}."
    print(f"looked up {order_id}", file=sys.stderr)  # logs go to stderr, never stdout
    return f"Order {order_id}: {order['status']} (ETA {order['eta']})."


if __name__ == "__main__":
    mcp.run()  # stdio transport by default

Line by line, the new parts:

  • FastMCP("crumb") creates the server and gives it a name, which hosts will display.
  • @mcp.tool() turns the function into an MCP tool. The function name becomes the tool name. The type hint order_id: str becomes the input schema. The docstring becomes the description the model reads, including the “Args” line, so write it for a reader who has never seen your code.
  • .strip().upper() forgives a model that sends " a1001". Models are good at following schemas, not perfect.
  • A missing order returns a helpful message, not an exception. “No order found” is a normal answer the model can pass on.
  • The print goes to sys.stderr. Remember the last lesson: on stdio, standard output belongs to the protocol.
  • mcp.run() starts the server, speaking over stdio.

Now run it:

uv run server.py

Arjun did this and waited. Nothing happened. No message, no prompt. He assumed it had frozen and pressed Ctrl+C.

“It was working perfectly,” Priya said. “What is a stdio server waiting for?”

He thought back to the hand-written JSON. “A message on standard input. An initialize. It’s waiting for a client to talk to it.”

A stdio server is like a phone that has been switched on: silent until someone calls. You do not usually run it yourself; the host starts it. Press Ctrl+C to stop it.

You should now see the server start and sit silently waiting, with no errors, until you stop it.

Note

Pause and think. Arjun’s first version of order_status had print(f"Looking up {order_id}...") without file=sys.stderr. It worked fine when he ran the function in a quick test script. Why would it break the moment a host connected?

Show the answer

In a test script, print writes to the terminal, which is harmless. When a host runs the server over stdio, standard output is the channel for JSON-RPC messages. The host would read “Looking up A1001…” where it expected a JSON response and treat it as a broken message, failing the call or dropping the connection. Same code, different context: always log to stderr in a stdio server.

Step 3: Add an allergen tool, a resource and a prompt

Add a menu, a second tool, a resource and a prompt. Put this above the if __name__ == "__main__": line:

MENU = {
    "walnut brownie": {"price": 120, "allergens": ["tree nuts", "gluten", "egg", "milk"]},
    "eggless chocolate cake": {"price": 650, "allergens": ["gluten", "milk"]},
    "millet cookies": {"price": 180, "allergens": ["milk"]},
    "peanut chikki": {"price": 90, "allergens": ["peanuts"]},
}


@mcp.tool()
def check_allergens(product: str) -> str:
    """List the allergens in a Crumb menu item.

    Args:
        product: The product name, like "walnut brownie".
    """
    item = MENU.get(product.strip().lower())
    if item is None:
        return f"'{product}' is not on today's menu."
    return f"{product.title()} contains: {', '.join(item['allergens'])}."


@mcp.resource("crumb://menu/today")
def todays_menu() -> str:
    """Today's menu with prices in rupees."""
    lines = [f"- {name.title()}: Rs {item['price']}" for name, item in MENU.items()]
    return "Today's menu at Crumb & Co.\n" + "\n".join(lines)


@mcp.prompt()
def allergy_reply(customer_name: str, product: str) -> str:
    """A template for answering an allergy question warmly and safely."""
    return (
        f"Write a short, warm reply to {customer_name}, who asked whether "
        f"{product} is safe for them. Use the check_allergens tool first. "
        "Never guess. If unsure, ask them to call the store."
    )

What each one is for, using last lesson’s “who controls it” idea:

  • check_allergens is a tool: the model decides to call it, in the middle of a conversation, with an argument it picks.
  • crumb://menu/today is a resource: read-only data at an address. The app or user chooses to attach it; nothing is decided or changed.
  • allergy_reply is a prompt: a template the user picks, with blanks (customer_name, product) to fill in. Notice it tells the model to use the tool rather than guess. Prompts are a good place to package the procedure you want followed.

Run uv run server.py once more to check for typos (it should start silently again), then stop it.

You should now see the server start without errors. You cannot see the new features yet. That is the next step.

Step 4: Test everything in MCP Inspector

You could connect straight to Claude Desktop now, but when something fails there, you get very little information. The MCP Inspector is the official testing tool: a web page that acts as an MCP client and shows you every tool, resource and prompt, lets you call them by hand, and shows the raw messages. Test here first, always.

npx @modelcontextprotocol/inspector uv run server.py

npx downloads and runs the Inspector (it needs Node.js). Everything after the package name is the command to start your server. The Inspector prints a local address to open in your browser, usually http://localhost:6274, with a session token on the end so that only you can use it.

The SDK also has a shortcut that does the same thing:

uv run mcp dev server.py

In the browser (the layout varies a little between Inspector versions):

  1. Check the transport is STDIO and the command matches, then click Connect. The status should turn to connected, showing the server name crumb.
  2. Open Tools and click List Tools. You should see order_status and check_allergens, each with its docstring.
  3. Click order_status, type A1001 in the order_id box, and run it. The result: Order A1001: Out for delivery (ETA 6:30 pm).
  4. Try A9999. You get No order found with ID A9999. as a normal result, which is exactly what you want the model to see.
  5. Open Resources, list them, and click crumb://menu/today to read the menu.
  6. Open Prompts, choose allergy_reply, fill in Sunita and walnut brownie, and see the generated message.

This is where Arjun’s second mistake surfaced. He had added print("Server ready!") near the top of the file for reassurance. The Inspector connected, then showed an error about an invalid message. He recognised it immediately this time, deleted the line, and reconnected. “It took me a whole evening yesterday,” he said. “Today it took ten seconds.” Priya nodded. “That’s what understanding the plumbing buys you.”

Tip

Keep the Inspector open while you develop. After you edit server.py, reconnect (or restart the Inspector) to load the new version. Each tool call in the Inspector is exactly what a host would send, without a model in the way, so you can tell “my server is wrong” apart from “the model chose badly”.

You should now see both tools, the resource and the prompt in the Inspector, and get correct results for A1001, A9999, walnut brownie and crumb://menu/today.

Step 5: Connect it to Claude Desktop and Claude Code

Claude Desktop

Claude Desktop reads a configuration file called claude_desktop_config.json. The easiest way to open it is from Claude Desktop’s settings, in the Developer section, with Edit Config. Its location:

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: %APPDATA%\Claude\claude_desktop_config.json

First find two absolute paths (paths that start from the root of the disk). In a terminal:

which uv     # for example /Users/arjun/.local/bin/uv
pwd          # run inside crumb-mcp, for example /Users/arjun/code/crumb-mcp

Then add your server to the file (keep any servers already there):

{
  "mcpServers": {
    "crumb": {
      "command": "/Users/arjun/.local/bin/uv",
      "args": ["--directory", "/Users/arjun/code/crumb-mcp", "run", "server.py"]
    }
  }
}

This tells Claude Desktop: “To start the crumb server, run uv in the crumb-mcp folder with run server.py.”

Why the full path to uv? Arjun’s first attempt said "command": "uv", which works in his terminal. Claude Desktop reported that the server failed to start, and its log said spawn uv ENOENT: it could not find a program called uv. Apps opened from the dock or Start menu do not always get the same search path (the PATH setting) as your terminal, so a command your shell finds may be invisible to them. A full path removes the guesswork. The same goes for --directory: the host does not start in your project folder, so tell it where to look.

(The SDK can also write this entry for you with uv run mcp install server.py, but writing it by hand once means you will know how to fix it.)

Now quit Claude Desktop completely and reopen it. Closing the window is not enough; it reads the config only at startup.

In a new chat, open the tools or connectors menu in the message box; you should see crumb with its two tools. Ask:

“Where is order A1001?”

Claude asks permission to use order_status. Allow it. You should get an answer about the order being out for delivery, with a 6:30 pm ETA. Then try: “Is the walnut brownie okay for someone with a nut allergy?”

Claude Code

Claude Code, Anthropic’s coding agent for the terminal, is also an MCP host. From inside your crumb-mcp folder:

claude mcp add crumb -- uv --directory "$(pwd)" run server.py

Everything after -- is the command to start the server; $(pwd) fills in the folder’s absolute path. Start Claude Code with claude, type /mcp to see connected servers, and ask it the same questions. To share the server with the whole team through the repository, add --scope project to that command, which saves the setting in a .mcp.json file you can commit.

You should now see Claude Desktop (and Claude Code, if you use it) list the crumb tools and answer the order question using order_status, after you approve the call.

Note

Pause and think. You built one server and connected it to two different apps without changing a line of server.py. Which part of the N + M idea from the first lesson did you just experience, and what would it have cost without MCP?

Show the answer

You wrote the “M” side once: one server for one system. Each host already had its “N” side, an MCP client, built in. Without MCP you would have needed one integration per app: a Claude tool definition and handler for one, something else for the other, and no way at all to extend Claude Desktop with your own Python code. Adding a third host tomorrow costs you nothing.

Step 6: Call it from your own Python client

Hosts are not the only clients. Your own programs can use MCP servers too, which is how Arjun’s agents will reach Crumb’s systems from now on. Create client.py:

"""Talk to the Crumb MCP server from plain Python, no AI involved."""
import asyncio

from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

# How to start the server: the same command you would type in a terminal.
server = StdioServerParameters(command="uv", args=["run", "server.py"])


async def main() -> None:
    async with stdio_client(server) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()  # the handshake

            tools = await session.list_tools()
            print("Tools:", [tool.name for tool in tools.tools])

            result = await session.call_tool("order_status", {"order_id": "A1001"})
            print("Result:", result.content[0].text)

            menu = await session.read_resource("crumb://menu/today")
            print(menu.contents[0].text)


asyncio.run(main())

Every line maps onto the messages from the last lesson. stdio_client starts the server as a child process. session.initialize() sends initialize, waits for the reply and sends notifications/initialized. list_tools, call_tool and read_resource send tools/list, tools/call and resources/read. The SDK uses async code because a client may wait on several servers at once; asyncio.run starts it.

Run it from the crumb-mcp folder:

uv run client.py

You should now see (along with some log lines from the server on stderr):

Tools: ['order_status', 'check_allergens']
Result: Order A1001: Out for delivery (ETA 6:30 pm).
Today's menu at Crumb & Co.
- Walnut Brownie: Rs 120
- Eggless Chocolate Cake: Rs 650
- Millet Cookies: Rs 180
- Peanut Chikki: Rs 90

No model was involved: your code chose the tool. In the next lesson, an agent framework will plug this same server into an agent, and the model will choose.

What could go wrong, and where to look

Symptom Likely cause Fix
Server “disconnects” right after connecting Something printed to stdout Log to stderr; remove stray prints
spawn uv ENOENT or “command not found” Host cannot find uv Use the full path from which uv
Server starts but tools never appear Config edited but app not fully restarted, or a JSON syntax error in the config Quit and reopen; validate the JSON (a missing comma breaks every server in the file)
ModuleNotFoundError: mcp.server.fastmcp MCP SDK version 2 installed Pin mcp[cli]<2, or switch to MCPServer
Model never uses your tool Vague name or docstring Say what the tool does and when to use it
Works in Inspector, fails in the host Different working directory or environment variables Use absolute paths; pass secrets with an env block in the config

That last row matters for real servers. When crumb-orders talks to the real database, it will need a password. Never hard-code it in server.py. Claude Desktop’s config accepts an "env": {"DATABASE_URL": "..."} block next to command and args, and your server reads it with os.environ, the same way you have read API keys all course.

Recap

  • An MCP server is a normal Python program: FastMCP plus decorators turns functions into tools, resources and prompts, using names, type hints and docstrings as their descriptions.
  • A stdio server waits silently for a client. That is normal. Never print to stdout.
  • The MCP Inspector (npx @modelcontextprotocol/inspector ... or uv run mcp dev server.py) tests a server without a model in the way. Test there first.
  • Claude Desktop needs one config entry with absolute paths and a full restart; Claude Code needs one claude mcp add command.
  • Your own programs can be MCP clients with ClientSession, which makes the same server reusable by your agents.

On Friday, Arjun sat beside Meera while she typed into her own Claude Desktop: “Is the walnut brownie okay for Sunita’s son? He’s allergic to peanuts.” Claude asked to use check_allergens; she clicked Allow. It replied that the brownie contains tree nuts, gluten, egg and milk but no peanuts, and suggested checking with the store about cross-contamination.

“That’s the first time a computer at Crumb has answered me in my own app,” she said. Then she clicked into the tools menu and frowned. “But it can only look things up. Your support bot does refunds, routes complaints and checks allergies. Can it be built from pieces like this too?” It could. The next lesson is about agent SDKs: frameworks that combine tools like these, built-in tools from the model provider, and other agents, into one working system.

Go deeper

Your build checklist

0/6 done

Check your understanding

3 quick questions
1. You run uv run server.py and nothing happens: no output, no prompt. What is going on?
2. Claude Desktop says your server failed to start, and the log shows "spawn uv ENOENT". What is the most likely fix?
3. Why test a new server in MCP Inspector before connecting it to Claude Desktop?