TL;DR
The Model Context Protocol (MCP) is an open standard that lets LLMs like Claude call external tools, read resources, and use reusable prompts through a well-defined interface. To build an MCP server in Python: install
mcpwithpip install mcp, create aFastMCPapp, decorate Python functions with@mcp.tool(),@mcp.resource(), and@mcp.prompt(), then run the server with stdio or SSE transport. Register it with Claude Desktop viaclaude_desktop_config.json. The full filesystem example in this article is production-ready in under 100 lines of Python.
Build an MCP Server in Python: Complete Tutorial 2026
The AI tooling ecosystem moved fast in 2025 and 2026. One of the most impactful shifts was the emergence of the Model Context Protocol (MCP) as the de facto standard for connecting language models to external data and actions. If you have ever wanted Claude — or any compatible LLM host — to read files, query a database, or call an API on your behalf, MCP is the right abstraction layer to learn.
This tutorial walks through everything from the conceptual architecture to a deployable filesystem tool server, tested with the MCP Inspector, secured for production, and registered with Claude Desktop.
1. What Is MCP? Architecture Overview
MCP is an open protocol published by Anthropic in late 2024. Think of it as "USB-C for AI context": a single, standardised connector that any LLM host can plug into any context provider without custom glue code on every side.
Core roles
| Role | Responsibility |
|---|---|
| Host | The application the user interacts with (e.g. Claude Desktop, a custom chat UI). Manages LLM sessions and user trust. |
| Client | Lives inside the host; initiates and owns one MCP connection per server. |
| Server | Your code. Exposes capabilities (tools, resources, prompts) over the MCP wire protocol. |
A single host can connect to multiple servers simultaneously. Each server is an isolated process — the host client speaks to them over a transport layer.
Capabilities exposed by a server
- Tools — callable functions the LLM can invoke (side-effects allowed). Example:
read_file,run_sql. - Resources — read-only data the LLM can pull into context. Example: a live config file, a database row.
- Prompts — reusable prompt templates with parameters. Example: a code-review prompt that injects a diff.
Transports
MCP defines two standard transports:
- stdio — the host launches your server as a subprocess and communicates over stdin/stdout. Zero network configuration. Best for local tools tied to a single machine.
- SSE (Server-Sent Events) — your server runs as an HTTP service; the client connects with GET and sends requests over POST. Best for shared, multi-user, or remotely hosted servers.
Both transports carry the same JSON-RPC 2.0 message format, so switching is one line of code.
The protocol flow for a tool call looks like this:
Host (Claude Desktop)
└─ Client ──[initialize]──► Server
◄─[capabilities]──
──[tools/list]────►
◄─[tool schemas]──
──[tools/call]────► (your Python function executes)
◄─[tool result]───
LLM sees the result and continues generating its response
2. Install the MCP SDK
The official Python SDK is published on PyPI under the name mcp. As of May 2026 the current stable release is 1.8.x.
pip install mcp
For projects that use uv (the fast Python package manager):
uv add mcp
The SDK ships FastMCP, a high-level decorator-based API that handles serialisation, schema generation, and transport negotiation for you. There is also a lower-level Server class if you need full control, but FastMCP covers the vast majority of use cases and is what this tutorial uses throughout.
Verify the installation:
python -c "import mcp; print(mcp.__version__)"
You should see output like 1.8.0 (or later). If you see an ImportError, confirm your virtual environment is activated.
For the SSE transport and unit testing you will also want:
pip install mcp[cli] # includes the mcp dev CLI and inspector launcher
pip install uvicorn # ASGI server for HTTP/SSE deployment
pip install pytest # unit testing
3. Build a Minimal Server with @mcp.tool()
Create a file called hello_mcp.py:
from mcp.server.fastmcp import FastMCP
# Instantiate the server with a human-readable name.
# This name appears in Claude Desktop's MCP server list.
mcp = FastMCP("Hello MCP")
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two integers and return the result."""
return a + b
@mcp.tool()
def greet(name: str) -> str:
"""Return a personalised greeting for the given name."""
return f"Hello, {name}! Welcome to MCP."
if __name__ == "__main__":
mcp.run()
Run it to confirm there are no import errors:
python hello_mcp.py
The server starts in stdio mode by default and waits for JSON-RPC messages on stdin. It will appear to hang — that is correct behaviour. The host (Claude Desktop, the MCP Inspector, or your own client) sends the initialize handshake and subsequent requests.
What @mcp.tool() does under the hood
When Python evaluates the decorator, FastMCP:
- Inspects the function's type annotations and docstring.
- Generates a JSON Schema for the input parameters (e.g.
{"type": "object", "properties": {"a": {"type": "integer"}, "b": {"type": "integer"}}, "required": ["a", "b"]}). - Registers the function in an internal tool registry keyed by function name.
- At runtime, deserialises incoming JSON arguments, calls your function, and serialises the return value back to the client.
Type annotations are not optional. The SDK uses them to produce the schema that the LLM reads to understand how to call your tool. A function without annotations will fail to register.
Async tools
Both synchronous and async functions are supported:
import asyncio
import httpx
@mcp.tool()
async def fetch_page_title(url: str) -> str:
"""Fetch the HTML title of a web page given its URL."""
async with httpx.AsyncClient(follow_redirects=True, timeout=10) as client:
r = await client.get(url)
r.raise_for_status()
# A naive title extractor — good enough for demonstration
start = r.text.find("<title>") + 7
end = r.text.find("</title>", start)
return r.text[start:end].strip() if start > 6 else "(no title found)"
Use async def whenever your tool performs I/O (HTTP, database, filesystem) to avoid blocking the event loop.
4. Add Resources and Prompts
Resources with @mcp.resource()
Resources expose read-only data. The URI scheme is flexible; use whatever makes sense for your domain.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("Config Server")
APP_CONFIG = {
"version": "2.1.0",
"feature_flags": {"dark_mode": True, "beta_search": False},
"max_connections": 100,
}
@mcp.resource("config://app")
def get_app_config() -> dict:
"""Expose the current application configuration as a resource."""
return APP_CONFIG
@mcp.resource("config://feature/{flag_name}")
def get_feature_flag(flag_name: str) -> bool:
"""Return whether a specific feature flag is enabled."""
return APP_CONFIG["feature_flags"].get(flag_name, False)
The client can subscribe to config://app and the host will inject it as context before the LLM generates its response. URI templates (with {parameter} segments) let you expose dynamic resources without registering every variant up front.
Resources vs tools: use resources for ambient context that the host can pre-load (configuration, user preferences, reference documents). Use tools for on-demand or side-effecting operations. The LLM does not need to "call" a resource — the host injects it automatically.
Prompts with @mcp.prompt()
Prompts are pre-defined message templates with typed parameters. They appear in Claude Desktop's prompt library for users to invoke directly.
from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.prompts import base
mcp = FastMCP("Prompt Library")
@mcp.prompt()
def code_review(language: str, diff: str) -> list[base.Message]:
"""Generate a structured code review prompt for a given diff."""
return [
base.UserMessage(
f"Please review the following {language} code changes.\n\n"
f"Focus on: correctness, security, performance, and readability.\n\n"
f"Diff:\n```{language}\n{diff}\n```"
)
]
@mcp.prompt()
def summarise_document(document: str, audience: str = "general") -> list[base.Message]:
"""Summarise a document for a target audience."""
return [
base.UserMessage(
f"Summarise the following document for a {audience} audience "
f"in 3 to 5 bullet points.\n\n{document}"
)
]
The LLM host can enumerate available prompts, let the user pick one, fill in the parameters, and inject the resulting messages into the conversation — all without the user hand-crafting the prompt text.
5. Stdio vs SSE Transport — When to Use Each
Stdio (default)
if __name__ == "__main__":
mcp.run() # transport="stdio" is the default
Use stdio when:
- The server is a local tool for a single developer or machine.
- You are integrating with Claude Desktop.
- You want zero network exposure and maximum simplicity.
- The server starts and stops with the host process — no daemon management.
- You are on a corporate network where opening new ports is bureaucratic.
Limitations: only one client can connect (the spawning host process). The server cannot outlive Claude Desktop. Not suitable for multi-user or cloud deployments.
SSE — Server-Sent Events (HTTP)
if __name__ == "__main__":
mcp.run(transport="sse", host="127.0.0.1", port=8000)
Use SSE when:
- Multiple users or multiple host instances need to share one server.
- You are deploying to a cloud environment (Fly.io, Railway, a VPS, Docker).
- You need persistent server state independent of any single client session.
- You want to put the server behind a reverse proxy with TLS termination.
- The server is expensive to start (loads a large model, connects to a database pool) and should stay running.
Considerations: the SSE endpoint is network-accessible and requires explicit authentication (covered in section 9). You must manage the server process lifecycle separately from the host.
Switch between them with a single argument — the rest of your tool/resource/prompt code is identical. The SDK handles all transport-layer differences transparently.
6. Register the Server with Claude Desktop
Claude Desktop reads server definitions from claude_desktop_config.json. The file lives at:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json - Linux:
~/.config/Claude/claude_desktop_config.json
Stdio server registration
{
"mcpServers": {
"filesystem": {
"command": "python",
"args": ["/absolute/path/to/your/server.py"],
"env": {
"PYTHONPATH": "/absolute/path/to/your/project"
}
}
}
}
Claude Desktop spawns python /absolute/path/to/your/server.py as a subprocess when it starts. The server's stdin/stdout become the MCP channel.
Using a virtual environment? Point to the interpreter inside it:
{
"mcpServers": {
"filesystem": {
"command": "/home/user/projects/myserver/.venv/bin/python",
"args": ["/home/user/projects/myserver/server.py"]
}
}
}
Using uv?
{
"mcpServers": {
"filesystem": {
"command": "uv",
"args": ["run", "--project", "/home/user/projects/myserver", "server.py"]
}
}
}
SSE server registration
Start your SSE server independently first, then register its URL:
{
"mcpServers": {
"remote-filesystem": {
"url": "http://127.0.0.1:8000/sse"
}
}
}
After editing the config
Restart Claude Desktop fully — quit from the tray or application menu, then reopen. A simple window close does not restart the MCP server processes. After restart, navigate to Settings > MCP Servers. Your server should appear with a green status indicator. If it shows red, check the Claude Desktop log files for stderr output from your server.
Log file locations: - macOS: ~/Library/Logs/Claude/ - Linux: ~/.config/Claude/logs/ - Windows: %APPDATA%\Claude\logs\
Important: always log to sys.stderr, never to sys.stdout. The MCP protocol uses stdout for its JSON-RPC stream; print statements to stdout will corrupt the protocol and cause cryptic connection failures.
7. Real Example: Filesystem Tool Server
Here is a complete, production-ready MCP server that exposes filesystem operations to Claude. Save it as filesystem_server.py.
"""
filesystem_server.py — MCP server exposing safe filesystem tools.
Tools exposed:
- read_file(path) Read a file's text content
- list_directory(path) List files and directories at a path
- write_file(path, content) Write text content to a file
Security: all paths are validated against an ALLOWED_ROOT directory.
Set the MCP_FS_ROOT environment variable to control which directory
the server may access (defaults to ~/mcp-workspace).
"""
import os
import sys
from pathlib import Path
from mcp.server.fastmcp import FastMCP
# ── Configuration ────────────────────────────────────────────────────────────
ALLOWED_ROOT = Path(os.environ.get("MCP_FS_ROOT", Path.home() / "mcp-workspace"))
ALLOWED_ROOT.mkdir(parents=True, exist_ok=True)
print(f"[filesystem_server] Serving files under: {ALLOWED_ROOT}", file=sys.stderr)
mcp = FastMCP(
"Filesystem Tools",
instructions=(
f"You have access to filesystem tools scoped to: {ALLOWED_ROOT}. "
"Use read_file to read files, list_directory to browse directories, "
"and write_file to create or overwrite files. "
"Never attempt paths outside the allowed root."
),
)
# ── Path validation helper ────────────────────────────────────────────────────
def _safe_path(raw: str) -> Path:
"""
Resolve a user-supplied path and confirm it stays inside ALLOWED_ROOT.
Raises ValueError for path traversal attempts.
"""
resolved = (ALLOWED_ROOT / raw).resolve()
try:
resolved.relative_to(ALLOWED_ROOT.resolve())
except ValueError:
raise ValueError(
f"Access denied: '{raw}' resolves outside the allowed root "
f"({ALLOWED_ROOT})."
)
return resolved
# ── Tools ─────────────────────────────────────────────────────────────────────
@mcp.tool()
def read_file(path: str) -> str:
"""
Read the text content of a file.
Args:
path: Path to the file, relative to the allowed root directory.
Returns:
The UTF-8 text content of the file.
"""
target = _safe_path(path)
if not target.exists():
raise FileNotFoundError(f"File not found: {path}")
if not target.is_file():
raise IsADirectoryError(f"Path is a directory, not a file: {path}")
size = target.stat().st_size
if size > 1_000_000:
raise ValueError(
f"File is {size:,} bytes — too large to read safely (limit: 1 MB)."
)
return target.read_text(encoding="utf-8")
@mcp.tool()
def list_directory(path: str = ".") -> list[dict]:
"""
List the contents of a directory.
Args:
path: Path to the directory, relative to the allowed root.
Defaults to the root directory itself.
Returns:
A list of entries, each with 'name', 'type' ('file' or 'directory'),
and 'size_bytes' (for files only).
"""
target = _safe_path(path)
if not target.exists():
raise FileNotFoundError(f"Directory not found: {path}")
if not target.is_dir():
raise NotADirectoryError(f"Path is a file, not a directory: {path}")
entries = []
for item in sorted(target.iterdir()):
entry: dict = {
"name": item.name,
"type": "directory" if item.is_dir() else "file",
}
if item.is_file():
entry["size_bytes"] = item.stat().st_size
entries.append(entry)
return entries
@mcp.tool()
def write_file(path: str, content: str) -> str:
"""
Write text content to a file, creating it or overwriting it if it exists.
Args:
path: Path to the file, relative to the allowed root directory.
content: The UTF-8 text content to write.
Returns:
A confirmation message with the number of bytes written.
"""
target = _safe_path(path)
target.parent.mkdir(parents=True, exist_ok=True)
bytes_written = target.write_text(content, encoding="utf-8")
rel = target.relative_to(ALLOWED_ROOT)
return f"Written {bytes_written} bytes to {rel}."
# ── Resource: top-level directory listing ─────────────────────────────────────
@mcp.resource("fs://root")
def root_listing() -> list[dict]:
"""Expose the top-level directory listing as an always-available resource."""
return [
{"name": item.name, "type": "directory" if item.is_dir() else "file"}
for item in sorted(ALLOWED_ROOT.iterdir())
]
# ── Entry point ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
transport = "sse" if "--sse" in sys.argv else "stdio"
if transport == "sse":
mcp.run(transport="sse", host="127.0.0.1", port=8000)
else:
mcp.run()
Register it with Claude Desktop
{
"mcpServers": {
"filesystem": {
"command": "python",
"args": ["/home/user/projects/filesystem_server.py"],
"env": {
"MCP_FS_ROOT": "/home/user/mcp-workspace"
}
}
}
}
After restarting Claude Desktop you can issue natural-language requests like:
- "Read the contents of
notes.txt." - "List all files in the
reportssubfolder." - "Create a file called
summary.mdwith the following content: ..."
Claude will automatically invoke the appropriate tool, interpret the result, and respond in natural language.
8. Testing with MCP Inspector
The MCP Inspector is the official interactive debugging UI for MCP servers. Install it via npm (Node.js 18+ required):
npm install -g @modelcontextprotocol/inspector
Then point it at your server:
mcp-inspector python /path/to/filesystem_server.py
The Inspector opens a browser UI at http://localhost:5173 where you can:
- Browse all registered tools, resources, and prompts with their full JSON schemas.
- Call tools interactively with a form-based UI and inspect raw JSON-RPC request/response pairs.
- Subscribe to resources and watch them update in real time.
- View the full message log for low-level protocol debugging.
For SSE servers, provide the URL instead:
mcp-inspector --url http://127.0.0.1:8000/sse
Always test with the Inspector before wiring Claude Desktop into a server. It surfaces schema errors, exception tracebacks, and malformed return values much faster than round-tripping through an LLM.
Automated unit tests with MCPTestClient
For regression testing, use the SDK's in-process test client:
# test_filesystem_server.py
import pytest
from pathlib import Path
from mcp.server.fastmcp.testing import MCPTestClient
import filesystem_server
from filesystem_server import mcp
@pytest.fixture
def workspace(tmp_path, monkeypatch):
"""Redirect ALLOWED_ROOT to a temporary directory for each test."""
monkeypatch.setattr(filesystem_server, "ALLOWED_ROOT", tmp_path)
return tmp_path
@pytest.fixture
def client():
with MCPTestClient(mcp) as c:
yield c
def test_write_and_read_roundtrip(client, workspace):
result = client.call_tool("write_file", {"path": "hello.txt", "content": "hi there"})
assert "8 bytes" in result
text = client.call_tool("read_file", {"path": "hello.txt"})
assert text == "hi there"
def test_list_directory_returns_entries(client, workspace):
(workspace / "alpha.txt").write_text("a")
(workspace / "beta.txt").write_text("b")
entries = client.call_tool("list_directory", {"path": "."})
names = [e["name"] for e in entries]
assert "alpha.txt" in names
assert "beta.txt" in names
def test_path_traversal_is_blocked(client, workspace):
with pytest.raises(ValueError, match="Access denied"):
client.call_tool("read_file", {"path": "../../etc/passwd"})
def test_missing_file_raises(client, workspace):
with pytest.raises(FileNotFoundError):
client.call_tool("read_file", {"path": "does_not_exist.txt"})
Run the suite:
pytest test_filesystem_server.py -v
9. Authentication and Security Considerations
MCP servers often have access to sensitive resources. Security must be designed in from the start, not bolted on afterwards.
Path traversal (filesystem servers)
Always resolve paths with Path.resolve() and verify they remain inside your allowed root — the _safe_path helper in section 7 implements this correctly. Never pass user-supplied strings directly to open(), Path(), or os.path functions without this check. The sequence ../../etc/passwd is a classic attack; .resolve() collapses it before comparison.
Stdio servers — OS-level isolation
Stdio servers inherit the OS-level access of the spawning user. No network auth layer is needed (or possible), but the server can do anything that user can do. Run the server under a dedicated low-privilege user account if the tools it exposes are sensitive.
Authentication for SSE servers
SSE servers are network-accessible and require explicit authentication. The cleanest approach with Starlette middleware:
import os
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
VALID_TOKEN = os.environ["MCP_SECRET_TOKEN"]
class BearerAuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer ") or auth[7:] != VALID_TOKEN:
return Response("Unauthorized", status_code=401)
return await call_next(request)
# Attach middleware to the FastMCP ASGI app before starting
app = mcp.get_asgi_app()
app.add_middleware(BearerAuthMiddleware)
For multi-user deployments, consider OAuth 2.0 or API key management rather than a single shared token.
Principle of least privilege
- Set
ALLOWED_ROOTto the minimum directory the server needs. Never point it at/or~. - Open database connections with a read-only role unless writes are explicitly required.
- Audit every tool for what it can do if the LLM is manipulated by a malicious document (prompt injection attacks can instruct Claude to call tools in unintended ways).
Input validation beyond type annotations
Type annotations enforce basic types, but add explicit business-logic validation inside your tool functions:
import re
SAFE_FILENAME_RE = re.compile(r'^[\w\-. /]+$')
@mcp.tool()
def read_file(path: str) -> str:
"""Read a text file (safe characters only, max 1 MB)."""
if not SAFE_FILENAME_RE.match(path):
raise ValueError(f"Invalid characters in path: {path!r}")
target = _safe_path(path)
# ... rest of implementation
Secrets management
Never hard-code API keys, tokens, or credentials in server source code. Pass them via environment variables and read them with os.environ. In Claude Desktop's config, use the env key to inject secrets without exposing them in shell history or process listings.
10. Deployment
Local process via stdio — recommended for personal tools
This is the simplest deployment model. Claude Desktop manages the process lifecycle: it starts your server on launch and kills it on quit. No port management, no firewall rules, no TLS.
Tips for reliable stdio deployments: - Use absolute paths in claude_desktop_config.json — relative paths break when Claude Desktop changes its working directory. - Pin your Python version in the virtual environment to avoid silent breakage on system Python upgrades. - Log startup confirmation to sys.stderr so it appears in Claude Desktop's logs.
HTTP server via SSE — for shared or remote tools
As a systemd service on Linux:
# /etc/systemd/system/mcp-filesystem.service
[Unit]
Description=MCP Filesystem Tool Server
After=network.target
[Service]
User=mcp
WorkingDirectory=/home/mcp/projects
ExecStart=/home/mcp/projects/.venv/bin/python /home/mcp/projects/filesystem_server.py --sse
Restart=on-failure
RestartSec=5
Environment=MCP_FS_ROOT=/home/mcp/mcp-workspace
Environment=MCP_SECRET_TOKEN=replace-with-a-long-random-string
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now mcp-filesystem
sudo journalctl -u mcp-filesystem -f # tail logs
With Caddy for automatic TLS:
# /etc/caddy/Caddyfile
mcp.example.com {
reverse_proxy 127.0.0.1:8000
}
As a Docker container:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY filesystem_server.py .
ENV MCP_FS_ROOT=/workspace
RUN mkdir /workspace
EXPOSE 8000
CMD ["python", "filesystem_server.py", "--sse"]
docker build -t mcp-filesystem .
docker run -d \
-p 8000:8000 \
-e MCP_SECRET_TOKEN=replace-with-a-long-random-string \
-v "$HOME/mcp-workspace:/workspace" \
--restart unless-stopped \
mcp-filesystem
Cloud platforms: the SSE server is a standard HTTP application and deploys to any platform that runs Docker containers — Fly.io, Railway, Render, Google Cloud Run, AWS Fargate. The only requirement is a persistent TCP connection for SSE (most managed platforms support this; verify the platform does not buffer SSE responses before choosing one).
TL;DR Summary
| Topic | Key point |
|---|---|
| What is MCP | Open protocol: host connects client to server via JSON-RPC over stdio or SSE |
| Architecture | Host manages trust and LLM; client owns connection; server exposes capabilities |
| Install | pip install mcp (current: 1.8.x) |
| Minimal server | FastMCP + @mcp.tool() — type annotations are mandatory |
| Resources | @mcp.resource("uri://scheme") for read-only, auto-injected context |
| Prompts | @mcp.prompt() returns list[Message] — appears in Claude's prompt library |
| Transport | stdio for local tools; SSE for shared or remote servers |
| Claude Desktop | Edit claude_desktop_config.json, fully restart Claude |
| Testing | MCP Inspector + MCPTestClient for automated unit tests |
| Security | Validate paths, bearer auth for SSE, least-privilege, no hard-coded secrets |
| Deploy | systemd + Caddy, or Docker, or any container cloud |
FAQ
Q: Do I need to know async Python to write MCP tools?
No. FastMCP supports both synchronous and async def tool functions. Start with regular functions and switch to async only if you are making concurrent I/O calls (e.g. hitting multiple APIs in parallel). Mixing sync and async tools in the same server is fine.
Q: Can one MCP server expose hundreds of tools?
Technically yes, but in practice you should keep related tools in focused servers. LLMs have limited context windows and perform better when the tool list is short and cohesive. Split large tool sets across multiple servers, each registered separately in Claude Desktop.
Q: Is MCP only for Claude?
No. MCP is an open protocol. Any LLM host that implements the client side can connect to your server. In 2026 this includes Claude Desktop, several third-party chat clients, IDE plugins, and custom agent frameworks. Anthropic publishes the spec at modelcontextprotocol.io.
Q: How does the LLM decide when to call a tool?
The host injects tool schemas (names, descriptions, parameter schemas) into the system prompt or a dedicated tools context block. The LLM generates a structured tool call when it determines a tool is needed. The host intercepts this, calls your server, injects the result, and lets the model continue. Your code never sees the LLM's internal reasoning — only the call.
Q: Can MCP tools call other MCP tools?
Not directly via the protocol — a server cannot initiate outbound MCP calls to other servers. Orchestration happens at the host/client level. If you need tool chaining, implement it inside a single tool function (call the relevant logic directly in Python) or build a custom agent loop at the host layer.
Q: What happens if my tool raises an exception?
FastMCP catches unhandled exceptions and serialises them as MCP error responses. The LLM sees the error message and can decide to retry, inform the user, or try a different approach. Always raise descriptive exceptions (ValueError, FileNotFoundError, etc.) with clear messages — they become the LLM's only signal about what went wrong.
Q: How do I pass API keys to my server when using Claude Desktop?
Use the env key in claude_desktop_config.json. Claude Desktop sets these as environment variables before launching the subprocess. Read them in your server with os.environ["MY_API_KEY"]. Never commit claude_desktop_config.json to version control if it contains secrets.
Q: What is the difference between a resource and a tool that returns data?
Resources are declared upfront and the host can subscribe to them for automatic context injection — without the LLM needing to "call" anything. Tools are invoked on demand, mid-conversation, when the LLM decides it needs information or needs to perform an action. Use resources for ambient context (config, user preferences, reference documents) and tools for on-demand queries or side-effecting operations.
Q: My server works in the Inspector but not in Claude Desktop. What is wrong?
The most common causes are: (1) wrong Python interpreter path in the config — Claude Desktop spawns a new shell and may not activate your virtualenv; use the absolute path to the venv's Python binary. (2) stdout pollution — a dependency is printing to stdout, corrupting the protocol stream; capture and redirect to stderr. (3) Missing environment variables — Claude Desktop does not inherit your shell environment; pass everything needed via the env key in the config.
Sources
- Model Context Protocol — Official Specification — the canonical protocol reference covering the full JSON-RPC message schema.
- MCP Python SDK — GitHub — source code, changelog, and official examples.
- MCP Python SDK — PyPI — installation and release history.
- MCP Inspector — official debugging and testing UI for MCP servers.
- Claude MCP Integration Guide — Anthropic Docs — Anthropic's official documentation for connecting MCP servers to Claude.
- modelcontextprotocol.io — protocol home page with ecosystem listings, compatible hosts, and community tutorials.
- FastMCP Server Documentation — high-level API reference for the decorator-based server interface.