Setting Up Your First MCP Server

You have read how MCP works on the wire. Now you want the loop that makes it click: write a server, wire it into Claude Desktop, and watch the model call your code. This is that loop. One tool, about 20 lines, working in roughly half an hour.

We will build a get_time server, register it, restart the host, and confirm Claude actually calls it. Then we will cover what goes wrong on the first run, because something usually does, and why.

What you are about to build

The plan is small on purpose. A stdio server, one tool, no network, no auth. The host launches your Python process as a subprocess and talks to it over stdin and stdout. You focus on the tool logic and let the SDK handle the JSON-RPC wire format described in article 02: MCP Architecture.

flowchart LR
    A["You write<br/>server.py"] --> B["Register in<br/>Claude Desktop config"]
    B --> C["Restart host"]
    C --> D["Host launches<br/>server subprocess"]
    D --> E["Ask Claude<br/>the time"]
    E --> F["Claude calls<br/>get_time"]

You need Python 3.10 or newer and the Claude Desktop app. That is the whole prerequisite list.

Install the SDK

The official Python SDK is the mcp package. It ships a high-level FastMCP helper that turns a plain Python function into a registered tool, so you do not hand-write JSON-RPC handlers.

Use a fresh virtual environment so the host launches the server with the exact interpreter that has mcp installed. This matters more than it sounds; a mismatched interpreter is a common first-run failure.

mkdir time-server && cd time-server
python -m venv .venv
source .venv/bin/activate
pip install "mcp[cli]"

On Windows the activate line is .venv\Scripts\activate. The [cli] extra pulls in the mcp command-line tool, which is handy for local testing.

The whole server

Here is the complete server. Save it as server.py in the folder you just made.

import sys
from datetime import datetime, timezone
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("time-server")

@mcp.tool()
def get_time() -> str:
    """Return the current UTC time as an ISO 8601 string."""
    print("get_time called", file=sys.stderr)  # logs go to stderr, never stdout
    return datetime.now(timezone.utc).isoformat()

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

That is the entire server. Three things are doing the work:

  • FastMCP("time-server") creates the server and names it. The name shows up in the host and in logs.
  • @mcp.tool() registers get_time as a callable tool. The SDK reads the function signature and docstring to build the tool’s schema and description automatically, so the model knows what the tool does and what it returns.
  • mcp.run(transport="stdio") starts the stdio loop: read requests from stdin, write responses to stdout, run until the host closes the pipe.

The docstring is not decoration. It becomes the tool description the model sees when deciding whether to call get_time. Write it the way you would write a hint to a teammate: short, literal, about what the tool does.

Why does the SDK build the schema from a plain function?

The client needs a JSON Schema for each tool: its name, description, and the shape of its arguments. Writing that by hand is tedious and drifts out of sync with the code.

FastMCP reads your function’s type hints and docstring and generates the schema for you. A parameter typed city: str becomes a required string argument. The docstring becomes the description the model reads. Change the signature and the advertised schema changes with it, so the two never disagree.

This is why type hints matter here even though Python does not enforce them at runtime: the SDK uses them to tell the client what your tool accepts.

Register it in Claude Desktop

The host does not discover servers on its own. You list them in a config file, and the host launches each one as a subprocess on startup. Open Claude Desktop, go to Settings, Developer, Edit Config. That opens claude_desktop_config.json. On macOS it lives at ~/Library/Application Support/Claude/claude_desktop_config.json; on Windows, under %APPDATA%\Claude\.

Add your server to the mcpServers block:

{
  "mcpServers": {
    "time-server": {
      "command": "/absolute/path/to/time-server/.venv/bin/python",
      "args": ["/absolute/path/to/time-server/server.py"]
    }
  }
}

Two rules save you the most grief here. Use absolute paths for both the interpreter and the script; the host does not run from your project directory, so a relative path resolves to the wrong place or nothing at all. And point command at the venv’s Python, not a bare python, so the process starts with the interpreter that has mcp installed.

command plus args is exactly the shell line the host runs: command args.... If you can run that same line by hand in a terminal and the server starts without printing anything to stdout, the host can run it too.

Restart and test

Config changes are read at startup, so quit Claude Desktop completely and reopen it. On macOS, quit from the menu; closing the window is not enough. When it comes back, look for the tools indicator in the message box (a small tools or slider icon). Click it and you should see time-server with its get_time tool listed.

Now the payoff. Ask, in plain language:

What time is it in UTC right now?

Claude sees the get_time tool, decides it fits, and calls it. You will get an approval prompt the first time; allow it. The reply comes back with a real ISO timestamp that your Python function produced. That round trip, natural-language question to your code and back, is the whole point of MCP.

sequenceDiagram
    participant U as You
    participant H as Claude Desktop
    participant S as server.py
    U->>H: "What time is it in UTC?"
    H->>S: tools/call get_time
    S->>H: "2026-07-13T14:22:00+00:00"
    H->>U: "It is 14:22 UTC."

When the first run fails

It often does, and the failure modes are few. Here is how to read them.

The server does not appear at all. Almost always a path or interpreter problem. Copy the exact command and args from your config and run them by hand in a terminal: /path/to/.venv/bin/python /path/to/server.py. If Python reports ModuleNotFoundError: No module named 'mcp', the command points at the wrong interpreter. If the process starts and just waits, that is correct: a stdio server sits reading stdin, so no output is the healthy state.

It appears, then drops. This is the classic stdout bug from article 02. On stdio, stdout is the JSON-RPC channel. Any stray print() without file=sys.stderr lands in the message stream, the client fails to parse it, and the connection tears down. Route every log line to stderr. That is why the get_time sample writes its log with file=sys.stderr.

It connects but no tool works. The initialize and initialized handshake from article 02 did not complete, so the connection never went live. Usually the server crashed during startup, before it could answer initialize. The host’s MCP logs show it. On macOS they are under ~/Library/Logs/Claude/, one file per server (mcp-server-time-server.log), plus a general mcp.log. Read those first; they capture your stderr output and the host’s side of the handshake.

Why must I restart the host after editing the config?

Claude Desktop reads claude_desktop_config.json once, at launch, and spawns a subprocess for each entry then. There is no live reload.

So any change, a new server, a fixed path, an added argument, only takes effect after a full quit and relaunch. On macOS, “quit” means Cmd+Q or Quit from the menu, not closing the window; a closed window leaves the app and its subprocesses running with the old config.

The same is true while you iterate on the server code itself: the host is running the process it launched at startup, so restart to pick up your edits.

A note on TypeScript

If your stack is Node, the official @modelcontextprotocol/sdk package mirrors this exactly. You create a server, register a tool with a name, a description, and a schema (typically a Zod schema), and connect it over a StdioServerTransport. The config entry uses "command": "node" with the path to your built script in args. Everything in this article, the handshake, the stdout rule, the restart, applies unchanged. The Python version is shorter for a first server, which is why it leads here.

Common beginner mistakes

  • Relative paths in the config: the host runs from its own directory, not yours. Use absolute paths for both command and args.
  • Bare python as the command: it may resolve to a system interpreter without mcp installed. Point at the venv’s Python.
  • print() to stdout: corrupts the JSON-RPC stream and drops the connection. Always pass file=sys.stderr.
  • Forgetting to fully quit the host: closing the window is not a restart. Config and code changes need a real relaunch.
  • A vague or missing docstring: it is the tool description the model reads. A blank one makes the model guess when to call your tool.
  • Editing the config while the app is open: the new entry is ignored until the next launch.

Questions you will face in production

“How do I see what my server is actually doing?” Watch the host’s MCP logs, which capture your stderr. On macOS: tail -f ~/Library/Logs/Claude/mcp-server-<name>.log. For a faster loop that skips the host entirely, run mcp dev server.py from the SDK’s CLI to open the MCP Inspector, a local UI that connects to your server, lists its tools, and lets you call them by hand.

“Do I have to restart the whole app every time I change a line of code?” For Claude Desktop, yes, because it holds the subprocess it launched at startup. During active development, iterate against the MCP Inspector instead; it reconnects to a fresh process each run, so you skip the quit-and-relaunch cycle until you are ready to test inside the host.

“My tool needs an argument. How does the model know to pass it?” Add a typed parameter, for example def get_time(city: str) -> str. The SDK turns the type hint into the argument’s JSON Schema and hands it to the client, so the model fills in city from the conversation. The docstring should mention the parameter’s meaning so the model uses it correctly.

What to remember

  • A stdio server is a subprocess the host launches; command plus args is the exact line it runs
  • FastMCP plus @mcp.tool() turns a typed, documented function into a registered tool, no hand-written JSON-RPC
  • Register the server in claude_desktop_config.json, using absolute paths and the venv’s Python
  • The host reads config only at launch, so a full quit and relaunch is required after any change
  • On stdio, stdout is the protocol channel; log to stderr or the connection drops
  • When it fails, run the command and args by hand and read the host’s MCP logs first

What to study next

You have one tool returning a string. The next step is the full vocabulary of what a server can expose: tools that take arguments and do work, resources the model can read, and prompts you can pre-package. That is covered in article 04: Tools, Resources, and Prompts, which builds directly on the server you just wrote.

Further reading

Where this article comes from. This is a synthesis of the MCP specification, the official SDK, and common practice as of 2026, not a citation of any single paper. The sources above are where the mechanics come from. If you find an error or have a better source for a claim, the article gets fixed within a day, send me a note.


Auto-marks when you reach the end. Click to toggle.

If this helped, buy me a coffee

Everything is free. Tips keep me writing the rest.

Buy me a coffee →