What is MCP and why it matters

Model Context Protocol (MCP) is an open standard from Anthropic that defines how AI language models connect to external tools and data sources. It is to AI agents what USB-C is to devices: a single connector that works everywhere.

Before MCP, every AI tool integration was a bespoke implementation. If you wanted Claude to query your database, you wrote a custom tool in a custom format specific to Claude's API. If you wanted it to work with a different model or client, you rewrote the integration. MCP standardizes this — you build one server, and any MCP-compatible client (Claude Desktop, Claude Code, Cursor, Windsurf, and a growing list of others) can use it automatically.

The protocol defines three primitive types an MCP server can expose:

  • Tools — functions the AI can call (query a database, decode a VIN, send an email, run a calculation). Tools take structured input and return output. This is the most commonly used primitive.
  • Resources — data the AI can read (a file, a database row, a webpage, live metrics). Resources are URI-addressed and represent context, not actions.
  • Prompts — pre-written prompt templates the AI can invoke. Useful for standardizing complex workflows across a team.

As of early 2026, MCP has broad adoption. Anthropic's Claude products support it natively. GitHub Copilot, Cursor, Windsurf, and the open-source community have all shipped MCP integrations. The WebMCP directory lists hundreds of available servers. If you are building anything AI-adjacent, learning to build an MCP server is worth your time.

MCP architecture: hosts, clients, servers, transports

The MCP spec defines four layers:

LayerWhat it isExample
HostThe AI application the user interacts withClaude Desktop, Cursor, your custom agent
ClientThe MCP client embedded in the hostThe MCP client library inside Claude Desktop
ServerYour process that exposes tools/resourcesYour Node.js or Python server
TransportHow client and server talk to each otherstdio, SSE, HTTP Streaming

In practice, you are building the server layer. The host and client are handled by whichever AI tool you are targeting.

Transport options

MCP supports three transport mechanisms:

stdio (Standard I/O) — The client launches your server as a subprocess and communicates via stdin/stdout. This is the simplest option and works without any networking setup. It is the right choice for local tools (CLI utilities, file access, code execution). Nearly all locally-installed MCP servers use stdio.

SSE (Server-Sent Events) — The client connects to your server over HTTP. The server pushes events via SSE, and the client POSTs requests to a separate endpoint. SSE was the original remote transport but is being superseded.

HTTP Streaming (Streamable HTTP) — The newer, preferred remote transport as of the 2025 MCP spec update. Uses standard HTTP POST with streaming responses. Better proxy compatibility than SSE, easier to load-balance, and simpler to implement behind nginx or a CDN.

The rule of thumb: use stdio for local tools, use HTTP Streaming for remote/deployed servers. Unless you have a specific reason to use SSE, skip it for new projects.

Message format

MCP messages are JSON-RPC 2.0. Every message has a jsonrpc: "2.0" field, a method, optional params, and an id for request/response matching. You do not need to implement this manually — the SDK handles it — but understanding the wire format helps when debugging.

Node.js quickstart

The official TypeScript SDK is @modelcontextprotocol/sdk. Install it:

npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node ts-node

Create src/server.ts:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

// Create the server
const server = new McpServer({
  name: "my-mcp-server",
  version: "1.0.0",
});

// ── TOOL: weather lookup ──────────────────────────────────────────────────────
server.tool(
  "get_weather",
  "Get current weather for a city",
  {
    city: z.string().describe("City name, e.g. 'San Francisco'"),
    units: z.enum(["celsius", "fahrenheit"]).default("celsius").describe("Temperature units"),
  },
  async ({ city, units }) => {
    // Real implementation would call a weather API
    const temp = units === "celsius" ? 18 : 64;
    return {
      content: [
        {
          type: "text",
          text: JSON.stringify({
            city,
            temperature: temp,
            units,
            conditions: "partly cloudy",
            humidity: 72,
          }, null, 2),
        },
      ],
    };
  }
);

// ── TOOL: calculator ──────────────────────────────────────────────────────────
server.tool(
  "calculate",
  "Evaluate a mathematical expression",
  {
    expression: z.string().describe("Math expression to evaluate, e.g. '(4 + 5) * 3'"),
  },
  async ({ expression }) => {
    try {
      // Safe eval — only allow math characters
      if (!/^[\d\s\+\-\*\/\(\)\.\%]+$/.test(expression)) {
        throw new Error("Invalid expression: only numbers and operators allowed");
      }
      const result = Function(`"use strict"; return (${expression})`)();
      return {
        content: [{ type: "text", text: String(result) }],
      };
    } catch (err) {
      return {
        content: [{ type: "text", text: `Error: ${(err as Error).message}` }],
        isError: true,
      };
    }
  }
);

// ── RESOURCE: server status ───────────────────────────────────────────────────
server.resource(
  "status",
  "mcp://my-server/status",
  { mimeType: "application/json" },
  async () => {
    return {
      contents: [
        {
          uri: "mcp://my-server/status",
          mimeType: "application/json",
          text: JSON.stringify({
            status: "ok",
            uptime: process.uptime(),
            timestamp: new Date().toISOString(),
          }, null, 2),
        },
      ],
    };
  }
);

// ── PROMPT: code review ───────────────────────────────────────────────────────
server.prompt(
  "review_code",
  "Generate a code review prompt for a given snippet",
  {
    code: z.string().describe("The code to review"),
    language: z.string().default("typescript").describe("Programming language"),
  },
  async ({ code, language }) => {
    return {
      messages: [
        {
          role: "user",
          content: {
            type: "text",
            text: `Please review this ${language} code. Focus on: correctness, security issues, performance, and readability. Be specific.\n\n\`\`\`${language}\n${code}\n\`\`\``,
          },
        },
      ],
    };
  }
);

// ── Start server ──────────────────────────────────────────────────────────────
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("MCP server running on stdio");
}

main().catch((err) => {
  console.error("Fatal:", err);
  process.exit(1);
});

Add to package.json:

{
  "scripts": {
    "start": "ts-node src/server.ts",
    "build": "tsc"
  },
  "bin": {
    "my-mcp-server": "./dist/server.js"
  }
}

And a minimal tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "dist",
    "strict": true,
    "esModuleInterop": true
  },
  "include": ["src"]
}

Test it immediately:

echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | npx ts-node src/server.ts

HTTP transport (for remote deployment)

If you want to deploy the server remotely rather than run it as a subprocess, swap the transport:

import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";

const app = express();
app.use(express.json());

const transport = new StreamableHTTPServerTransport({ path: "/mcp" });
await server.connect(transport);

app.use("/mcp", transport.requestHandler());

app.listen(3000, () => {
  console.log("MCP server listening on :3000/mcp");
});

Clients then connect to https://your-domain.com/mcp instead of launching a subprocess.

Python quickstart

The official Python package is mcp. It uses async/await throughout.

pip install mcp pydantic

Create server.py:

import asyncio
import json
from datetime import datetime
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp import types
from pydantic import BaseModel, Field


app = Server("my-mcp-server")


# ── Input schemas ─────────────────────────────────────────────────────────────

class WeatherInput(BaseModel):
    city: str = Field(description="City name, e.g. 'San Francisco'")
    units: str = Field(default="celsius", description="Temperature units: celsius or fahrenheit")

class CalculateInput(BaseModel):
    expression: str = Field(description="Math expression to evaluate")

class SummarizeInput(BaseModel):
    text: str = Field(description="Text to summarize")
    max_words: int = Field(default=100, description="Maximum words in summary")


# ── Tools ─────────────────────────────────────────────────────────────────────

@app.list_tools()
async def list_tools() -> list[types.Tool]:
    return [
        types.Tool(
            name="get_weather",
            description="Get current weather for a city",
            inputSchema=WeatherInput.model_json_schema(),
        ),
        types.Tool(
            name="calculate",
            description="Evaluate a mathematical expression safely",
            inputSchema=CalculateInput.model_json_schema(),
        ),
        types.Tool(
            name="summarize",
            description="Summarize a piece of text",
            inputSchema=SummarizeInput.model_json_schema(),
        ),
    ]


@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
    if name == "get_weather":
        params = WeatherInput(**arguments)
        temp = 18 if params.units == "celsius" else 64
        result = {
            "city": params.city,
            "temperature": temp,
            "units": params.units,
            "conditions": "partly cloudy",
        }
        return [types.TextContent(type="text", text=json.dumps(result, indent=2))]

    elif name == "calculate":
        params = CalculateInput(**arguments)
        # Only allow safe characters
        allowed = set("0123456789 +-*/.%()")
        if not all(c in allowed for c in params.expression):
            raise ValueError("Invalid expression: only numbers and basic operators allowed")
        try:
            result = eval(params.expression, {"__builtins__": {}})
            return [types.TextContent(type="text", text=str(result))]
        except Exception as e:
            raise ValueError(f"Calculation error: {e}")

    elif name == "summarize":
        params = SummarizeInput(**arguments)
        words = params.text.split()
        truncated = " ".join(words[:params.max_words])
        if len(words) > params.max_words:
            truncated += "..."
        return [types.TextContent(type="text", text=truncated)]

    else:
        raise ValueError(f"Unknown tool: {name}")


# ── Resources ─────────────────────────────────────────────────────────────────

@app.list_resources()
async def list_resources() -> list[types.Resource]:
    return [
        types.Resource(
            uri="mcp://my-server/status",
            name="Server Status",
            mimeType="application/json",
        ),
    ]


@app.read_resource()
async def read_resource(uri: str) -> str:
    if uri == "mcp://my-server/status":
        return json.dumps({
            "status": "ok",
            "timestamp": datetime.utcnow().isoformat(),
            "server": "my-mcp-server",
        }, indent=2)
    raise ValueError(f"Unknown resource: {uri}")


# ── Prompts ───────────────────────────────────────────────────────────────────

@app.list_prompts()
async def list_prompts() -> list[types.Prompt]:
    return [
        types.Prompt(
            name="review_code",
            description="Generate a thorough code review",
            arguments=[
                types.PromptArgument(name="code", description="Code to review", required=True),
                types.PromptArgument(name="language", description="Programming language", required=False),
            ],
        ),
    ]


@app.get_prompt()
async def get_prompt(name: str, arguments: dict | None) -> types.GetPromptResult:
    if name == "review_code":
        args = arguments or {}
        code = args.get("code", "")
        lang = args.get("language", "python")
        return types.GetPromptResult(
            messages=[
                types.PromptMessage(
                    role="user",
                    content=types.TextContent(
                        type="text",
                        text=(
                            f"Please review this {lang} code. Check for: correctness, "
                            f"security vulnerabilities, performance issues, and readability.\n\n"
                            f"```{lang}\n{code}\n```"
                        ),
                    ),
                )
            ]
        )
    raise ValueError(f"Unknown prompt: {name}")


# ── Entry point ───────────────────────────────────────────────────────────────

async def main():
    async with stdio_server() as (read_stream, write_stream):
        await app.run(read_stream, write_stream, app.create_initialization_options())


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

Run it:

python server.py

For HTTP transport in Python, use the mcp.server.fastmcp module which wraps FastAPI:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("my-server")

@mcp.tool()
def get_weather(city: str, units: str = "celsius") -> dict:
    """Get current weather for a city."""
    return {"city": city, "temperature": 18, "units": units}

if __name__ == "__main__":
    mcp.run(transport="streamable-http", host="0.0.0.0", port=3000)

FastMCP auto-generates tool schemas from type annotations, making it the fastest way to get a Python HTTP MCP server running.

Registering tools with schemas

Tools are the heart of most MCP servers. Getting tool schemas right is important — the schema is what the AI uses to understand what inputs are valid and what the tool does.

Tool schema anatomy

Every tool has three required parts:

  • name — a snake_case identifier. The AI uses this to call the tool. Keep it short and descriptive: get_user, send_email, decode_vin.
  • description — a natural language description of what the tool does and when to use it. Write this for the AI, not for humans. Be specific about edge cases and limitations.
  • inputSchema — a JSON Schema object describing the accepted input. Include descriptions for every field.

Using Zod for input validation (TypeScript)

The TypeScript SDK accepts Zod schemas directly and converts them to JSON Schema automatically:

import { z } from "zod";

server.tool(
  "search_products",
  "Search the product catalog. Returns up to 20 results sorted by relevance.",
  {
    query: z.string().min(1).max(200).describe("Search query"),
    category: z.enum(["electronics", "clothing", "food", "all"]).default("all"),
    max_price: z.number().positive().optional().describe("Maximum price in USD"),
    in_stock: z.boolean().default(true).describe("Only show in-stock items"),
    page: z.number().int().min(1).default(1).describe("Page number for pagination"),
  },
  async ({ query, category, max_price, in_stock, page }) => {
    // Your implementation
    const results = await searchProducts({ query, category, max_price, in_stock, page });
    return {
      content: [{ type: "text", text: JSON.stringify(results) }],
    };
  }
);

Using Pydantic for input validation (Python)

from pydantic import BaseModel, Field, field_validator
from typing import Optional, Literal
import re

class SearchInput(BaseModel):
    query: str = Field(min_length=1, max_length=200, description="Search query")
    category: Literal["electronics", "clothing", "food", "all"] = Field(
        default="all", description="Product category filter"
    )
    max_price: Optional[float] = Field(default=None, gt=0, description="Maximum price in USD")
    in_stock: bool = Field(default=True, description="Only show in-stock items")
    page: int = Field(default=1, ge=1, description="Page number for pagination")

    @field_validator("query")
    @classmethod
    def sanitize_query(cls, v: str) -> str:
        # Strip SQL injection attempts
        return re.sub(r"['\";\\]", "", v).strip()

@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
    if name == "search_products":
        params = SearchInput(**arguments)  # Pydantic validates + sanitizes
        results = await search_products(params)
        return [types.TextContent(type="text", text=results.model_dump_json())]

Writing good tool descriptions

The description field has an outsized impact on how reliably the AI calls your tool correctly. A few guidelines:

  • Start with the action: "Fetches...", "Creates...", "Searches...", "Calculates..."
  • Mention return format: "Returns a JSON object with fields: name, price, sku"
  • State limitations: "Only works for US addresses", "Rate-limited to 10 calls/minute"
  • Give examples when the input format is non-obvious: "VIN must be 17 characters, e.g. '1HGBH41JXMN109186'"
  • Explain when NOT to use it: "Use search_products for discovery; use get_product for fetching a known SKU"

Resources and prompts

Resources

Resources expose data for the AI to read rather than actions for it to take. Think of them as read-only endpoints. The AI can fetch a resource to understand the current state of something before deciding what to do.

Resources are URI-addressed. Common patterns:

// Static resource — same content every call
server.resource(
  "company_guidelines",
  "mcp://my-server/docs/guidelines",
  { mimeType: "text/markdown" },
  async () => ({
    contents: [{
      uri: "mcp://my-server/docs/guidelines",
      mimeType: "text/markdown",
      text: await fs.readFile("./docs/guidelines.md", "utf8"),
    }],
  })
);

// Dynamic resource — content varies by URI parameter
server.resource(
  "user_profile",
  new ResourceTemplate("mcp://my-server/users/{userId}", { list: undefined }),
  { mimeType: "application/json" },
  async (uri, { userId }) => {
    const user = await db.users.findById(userId);
    return {
      contents: [{
        uri: uri.href,
        mimeType: "application/json",
        text: JSON.stringify(user),
      }],
    };
  }
);

Resources are most useful for:

  • Configuration files and documentation the AI needs as context
  • Live system state (current queue depth, active jobs, recent errors)
  • User-specific data (their profile, preferences, history)
  • Database rows or collections the AI will reason about before acting

When to use a resource vs a tool: if the operation has side effects, it should be a tool. If it is purely read-only and represents "state of the world", it should be a resource.

Prompts

Prompts are reusable message templates that the AI host can surface to the user. They are less commonly implemented than tools or resources, but very useful for standardizing complex multi-step workflows.

// Node.js prompt with dynamic content
server.prompt(
  "generate_pr_description",
  "Generate a pull request description from a git diff",
  {
    diff: z.string().describe("Output of git diff --stat"),
    ticket: z.string().optional().describe("JIRA ticket number, e.g. ENG-1234"),
  },
  async ({ diff, ticket }) => {
    const ticketRef = ticket ? `\n\nJIRA: ${ticket}` : "";
    return {
      messages: [{
        role: "user",
        content: {
          type: "text",
          text: `Write a clear pull request description for this diff. Include: summary of changes, motivation, and testing notes.${ticketRef}\n\nDiff:\n${diff}`,
        },
      }],
    };
  }
);

Prompts shine when you want to ship a specific "expert workflow" baked into the server — your team's preferred code review format, a customer support response template, a standardized incident report structure.

Testing locally

MCP Inspector

The fastest way to test a new server is the official MCP Inspector, a web UI for interacting with any MCP server:

# Test a stdio server
npx @modelcontextprotocol/inspector npx ts-node src/server.ts

# Test an HTTP server (already running on :3000)
npx @modelcontextprotocol/inspector --url http://localhost:3000/mcp

Inspector opens a browser tab where you can list tools, call them with inputs, browse resources, and view the raw JSON-RPC messages. It is invaluable for debugging schema issues before connecting to a real AI client.

Claude Desktop config

To test with actual Claude Desktop, add your server to ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows):

{
  "mcpServers": {
    "my-server": {
      "command": "node",
      "args": ["/absolute/path/to/dist/server.js"],
      "env": {
        "API_KEY": "your-api-key-here"
      }
    }
  }
}

For a Python server:

{
  "mcpServers": {
    "my-python-server": {
      "command": "python",
      "args": ["/absolute/path/to/server.py"]
    }
  }
}

Restart Claude Desktop after editing the config. The server name appears in the bottom-left tools panel when a conversation opens.

Claude Code config

If you use Claude Code (the CLI), add servers to your project's .mcp.json or user config at ~/.claude/mcp.json:

{
  "mcpServers": {
    "my-server": {
      "command": "node",
      "args": ["./dist/server.js"]
    }
  }
}

Testing with raw JSON-RPC

For debugging transport issues, you can talk to a stdio server directly:

# List tools
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | node dist/server.js

# Call a tool
echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"calculate","arguments":{"expression":"(4+5)*3"}}}' | node dist/server.js

For HTTP servers, use curl:

# Initialize session
curl -X POST http://localhost:3000/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'

# List tools
curl -X POST http://localhost:3000/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'

Writing automated tests

For Node.js, use an in-process transport for unit testing:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { describe, it, expect } from "vitest";

describe("calculate tool", () => {
  it("evaluates expressions correctly", async () => {
    const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
    await server.connect(serverTransport);

    const client = new Client({ name: "test", version: "1.0" }, { capabilities: {} });
    await client.connect(clientTransport);

    const result = await client.callTool({ name: "calculate", arguments: { expression: "(4+5)*3" } });
    expect(result.content[0].text).toBe("27");
  });
});

Deploying to production

Docker

For Node.js:

# Dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY dist/ ./dist/
EXPOSE 3000
CMD ["node", "dist/server.js"]

For Python:

# Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY server.py .
EXPOSE 3000
CMD ["python", "server.py"]

Build and run:

docker build -t my-mcp-server .
docker run -d \
  --name my-mcp-server \
  -p 3000:3000 \
  -e API_KEY=your-key \
  --restart unless-stopped \
  my-mcp-server

systemd (for VPS / bare metal)

Create /etc/systemd/system/my-mcp.service:

[Unit]
Description=My MCP Server
After=network.target

[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/my-mcp-server
ExecStart=/usr/bin/node /opt/my-mcp-server/dist/server.js
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
Environment=PORT=3000
# Load secrets from an env file — do NOT bake secrets into the unit file
EnvironmentFile=/etc/my-mcp-server/env

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now my-mcp.service
sudo systemctl status my-mcp.service

nginx reverse proxy

Put nginx in front of your MCP server for SSL termination, rate limiting, and logging:

server {
    listen 443 ssl;
    server_name mcp.yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/mcp.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/mcp.yourdomain.com/privkey.pem;

    # Rate limiting (define this in the http{} block)
    # limit_req_zone $binary_remote_addr zone=mcp:10m rate=30r/m;

    location /mcp {
        limit_req zone=mcp burst=10 nodelay;

        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;

        # Required for streaming responses
        proxy_buffering off;
        proxy_cache off;
        proxy_set_header Connection "";
        proxy_read_timeout 300s;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

The key nginx setting for MCP is proxy_buffering off. Without it, nginx buffers streaming responses and breaks the SSE/streaming HTTP transport.

Environment variables and secrets

Never hardcode API keys or secrets. Use environment variables loaded from a secure source:

// Node.js — load from process.env
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) throw new Error("OPENAI_API_KEY is required");

// In Docker: pass with -e or --env-file
// In systemd: use EnvironmentFile=/etc/myservice/env
// In Kubernetes: use Secrets mounted as env vars

For local development, use a .env file with dotenv. Make sure .env is in .gitignore.

Real open-source examples

Two production MCP servers you can study and run:

mcp.vin — VIN Decoder MCP

mcp.vin is a live MCP server that decodes Vehicle Identification Numbers using the NHTSA API. It exposes a single decode_vin tool that takes a 17-character VIN and returns make, model, year, engine, and other vehicle specs. It is deployed on a VPS with nginx and systemd, using the HTTP streaming transport.

It demonstrates: clean tool schema design, calling an external API, structured JSON output, nginx config for MCP, and systemd service management. The repo is at github.com/keptlive/vin-mcp.

qrmcp.dev — QR Code MCP

qrmcp.dev is an MCP server that generates QR codes. It exposes tools for generating QR codes from URLs, text, and vCards, returning the QR as a base64-encoded PNG or SVG. It shows how to return binary/image content from an MCP tool, not just text.

Returning an image from an MCP tool looks like this:

return {
  content: [
    {
      type: "image",
      data: base64EncodedPng,   // base64 string, no data: prefix
      mimeType: "image/png",
    },
  ],
};

Most tools return text, but the MCP spec supports image and audio content types too. QR code generation is a good example of when returning an image is more useful than returning a data URL.

Security considerations

Authentication

stdio servers run locally and are launched by the AI client — they inherit the user's local permissions and typically do not need additional authentication. The security model is the same as running any local script.

HTTP servers exposed to the internet need authentication. The MCP spec supports OAuth 2.0 for remote servers. A simpler approach for private deployments is API key authentication via a header:

// Express middleware for HTTP MCP server
app.use("/mcp", (req, res, next) => {
  const key = req.headers["x-api-key"];
  if (!key || key !== process.env.MCP_API_KEY) {
    res.status(401).json({ error: "Unauthorized" });
    return;
  }
  next();
});

Configure the key in Claude Desktop:

{
  "mcpServers": {
    "my-remote-server": {
      "url": "https://mcp.yourdomain.com/mcp",
      "headers": {
        "x-api-key": "your-api-key"
      }
    }
  }
}

Rate limiting

AI agents can call tools in rapid loops. Add rate limiting at both the nginx layer and the application layer:

// Node.js — simple in-process rate limiter
import { RateLimiterMemory } from "rate-limiter-flexible";

const rateLimiter = new RateLimiterMemory({
  points: 60,      // 60 calls
  duration: 60,    // per minute
});

// In your tool handler:
try {
  await rateLimiter.consume(clientId);
} catch {
  return {
    content: [{ type: "text", text: "Rate limit exceeded. Try again in a moment." }],
    isError: true,
  };
}

Input validation

Never trust input from the AI. The AI might pass malformed data, an adversarial prompt might inject unexpected values, or your tool description might be misinterpreted. Validate everything:

  • Use Zod or Pydantic to enforce types and constraints — do not roll your own validation
  • Sanitize strings before using them in database queries, file paths, or shell commands
  • For file access tools, validate that the resolved path is within your allowed directory (path traversal)
  • For SQL tools, use parameterized queries — never string-interpolate user input into SQL
// Path traversal protection
import path from "path";

const ALLOWED_DIR = "/var/data/user-files";

function safeReadFile(userPath: string): string {
  const resolved = path.resolve(ALLOWED_DIR, userPath);
  if (!resolved.startsWith(ALLOWED_DIR + path.sep)) {
    throw new Error("Access denied: path outside allowed directory");
  }
  return fs.readFileSync(resolved, "utf8");
}

Principle of least privilege

Run your MCP server with the minimum permissions it needs:

  • Database access: create a read-only user if the tool only reads data
  • File access: restrict to the specific directories the tool needs
  • Network access: firewall outbound connections to only the APIs your tool calls
  • OS user: run as a non-root user with no sudo access

Sensitive data in responses

Be thoughtful about what you return. The AI will include tool output in its context window, which may be logged or sent to an LLM provider. Avoid returning:

  • Full API keys or secrets (even partial ones)
  • Passwords or credential materials
  • PII beyond what the user explicitly requested
  • Internal stack traces with full file paths (expose error codes instead)

MCP vs REST API: which to build

If you are building something that AI agents will use, the choice between MCP and a regular REST API is not always obvious. Here is the practical breakdown:

Situation Build MCP Build REST API
Primary consumer is an AI agent / LLM client Yes No
Primary consumer is a web app or mobile app No Yes
Want to work with Claude Desktop, Cursor, etc. out of the box Yes No (need a wrapper)
Need fine-grained HTTP caching No Yes
Exposing to third-party developers broadly Maybe (plus REST) Yes
Internal tooling for a dev team using AI tools Yes Optional
Need webhooks or push notifications No Yes

You can build both. Many teams expose a REST API for their existing clients and add an MCP server that wraps the same business logic for AI access. This avoids duplicating implementation while making the service usable by AI tools without needing custom integrations.

The pattern looks like:

// Shared business logic
async function getProductById(id: string): Promise {
  return db.products.findById(id);
}

// REST endpoint (for web/mobile apps)
app.get("/api/products/:id", async (req, res) => {
  const product = await getProductById(req.params.id);
  res.json(product);
});

// MCP tool (for AI agents)
server.tool(
  "get_product",
  "Fetch a product by its SKU or ID",
  { id: z.string().describe("Product ID or SKU") },
  async ({ id }) => {
    const product = await getProductById(id);
    return { content: [{ type: "text", text: JSON.stringify(product) }] };
  }
);

The business logic runs once. Both interfaces stay thin.

What to build next

The MCP ecosystem is still early. Some of the most useful servers that do not exist yet (or exist but are poorly done): calendar/scheduling access, CRM integrations, CI/CD pipeline tools, monitoring/alerting queries, documentation search with semantic retrieval.

If you are building a developer tool and want it discovered by AI agents, add it to webmcplist.com — a directory of Web MCP servers. Real-world examples like mcp.vin and qrmcp.dev show that even small focused servers with one or two well-designed tools get used.

Building an MCP server and want to generate better tool schemas? Try the CLAUDE.md writer and API tester at helloandy.net — free, no signup required.