Building MCP Servers: Model Context Protocol Architecture, Python and TypeScript Servers, Claude Desktop

Key takeaways

MCP (Model Context Protocol) is an open protocol that allows AI models to communicate with external resources — file systems, databases, web APIs — in a standardized way. Supported by Claude Desktop, Cursor, VS Code, and more.

What is MCP?

MCP (Model Context Protocol) is an open-source protocol Anthropic published in late 2024. It lets AI models communicate with external resources — file systems, databases, web APIs, dev tools — in a standardized way.

Client (Claude Desktop, Cursor, VS Code)
    ↕ MCP Protocol
Server (file system, DB, web API, Git, Slack...)

Why MCP?

Before MCP, every AI app had to write its own integration code for every external tool. MCP provides a USB-like standard interface: write an MCP server once and reuse it across all compatible clients.

Before MCPWith MCP
Each app writes its own API integrationWrite MCP server once → all clients use it
Model changes require code rewritesWorks regardless of model or client
Security/auth reimplemented repeatedlyHandled consistently on the server side

The USB comparison is apt because it captures the specific kind of standardization MCP provides: before USB, connecting a peripheral to a computer meant a device-specific port and driver; USB moved that complexity to the protocol layer, so any USB device works with any USB host. MCP does the same for AI-to-tool integrations — before it, connecting Claude to your company’s internal database meant writing bespoke integration code that only worked with Claude, and doing it again from scratch for Cursor or a custom agent. An MCP server for that database, written once, now works with any MCP-compatible client, because the protocol (not the specific AI model or app) defines how tools are discovered and called.


MCP Architecture

┌─────────────────────────────────────────┐
│             MCP Client                  │
│  (Claude Desktop / Cursor / VS Code)    │
└────────────────┬────────────────────────┘
                 │ JSON-RPC over stdio / Streamable HTTP
┌────────────────▼────────────────────────┐
│              MCP Server                 │
│  ┌──────────┐  ┌──────────┐  ┌───────┐ │
│  │  Tools   │  │Resources │  │Prompts│ │
│  └──────────┘  └──────────┘  └───────┘ │
└────────────────┬────────────────────────┘
                 │
     ┌───────────┼───────────┐
     ▼           ▼           ▼
  File system  Database   Web API

Messages are JSON-RPC 2.0, carried over one of two transports. With stdio, the client launches the server as a child process and exchanges messages over standard input and output, which is what local tools use. Remote servers originally used HTTP+SSE, but the 2025-03-26 spec revision replaced it with Streamable HTTP, where a single HTTP endpoint handles both requests and streamed responses. HTTP+SSE now survives only for backward compatibility with older clients, so a new remote server should use Streamable HTTP.

3 Core Concepts

  • Tools: Functions the AI can call (similar to Function Calling)
  • Resources: Data the AI can read (files, DB records, etc.)
  • Prompts: Reusable prompt templates

These three primitives map to three different questions a client might ask a server, and keeping them distinct matters for how you design a server. “What can you do?” is Tools — actions with side effects or computation, the MCP equivalent of function calling. “What can you show me?” is Resources — read-only data the model can pull into context without “calling” anything, closer to a file or API response than an action. “What templates do you offer?” is Prompts — pre-written prompt text a user or client can invoke directly, useful for standardizing a common request (like the code_review prompt built later in this guide) so users don’t have to phrase it themselves every time. A server that puts everything under Tools, including pure data reads, works but blurs a distinction MCP clients are designed to present differently to users — Claude Desktop, for instance, surfaces Resources and Prompts through different UI than Tools.


Quick Start: Python MCP Server

Installation

pip install mcp
# or using uv (recommended)
pip install uv
uv add mcp

Your First MCP Server

Every MCP server needs to answer two questions from clients: “what tools do you have?” (list_tools) and “run this tool with these arguments” (call_tool). This minimal server implements both for a calculator and a clock:

# server.py
from mcp.server import Server
from mcp.server.models import InitializationOptions
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
import mcp.types as types

# Create server instance
server = Server("my-first-server")

@server.list_tools()
async def list_tools() -> list[Tool]:
    """Return list of available tools"""
    return [
        Tool(
            name="calculate",
            description="Evaluates a mathematical expression",
            inputSchema={
                "type": "object",
                "properties": {
                    "expression": {
                        "type": "string",
                        "description": "Expression to evaluate (e.g. 2 + 3 * 4)"
                    }
                },
                "required": ["expression"]
            }
        ),
        Tool(
            name="get_current_time",
            description="Returns the current time",
            inputSchema={
                "type": "object",
                "properties": {}
            }
        )
    ]

@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
    """Handle tool calls"""
    if name == "calculate":
        expression = arguments.get("expression", "")
        try:
            # Safe eval (use a proper sandbox in production)
            result = eval(expression, {"__builtins__": {}}, {})
            return [TextContent(type="text", text=f"{expression} = {result}")]
        except Exception as e:
            return [TextContent(type="text", text=f"Error: {str(e)}")]
    
    elif name == "get_current_time":
        from datetime import datetime
        now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        return [TextContent(type="text", text=f"Current time: {now}")]
    
    raise ValueError(f"Unknown tool: {name}")

async def main():
    # Run in stdio mode (for local Claude Desktop connections)
    async with stdio_server() as (read_stream, write_stream):
        await server.run(
            read_stream,
            write_stream,
            InitializationOptions(
                server_name="my-first-server",
                server_version="1.0.0",
                capabilities=server.get_capabilities(
                    notification_options=None,
                    experimental_capabilities={}
                )
            )
        )

if __name__ == "__main__":
    import asyncio
    asyncio.run(main())

The # Safe eval (use a proper sandbox in production) comment is not a throwaway caveat — it’s flagging a genuine, well-known vulnerability. eval() with an empty __builtins__ dict blocks the most obvious escapes (you can’t call open() or import os directly), but it’s a famously incomplete sandbox: Python’s object model has enough introspection (().__class__.__bases__, walking up to object and finding subclasses) that determined attackers have repeatedly found ways to reach arbitrary code execution through “restricted” eval even with builtins stripped. For a toy calculator you run locally and never expose to untrusted input, this is fine; for anything reachable from a network-facing MCP server or from a client you don’t fully trust, use a real expression parser (like Python’s ast.literal_eval for pure data, or a dedicated math-expression library) instead of eval in any form.

The inputSchema on each Tool deserves a second look too — it’s JSON Schema, and the client uses it for more than documentation. Claude Desktop (and any well-behaved MCP client) validates arguments against this schema before ever calling your tool, and it’s also what the model reads to decide whether and how to call the tool in the first place. A vague description or a loosely-typed schema (accepting any string when you actually need an integer) doesn’t just risk a runtime error in your handler — it directly degrades how reliably the model picks the right tool and fills in correct arguments, since the schema is effectively the tool’s API contract as the model sees it.


Real-World Example: File System MCP Server

Tools that touch the file system need one more consideration beyond the calculator example above: path traversal. Any tool that takes a file path as user- or model-supplied input has to defend against paths like ../../etc/passwd trying to escape the directory you meant to expose:

# file_server.py
from mcp.server import Server
from mcp.server.models import InitializationOptions
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
from pathlib import Path
import os

server = Server("file-system-server")
ALLOWED_DIR = Path(os.environ.get("ALLOWED_DIR", "/tmp/mcp-files"))
ALLOWED_DIR.mkdir(exist_ok=True)

@server.list_tools()
async def list_tools() -> list[Tool]:
    return [
        Tool(
            name="read_file",
            description="Read file contents",
            inputSchema={
                "type": "object",
                "properties": {
                    "path": {"type": "string", "description": "File path"}
                },
                "required": ["path"]
            }
        ),
        Tool(
            name="write_file",
            description="Write content to a file",
            inputSchema={
                "type": "object",
                "properties": {
                    "path": {"type": "string", "description": "File path"},
                    "content": {"type": "string", "description": "Content to write"}
                },
                "required": ["path", "content"]
            }
        ),
        Tool(
            name="list_files",
            description="List files in a directory",
            inputSchema={
                "type": "object",
                "properties": {
                    "directory": {"type": "string", "description": "Directory path (defaults to base dir)"}
                }
            }
        )
    ]

def validate_path(path_str: str) -> Path:
    """Security: only allow paths within the permitted directory"""
    path = (ALLOWED_DIR / path_str).resolve()
    if not str(path).startswith(str(ALLOWED_DIR)):
        raise ValueError("Access denied: path is outside the allowed directory")
    return path

@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
    if name == "read_file":
        path = validate_path(arguments["path"])
        if not path.exists():
            return [TextContent(type="text", text=f"Error: file not found: {path}")]
        content = path.read_text(encoding="utf-8")
        return [TextContent(type="text", text=content)]
    
    elif name == "write_file":
        path = validate_path(arguments["path"])
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(arguments["content"], encoding="utf-8")
        return [TextContent(type="text", text=f"File saved: {path.name}")]
    
    elif name == "list_files":
        dir_path = validate_path(arguments.get("directory", "."))
        if not dir_path.is_dir():
            return [TextContent(type="text", text="Not a directory")]
        
        files = []
        for item in sorted(dir_path.iterdir()):
            if item.is_dir():
                files.append(f"📁 {item.name}/")
            else:
                size = item.stat().st_size
                files.append(f"📄 {item.name} ({size:,} bytes)")
        
        result = "\n".join(files) if files else "No files found"
        return [TextContent(type="text", text=result)]
    
    raise ValueError(f"Unknown tool: {name}")

async def main():
    async with stdio_server() as (read_stream, write_stream):
        await server.run(read_stream, write_stream,
            InitializationOptions(server_name="file-system-server", server_version="1.0.0",
                capabilities=server.get_capabilities(notification_options=None, experimental_capabilities={})))

if __name__ == "__main__":
    import asyncio
    asyncio.run(main())

validate_path is the load-bearing function in this whole server, and it’s worth understanding exactly why .resolve() comes before the containment check. (ALLOWED_DIR / "../../etc/passwd") as a raw path string still textually starts somewhere unpredictable depending on how it’s joined, but .resolve() collapses all the .. segments and symlinks into an absolute, canonical path — only after that resolution does str(path).startswith(str(ALLOWED_DIR)) become a meaningful security check. Checking containment before resolving is the classic version of this bug: a path like ALLOWED_DIR / "safe/../../../etc/passwd" would pass a naive string-prefix check on the unresolved path while still escaping the directory once actually resolved. This pattern — resolve first, validate second — is worth internalizing any time you build a tool that turns user- or model-supplied strings into file system paths, MCP or otherwise.


Building an MCP Server with TypeScript

npm install @modelcontextprotocol/sdk

The TypeScript SDK uses a different API shape than Python’s decorator-based @server.list_tools() style — here you register a schema-keyed request handler with setRequestHandler, which is more verbose per-tool but scales better once a server has many request types, since each handler is registered against a specific, typed request schema rather than relying on a function name matching a convention. Both SDKs implement the same underlying protocol, so a Python-built and a TypeScript-built server are interchangeable from any client’s perspective — the choice between them comes down to which language your existing tooling (the API you’re wrapping, the codebase you’re integrating with) already lives in:

// server.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import fetch from "node-fetch";

const server = new Server(
  { name: "weather-server", version: "1.0.0" },
  { capabilities: { tools: {} } }
);

// Define tools
server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: "get_weather",
      description: 'Get current weather for a city.',
      inputSchema: {
        type: "object",
        properties: {
          city: {
            type: "string",
            description: "City name (in English)",
          },
        },
        required: ["city"],
      },
    },
  ],
}));

// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;

  if (name === "get_weather") {
    const city = args?.city as string;
    const apiKey = process.env.OPENWEATHER_API_KEY;

    const response = await fetch(
      `https://api.openweathermap.org/data/2.5/weather?q=${city}&appid=${apiKey}&units=metric`
    );
    const data = (await response.json()) as any;

    if (data.cod !== 200) {
      return {
        content: [{ type: "text", text: `Error: ${data.message}` }],
      };
    }

    return {
      content: [
        {
          type: "text",
          text: `${city} weather: ${data.weather[0].description}, ${data.main.temp}°C (feels like ${data.main.feels_like}°C)`,
        },
      ],
    };
  }

  throw new Error(`Unknown tool: ${name}`);
});

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

Notice the weather tool has no input validation on city beyond the JSON Schema’s type: "string" check, and the API key is read straight from an environment variable with no fallback error message if it’s missing — fetch will still run with appid=undefined in the URL, and OpenWeatherMap’s response comes back as an auth error that the code does surface via data.message, but only because data.cod !== 200 happens to catch it. That’s more luck than design: a production MCP server should validate that required environment variables are present at startup (failing fast with a clear error) rather than relying on every downstream API call to eventually surface a missing-config problem as a generic error message.


Connecting to Claude Desktop

Configuration file:

// ~/Library/Application Support/Claude/claude_desktop_config.json (macOS)
// %APPDATA%\Claude\claude_desktop_config.json (Windows)
{
  "mcpServers": {
    "my-calculator": {
      "command": "python",
      "args": ["/path/to/server.py"],
      "env": {}
    },
    "file-system": {
      "command": "python",
      "args": ["/path/to/file_server.py"],
      "env": {
        "ALLOWED_DIR": "/Users/username/Documents"
      }
    },
    "weather": {
      "command": "node",
      "args": ["/path/to/weather-server/build/index.js"],
      "env": {
        "OPENWEATHER_API_KEY": "your-api-key-here"
      }
    }
  }
}

Restart Claude Desktop and a 🔧 tool icon will appear at the bottom of the chat window.

This config file is the entire connection mechanism for stdio-based servers — Claude Desktop literally spawns your server as a child process using command and args, and communicates over its stdin/stdout using JSON-RPC. That’s why the env block matters: environment variables set here are the only way the spawned process sees them, since it doesn’t inherit your shell’s environment the way a terminal-launched process would. A server that reads os.environ.get("ALLOWED_DIR") and works fine when you run python file_server.py manually can fail silently (or fall back to a default) when launched this way if you forget to mirror that variable into the config’s env block.


Using Resources and Prompts

Resources (Reading Data)

Resources are the “read” half of MCP — data the client can fetch and hand to the model as context, without the model needing to invoke anything or interpret a function result. This example exposes a single static config file, but the same shape scales to database rows, log files, or paginated API results by making list_resources return dynamically generated URIs instead of a hardcoded list:

from mcp.types import Resource, TextResourceContents

@server.list_resources()
async def list_resources() -> list[Resource]:
    return [
        Resource(
            uri="file:///config.json",
            name="Config file",
            description="Application configuration",
            mimeType="application/json"
        )
    ]

@server.read_resource()
async def read_resource(uri: str) -> str | bytes:
    if uri == "file:///config.json":
        return '{"version": "1.0", "debug": false}'
    raise ValueError(f"Unknown resource: {uri}")

Prompts (Reusable Templates)

Prompts solve a different problem than Tools or Resources: standardizing a request rather than an action or a data source. Without a shared prompt, every user asking for a code review phrases it slightly differently, and review quality varies with how well each person happens to prompt. A code_review Prompt, defined once on the server, gives every client a consistent, well-structured request to invoke — the arguments (language, focus) let it stay reusable across different review contexts while keeping the underlying instructions (the five-point checklist below) identical every time:

from mcp.types import Prompt, PromptArgument, GetPromptResult, PromptMessage

@server.list_prompts()
async def list_prompts() -> list[Prompt]:
    return [
        Prompt(
            name="code_review",
            description="Code review request prompt",
            arguments=[
                PromptArgument(name="language", description="Programming language", required=True),
                PromptArgument(name="focus", description="Review focus area", required=False)
            ]
        )
    ]

@server.get_prompt()
async def get_prompt(name: str, arguments: dict | None) -> GetPromptResult:
    if name == "code_review":
        language = arguments.get("language", "code") if arguments else "code"
        focus = arguments.get("focus", "overall quality") if arguments else "overall quality"
        
        return GetPromptResult(
            description=f"{language} code review",
            messages=[
                PromptMessage(
                    role="user",
                    content=TextContent(
                        type="text",
                        text=f"""Please review the following {language} code with a focus on {focus}:
                        
1. Code quality and readability
2. Potential bugs and errors
3. Performance optimization opportunities
4. Security vulnerabilities
5. Improvement suggestions (with code examples)"""
                    )
                )
            ]
        )
    raise ValueError(f"Unknown prompt: {name}")

Official MCP servers from Anthropic and the community. Each of these is a complete, ready-to-run implementation of the same patterns built by hand above — worth trying before writing a custom server for a common integration, since a well-maintained official server will handle edge cases (pagination, rate limits, auth token refresh) that a first-pass custom implementation typically won’t:

# Filesystem server (official)
npx -y @modelcontextprotocol/server-filesystem /path/to/directory

# SQLite server (official)
npx -y @modelcontextprotocol/server-sqlite /path/to/database.db

# GitHub server (official)
npx -y @modelcontextprotocol/server-github

# PostgreSQL server
npx -y @modelcontextprotocol/server-postgres postgresql://user:pass@localhost/db

# Slack server
npx -y @modelcontextprotocol/server-slack

# Brave Search server
npx -y @modelcontextprotocol/server-brave-search

The stdout mistake that breaks stdio servers

Because a stdio server talks JSON-RPC over its own stdin and stdout, anything else written to stdout lands in the middle of the protocol stream. A stray print() for debugging in Python, a console.log in Node, or a library that prints a banner on import is enough to make the client fail to parse the next message, and the symptom in Claude Desktop is usually just a server that shows as disconnected. Send all logging to stderr (Python’s logging does this by default; in Node use console.error), and check the client’s MCP log files when a server that works from the terminal refuses to connect.

Beyond that, most of the work in building a server is the same work as building any tool integration: deciding what to expose, and validating input carefully at the boundary where a model’s output becomes a system call.