AnyLearn
All lessons
AIadvanced

Building MCP Servers in Python and TypeScript

Learn to build Model Context Protocol servers from scratch using the official Python and TypeScript SDKs. Cover tool schemas, resource URIs, prompt templates, and how Claude Code, Claude.ai, and Cursor consume them.

Not signed in โ€” your progress and quiz score won't be saved.
Lesson progress1 / 12

What MCP Actually Is

The Model Context Protocol (MCP) is an open standard โ€” published by Anthropic in late 2024 โ€” that lets LLM hosts (Claude Code, Claude.ai, Cursor, etc.) communicate with external servers over a well-defined JSON-RPC 2.0 wire format. Think of it as USB-C for AI context: one plug, many devices.

An MCP server exposes three primitives:

  • Tools โ€” callable functions the model can invoke (e.g., run_query, send_email)
  • Resources โ€” addressable blobs the model can read, referenced by URI (e.g., file://repo/README.md, db://customers/42)
  • Prompts โ€” reusable prompt templates that can be parameterised and surfaced in the host UI

The host negotiates capabilities at startup (initialize / initialized handshake), then drives the server via tools/call, resources/read, or prompts/get requests. Your server is just a process that speaks this protocol โ€” stdio or HTTP+SSE are the two supported transports.

Full lesson text

All 12 steps on one page โ€” for reading, reference, and search.

Show

1. What MCP Actually Is

The Model Context Protocol (MCP) is an open standard โ€” published by Anthropic in late 2024 โ€” that lets LLM hosts (Claude Code, Claude.ai, Cursor, etc.) communicate with external servers over a well-defined JSON-RPC 2.0 wire format. Think of it as USB-C for AI context: one plug, many devices.

An MCP server exposes three primitives:

  • Tools โ€” callable functions the model can invoke (e.g., run_query, send_email)
  • Resources โ€” addressable blobs the model can read, referenced by URI (e.g., file://repo/README.md, db://customers/42)
  • Prompts โ€” reusable prompt templates that can be parameterised and surfaced in the host UI

The host negotiates capabilities at startup (initialize / initialized handshake), then drives the server via tools/call, resources/read, or prompts/get requests. Your server is just a process that speaks this protocol โ€” stdio or HTTP+SSE are the two supported transports.

2. MCP Request-Response Flow

flowchart LR
  A["Host (Claude Code / Cursor)"] -- "initialize" --> B["MCP Server"]
  B -- "capabilities" --> A
  A -- "tools/list" --> B
  B -- "tool schemas" --> A
  A -- "tools/call" --> B
  B -- "tool result" --> A
  A -- "resources/list" --> B
  B -- "resource URIs" --> A
  A -- "resources/read" --> B
  B -- "resource content" --> A

3. Setting Up the Python SDK

Install the official package โ€” it brings the JSON-RPC plumbing, schema validation, and stdio transport out of the box:

pip install mcp

The SDK ships two APIs: a low-level Server class and a high-level FastMCP decorator-based wrapper. Use FastMCP for new projects โ€” it auto-generates JSON Schema from Python type hints and handles the handshake for you.

from mcp.server.fastmcp import FastMCP

app = FastMCP(name="my-server", version="0.1.0")

Run it over stdio (the default, used by Claude Code and Cursor):

if __name__ == "__main__":
    app.run()  # blocks, speaks MCP over stdin/stdout

For HTTP+SSE (useful for multi-client deployments), pass transport="sse" and the SDK starts a Starlette app on the port you configure. Stdio is simpler for local tooling; SSE is better for shared team servers behind a URL.

4. Defining Tools in Python

Tools are the most-used primitive. Decorate a function with @app.tool() and the SDK infers the JSON Schema from your type hints and docstring:

from mcp.server.fastmcp import FastMCP
import httpx

app = FastMCP(name="weather")

@app.tool()
async def get_current_weather(city: str, units: str = "metric") -> str:
    """Fetch current weather for a city from Open-Meteo."""
    geo = httpx.get(f"https://geocoding-api.open-meteo.com/v1/search?name={city}").json()
    lat, lon = geo["results"][0]["latitude"], geo["results"][0]["longitude"]
    data = httpx.get(
        f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}&current_weather=true"
    ).json()
    return f"{city}: {data['current_weather']['temperature']}ยฐ{'C' if units == 'metric' else 'F'}"

The generated schema has type: object, a properties block with city (required string) and units (optional string, default "metric"), and a description pulled from the docstring. The model sees exactly this โ€” so clear docstrings are load-bearing documentation, not optional comments.

Heads up: MCP tool results must be strings. Return JSON-serialised data or plain text; the SDK will raise if you return a bare dict.

5. Resources and Resource Templates

Resources let the model read data without invoking a tool. They use URI patterns:

@app.resource("config://app/settings")
def read_settings() -> str:
    """Return current app configuration as JSON."""
    import json, pathlib
    return pathlib.Path("settings.json").read_text()

For dynamic URIs, use a resource template with {param} placeholders:

@app.resource("db://users/{user_id}/profile")
def read_user_profile(user_id: str) -> str:
    row = db.execute("SELECT * FROM users WHERE id = ?", [user_id]).fetchone()
    return json.dumps(dict(row)) if row else "null"

The host lists all resource URIs via resources/list, then fetches individual ones with resources/read. Think of resources as GET endpoints โ€” they should be idempotent and cheap. If the operation mutates state or costs money, make it a tool instead.

MIME types matter: return text/plain or application/json for text resources; for binary (images, PDFs), return a BlobResourceContents with base64 data.

6. Prompt Templates

Prompts are reusable, parameterised message sequences the host can surface as slash-commands or quick-actions:

from mcp.types import PromptMessage, TextContent

@app.prompt()
def code_review_prompt(language: str, focus: str = "correctness") -> list[PromptMessage]:
    """Generate a code-review prompt for a given language."""
    return [
        PromptMessage(
            role="user",
            content=TextContent(
                type="text",
                text=f"Review the following {language} code with a focus on {focus}. "
                     "Point out bugs, suggest improvements, and explain your reasoning."
            )
        )
    ]

Hosts call prompts/list to enumerate templates and prompts/get with arguments to render them. In Claude Code, prompts surface as /mcp__<server-name>__<prompt-name> slash commands. In Claude.ai, they appear in the prompt library when the server is connected.

Keep prompt templates generic โ€” they are schema, not content. The model fills in the content when invoked with actual user arguments.

7. Building the TypeScript SDK Server

The TypeScript SDK (@modelcontextprotocol/sdk) mirrors the Python API but uses explicit schema objects instead of type inference:

npm install @modelcontextprotocol/sdk zod
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: "my-ts-server", version: "0.1.0" });

server.registerTool(
  "search_codebase",
  {
    description: "Full-text search over the repository index.",
    inputSchema: { query: z.string(), maxResults: z.number().int().min(1).max(50).default(10) },
  },
  async ({ query, maxResults }) => {
    const hits = await codeIndex.search(query, maxResults);
    return { content: [{ type: "text", text: JSON.stringify(hits) }] };
  }
);

const transport = new StdioServerTransport();
await server.connect(transport);

Zod schemas get converted to JSON Schema automatically. The tool handler returns a CallToolResult โ€” always wrap text in { type: "text", text: ... } inside the content array.

8. Tool Schema Design โ€” What the Model Sees

The JSON Schema emitted for each tool is the only documentation the model gets at inference time. Get it wrong and the model will hallucinate arguments.

Good schema patterns:

  • Use enum for fixed-value parameters: {"type": "string", "enum": ["asc", "desc"]}
  • Add description to every property, not just the tool itself
  • Use minimum/maximum on numbers to prevent nonsense inputs
  • Avoid deeply nested objects โ€” the model makes fewer mistakes with flat schemas

Common mistakes:

MistakeFix
any or untyped object parametersAdd explicit property definitions
Optional params with no defaultAdd default or mark required
Vague descriptions like "the query"Be specific: "SQL SELECT statement, no DML allowed"
Returning raw exceptions as errorsReturn { isError: true, content: [...] }

The host may trim or truncate tool lists if there are too many โ€” keep total schema size under ~8 KB. Prefer fewer, well-described tools over many narrow ones.

9. Wiring to Claude Code

Claude Code reads MCP server config from ~/.claude/claude_code_config.json (global) or .claude/settings.json (project-local). Add your server under mcpServers:

{
  "mcpServers": {
    "weather": {
      "command": "python",
      "args": ["/path/to/server.py"],
      "env": { "OPENMETEO_KEY": "optional" }
    },
    "ts-server": {
      "command": "node",
      "args": ["/path/to/dist/index.js"]
    }
  }
}

Claude Code spawns each server as a subprocess connected via stdio. After a /mcp or restart, run /mcp in the Claude Code REPL to list connected servers and their exposed tools. Tool names appear as mcp__<server>__<tool> in the allow-list configuration.

For HTTP+SSE servers, use "url": "http://localhost:3000/sse" instead of command/args. This is useful when the server runs remotely or manages long-lived state across sessions.

10. Wiring to Cursor and Claude.ai

Cursor uses the same stdio config format, stored in ~/.cursor/mcp.json:

{
  "mcpServers": {
    "my-server": {
      "command": "python",
      "args": ["-m", "my_server"],
      "cwd": "/path/to/project"
    }
  }
}

Cursor exposes tools to its Composer agent. The model can call them during multi-step coding tasks โ€” e.g., calling your run_tests tool mid-refactor and using the output to decide the next edit.

Claude.ai supports MCP via the Remote MCP connector (available on Pro/Team/Enterprise). Your server must use HTTP+SSE transport and be reachable over the internet (or via a tunnel like ngrok). In the Claude.ai settings UI, add the SSE URL โ€” Claude.ai will call initialize, enumerate tools and prompts, and surface them during conversations.

Heads up: Claude.ai remote MCP is distinct from local Claude Code MCP. The former requires a public URL; the latter runs on your machine.

11. Testing, Errors, and Production Hardening

The MCP Inspector (npx @modelcontextprotocol/inspector) is the fastest way to validate your server without a host:

npx @modelcontextprotocol/inspector python server.py
# Opens a web UI at http://localhost:5173 โ€” list tools, call them, inspect responses

For unit tests, instantiate the FastMCP app and call tools directly without starting the transport:

result = await app.call_tool("get_current_weather", {"city": "Paris"})
assert "ยฐC" in result

Error handling conventions:

  • Tool errors โ€” return CallToolResult(isError=True, content=[...]). Do NOT raise exceptions; they surface as protocol errors and confuse hosts.
  • Resource errors โ€” raise McpError(ErrorCode.ResourceNotFound, "...") from within the handler.
  • Timeouts โ€” set a sensible deadline inside your handler. MCP has no built-in timeout; a hung tool blocks the model indefinitely.

For production: run the server under a process supervisor (systemd, Docker), log to stderr (stdout is the protocol channel), and pin the SDK version in your lockfile.

12. Python vs TypeScript SDK Tool Flow

flowchart TD
  A["Define a Tool"] --> B["Python: @app.tool() decorator"]
  A --> C["TypeScript: server.registerTool()"]
  B --> D["Schema from type hints + docstring"]
  C --> E["Schema from Zod object"]
  D --> F["Returns str"]
  E --> G["Returns CallToolResult"]
  F --> H["Transport: app.run()"]
  G --> H
  H --> I["stdio (default) or HTTP+SSE"]

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. Which MCP primitive should you use when you want the model to trigger a state-mutating operation, like inserting a database row?
    • A resource with a write-capable URI
    • A tool decorated with @app.tool()
    • A prompt template with side-effect arguments
    • A resource template with a POST handler
  2. In the Python FastMCP SDK, what happens if your tool handler returns a Python dict instead of a string?
    • The SDK serialises it to JSON automatically
    • The SDK raises an error because tool results must be strings
    • The dict is passed through as a JSON object in the content array
    • The host receives a null result silently
  3. You have a TypeScript MCP server using HTTP+SSE transport running locally. A colleague on a different machine wants to connect their Claude.ai account to it. What is the minimum requirement?
    • Share the stdio command string so they can run the server locally
    • Expose the server at a public URL reachable over the internet
    • Convert the server to stdio transport first
    • Register the server in the MCP registry at modelcontextprotocol.io
  4. When designing a tool input schema, which practice most reduces model hallucination on parameter values?
    • Using deeply nested objects to mirror the internal data model
    • Adding enum constraints for fixed-value parameters and descriptions on every property
    • Keeping all parameters optional so the model can omit uncertain ones
    • Using anyOf / oneOf to let the model choose the parameter type at runtime
  5. In Claude Code, after adding a new MCP server to the config file, which command shows you the list of connected servers and their tools?
    • /tools
    • /mcp
    • /servers
    • /context

Related lessons