Your First MCP Server — Tools
Go from a blank file to a fully working MCP server with three tools, connected to Claude Desktop and Claude Code. Both Python and TypeScript, all code runnable.
- ✓Install the MCP Python and TypeScript SDKs and understand what each package provides
- ✓Explain why FastMCP exists and what it abstracts away vs the raw protocol
- ✓Build a complete 3-tool server with type-validated inputs and proper error handling
- ✓Test tools interactively with MCP Inspector before connecting to a real LLM
- ✓Connect the server to Claude Desktop and Claude Code using their respective config files
1. Install the MCP SDK
Both official SDKs are lightweight — no bundled LLM client, just the protocol implementation. The Python SDK includes FastMCPFastMCP — a high-level Python wrapper in the MCP SDK that reduces boilerplate. It handles JSON-RPC serialization, capability negotiation, and the stdio/SSE transport loop so you only write tool functions., the ergonomic helper you'll use for all examples in this module.
# Python SDK (includes FastMCP helper class)
pip install mcp
# MCP Inspector — browser UI for calling tools interactively
# Requires Node.js >= 18
npm install -g @modelcontextprotocol/inspector
# Verify
python -c "import mcp; print(mcp.__version__)"
mcp --version
# TypeScript SDK
npm install @modelcontextprotocol/sdk
# You'll also need these for a standalone TS server
npm install zod # runtime schema validation
npm install -D tsx # run TypeScript without compiling
# MCP Inspector (same for both Python and TS servers)
npm install -g @modelcontextprotocol/inspector
# Verify
node -e "require('@modelcontextprotocol/sdk'); console.log('SDK OK')"
mcp >= 1.0 (Python) and @modelcontextprotocol/sdk >= 0.5 (TypeScript). The FastMCP decorator API was stabilized in mcp 1.0. If you get import errors, run pip install --upgrade mcp.
2. What FastMCP Does
FastMCP is a high-level class inside the mcp Python package. It replaces ~150 lines of low-level protocol plumbing with a decorator-based API. It handles: JSON-RPC 2.0 serialization, capability negotiation (the initialize handshake), tool list generation, input validation via Python type hints → JSON Schema, error wrapping, and the stdio/SSE transport loop.
The alternative is writing all of that yourself using the lower-level mcp.server.Server class. Here's the same "add two numbers" tool — first at the low level, then with FastMCP:
When FastMCP sees def search_documents(query: str, limit: int = 10) -> list[dict], it automatically generates this JSON Schema for the tool input:
{
"type": "object",
"properties": {
"query": { "type": "string" },
"limit": { "type": "integer", "default": 10 }
},
"required": ["query"]
}
This schema goes into the tools/list response. The LLM uses it to know exactly what arguments to pass. You write one Python function; FastMCP handles the rest.
3. Your First Tool
A minimal but fully runnable MCP server. One tool, stdio transport, error handling included. This is the "Hello World" of MCP — understand every line before moving on.
Four lines are required: import, instantiate, decorate, run. Everything else is your tool logic.
The LLM uses the docstring to decide when to call the tool. Write it as if you're describing the function to a colleague who only reads docstrings. Missing or vague docstrings result in the LLM calling tools incorrectly.
FastMCP serializes your return value to JSON. Returning a datetime, a custom class, or a pandas DataFrame will crash the server. Return str, dict, list, int, float, or bool.
# hello_server.py — minimal MCP server (Python)
from mcp.server.fastmcp import FastMCP
# 1. Instantiate FastMCP with a server name.
# This name appears in capability negotiation and tool listings.
mcp = FastMCP("hello-server")
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two integers and return their sum.
Use this when the user wants to perform addition or
compute a total from two numeric values.
"""
# Type hints above become the JSON Schema automatically.
# FastMCP validates incoming arguments before calling this function.
return a + b
if __name__ == "__main__":
# mcp.run() starts the stdio transport loop.
# The process reads JSON-RPC from stdin and writes to stdout.
# Your shell will appear to hang — that's normal, it's listening.
mcp.run()
// helloServer.ts — minimal MCP server (TypeScript)
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
// 1. Create the server (equivalent to FastMCP("hello-server"))
const server = new McpServer({ name: "hello-server", version: "1.0.0" });
// 2. Register a tool with an explicit Zod schema.
// In TS there are no runtime type hints, so we use Zod for validation.
server.tool(
"add",
"Add two integers and return their sum.",
{ a: z.number().int(), b: z.number().int() },
async ({ a, b }) => ({
content: [{ type: "text", text: String(a + b) }],
})
);
// 3. Connect to stdio transport and start listening
const transport = new StdioServerTransport();
await server.connect(transport);
// Process now listens on stdin — same behavior as Python mcp.run()
You have a working MCP server. When you run it, it does nothing visible — it silently listens on stdin for JSON-RPC messages. The add function is now a registered tool with name "add", description from the docstring, and an input schema of {a: integer, b: integer} — all derived automatically by FastMCP.
4. The Full 3-Tool Server
A production-realistic MCP server with three tools, full error handling, and detailed docstrings. The three tools — search_documents, calculate_similarity, and save_note — cover the three most common tool patterns: read, compute, and write.
search_documents is a read/query tool. calculate_similarity is a pure computation tool. save_note is a write/side-effect tool. Together they cover the full spectrum of what MCP tools do.
FastMCP maps Python default values to JSON Schema default fields. If a parameter has a default, it's not added to required in the schema — the LLM may omit it. This lets you write flexible tools without overloading the LLM with required parameters.
If your tool raises an unhandled exception, FastMCP catches it and returns a JSON-RPC error response with the exception message. The LLM will see this error and can decide to retry or report it. Always wrap external I/O (file access, network calls, DB queries) in try/except and raise descriptive errors — they become the LLM's error message.
# document_server.py — 3-tool MCP server with full error handling
from __future__ import annotations
import math
import json
import os
from pathlib import Path
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("document-server")
# ── In-memory document store (replace with real DB in production) ──
DOCUMENTS: dict[str, str] = {
"mcp-overview": "MCP is an open protocol for connecting AI to tools.",
"fastmcp-guide": "FastMCP reduces MCP server boilerplate with decorators.",
"transport-docs": "stdio is local; HTTP/SSE is for shared, remote servers.",
}
NOTES: dict[str, str] = {}
# ─────────────────────────────────────────────
# Tool 1: search_documents
# Pattern: read/query — no side effects
# ─────────────────────────────────────────────
@mcp.tool()
def search_documents(query: str, limit: int = 5) -> list[dict]:
"""Search the document store for documents matching a query string.
Performs case-insensitive substring matching across document IDs
and content. Returns a ranked list of matching documents.
Args:
query: The search string. Case-insensitive.
limit: Maximum number of results to return. Default is 5.
Returns:
List of dicts with 'id', 'preview', and 'score' fields.
Empty list if no matches found.
"""
if not query or not query.strip():
raise ValueError("query must be a non-empty string")
if limit < 1 or limit > 50:
raise ValueError("limit must be between 1 and 50")
query_lower = query.lower()
results = []
for doc_id, content in DOCUMENTS.items():
score = 0
if query_lower in doc_id.lower():
score += 2
if query_lower in content.lower():
score += 1
if score > 0:
results.append({
"id": doc_id,
"preview": content[:120] + ("..." if len(content) > 120 else ""),
"score": score,
})
results.sort(key=lambda r: r["score"], reverse=True)
return results[:limit]
# ─────────────────────────────────────────────
# Tool 2: calculate_similarity
# Pattern: pure computation — deterministic, no I/O
# ─────────────────────────────────────────────
@mcp.tool()
def calculate_similarity(text_a: str, text_b: str) -> dict:
"""Calculate cosine similarity between two text strings using word overlap.
Uses a bag-of-words approach: splits both texts into word sets,
then computes cosine similarity from word frequency vectors.
Not a deep embedding model — use for quick relevance checks.
Args:
text_a: First text to compare.
text_b: Second text to compare.
Returns:
Dict with 'similarity' (0.0–1.0), 'common_words', and
'interpretation' fields.
"""
if not text_a or not text_b:
raise ValueError("Both text_a and text_b must be non-empty strings")
def word_freq(text: str) -> dict[str, int]:
words = text.lower().split()
freq: dict[str, int] = {}
for w in words:
w = w.strip(".,!?;:")
if w:
freq[w] = freq.get(w, 0) + 1
return freq
freq_a = word_freq(text_a)
freq_b = word_freq(text_b)
all_words = set(freq_a) | set(freq_b)
if not all_words:
return {"similarity": 0.0, "common_words": [], "interpretation": "no words"}
dot = sum(freq_a.get(w, 0) * freq_b.get(w, 0) for w in all_words)
mag_a = math.sqrt(sum(v ** 2 for v in freq_a.values()))
mag_b = math.sqrt(sum(v ** 2 for v in freq_b.values()))
similarity = dot / (mag_a * mag_b) if mag_a and mag_b else 0.0
common = sorted(set(freq_a) & set(freq_b))
if similarity > 0.7:
interpretation = "highly similar"
elif similarity > 0.3:
interpretation = "somewhat related"
else:
interpretation = "largely different"
return {
"similarity": round(similarity, 4),
"common_words": common[:10], # cap at 10 for readability
"interpretation": interpretation,
}
# ─────────────────────────────────────────────
# Tool 3: save_note
# Pattern: write/side-effect — mutates state
# ─────────────────────────────────────────────
@mcp.tool()
def save_note(title: str, content: str) -> dict:
"""Save a note with a title and content.
Notes persist for the lifetime of the server process. Use this
to store information the LLM wants to remember across turns.
Args:
title: Note identifier. Alphanumeric + hyphens only. Max 100 chars.
content: Note body text. Max 5000 chars.
Returns:
Dict with 'success', 'title', and 'total_notes' fields.
"""
# Validate inputs before mutating state
if not title or not title.strip():
raise ValueError("title must be a non-empty string")
if len(title) > 100:
raise ValueError("title must be 100 characters or fewer")
if not all(c.isalnum() or c in "-_ " for c in title):
raise ValueError("title may only contain letters, numbers, hyphens, underscores, and spaces")
if not content:
raise ValueError("content must be a non-empty string")
if len(content) > 5000:
raise ValueError(f"content must be 5000 chars or fewer (got {len(content)})")
NOTES[title.strip()] = content
return {
"success": True,
"title": title.strip(),
"total_notes": len(NOTES),
}
if __name__ == "__main__":
mcp.run() # stdio transport
// documentServer.ts — 3-tool MCP server (TypeScript)
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({ name: "document-server", version: "1.0.0" });
// In-memory stores
const DOCUMENTS: Record = {
"mcp-overview": "MCP is an open protocol for connecting AI to tools.",
"fastmcp-guide": "FastMCP reduces MCP server boilerplate with decorators.",
"transport-docs": "stdio is local; HTTP/SSE is for shared, remote servers.",
};
const NOTES: Record = {};
// ── Tool 1: search_documents ──
server.tool(
"search_documents",
"Search the document store for documents matching a query string.",
{
query: z.string().min(1, "query must be non-empty"),
limit: z.number().int().min(1).max(50).default(5),
},
async ({ query, limit }) => {
const q = query.toLowerCase();
const results: Array<{ id: string; preview: string; score: number }> = [];
for (const [id, content] of Object.entries(DOCUMENTS)) {
let score = 0;
if (id.toLowerCase().includes(q)) score += 2;
if (content.toLowerCase().includes(q)) score += 1;
if (score > 0) {
results.push({
id,
preview: content.slice(0, 120) + (content.length > 120 ? "..." : ""),
score,
});
}
}
results.sort((a, b) => b.score - a.score);
const top = results.slice(0, limit);
return { content: [{ type: "text", text: JSON.stringify(top) }] };
}
);
// ── Tool 2: calculate_similarity ──
server.tool(
"calculate_similarity",
"Calculate cosine similarity between two text strings using word overlap.",
{
text_a: z.string().min(1),
text_b: z.string().min(1),
},
async ({ text_a, text_b }) => {
const wordFreq = (text: string): Map => {
const freq = new Map();
for (const w of text.toLowerCase().split(/\s+/)) {
const clean = w.replace(/[.,!?;:]/g, "");
if (clean) freq.set(clean, (freq.get(clean) ?? 0) + 1);
}
return freq;
};
const fa = wordFreq(text_a);
const fb = wordFreq(text_b);
const allWords = new Set([...fa.keys(), ...fb.keys()]);
let dot = 0, magA = 0, magB = 0;
for (const w of allWords) {
const a = fa.get(w) ?? 0, b = fb.get(w) ?? 0;
dot += a * b; magA += a * a; magB += b * b;
}
const similarity = magA && magB ? dot / (Math.sqrt(magA) * Math.sqrt(magB)) : 0;
const common = [...fa.keys()].filter((w) => fb.has(w)).slice(0, 10);
const interpretation =
similarity > 0.7 ? "highly similar" : similarity > 0.3 ? "somewhat related" : "largely different";
const result = { similarity: Math.round(similarity * 10000) / 10000, common_words: common, interpretation };
return { content: [{ type: "text", text: JSON.stringify(result) }] };
}
);
// ── Tool 3: save_note ──
server.tool(
"save_note",
"Save a note with a title and content. Notes persist for the server's lifetime.",
{
title: z.string().min(1).max(100).regex(/^[\w\- ]+$/, "alphanumeric, hyphens, underscores, spaces only"),
content: z.string().min(1).max(5000),
},
async ({ title, content }) => {
const key = title.trim();
NOTES[key] = content;
const result = { success: true, title: key, total_notes: Object.keys(NOTES).length };
return { content: [{ type: "text", text: JSON.stringify(result) }] };
}
);
// Connect to stdio
const transport = new StdioServerTransport();
await server.connect(transport);
- FastMCP registered all three tools — each with a name, description, and JSON Schema derived from type hints.
- Input validation runs before your code — FastMCP validates against the schema and returns a -32602 error if the LLM sends wrong types.
- Exceptions become protocol errors — any unhandled exception in your tool becomes a
isError: trueresponse the LLM can reason about. - Return values are JSON-serialized automatically —
list[dict]becomes a JSON array;dictbecomes a JSON object in the response content.
5. Testing with MCP Inspector
MCP InspectorMCP Inspector — a browser-based development tool (npm install -g @modelcontextprotocol/inspector) that connects to any MCP server over stdio, lists its tools, and lets you call them with a GUI. Essential for debugging before connecting to an LLM. is the fastest way to verify your server works before connecting it to any LLM. It connects over stdio, lists your tools, and gives you a JSON form to call each one.
One command launches MCP Inspector, connects it to your server, and opens a browser tab at localhost:5173.
mcp dev document_server.pynpx @modelcontextprotocol/inspector tsx documentServer.tsWhat to verify in the Inspector
- All three tools appear in the Tools tab with correct descriptions
search_documents("MCP")returns a non-empty listcalculate_similarity("hello world", "world peace")returns a similarity score and common_words including "world"save_note("test-note", "My first note")returns{"success": true, "total_notes": 1}- Calling
search_documentswith an empty string returns an error (test error handling)
6. Connecting to Claude Desktop
Claude Desktop reads its MCP server configuration from a JSON file. The exact path depends on your OS. Adding your server here makes all its tools available in every Claude Desktop conversation.
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json - Linux:
~/.config/Claude/claude_desktop_config.json
Claude Desktop spawns each server as a subprocess. Relative paths resolve from Claude's working directory (which is not your project folder). Always use absolute paths to the interpreter and your script to avoid "server not found" errors.
Claude Desktop reads the config at launch only. If you add or modify a server, you must fully quit and relaunch Claude Desktop — using File → Quit on macOS or Task Manager kill on Windows — not just close the window.
{
"mcpServers": {
"document-server": {
"command": "/usr/bin/python3",
"args": ["/absolute/path/to/document_server.py"],
"env": {
"PYTHONPATH": "/absolute/path/to/your/project"
}
}
}
}
// Windows example:
// "command": "C:\\Python311\\python.exe",
// "args": ["C:\\Users\\YourName\\projects\\document_server.py"]
// Using a venv (recommended for dependency isolation):
// "command": "/absolute/path/to/venv/bin/python",
// "args": ["/absolute/path/to/document_server.py"]
{
"mcpServers": {
"document-server": {
"command": "node",
"args": ["/absolute/path/to/dist/documentServer.js"],
"env": {}
}
}
}
// If using tsx (no compilation step):
// "command": "/absolute/path/to/node_modules/.bin/tsx",
// "args": ["/absolute/path/to/documentServer.ts"]
// Multiple servers — add more entries to mcpServers:
// "document-server": { ... },
// "another-server": { ... }
After adding the config and restarting Claude Desktop, open a new conversation and type "What tools do you have?". Claude will list your three tools. Then try: "Search my documents for anything about MCP" — Claude will call search_documents automatically.
7. Connecting to Claude Code
Claude Code reads MCP server configuration from .claude/settings.json in your project root (project-scoped) or from ~/.claude/settings.json (user-scoped, applies to all projects).
The mcpServers key in .claude/settings.json follows the same schema as Claude Desktop config. Each entry becomes an MCP server available in Claude Code sessions.
Putting the config in .claude/settings.json means your team gets the same MCP servers when they clone the repo. The server binary or script path still needs to be valid on each developer's machine — use relative paths with cwd or document the setup in your README.
{
"mcpServers": {
"document-server": {
"command": "python",
"args": ["document_server.py"],
"cwd": "/absolute/path/to/your/project"
}
}
}
// After saving, restart Claude Code or run:
// /mcp — to view currently connected servers and their tools
{
"mcpServers": {
"document-server": {
"command": "npx",
"args": ["tsx", "documentServer.ts"],
"cwd": "/absolute/path/to/your/project"
}
}
}
// Verify in Claude Code session with the /mcp command
// This lists connected servers, their status, and available tools.
Claude Code will spawn your server as a subprocess on the next session start. The server stays running for the entire session — it does not restart per-message. If you change your server code, you need to start a new Claude Code session for the changes to take effect.
8. Error Handling
MCP defines two kinds of failures, and they behave very differently:
| Error type | When it occurs | Response shape | LLM behavior |
|---|---|---|---|
| Protocol error | Unknown method, malformed JSON, schema validation failure | {"error": {"code": -32602, "message": "..."}} |
Usually surfaces as a hard error in the host |
| Tool error | Your tool raises an exception | {"result": {"isError": true, "content": [...]}} |
Sees the error text and can retry, rephrase, or report to user |
The most important pattern: raise descriptive exceptions rather than returning error dicts. FastMCP catches exceptions and turns them into isError: true tool results automatically. The LLM then decides what to do next.
Any ValueError, FileNotFoundError, PermissionError, or any other exception raised inside a tool function becomes a isError: true response. The exception message becomes the content the LLM reads.
If you catch an exception and return a "success" response to hide the error, the LLM will think the operation succeeded. Always let real errors propagate as exceptions so the LLM can take corrective action.
from mcp.server.fastmcp import FastMCP
import httpx
mcp = FastMCP("error-demo-server")
# PATTERN 1: Input validation — raise ValueError with descriptive message
@mcp.tool()
def get_document(doc_id: str) -> dict:
"""Retrieve a document by its ID."""
if not doc_id or not doc_id.strip():
raise ValueError("doc_id must be a non-empty string") # LLM sees this message
if doc_id not in DOCUMENTS:
raise KeyError(f"Document '{doc_id}' not found. Available: {list(DOCUMENTS.keys())}")
return {"id": doc_id, "content": DOCUMENTS[doc_id]}
# PATTERN 2: External I/O — always wrap in try/except
@mcp.tool()
async def fetch_url(url: str) -> dict:
"""Fetch the content of a URL and return the status code and body."""
if not url.startswith(("http://", "https://")):
raise ValueError("url must start with http:// or https://")
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url)
response.raise_for_status() # raises httpx.HTTPStatusError on 4xx/5xx
return {
"status_code": response.status_code,
"body_preview": response.text[:500],
}
except httpx.TimeoutException:
raise RuntimeError(f"Request to {url} timed out after 10 seconds")
except httpx.HTTPStatusError as e:
raise RuntimeError(f"HTTP {e.response.status_code} error from {url}: {e.response.text[:200]}")
except httpx.RequestError as e:
raise RuntimeError(f"Network error fetching {url}: {str(e)}")
# What happens when save_note raises ValueError:
# The client receives:
# {
# "jsonrpc": "2.0",
# "id": 42,
# "result": {
# "isError": true,
# "content": [{"type": "text", "text": "ValueError: title must be non-empty"}]
# }
# }
# The LLM reads "ValueError: title must be non-empty" and can fix the call.
if __name__ == "__main__":
mcp.run()
// The TypeScript SDK exposes McpError for protocol-level errors.
// For tool errors, throw a regular Error — the SDK wraps it in isError: true.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
const server = new McpServer({ name: "error-demo", version: "1.0.0" });
// PATTERN 1: Zod schema validation happens automatically before your handler runs.
// Any Zod validation failure → -32602 Invalid params (protocol error).
// Your handler only runs when inputs are valid.
// PATTERN 2: Throw Error for tool-level failures → isError: true result
server.tool(
"get_document",
"Retrieve a document by its ID.",
{ doc_id: z.string().min(1) },
async ({ doc_id }) => {
const DOCUMENTS: Record = {
"mcp-overview": "MCP is an open protocol...",
};
if (!(doc_id in DOCUMENTS)) {
// Throwing here → isError: true in the protocol response
throw new Error(`Document '${doc_id}' not found. Available: ${Object.keys(DOCUMENTS).join(", ")}`);
}
return { content: [{ type: "text", text: DOCUMENTS[doc_id] }] };
}
);
// PATTERN 3: Wrap external I/O
server.tool(
"fetch_url",
"Fetch a URL and return the response body preview.",
{ url: z.string().url("must be a valid URL") },
async ({ url }) => {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(10000) });
if (!response.ok) {
throw new Error(`HTTP ${response.status} from ${url}`);
}
const text = await response.text();
return { content: [{ type: "text", text: text.slice(0, 500) }] };
} catch (err) {
// Re-throw with a clear message — do not swallow errors
throw new Error(`Failed to fetch ${url}: ${err instanceof Error ? err.message : String(err)}`);
}
}
);
The key insight: in MCP, tool errors are first-class results, not crashes. The server keeps running after a tool error. The LLM receives a structured error response it can reason about. This is fundamentally different from REST APIs where a 500 breaks the request — here the protocol continues normally and the LLM can retry with different parameters.
9. TypeScript vs Python — Side by Side
Key Differences: Python FastMCP vs TypeScript SDK
| Feature | Python (FastMCP) | TypeScript SDK |
|---|---|---|
| Schema source | Python type hints + docstrings (automatic) | Zod schema (explicit) |
| Validation | FastMCP introspects types at runtime | Zod validates before handler |
| Async | Sync or async functions both supported | All handlers are async |
| Return value | Return any JSON-serializable value | Must return {content: [{type:"text",text:"..."}]} |
| Error handling | Raise any Exception → auto-wrapped | Throw Error → auto-wrapped |
| Run command | python server.py | tsx server.ts or node dist/server.js |
Knowledge Check
Five questions mixing conceptual understanding and code mechanics.
1. What does the @mcp.tool() decorator do to Python type hints like query: str, limit: int = 5?
tools/list response so the LLM knows what arguments to provide2. You want to run mcp dev document_server.py to test your server. Which tool does this command launch?
3. Your tool function raises a ValueError("limit must be between 1 and 50"). What does the LLM receive?
isError: true and the error message in the content4. After adding a server to claude_desktop_config.json, what must you do for Claude Desktop to pick up the change?
mcp reload in a terminal5. In the TypeScript SDK, what must every tool handler return to comply with the MCP protocol?
Promise<string> containing the result as a plain stringcontent array of content blocks (e.g., [{type:"text", text:"..."}])