⌂ Home MCP Track — Building with the Model Context Protocol ⚡ MODULE 01 of 8 · MCP Track
~60 min Beginner → Intermediate Lab — 80% Code
MODULE 01 · First MCP Server

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.

After this module you will:
  • 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.

WHAT
Install the Python MCP SDK (includes FastMCP) and the MCP Inspector dev tool. You need Node.js ≥ 18 installed for the Inspector, even if your server is Python.
bash — Python
# 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
bash — TypeScript / Node.js
# 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')"
Version Note These examples use 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.
Now that the SDK is installed, let's understand what FastMCP actually does — because it saves a surprising amount of work.

2. What FastMCP Does

Technical Definition — FastMCP 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:

Animation 1 — Decorator Pipeline: Python Function → JSON Schema → Protocol Message
STEP 1
@mcp.tool()
Python function with type hints
STEP 2
JSON Schema
FastMCP introspects hints → schema
STEP 3
tools/list response
Protocol message sent to client

When FastMCP sees def search_documents(query: str, limit: int = 10) -> list[dict], it automatically generates this JSON Schema for the tool input:

Generated JSON Schema (automatic)
{
  "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.

FastMCP understood — let's write the first tool. We'll start with the simplest possible server, then build up to the full 3-tool version.

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.

WHAT — The full server skeleton

Four lines are required: import, instantiate, decorate, run. Everything else is your tool logic.

WHY — The docstring becomes the tool description

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.

GOTCHA — Return types must be serializable to JSON

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.

Python — hello_server.py
# 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()
TypeScript — helloServer.ts
// 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()
What Just Happened?

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.

One-tool servers are great for learning, but real servers need multiple tools with proper error handling. Let's build the complete 3-tool version.

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.

WHAT — Three tool patterns

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.

WHY — Optional parameters with defaults

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.

GOTCHA — Exceptions inside tools

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.

Python — document_server.py
# 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
TypeScript — documentServer.ts
// 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);
What Just Happened?
  • 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: true response the LLM can reason about.
  • Return values are JSON-serialized automaticallylist[dict] becomes a JSON array; dict becomes a JSON object in the response content.
The server is built. Before connecting it to Claude, test it interactively with MCP Inspector so you can verify every tool manually.

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.

WHAT

One command launches MCP Inspector, connects it to your server, and opens a browser tab at localhost:5173.

Terminal
mcp dev document_server.py
Starting MCP Inspector... Connecting to server: python document_server.py Server connected: document-server v1.0.0 Tools discovered: search_documents, calculate_similarity, save_note Inspector running at: http://localhost:5173 # In the browser UI: # 1. Click "Tools" in the left panel — you should see all 3 tools listed # 2. Click "search_documents" — a JSON form appears with query and limit fields # 3. Enter: {"query": "MCP", "limit": 2} and click "Run" # 4. Expected result: [{"id":"mcp-overview","preview":"MCP is an open protocol...","score":3}, {"id":"fastmcp-guide","preview":"FastMCP reduces MCP server...","score":1}]
Terminal
npx @modelcontextprotocol/inspector tsx documentServer.ts
Starting MCP Inspector... Connecting to server: tsx documentServer.ts Server connected: document-server v1.0.0 Tools discovered: search_documents, calculate_similarity, save_note Inspector running at: http://localhost:5173 # Same browser testing steps as Python — tools behave identically

What to verify in the Inspector

  1. All three tools appear in the Tools tab with correct descriptions
  2. search_documents("MCP") returns a non-empty list
  3. calculate_similarity("hello world", "world peace") returns a similarity score and common_words including "world"
  4. save_note("test-note", "My first note") returns {"success": true, "total_notes": 1}
  5. Calling search_documents with an empty string returns an error (test error handling)
Pro Tip — Test Errors Explicitly The Inspector shows raw JSON-RPC error responses. Always call each tool with invalid inputs to confirm your error handling works before connecting to Claude. A tool that crashes silently in an LLM session is much harder to debug than one tested in the Inspector.
Tests pass. Now connect the server to real LLM hosts — Claude Desktop first, then Claude Code.

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.

WHAT — The config file location
  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: %APPDATA%\Claude\claude_desktop_config.json
  • Linux: ~/.config/Claude/claude_desktop_config.json
WHY — Absolute paths are required

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.

GOTCHA — Restart Claude Desktop after config changes

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.

JSON — claude_desktop_config.json (Python server)
{
  "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"]
JSON — claude_desktop_config.json (TypeScript server)
{
  "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.

Claude Desktop is a GUI app. If you use Claude Code (the CLI), the config is slightly different but equally straightforward.

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).

WHAT

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.

WHY — Project-scoped config is version-controlled

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.

JSON — .claude/settings.json (Python server)
{
  "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
JSON — .claude/settings.json (TypeScript server)
{
  "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.
What Just Happened?

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.

Configuration done. Now let's talk about what happens when things go wrong — because production tools will fail, and how they fail matters.

8. Error Handling

MCP defines two kinds of failures, and they behave very differently:

Error typeWhen it occursResponse shapeLLM 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.

WHAT — Exception to protocol error mapping

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.

GOTCHA — Never swallow exceptions silently

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.

Python — error handling patterns
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()
TypeScript — error handling patterns
// 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)}`);
    }
  }
);
What Just Happened?

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.

Error handling solid. Let's cover the transport and tool-call lifecycle visually, then complete the TypeScript comparison.

9. TypeScript vs Python — Side by Side

Animation 2 — Tool Call Lifecycle: LLM → Client → Server → LLM
1
LLM decides
Picks tool + args
2
Client sends
tools/call JSON-RPC
3
Server executes
Validates → runs fn
4
Result sent
isError: false/true
5
LLM sees result
Continues reasoning
Animation 3 — stdio vs HTTP/SSE Transport
stdio
Host spawns subprocess
Writes to stdin
Reads from stdout
✓ Local, zero-latency
HTTP/SSE
Host POSTs to /mcp endpoint
Server streams SSE events
OAuth 2.0 auth header
✓ Multi-client, deployable

Key Differences: Python FastMCP vs TypeScript SDK

FeaturePython (FastMCP)TypeScript SDK
Schema sourcePython type hints + docstrings (automatic)Zod schema (explicit)
ValidationFastMCP introspects types at runtimeZod validates before handler
AsyncSync or async functions both supportedAll handlers are async
Return valueReturn any JSON-serializable valueMust return {content: [{type:"text",text:"..."}]}
Error handlingRaise any Exception → auto-wrappedThrow Error → auto-wrapped
Run commandpython server.pytsx server.ts or node dist/server.js
Which Should I Use? Python FastMCP is faster to prototype — type hints are often already present in your functions, so tools require minimal extra code. TypeScript is better for Node.js-heavy teams, front-end developers, and cases where you're integrating into an existing Express/Fastify service. The protocol is identical — both options run in the same hosts.

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?

A
Nothing — type hints are ignored by the MCP framework
B
They are converted to a JSON Schema and included in the tools/list response so the LLM knows what arguments to provide
C
They are used to generate documentation only — not for runtime validation
D
They cause mypy type checking to run on tool inputs

2. You want to run mcp dev document_server.py to test your server. Which tool does this command launch?

A
Claude Desktop in developer mode
B
MCP Inspector — a browser-based GUI for calling tools interactively
C
The Python test runner (pytest)
D
A local Claude API proxy that simulates LLM tool calls

3. Your tool function raises a ValueError("limit must be between 1 and 50"). What does the LLM receive?

A
Nothing — the server crashes and the connection drops
B
A JSON-RPC error response with code -32603 and the exception message
C
A successful tool result with isError: true and the error message in the content
D
The host (Claude Desktop / Code) shows a modal error dialog

4. After adding a server to claude_desktop_config.json, what must you do for Claude Desktop to pick up the change?

A
Wait 30 seconds — Claude Desktop polls the config file automatically
B
Click "Reload MCP servers" in the Claude Desktop settings panel
C
Fully quit and relaunch Claude Desktop — it only reads config at startup
D
Run mcp reload in a terminal

5. In the TypeScript SDK, what must every tool handler return to comply with the MCP protocol?

A
Any JSON-serializable JavaScript value (string, number, object, array)
B
A Promise<string> containing the result as a plain string
C
An object with a content array of content blocks (e.g., [{type:"text", text:"..."}])
D
A Zod schema instance describing the output shape