Lesson 04 Your First MCP Server
Theme:

Your First MCP Server

Lesson 04 · MCP Servers · ~10 min read

The shape of an MCP tool

At the protocol level, an MCP tool is a tiny but strict contract. It has a name (the identifier the client uses to call it), a description (a natural-language summary the LLM reads to decide whether the tool is relevant), an input schema expressed as JSON Schema, and a return type that resolves to one or more content blocks — most commonly a TextContent, but images, embedded resources, and structured JSON are also on the menu. The schema is not decoration: clients validate arguments against it before forwarding the call, so a malformed request never reaches your handler.

In FastMCP, you almost never write any of this by hand. The function's signature becomes the input schema, the function's docstring becomes the description, the function's name becomes the tool name, and the return type is wrapped automatically into a content block. Two lines of Python produce a complete, schema-checked, LLM-discoverable capability.

When the client calls tools/list, the server replies with an array of entries that look like this:

{
  "tools": [
    {
      "name": "echo",
      "description": "Echo the message back.",
      "inputSchema": {
        "type": "object",
        "properties": {
          "message": {"type": "string", "title": "Message"}
        },
        "required": ["message"]
      }
    }
  ]
}

That JSON is what Claude actually reads. The cleaner your function signatures and docstrings, the better the model's tool-selection behaviour, because that is the entire surface area it has to reason about.

FastMCP: the decorator path

@mcp.tool() is the workhorse decorator of the high-level API. At registration time — which means at module import, before mcp.run() is ever called — it does four things in sequence. It introspects the function's signature via Python's inspect module, extracting parameter names and their type annotations. It builds a JSON Schema from those annotations using Pydantic v2's schema generation pipeline; primitive types map directly, dataclasses and BaseModel subclasses produce nested object schemas, and Optional / Union annotations become anyOf entries. It pulls the docstring (just the first paragraph, by convention) as the tool's description. And finally it registers a handler with the underlying low-level Server instance — yes, FastMCP is a wrapper around mcp.server.Server, the same object you saw in Lesson 03 — so dispatch on tools/call just looks up your function by name and awaits it.

This is why FastMCP is the default path for any new server. You write Python, you get a protocol server; the schema and the implementation can never drift because they come from the same source. The lineage is worth knowing: mcp.server.fastmcp.FastMCP lives next to mcp.server.Server in the official Python SDK. Reach for the low-level object only when you need behaviour FastMCP does not wrap yet (dynamic tool lists, custom error envelopes, alternate capability advertisements).

The complete pkb/server.py after this lesson is just this:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("pkb", version="0.1.0")

@mcp.tool()
def echo(message: str) -> str:
    """Echo the message back."""
    return message

def main() -> None:
    mcp.run()

Eleven lines, one tool, a working MCP server. mcp.run() defaults to stdio transport — exactly what Claude Desktop expects. The main() indirection exists so the [project.scripts] entry in pyproject.toml has something to point at.

The Claude Desktop config

Claude Desktop discovers MCP servers through a single file: claude_desktop_config.json (located at ~/Library/Application Support/Claude/ on macOS, %APPDATA%\Claude\ on Windows). The mcpServers object maps a server name to a launch spec. Each entry has three fields. command is the executable to launch — a bare name resolved against PATH, or an absolute path. args is an array of CLI arguments passed to that executable. env is an object of environment variables to set before launch; this is where API keys and config paths live.

For PKB, the entry is the smallest possible:

{
  "mcpServers": {
    "pkb": {
      "command": "pkb"
    }
  }
}

No args, no env — just a command name. This works because pip install -e . in the PKB project root reads the [project.scripts] table in pyproject.toml and installs a pkb shim onto the active environment's PATH. That shim resolves to pkb.server:main, which calls mcp.run(). Claude Desktop spawns it as a subprocess, attaches to its stdin/stdout, and speaks JSON-RPC. Move the project, switch virtualenvs, or rename the package and the command still works — there is no fragile path inside the config file.

The call path, end to end

Once the config is in place, restart Claude Desktop. What happens next is the full lifecycle from Lesson 02, played out in production. The host process spawns pkb as a child over stdio. It sends an initialize request announcing its protocol version and supported capabilities; your server replies with its own version and the capabilities it offers — for PKB, just tools. The host then sends tools/list; FastMCP returns the echo entry with the schema described above.

From that moment on, the echo tool is part of the LLM's effective toolbox. When the user types "echo back the word hello", Claude (the model) reads its tool list, decides echo is the right call, and produces a tools/call request with name="echo" and arguments={"message": "hello"}. The Desktop client validates the arguments against the schema and forwards the call over the stdio pipe. FastMCP receives it, looks up the handler by name, awaits echo(message="hello"), takes the return value, wraps it in a TextContent block, and ships the response back. The client passes that content back into the LLM's context as a tool result, the model composes a natural-language reply, and the user sees "I echoed back: hello" in the chat. Five hops, two processes, one tool call — and every step is the same MCP protocol you have been reading about.

The Inspector path

You do not have to drag Claude Desktop into the loop for every change. The SDK's mcp dev command runs your server under the MCP Inspector, a small browser UI that connects directly to the protocol. Run mcp dev pkb/server.py from the project root and the Inspector opens at a local URL with your server already attached.

The UI shows a list of advertised tools (clickable), a call form per tool that builds requests from the JSON Schema, and a live transcript of every JSON-RPC message in both directions. This is the right place to verify a tool works at the protocol level — no Claude, no LLM, no ambiguity about whether a problem is in your code or in the model's tool-selection. Use Inspector for fast iteration while building; switch to Claude Desktop only to confirm the natural-language UX.

Common pitfalls:

  1. Forgot to pip install -e . after editing server.py. Claude Desktop launches whatever pkb binary is currently on PATH; if you have not reinstalled, you are running the previous version even though the source has changed. Use editable installs (-e) so the shim follows your edits without a re-install on every change.
  2. Forgot to restart Claude Desktop after editing the config. claude_desktop_config.json is read once at startup. If you add or rename a server, fully quit Claude Desktop (not just close the window) and reopen it; otherwise the new entry never appears in the tool tray.
  3. Naming collisions across servers. If two configured MCP servers both export a tool literally called echo, Claude Desktop disambiguates by server prefix in some UIs but not all — and the LLM has no reliable way to choose between them. Give your tools server-scoped names (pkb_echo, pkb_note_create) from the start; it costs nothing and prevents an entire category of confusing failures.

References