Every LLM host invents its own way to expose tools. Claude Desktop has its own config. Cursor has another. Cline has a third. If you build a tool integration twice, you start feeling the duplication. Model Context Protocol (MCP) is the attempt to fix that. It is a JSON-RPC based standard for letting an LLM host call tools, read resources, and use prompts that live in a separate process.
The 2025-06-18 spec is the current stable version. The Python SDK on PyPI sits at 1.28.1 and supports Python 3.10+. This walks through building a real server with that SDK, then connecting it to two real hosts (the MCP Inspector and Claude Desktop).
Prerequisites
- Python 3.10 or newer (check with
python --version) uvinstalled (the modern Python package manager)- About 5 minutes
If you do not have uv yet, install it first:
curl -LsSf https://astral.sh/uv/install.sh | sh
Step 1: Scaffold the Project
uv init notes-mcp
cd notes-mcp
uv venv
source .venv/bin/activate
uv add "mcp[cli]"
The [cli] extra pulls in the Inspector launcher and dev server. Without it, you would have to install mcp[cli] separately later.
Step 2: Write the Server
Create server.py. The whole server fits in one file.
from mcp.server.fastmcp import FastMCP
import os
import json
from pathlib import Path
mcp = FastMCP("notes")
NOTES_DIR = Path.home() / "notes"
@mcp.tool()
def list_notes() -> str:
"""List all note files in the notes directory."""
if not NOTES_DIR.exists():
return "Notes directory does not exist yet."
files = sorted([p.name for p in NOTES_DIR.glob("*.md")])
return json.dumps(files)
@mcp.tool()
def read_note(filename: str) -> str:
"""Read the contents of a note by filename (e.g. "todo.md")."""
path = NOTES_DIR / filename
if not path.exists():
return f"Error: {filename} not found"
return path.read_text(encoding="utf-8")
@mcp.tool()
def write_note(filename: str, content: str) -> str:
"""Create or overwrite a note file with the given content."""
NOTES_DIR.mkdir(parents=True, exist_ok=True)
(NOTES_DIR / filename).write_text(content, encoding="utf-8")
return f"Wrote {filename} ({len(content)} chars)"
@mcp.resource("notes://list")
def notes_resource() -> str:
"""A resource that returns the list of notes as plain text."""
if not NOTES_DIR.exists():
return "No notes directory."
return "\n".join(sorted(p.name for p in NOTES_DIR.glob("*.md")))
@mcp.prompt()
def summarize_notes() -> str:
"""A prompt template for summarizing all notes."""
return "Read every note in the notes directory and produce a 5-bullet summary."
if __name__ == "__main__":
mcp.run(transport="stdio")
Three things to notice.
First, the @mcp.tool() decorator turns a Python function into an MCP tool. The function name, the type hints, and the docstring become the tool's schema. No JSON Schema to write by hand.
Second, @mcp.resource() exposes a read-only piece of context. Hosts can fetch it on demand or subscribe to changes. @mcp.prompt() exposes a reusable prompt template the user can invoke with a slash command.
Third, mcp.run(transport="stdio") starts the server speaking JSON-RPC over standard input and output. This is the default transport for local servers. Streamable HTTP is the other option, used when the server runs in a different process or on a different machine.
Step 3: Test It with the MCP Inspector
The Inspector is a web UI for poking at any MCP server. It speaks the protocol directly, so you can verify your server works before plugging it into a real host.
uv run mcp dev server.py
The command prints a URL. Open it in your browser (default is http://localhost:6274). You will see three tabs: Tools, Resources, Prompts. Click list_notes, hit Run Tool, and you get a JSON array of filenames. The Inspector also captures every JSON-RPC frame in a panel on the right, which is invaluable when something goes wrong.
Step 4: Connect to Claude Desktop
Claude Desktop reads its MCP config from a JSON file. On macOS that file is at ~/Library/Application Support/Claude/claude_desktop_config.json. On Windows, %APPDATA%\Claude\claude_desktop_config.json. On Linux it does not exist yet — the official tutorial points Linux users at the client-building path instead.
{
"mcpServers": {
"notes": {
"command": "uv",
"args": ["--directory", "/absolute/path/to/notes-mcp", "run", "server.py"]
}
}
}
Replace /absolute/path/to/notes-mcp with the actual path. Restart Claude Desktop. The new server shows up as a 🔧 icon. Ask "What notes do I have?" and Claude will call list_notes. Ask "Read todo.md" and it will call read_note. Ask it to draft a new note and it will call write_note after showing you the proposed content for approval.
Step 5: Connect to Claude Code or Cursor
Claude Code and Cursor use the same mcpServers shape, but the file lives in different places. Claude Code reads ~/.claude.json (or .mcp.json at a project root). Cursor reads ~/.cursor/mcp.json. Drop the same config block in and the server becomes available inside the editor.
Logging Rule That Will Save You
A small gotcha that catches everyone the first time. For stdio servers, never write to stdout. The protocol uses stdout for JSON-RPC frames, so any stray print() corrupts the stream and the host sees a parse error. Logging must go to stderr or to a file.
import sys
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
stream=sys.stderr,
)
If your server worked in the Inspector but breaks the moment you plug it into Claude Desktop, this is the first thing to check. The Inspector is more forgiving about stray output.
Exposing the Server Over HTTP
stdio works for local development, but production deployments usually want HTTP so the server can run on a different machine. The Python SDK supports Streamable HTTP out of the box.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("notes", host="127.0.0.1", port=8000)
# ... tools, resources, prompts defined the same way ...
if __name__ == "__main__":
mcp.run(transport="streamable-http")
The client config changes too: "type": "http" and "url": "http://your-host:8000/mcp" instead of "command" and "args".
Two security notes from the spec that are easy to skip. The server must validate the Origin header on every incoming HTTP request to prevent DNS rebinding attacks. And when running locally, the server should bind to 127.0.0.1, not 0.0.0.0, unless you have a real reason to expose it on the network.
When MCP Is Overkill
If you only need to give one host access to one tool, you can wire that up in the host's native config and skip MCP entirely. The protocol earns its keep when the same tool has to work across multiple hosts (Claude Desktop, Cursor, Claude Code, Cline, a custom agent you are building) or when you want a server your whole team can share. It also helps when the tool needs to be in a separate process for security — read-only database access, sandboxed code execution, anything where you do not want the LLM process holding the credentials.
Where to Go Next
- Add authentication to your Streamable HTTP server with the
BearerAuthhelper from the SDK - Publish your server to PyPI so others can install it with
uvx your-server - Browse the MCP Registry to see what reference servers already exist (filesystem, git, sqlite, fetch) and read their source for patterns
- Combine MCP with the local agent loop from the previous article — the same tools work whether the model runs locally or in the cloud