An MCP server is a Python function that Claude is allowed to call. That is the whole concept. You write a normal function, add one decorator, point Claude Desktop at the file, and Claude can run your code instead of guessing. This builds a working one in about ten lines, wires it into Claude Desktop, and covers the two config mistakes that silently break it.
The problem nobody talks about
Claude Desktop cannot see your machine. It has no clock, no reach into your files, no line to your database. Ask it for the exact time right now and it will guess, because it genuinely has no way to check. That gap is the point. Everything useful you want Claude to do, read your data, hit your API, look at your calendar, lives on the other side of a wall it cannot cross on its own.
There is also the assumption that MCP is some heavy framework you need to sit down and study. It is not. The Model Context Protocol is just the agreed way to hand Claude a tool it can call. FastMCP, which ships inside the official mcp SDK, turns a plain function into one of those tools. The server below needs nothing else and fits on one screen.
Why this is smaller than it looks
The decorator is the entire mechanism. Putting @mcp.tool() above a function is what exposes it to Claude. There is no registry to edit, no route to define.
The docstring is not a comment, it is the interface.FastMCP builds the tool's schema from your type hints and that docstring. Leave it out and Claude has no idea what the tool does or when to reach for it.
There are no dependencies to chase. get_current_time uses datetime from the standard library, so past the SDK itself there is nothing to install.
Step 1: Install uv (and skip pip)
curl -LsSf https://astral.sh/uv/install.sh | shRun the server with one line, no project scaffold:
uv run --with mcp server.pyNo uv init, no uv add, zero project files. uv pulls mcp in for that run. The reason to use it over pip is not taste. Stock macOS ships Python 3.9, but mcp needs 3.10 or newer, so pip install mcp fails out of the box on most machines with a cryptic error. uv downloads a correct Python and manages the environment for you, so the version landmine never goes off.
One thing that looks wrong but is fine: when you run that line, the terminal just sits there with no output, like it froze. That is a STDIO server waiting silently for Claude to connect over stdin and stdout. Ctrl+C stops it.
Step 2: Write the server
# server.py
from mcp.server.fastmcp import FastMCP
from datetime import datetime
mcp = FastMCP("my-first-server")
@mcp.tool()
def get_current_time() -> str:
"""Get the current date and time on this computer."""
return datetime.now().strftime("%A, %B %d, %Y at %I:%M %p")
if __name__ == "__main__":
mcp.run()mcp.run() defaults to STDIO, which is exactly what Claude Desktop connects to. The strftimeformat returns something like "Thursday, July 16, 2026 at 02:30 PM", which reads better on screen than raw ISO. That is the whole server, nothing hidden below the fold.
Step 3: The one line that silently kills it
Never print() to stdout in a STDIO server. The server talks to Claude over stdout using JSON-RPC messages, and a stray print() writes straight into that stream and corrupts it. The server dies with no obvious error, and you will swear the code is fine. If you need to log something, send it to stderr:
import sys
print("checking the time...", file=sys.stderr)This is the most common beginner mistake and almost nobody warns you about it.
Step 4: Wire it into Claude Desktop
Open Settings, then Developer, then Edit Config. That opens claude_desktop_config.json. Paste this:
{
"mcpServers": {
"my-first-server": {
"command": "/Users/you/.local/bin/uv",
"args": ["run", "--with", "mcp", "/Users/you/path/to/server.py"]
}
}
}Two gotchas, and both fail silently, meaning the server just never shows up:
- Absolute path to
server.py. In the terminal a bareserver.pyworks because you are already in that folder. Claude Desktop launches from its own working directory, so it needs the full path or you get "file not found." - Absolute path to
uvtoo. Claude Desktop starts with a minimal PATH and cannot find a bare"command": "uv". It fails withspawn uv ENOENTand no visible error. Runwhich uvand paste whatever it prints.
Config file location:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
Step 5: Fully quit Claude, do not just close it
Cmd+Q. Closing the window leaves Claude running and it will not reload the config, so you will think you broke something. You did not. Quit it properly, then reopen.
Step 6: Prove it is real
Ask "what's the exact time on my machine?" with nothing connected first. It guesses, because it has to. Now connect the server and ask again. Claude calls the tool, a permission popup appears, you approve it, and the real time prints. Check your menu-bar clock, same minute. It is not guessing, it ran your code. Every tool call asks permission the first time, which is both the safety layer and proof the call actually happened.
Where this goes
The clock is the demo because it is undeniable, you can check it against your own screen. The value is the swap. Replace get_current_timewith a function that reads your Postgres, hits your company's API, or checks your calendar, and the ten lines around it do not change. Same decorator, same docstring, real function inside.
Honest limitations
STDIO only, local only. Claude Desktop connects to local servers over stdin and stdout. Letting Claude on the web reach your server needs an HTTP transport plus a proxy (mcp-remote), which is a separate build. That is the part 2.
If it does not connect, it is almost always the two absolute paths in Step 4, and neither gives you a useful error to work from.
The pip path is a landmine. Skip uv and the config command becomes python with the absolute path to server.py, and it only works if you already have Python 3.10+. Stock macOS 3.9 will not run it.
Permission fires on every tool the first time you call it. Good for safety, mildly repetitive in a demo.
Quick reference
- Run it locally:
uv run --with mcp server.py. Terminal looks frozen because a STDIO server waits silently; Ctrl+C stops it. - The mechanism:
@mcp.tool()exposes the function, the docstring and type hints are its schema. - Never
print()to stdout. Log to stderr withfile=sys.stderr. - Config: Settings, Developer, Edit Config. Absolute path to both
uvandserver.py. - Restart: fully quit with Cmd+Q, not just close the window.