mcpstepbystep

skip the blank page

Templates

Fourteen copy-paste starters, each the distilled version of a pattern the tutorials build and test. Copy one, replace the angle-bracket placeholders, ship.

Minimal MCP server (Python / FastMCP)

One tool, one resource, stdio — the smallest server that is still shaped correctly: validated input, an error a model can act on.

# server.py — minimal MCP server (Python, stdio)
# Install: uv add "mcp[cli]"    (or: pip install "mcp[cli]")
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("my-server")

@mcp.tool()
def get_order_status(order_id: str) -> str:
    """Look up the shipping status of an order by its numeric id."""
    if not order_id.isdigit():
        return "invalid order_id: must be numeric"    # error a model can act on
    return f"order {order_id}: shipped"               # replace with your real lookup

@mcp.resource("docs://readme")
def readme() -> str:
    """Project documentation the client can pull in as context."""
    return "What this server exposes, and the ids it expects."

if __name__ == "__main__":
    mcp.run()  # stdio transport by default
# Try it in a browser UI: uv run mcp dev server.py

Minimal MCP server (TypeScript)

McpServer + zod + stdio, with the tsconfig settings that make the .js import extensions work instead of mystifying you.

// src/index.ts — minimal MCP server (TypeScript, stdio)
// npm install @modelcontextprotocol/sdk zod@3
//
// tsconfig note: set "module": "Node16" and "moduleResolution": "Node16".
// The .js extension on the imports below is correct ESM style — not a typo.
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-server", version: "1.0.0" });

server.registerTool(
  "get_order_status",
  {
    description: "Look up the shipping status of an order by its numeric id.",
    inputSchema: { order_id: z.string().regex(/^\d+$/, "must be numeric") },
  },
  async ({ order_id }) => ({
    content: [{ type: "text", text: `order ${order_id}: shipped` }],
  })
);

await server.connect(new StdioServerTransport());
// Build, then verify:
//   npx tsc && npx @modelcontextprotocol/inspector node build/index.js

Streamable HTTP server skeleton (Python)

The remote transport, in stateless mode so it scales behind a load balancer from day one.

# http_server.py — Streamable HTTP MCP server (Python)
# Stateless mode: no per-session state, so any replica can answer any
# request — the load-balancer-friendly shape for real deployments.
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("my-http-server", stateless_http=True)

@mcp.tool()
def ping() -> str:
    """Health check: returns pong."""
    return "pong"

if __name__ == "__main__":
    mcp.run(transport="streamable-http")  # serves /mcp on port 8000

# Point clients at http://127.0.0.1:8000/mcp
# Production notes:
# - Put TLS in front; validate Origin headers (spec: HTTP 403 for invalid Origin).
# - Streamable HTTP replaced HTTP+SSE in spec 2025-03-26 — build new servers on this.

OAuth resource-server middleware (sketch)

Bearer validation with the audience check that separates a compliant resource server from forbidden token passthrough. A sketch — adapt to your IdP.

// auth-middleware.ts — SKETCH to adapt, not production code.
// An MCP server is an OAuth *resource server*: it validates tokens issued by
// your (external) authorization server. It never mints or forwards tokens.
import type { Request, Response, NextFunction } from "express";

const RESOURCE = "https://mcp.example.com/mcp"; // this server's canonical URI

export async function requireBearer(req: Request, res: Response, next: NextFunction) {
  const auth = req.headers.authorization ?? "";
  const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;

  if (!token) {
    // RFC 9728: point clients at your protected-resource metadata
    res.setHeader(
      "WWW-Authenticate",
      `Bearer resource_metadata="${RESOURCE}/.well-known/oauth-protected-resource"`
    );
    return res.status(401).json({ error: "missing bearer token" });
  }

  // Verify signature + expiry against your IdP's JWKS — use a maintained
  // library (e.g. jose); do NOT hand-roll JWT validation.
  const claims = await verifyJwt(token);

  // The audience check is the whole point (RFC 8707 resource indicators):
  // accepting a token issued for some OTHER service is the forbidden
  // "token passthrough" pattern the spec calls out explicitly.
  if (claims.aud !== RESOURCE) {
    return res.status(403).json({ error: "token not issued for this resource" });
  }

  next();
}

claude_desktop_config.json starter

macOS and Windows variants with the three classics pre-solved: absolute paths, escaped backslashes, and the env block.

{
  "//": "claude_desktop_config.json — Claude Desktop's MCP server registry.",
  "//macos": "~/Library/Application Support/Claude/claude_desktop_config.json",
  "//windows": "%AppData%\\Claude\\claude_desktop_config.json",
  "mcpServers": {
    "my-server": {
      "//": "Absolute paths only — Claude Desktop does not inherit your shell PATH or cwd.",
      "command": "/Users/you/project/.venv/bin/python",
      "args": ["/Users/you/project/server.py"],
      "env": { "API_KEY": "your-key-here" }
    },
    "my-server-on-windows": {
      "//": "Windows: escape every backslash in JSON paths. Bare 'npx' often fails with spawn npx ENOENT — use the full path or cmd /c npx.",
      "command": "C:\\Users\\you\\project\\.venv\\Scripts\\python.exe",
      "args": ["C:\\Users\\you\\project\\server.py"]
    }
  }
}
// Fully quit and restart Claude Desktop after editing — closing the window is not enough.

.mcp.json for Claude Code (project scope)

Check it into the repo and every teammate gets the same servers — with ${VAR} expansion keeping secrets out of git.

{
  "//": ".mcp.json — project-scope MCP servers for Claude Code; check into the repo.",
  "//2": "Equivalent CLI: claude mcp add --transport stdio --scope project orders -- npx -y @your-org/orders-mcp",
  "mcpServers": {
    "orders": {
      "command": "npx",
      "args": ["-y", "@your-org/orders-mcp"],
      "env": {
        "//": "${VAR} expands from each teammate's environment — the secret never lands in git.",
        "ORDERS_API_KEY": "${ORDERS_API_KEY}"
      }
    },
    "docs-remote": {
      "type": "http",
      "url": "https://docs.example.com/mcp",
      "headers": { "Authorization": "Bearer ${DOCS_TOKEN}" }
    }
  }
}
// Teammates are prompted to approve project servers on first use. Check with: /mcp

.cursor/mcp.json starter

Same idea for Cursor: a local stdio server and a remote URL server, project-level or global.

{
  "//": ".cursor/mcp.json — project-level servers for Cursor.",
  "//2": "Global alternative: ~/.cursor/mcp.json (same shape).",
  "mcpServers": {
    "my-server": {
      "command": "npx",
      "args": ["-y", "@your-org/my-mcp-server"],
      "env": { "API_KEY": "your-key-here" }
    },
    "my-remote-server": {
      "url": "https://mcp.example.com/mcp"
    }
  }
}
// Enable under Cursor Settings → MCP; tools become available in Agent mode.

Dockerfile for a stdio MCP server

Containerise the server for a clean, reproducible sandbox — and never lose an hour to the missing -i flag again.

# Dockerfile — containerised stdio MCP server (Python example)
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt   # includes mcp[cli]
COPY server.py .
# stdio server: the client talks over stdin/stdout — nothing to EXPOSE.
ENTRYPOINT ["python", "server.py"]

# GOTCHA: always run with -i, or stdin closes and the server exits instantly:
#   docker build -t my-mcp-server .
#   docker run -i --rm my-mcp-server
# In client config:
#   "command": "docker", "args": ["run", "-i", "--rm", "my-mcp-server"]

Inspector smoke-test script

Four CLI-mode Inspector calls that catch "server loads but tools don't appear" before your users do. Drop it into CI.

#!/usr/bin/env bash
# smoke-test.sh — MCP smoke test via Inspector CLI mode (no UI, CI-friendly)
set -euo pipefail

SERVER_CMD="node build/index.js"
# Python equivalent: SERVER_CMD="uv --directory . run server.py"

# 1. Does it start, handshake, and list tools?
npx @modelcontextprotocol/inspector --cli $SERVER_CMD --method tools/list

# 2. Does a real call return what you expect?
npx @modelcontextprotocol/inspector --cli $SERVER_CMD \
  --method tools/call --tool-name get_order_status --tool-arg order_id=1001

# 3. Bad input: the error should be actionable, not a stack trace.
npx @modelcontextprotocol/inspector --cli $SERVER_CMD \
  --method tools/call --tool-name get_order_status --tool-arg order_id=abc

# 4. Resources, if you declare any
npx @modelcontextprotocol/inspector --cli $SERVER_CMD --method resources/list

# Interactive debugging instead? Drop --cli and everything after it:
#   npx @modelcontextprotocol/inspector node build/index.js

server.json registry-publish starter

The manifest for publishing to the official MCP registry, with the reverse-DNS namespace rule explained inline.

{
  "//": "server.json — publish to registry.modelcontextprotocol.io via the mcp-publisher CLI.",
  "//2": "Name is a reverse-DNS namespace you can prove you own: io.github.<user> is verified through GitHub login.",
  "//3": "The registry is in preview and feeds aggregators/marketplaces — clients don't consume it directly.",
  "$schema": "https://static.modelcontextprotocol.io/schemas/<latest>/server.schema.json",
  "name": "io.github.your-user/my-server",
  "description": "One sentence shown in registry search results.",
  "version": "1.0.0",
  "packages": [
    {
      "registryType": "npm",
      "identifier": "@your-user/my-mcp-server",
      "version": "1.0.0",
      "transport": { "type": "stdio" }
    }
  ]
}
// Publish: mcp-publisher login github && mcp-publisher publish
// Check <latest> schema URL + fields against the registry docs before publishing.

MCP server README template

A well-documented server declares its tools, scopes, env vars, and security posture up front — this is the shape reviewers hope to find.

# <server-name> — MCP server

One sentence: what this server lets a model do, and for whom.

## Tools
| Tool | What it does | Side effects |
|------|--------------|--------------|
| `get_<thing>` | Reads <what>, returns <shape> | none (read-only) |
| `create_<thing>` | Writes <what> | creates a record in <system> |

## Resources & prompts
- `docs://<name>` — <what context it provides>

## Environment variables
| Var | Required | Purpose | Scope of the credential |
|-----|----------|---------|-------------------------|
| `API_KEY` | yes | auth to <system> | read-only, <which data> |

## Install
Per-client config blocks: Claude Desktop, Claude Code (.mcp.json), Cursor,
VS Code. Include absolute-path and Windows variants.

## Transport & auth
- stdio for local use; Streamable HTTP at `/mcp` for remote.
- Remote auth: this server is an OAuth resource server — it accepts only
  tokens issued *for it* (audience-validated), never passed-through tokens.

## Security notes
- Data this server can reach: <systems, scopes>.
- Data it never returns to the model: <secrets, PII fields redacted>.
- Destructive tools and their guardrails: <list or "none — read-only">.

Tool design worksheet

Task-shaped tools, honest descriptions, a response budget, actionable errors — fill it in before code, not after.

# Tool design worksheet — fill one per tool BEFORE writing code

## Task shape
- [ ] Tool = one task a user would name ("check an order"), not one API
      endpoint. Would a competent intern know when to use it from the
      description alone?
- [ ] Could two of my draft tools be one? Could this one hide 3 API calls?

## Naming & description
- [ ] Name is verb_noun, snake_case, unambiguous next to my other tools.
- [ ] Description says WHEN to use it (and when not), not just what it is.
- [ ] Every parameter documented, with format + one example value.

## Response budget
- [ ] Typical response fits a budget I chose (aim well under ~1K tokens).
      Tool definitions and results all consume the model's context —
      a 106-tool server can burn ~54K tokens before the first question.
- [ ] Large results: return a summary + id to fetch detail, never the dump.
- [ ] No internal fields, no nulls-for-everything, no base64 blobs.

## Errors a model can act on
- [ ] Error strings say what to DO: "order_id must be numeric" beats
      "ValidationError in OrderService.lookup (code 4002)".
- [ ] Not-found is a normal answer ("no order 99999"), not an exception dump.
- [ ] Rate limits / auth failures tell the model whether retrying helps.

## Safety
- [ ] Inputs validated against a schema before any real work.
- [ ] Outputs redacted before they enter the model's context.
- [ ] If destructive: annotated as such, and the README's guardrails row is filled.

Third-party server security review checklist

Provenance, tool poisoning, token handling, egress — the questions to answer before any server reaches an allowlist.

# Third-party MCP server review — <server name>, <date>, <reviewer>
# Run BEFORE the server reaches anyone's allowlist. Spec's own threat list:
# confused deputy, token passthrough, session hijacking, tool poisoning.

## Provenance
- [ ] Package name verified character-by-character against the official repo
      (npm/PyPI typosquatting campaigns target MCP servers specifically)
- [ ] Publisher identity checked; repo has real history, not a fresh dump
- [ ] Version PINNED (no 'latest', no floating npx -y of unpinned packages)

## Tool surface
- [ ] Read every tool description in full — descriptions are prompt input to
      the model; hostile instructions hidden there = tool poisoning
- [ ] Re-review descriptions on every version bump (rug-pull updates)
- [ ] Each tool's blast radius listed: read-only / writes / destructive
- [ ] No tool accepts free-form commands to pass through to a shell or API

## Credentials & tokens
- [ ] Runs with a dedicated least-privilege credential, not a personal one
- [ ] Secrets via env vars, never hardcoded in config or code
- [ ] Remote server: validates token audience — a server that accepts tokens
      issued for other services is doing forbidden token passthrough

## Network & runtime
- [ ] Egress known: which hosts does it call? Any surprise telemetry?
- [ ] Outbound URL fetching blocks private ranges incl. 169.254.169.254 (SSRF)
- [ ] Can run sandboxed (container, -i for stdio) with a resource ceiling

## Verdict
approve (scope: ____________) / reject / needs-changes: ____________

MCP rollout one-pager

Intake, allowlist, approval tiers, audit — the whole governance loop for MCP at work, on one deliberate page.

# MCP rollout one-pager — <team / org>          (keep it one page)

## Intake
- Anyone can propose a server: name, source, business need, data it touches.
- Requests land in <channel / form>; triaged weekly by <owner>.

## Review → allowlist
- Security review (see checklist) required before any allowlist entry.
- Allowlist is config, not policy prose: the approved server list + pinned
  versions lives at <repo path>, deployed to managed clients.
- Local/stdio servers running as the user count too — "it's on my laptop"
  is still an agent with your credentials.

## Approval tiers
- Read-only, public data        → team-lead approval
- Read-only, internal data      → team lead + data owner
- Writes / destructive tools    → security review + named business owner
- Remote servers (HTTP)         → all of the above + auth review (audience-
  validated tokens; SSO via IdP where supported)

## Audit
- Every server has: an owner, a version, a review date, a re-review trigger
  (any version bump or tool-list change).
- Client logs sampled <monthly>: which tools fired, against which data.
- Quarterly: prune servers nobody used — every idle tool definition still
  costs context and attack surface.

## Help
- Questions: <channel>   ·   Suspected incident: <channel> + kill the allowlist entry first

newsletter

One practical MCP guide in your inbox. No news, no hype.

Tutorials and decision frameworks as they ship. Unsubscribe anytime.