Generated banner for MCP Builder
Generated logo for MCP Builder

MCP Builder

AnthropicOfficialPublished by Anthropic

  • Skill
  • Tool integration
  • Software development
  • Apache-2.0
  • Low risk
  • Has scripts

From the author

Guide for creating high-quality MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. Use when building MCP servers to integrate external APIs or services, whether in Python (FastMCP) or Node/TypeScript (MCP SDK).
— the description by Anthropic

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

mcp-builder provides a structured, four-phase workflow for developing robust MCP servers. It covers the entire lifecycle from initial research and planning to implementation, testing, and the creation of standardized evaluations. The guide emphasizes best practices for tool design, schema definition using Zod or Pydantic, and proper error handling to ensure LLMs can effectively interact with external APIs.

What it does

  • Outlines a four-phase development process for MCP servers.
  • Provides guidance on selecting between TypeScript (v2) and Python SDKs.
  • Details best practices for tool naming, discoverability, and context management.
  • Explains how to implement structured tool inputs and outputs.
  • Includes instructions for testing servers with the MCP Inspector.
  • Defines a standardized XML format for creating LLM-based evaluations.

When to use it

  • When you need to build a new MCP server to connect an external API to an LLM.
  • When you are unsure whether to use the TypeScript or Python SDK for your project.
  • When you need to migrate an existing MCP server from v1 to v2.
  • When you need to create a test suite to verify your MCP server's performance.

Advantages

  • Promotes consistent, high-quality tool design across different languages.
  • Provides clear migration paths for existing SDK versions.
  • Encourages the creation of verifiable, complex evaluation scenarios.
  • Focuses on actionable error messages to improve agent reliability.

Usage examples

“How do I start building a new MCP server in TypeScript?”
The agent will guide you through setting up a project using the v2 TypeScript SDK, including project structure and tool registration patterns.
“What are the best practices for naming tools in an MCP server?”
The agent will explain the importance of descriptive, action-oriented names and consistent prefixes like github_create_issue.
“How can I verify that my MCP server is working correctly?”
The agent will provide instructions on using the MCP Inspector and creating a 10-question evaluation suite in the required XML format.

Install

  1. Download the skill as a ZIP:

    https://autogineer.com/agent-skills/anthropics/mcp-builder/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip mcp-builder.zip -d .agents/skills

Documentation by Anthropic

MCP Server Development Guide

Overview

Create MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. The quality of an MCP server is measured by how well it enables LLMs to accomplish real-world tasks.


Process

🚀 High-Level Workflow

Creating a high-quality MCP server involves four main phases:

Phase 1: Deep Research and Planning
1.1 Understand Modern MCP Design

API Coverage vs. Workflow Tools: Balance comprehensive API endpoint coverage with specialized workflow tools. Workflow tools can be more convenient for specific tasks, while comprehensive coverage gives agents flexibility to compose operations. Performance varies by client—some clients benefit from code execution that combines basic tools, while others work better with higher-level workflows. When uncertain, prioritize comprehensive API coverage.

Tool Naming and Discoverability: Clear, descriptive tool names help agents find the right tools quickly. Use consistent prefixes (e.g., github_create_issue, github_list_repos) and action-oriented naming.

Context Management: Agents benefit from concise tool descriptions and the ability to filter/paginate results. Design tools that return focused, relevant data. Some clients support code execution which can help agents filter and process data efficiently.

Actionable Error Messages: Error messages should guide agents toward solutions with specific suggestions and next steps.

1.2 Study MCP Protocol Documentation

Navigate the MCP specification:

Start with the sitemap to find relevant pages: https://modelcontextprotocol.io/sitemap.xml

Then fetch specific pages with .md suffix for markdown format (e.g., https://modelcontextprotocol.io/specification/draft.md).

Key pages to review:

  • Specification overview and architecture

  • Transport mechanisms (streamable HTTP, stdio)

  • Tool, resource, and prompt definitions

1.3 Study Framework Documentation

Recommended stack:

  • Language: TypeScript (high-quality SDK support and good compatibility in many execution environments e.g. MCPB. Plus AI models are good at generating TypeScript code, benefiting from its broad usage, static typing and good linting tools)

  • Transport: Streamable HTTP for remote servers, using stateless JSON (simpler to scale and maintain, as opposed to stateful sessions and streaming responses). stdio for local servers.

Load framework documentation:

For TypeScript (recommended):

The TypeScript SDK has two major versions. Pick one before writing code:

  • v2 (@modelcontextprotocol/server): the current stable release. Use it for new servers.

  • v1 (@modelcontextprotocol/sdk): receives bug and security fixes only. Use it only when the project's package.json already depends on @modelcontextprotocol/sdk, or migrate that project with npx @modelcontextprotocol/codemod@latest v1-to-v2 .

If the project already uses v1, tell the user once, in a sentence or two, that v2 (@modelcontextprotocol/server) is the current stable release, that v1 receives only bug and security fixes, and that npx @modelcontextprotocol/codemod@latest v1-to-v2 . migrates the project. Then continue with v1 unless the user asks to migrate.

Don't mix the two in one project: v1 code samples (imports from @modelcontextprotocol/sdk/...) don't apply to v2.

  • TypeScript SDK (v2): Use WebFetch to load https://raw.githubusercontent.com/modelcontextprotocol/typescript-sdk/main/README.md; the full docs index is https://ts.sdk.modelcontextprotocol.io/v2/llms.txt

  • ⚡ TypeScript Guide (v2) - TypeScript patterns and examples

  • For v1 projects only: https://raw.githubusercontent.com/modelcontextprotocol/typescript-sdk/v1.x/README.md and the TypeScript Guide (v1)

For Python:

  • Python SDK: Use WebFetch to load https://raw.githubusercontent.com/modelcontextprotocol/python-sdk/main/README.md

  • 🐍 Python Guide - Python patterns and examples

1.4 Plan Your Implementation

Understand the API: Review the service's API documentation to identify key endpoints, authentication requirements, and data models. Use web search and WebFetch as needed.

Tool Selection: Prioritize comprehensive API coverage. List endpoints to implement, starting with the most common operations.


Phase 2: Implementation
2.1 Set Up Project Structure

See language-specific guides for project setup:

2.2 Implement Core Infrastructure

Create shared utilities:

  • API client with authentication

  • Error handling helpers

  • Response formatting (JSON/Markdown)

  • Pagination support

2.3 Implement Tools

For each tool:

Input Schema:

  • Use Zod (TypeScript) or Pydantic (Python)

  • Include constraints and clear descriptions

  • Add examples in field descriptions

Output Schema:

  • Define outputSchema where possible for structured data

  • Use structuredContent in tool responses (TypeScript SDK feature)

  • Helps clients understand and process tool outputs

Tool Description:

  • Concise summary of functionality

  • Parameter descriptions

  • Return type schema

Implementation:

  • Async/await for I/O operations

  • Proper error handling with actionable messages

  • Support pagination where applicable

  • Return both text content and structured data when using modern SDKs

Annotations:

  • readOnlyHint: true/false

  • destructiveHint: true/false

  • idempotentHint: true/false

  • openWorldHint: true/false


Phase 3: Review and Test
3.1 Code Quality

Review for:

  • No duplicated code (DRY principle)

  • Consistent error handling

  • Full type coverage

  • Clear tool descriptions

3.2 Build and Test

TypeScript:

  • Run npm run build to verify compilation

  • v2 projects: run the self-check script from the v2 guide against the built server

  • Test with MCP Inspector: npx @modelcontextprotocol/inspector

Python:

  • Verify syntax: python -m py_compile your_server.py

  • Test with MCP Inspector

See language-specific guides for detailed testing approaches and quality checklists.


Phase 4: Create Evaluations

After implementing your MCP server, create comprehensive evaluations to test its effectiveness.

Load ✅ Evaluation Guide for complete evaluation guidelines.

4.1 Understand Evaluation Purpose

Use evaluations to test whether LLMs can effectively use your MCP server to answer realistic, complex questions.

4.2 Create 10 Evaluation Questions

To create effective evaluations, follow the process outlined in the evaluation guide:

  1. Tool Inspection: List available tools and understand their capabilities

  2. Content Exploration: Use READ-ONLY operations to explore available data

  3. Question Generation: Create 10 complex, realistic questions

  4. Answer Verification: Solve each question yourself to verify answers

4.3 Evaluation Requirements

Ensure each question is:

  • Independent: Not dependent on other questions

  • Read-only: Only non-destructive operations required

  • Complex: Requiring multiple tool calls and deep exploration

  • Realistic: Based on real use cases humans would care about

  • Verifiable: Single, clear answer that can be verified by string comparison

  • Stable: Answer won't change over time

4.4 Output Format

Create an XML file with this structure:

<evaluation>
  <qa_pair>
    <question>Find discussions about AI model launches with animal codenames. One model needed a specific safety designation that uses the format ASL-X. What number X was being determined for the model named after a spotted wild cat?</question>
    <answer>3</answer>
  </qa_pair>
<!-- More qa_pairs... -->
</evaluation>

Reference Files

📚 Documentation Library

Load these resources as needed during development:

Core MCP Documentation (Load First)
  • MCP Protocol: Start with sitemap at https://modelcontextprotocol.io/sitemap.xml, then fetch specific pages with .md suffix

  • 📋 MCP Best Practices - Universal MCP guidelines including:

    • Server and tool naming conventions

    • Response format guidelines (JSON vs Markdown)

    • Pagination best practices

    • Transport selection (streamable HTTP vs stdio)

    • Security and error handling standards

SDK Documentation (Load During Phase 1/2)
  • Python SDK: Fetch from https://raw.githubusercontent.com/modelcontextprotocol/python-sdk/main/README.md

  • TypeScript SDK (v2): Fetch from https://raw.githubusercontent.com/modelcontextprotocol/typescript-sdk/main/README.md; full docs index at https://ts.sdk.modelcontextprotocol.io/v2/llms.txt

  • TypeScript SDK (v1, existing v1 projects only): Fetch from https://raw.githubusercontent.com/modelcontextprotocol/typescript-sdk/v1.x/README.md

Language-Specific Implementation Guides (Load During Phase 2)
  • 🐍 Python Implementation Guide - Complete Python/FastMCP guide with:

    • Server initialization patterns

    • Pydantic model examples

    • Tool registration with @mcp.tool

    • Complete working examples

    • Quality checklist

  • ⚡ TypeScript Implementation Guide (SDK v2) - Complete TypeScript guide for @modelcontextprotocol/server with:

    • Project setup and the server factory

    • Tool design: Zod 4 schemas, output formats, pagination, errors

    • A complete example serving stdio and Streamable HTTP, plus bearer auth

    • Resources, prompts, and notifications

    • A self-check script to verify the built server

    • Links to SDK examples for advanced features

    • A v1 → v2 translation table, migration steps, and a quality checklist

  • ⚡ TypeScript Implementation Guide (SDK v1) - The equivalent guide for existing @modelcontextprotocol/sdk projects

Evaluation Guide (Load During Phase 4)
  • ✅ Evaluation Guide - Complete evaluation creation guide with:

    • Question creation guidelines

    • Answer verification strategies

    • XML format specifications

    • Example questions and answers

    • Running an evaluation with the provided scripts

Files

reference/node_mcp_server_v2.md

Node/TypeScript MCP Server Implementation Guide (SDK v2)

This guide covers building MCP servers with v2 of the MCP TypeScript SDK (@modelcontextprotocol/server), the current stable release. If the project already depends on the v1 package @modelcontextprotocol/sdk, use the v1 guide or migrate first (see Migrating a v1 Server).

Code patterns are adapted from the MCP TypeScript SDK examples. The full v2 documentation index is https://ts.sdk.modelcontextprotocol.io/v2/llms.txt; every page is also served as markdown at its .md URL.


Quick Reference

Install
npm install @modelcontextprotocol/server zod
npm install @modelcontextprotocol/express @modelcontextprotocol/node express   # Streamable HTTP only
npm install -D @modelcontextprotocol/client typescript tsx @types/node @types/express
A Server in One Screen
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import * as z from "zod/v4";

function createServer(): McpServer {
  const server = new McpServer({ name: "example-mcp-server", version: "1.0.0" });

  server.registerTool(
    "example_greet",
    {
      title: "Greet",
      description: "Greet someone by name",
      inputSchema: z.object({ name: z.string().describe("Who to greet") }),
      annotations: { readOnlyHint: true }
    },
    async ({ name }) => ({ content: [{ type: "text", text: `Hello, ${name}!` }] })
  );

  return server;
}

serveStdio(createServer);
console.error("example-mcp-server running on stdio"); // stderr: stdout carries the protocol
v1 → v2 at a Glance

Most published examples and training data show v1 code. Translate it before use:

v1 (@modelcontextprotocol/sdk)v2
import ... from "@modelcontextprotocol/sdk/server/mcp.js"import ... from "@modelcontextprotocol/server"
import ... from "@modelcontextprotocol/sdk/server/stdio.js"import ... from "@modelcontextprotocol/server/stdio"
zod ^3, import { z } from "zod"zod ^4.2.0, import * as z from "zod/v4"
inputSchema: { q: z.string() } (raw shape)inputSchema: z.object({ q: z.string() })
server.tool(...), server.resource(...), server.prompt(...)Removed: use registerTool, registerResource, registerPrompt
new StdioServerTransport() + server.connect(transport)serveStdio(createServer) (StdioServerTransport from @modelcontextprotocol/server/stdio also still works)
Per-request StreamableHTTPServerTransport + express.json()createMcpHandler(createServer) + createMcpExpressApp() + toNodeHandler()
Handler extra.signal, extra.authInfoHandler ctx.mcpReq.signal, ctx.http?.authInfo
McpError, ErrorCodeProtocolError, ProtocolErrorCode
Node.js 18+Node.js 20+

Do not use imports from @modelcontextprotocol/sdk/..., raw-shape schemas, Zod 3 or zod/v3, hand-wired StreamableHTTPServerTransport instances, or a shared McpServer instance returned from the factory (one created at module scope and reused across HTTP requests) in a v2 server.


Project Setup

{service}-mcp-server/
├── package.json
├── tsconfig.json
├── README.md
├── src/
│   ├── index.ts          # createServer() factory and transport selection
│   ├── api.ts            # Upstream API client and error messages
│   ├── tools/            # One registerXxxTools(server) function per domain
│   └── constants.ts      # API_BASE_URL, CHARACTER_LIMIT
├── scripts/
│   └── smoke.ts          # Self-check client (see Verify Your Server)
└── dist/                 # Build output (entry point: dist/index.js)

Name the package and server {service}-mcp-server (lowercase, hyphens, no version numbers), e.g. github-mcp-server, jira-mcp-server.

package.json
{
  "name": "{service}-mcp-server",
  "version": "1.0.0",
  "description": "MCP server for the {Service} API",
  "type": "module",
  "bin": { "{service}-mcp-server": "dist/index.js" },
  "files": ["dist"],
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "tsx watch src/index.ts",
    "smoke": "tsx scripts/smoke.ts"
  },
  "engines": { "node": ">=20.3" },
  "dependencies": {
    "@modelcontextprotocol/express": "^2.0.2",
    "@modelcontextprotocol/node": "^2.1.1",
    "@modelcontextprotocol/server": "^2.3.1",
    "express": "^5.1.0",
    "zod": "^4.2.0"
  },
  "devDependencies": {
    "@modelcontextprotocol/client": "^2.3.1",
    "@types/express": "^5.0.0",
    "@types/node": "^24.0.0",
    "tsx": "^4.19.2",
    "typescript": "^5.9.3"
  }
}
  • bin plus the #!/usr/bin/env node line at the top of src/index.ts let hosts launch the server with npx -y {service}-mcp-server

  • A stdio-only server can drop express, @types/express, and the two adapter packages

  • Keep zod at ^4.2.0 or later: v2 does not support Zod 3, and Zod 4.0–4.1 schemas fail to type-check in registerTool. If the server runs anyway (tsx skips type-checking), the SDK logs a fallback warning and, before 4.1.13, drops .describe() text from the advertised schemas

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "sourceMap": true
  },
  "include": ["src/**/*"]
}

The Server Factory

Write a createServer() function that builds a fresh McpServer and registers every tool, resource, and prompt on it. You never call it yourself: the transport entry points do.

  • serveStdio(createServer) calls it once for the stdio connection

  • createMcpHandler(createServer) calls it once per HTTP request, so the HTTP server keeps no state between requests and scales horizontally as-is

  • Register everything inside the factory, never on a shared instance outside it

  • Return a new McpServer from every call. A factory that returns one shared instance passes manual tests and the self-check script, because they send one request at a time. A request that arrives while another is being served fails with -32603 "Internal server error", and the cause goes only to the handler's onerror option

  • Keep the factory cheap: create API clients, connection pools, caches, and configuration once at module scope and close over them

  • Behind createMcpHandler, the factory receives the request context: createMcpHandler(({ authInfo }) => ...) builds an instance for one authenticated caller

One file can serve both transports; the complete example picks one from the TRANSPORT environment variable.

Calling the Upstream API

Put the upstream API client at module scope, in one apiRequest helper that every tool calls. Node 20+ has fetch built in. The core of the complete example's helper:

const response = await fetch(url, {
  headers: { Authorization: `Bearer ${API_KEY}`, Accept: "application/json" },
  // Aborts when the client cancels the call, or after 30 seconds
  signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)])
});
if (!response.ok) throw new ApiError(response.status);
  • Send whatever the API requires (API key, User-Agent, version header) here, in one place

  • Tool handlers pass ctx.mcpReq.signal, so a cancelled call also cancels its upstream request

  • Map HTTP statuses and timeouts to messages that say how to recover (httpErrorMessage in the complete example): "Rate limit exceeded. Wait a minute before making more requests."

  • ApiError takes its message from httpErrorMessage and keeps the HTTP status, so a caller can treat a 404 differently from a 401 or a 429

  • Tool handlers can let these errors propagate: the SDK turns anything a tool handler throws into an isError: true result whose text is the error's message


Designing Tools

Names and Descriptions
  • Use snake_case, action-oriented names with a service prefix: slack_send_message, github_create_issue, asana_list_tasks

  • The description is what the model reads to decide when and how to call the tool. Say what it does, what it returns, when to use a sibling tool instead, and give one or two example requests. JSDoc comments are not extracted

  • Give every tool a title (display name), description, inputSchema (if it takes arguments), and annotations

Annotations

Annotations are hints for clients, for example to auto-approve read-only tools or to confirm destructive ones. They never change how the SDK runs the tool.

AnnotationSet to true when the tool...
readOnlyHintdoes not modify anything
destructiveHintmay delete or overwrite data
idempotentHintcan be repeated with the same arguments with no extra effect
openWorldHinttalks to an external system (most API tools)
Input Schemas

inputSchema is a Zod object schema: z.object({...}), or z.strictObject({...}) to reject unknown arguments. From that one schema the SDK derives the JSON Schema clients see, validates arguments before your handler runs, and infers the handler's argument types (no annotation needed).

const SearchInput = z.strictObject({
  query: z.string().min(2).max(200).describe("Text to match against user names and emails"),
  limit: z.number().int().min(1).max(100).default(20).describe("Maximum number of users to return"),
  offset: z.number().int().min(0).default(0).describe("Number of matches to skip, for pagination"),
  response_format: z.enum(ResponseFormat).default(ResponseFormat.MARKDOWN)
    .describe("'markdown' for readable text, 'json' for structured data")
});
  • .describe() text becomes the argument's JSON Schema description, the only documentation the model gets for it

  • Fields with .default() are optional for the caller and always present in the handler's arguments

  • Arguments that fail validation never reach the handler; the client gets an isError: true result such as Input validation error: Invalid arguments for tool example_search_users: query: Too small: expected string to have >=2 characters

  • Zod 4 replaced some v3 APIs: z.strictObject({...}) instead of .strict(), z.enum(MyTsEnum) instead of z.nativeEnum, and z.email() / z.url() / z.uuid() instead of z.string().email() and similar

Output: Markdown, JSON, and structuredContent

Let the caller choose the format with a response_format argument:

  • Markdown (default): readable text for the model. Headers and lists, human-readable dates, display names with IDs in parentheses, no verbose metadata

  • JSON: complete data for programmatic use, with consistent field names

When a result includes structuredContent, the spec says it SHOULD also include the same data serialized as JSON in a text block. So:

  • In JSON mode, return the data as structuredContent and its JSON.stringify as the text

  • In Markdown mode, return only the Markdown text

  • Declare outputSchema only on tools whose output is always JSON. With an outputSchema, every successful result must include matching structuredContent, or the SDK replaces it with an output validation error

  • The advertised outputSchema disallows extra fields, so build structuredContent from the schema, e.g. UserSchema.parse(raw), which also validates the upstream data

Pagination

List and search tools take limit and offset and return where the next page starts:

const nextOffset = offset + users.length;
const hasMore = data.total > nextOffset;
const page = {
  total: data.total,
  count: users.length,
  offset,
  users,
  has_more: hasMore,
  ...(hasMore ? { next_offset: nextOffset } : {})
};

In Markdown mode, end the text with how to get more, e.g. More results: call again with offset=20.

Character Limit

Cap responses with a CHARACTER_LIMIT (25,000 characters is a good default). Stay under it by returning fewer items, never by cutting text: next_offset then points at the first item left out, and the JSON text still matches structuredContent. Measure the text the tool sends, not an intermediate value: pretty-printing and the page fields count too.

let users = data.users;
let result = render(users);
while (users.length > 1 && result.text.length > CHARACTER_LIMIT) {
  users = users.slice(0, Math.ceil(users.length / 2));
  result = render(users);
}

render(users) builds the page and its text in the requested format; see the complete example.

Errors

MCP has two error channels:

  • Tool errors are tool results with isError: true. The model reads them, so the text should say how to recover. A tool handler produces them by returning isError: true, or by throwing: the thrown error's message becomes the result text

  • Protocol errors are JSON-RPC error responses handled by the client application; the model never sees them. Resource and prompt callbacks have no isError channel, so they throw ProtocolError (see Resources and Prompts)


Complete Example

One file, two tools, both transports. example_search_users shows pagination, the character limit, and the two output formats; example_get_user shows an outputSchema.

#!/usr/bin/env node
/**
 * MCP server for the Example API.
 * Serves stdio by default, Streamable HTTP when TRANSPORT=http.
 */
import { createMcpExpressApp } from "@modelcontextprotocol/express";
import { toNodeHandler } from "@modelcontextprotocol/node";
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import * as z from "zod/v4";

const API_BASE_URL = "https://api.example.com/v1";
const API_KEY = process.env.EXAMPLE_API_KEY;
const CHARACTER_LIMIT = 25_000;

if (!API_KEY) {
  console.error("EXAMPLE_API_KEY environment variable is required");
  process.exit(1);
}

// ---- Upstream API client: module scope, shared by every server instance ----

async function apiRequest<T>(
  path: string,
  params: Record<string, string | number>,
  signal: AbortSignal
): Promise<T> {
  const url = new URL(`${API_BASE_URL}/${path}`);
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, String(value));

  let response: Response;
  try {
    response = await fetch(url, {
      headers: { Authorization: `Bearer ${API_KEY}`, Accept: "application/json" },
      // Aborts when the client cancels the call, or after 30 seconds
      signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)])
    });
  } catch (error) {
    if (error instanceof DOMException && error.name === "TimeoutError") {
      throw new Error("The Example API did not respond within 30 seconds. Try again, or narrow the query.");
    }
    throw error;
  }
  if (!response.ok) throw new ApiError(response.status);
  return (await response.json()) as T;
}

// Thrown errors reach the model as the tool result's text, so say how to recover
function httpErrorMessage(status: number): string {
  switch (status) {
    case 401:
      return "The Example API rejected the API key. Check that EXAMPLE_API_KEY is valid.";
    case 403:
      return "Permission denied: this API key cannot access that resource.";
    case 404:
      return "Not found. Check the ID; example_search_users returns valid user IDs.";
    case 429:
      return "Rate limit exceeded. Wait a minute before making more requests.";
    default:
      return `The Example API returned HTTP ${status}.`;
  }
}

// Keeps the HTTP status so callers can tell a 404 from other failures
class ApiError extends Error {
  constructor(readonly status: number) {
    super(httpErrorMessage(status));
  }
}

// ---- Schemas and formatting ----

enum ResponseFormat {
  MARKDOWN = "markdown",
  JSON = "json"
}

const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string(),
  team: z.string().optional(),
  active: z.boolean()
});

type User = z.infer<typeof UserSchema>;

const UserPageSchema = z.object({
  users: z.array(UserSchema),
  total: z.number()
});

function formatUser(user: User): string {
  const lines = [`## ${user.name} (${user.id})`, `- Email: ${user.email}`];
  if (user.team) lines.push(`- Team: ${user.team}`);
  if (!user.active) lines.push("- Inactive");
  return lines.join("\n");
}

// ---- Server factory: serveStdio calls it per connection, createMcpHandler per HTTP request ----

function createServer(): McpServer {
  const server = new McpServer({ name: "example-mcp-server", version: "1.0.0" });

  server.registerTool(
    "example_search_users",
    {
      title: "Search Users",
      description: `Search Example users by name or email.

Returns one page of matches. When more matches exist, the result says so and gives next_offset; call again with that offset for the next page.

Examples:
  - "Find people on the marketing team" -> query="marketing"
  - "Look up [email protected]" -> query="[email protected]"
Use example_get_user instead when you already have a user ID.`,
      inputSchema: z.strictObject({
        query: z.string().min(2).max(200).describe("Text to match against user names and emails"),
        limit: z.number().int().min(1).max(100).default(20).describe("Maximum number of users to return"),
        offset: z.number().int().min(0).default(0).describe("Number of matches to skip, for pagination"),
        response_format: z.enum(ResponseFormat).default(ResponseFormat.MARKDOWN)
          .describe("'markdown' for readable text, 'json' for structured data")
      }),
      annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }
    },
    async ({ query, limit, offset, response_format }, ctx) => {
      const raw = await apiRequest<unknown>("users/search", { q: query, limit, offset }, ctx.mcpReq.signal);
      const data = UserPageSchema.parse(raw); // Validates the upstream data and drops fields outside the schema

      // Builds the page for these users and its text in the requested format
      const render = (users: User[]) => {
        const nextOffset = offset + users.length;
        const hasMore = data.total > nextOffset;
        const page = {
          total: data.total,
          count: users.length,
          offset,
          users,
          has_more: hasMore,
          ...(hasMore ? { next_offset: nextOffset } : {})
        };
        if (response_format === ResponseFormat.JSON) return { page, text: JSON.stringify(page, null, 2) };
        const lines = [`# Users matching '${query}'`, `Showing ${users.length} of ${data.total}.`, ""];
        lines.push(users.map(formatUser).join("\n\n"));
        if (hasMore) lines.push("", `More results: call again with offset=${nextOffset}.`);
        return { page, text: lines.join("\n") };
      };

      // Stay under CHARACTER_LIMIT by returning fewer users; next_offset then points at the first one left out
      let users = data.users;
      let result = render(users);
      while (users.length > 1 && result.text.length > CHARACTER_LIMIT) {
        users = users.slice(0, Math.ceil(users.length / 2));
        result = render(users);
      }

      if (response_format === ResponseFormat.JSON) {
        return { content: [{ type: "text", text: result.text }], structuredContent: result.page };
      }
      if (users.length === 0) {
        return { content: [{ type: "text", text: `No users match '${query}'. Try a shorter or different query.` }] };
      }
      return { content: [{ type: "text", text: result.text }] };
    }
  );

  server.registerTool(
    "example_get_user",
    {
      title: "Get User",
      description: "Get one Example user by ID. Use example_search_users to find IDs.",
      inputSchema: z.strictObject({
        user_id: z.string().min(1).describe("User ID, e.g. 'U123456789'")
      }),
      outputSchema: UserSchema,
      annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }
    },
    async ({ user_id }, ctx) => {
      const raw = await apiRequest<unknown>(`users/${encodeURIComponent(user_id)}`, {}, ctx.mcpReq.signal);
      const user = UserSchema.parse(raw); // Validates the upstream data and drops fields outside the schema
      return {
        content: [{ type: "text", text: JSON.stringify(user, null, 2) }],
        structuredContent: user
      };
    }
  );

  return server;
}

// ---- Transport selection ----

if (process.env.TRANSPORT === "http") {
  const handler = createMcpHandler(createServer);
  const app = createMcpExpressApp(); // express.json() plus Host/Origin checks for localhost
  const nodeHandler = toNodeHandler(handler);
  app.all("/mcp", (req, res) => void nodeHandler(req, res, req.body));

  const port = Number(process.env.PORT ?? 3000);
  app.listen(port, "127.0.0.1", () => {
    console.error(`example-mcp-server listening on http://127.0.0.1:${port}/mcp`);
  });
} else {
  serveStdio(createServer);
  console.error("example-mcp-server running on stdio");
}

Serving

stdio (Local Servers)

serveStdio(createServer) reads requests on stdin and writes responses on stdout. Never write to stdout yourself: one console.log corrupts the JSON-RPC stream. Log with console.error.

Streamable HTTP (Remote Servers)

createMcpHandler(createServer) returns a web-standard handler; toNodeHandler adapts it to Express:

const handler = createMcpHandler(createServer);
const app = createMcpExpressApp();
const nodeHandler = toNodeHandler(handler);
app.all("/mcp", (req, res) => void nodeHandler(req, res, req.body));
app.listen(3000, "127.0.0.1");
  • It serves clients on the 2026-07-28 protocol revision and older 2025-era clients from the same factory, with no sessions for either

  • createMcpExpressApp() installs express.json() and validates Host and Origin headers on localhost binds, which blocks DNS rebinding attacks. When binding all interfaces, name the hosts you serve: createMcpExpressApp({ host: "0.0.0.0", allowedHosts: ["mcp.example.com"] })

  • Hono and Fastify adapters are @modelcontextprotocol/hono and @modelcontextprotocol/fastify

  • On web-standard runtimes (Cloudflare Workers, Deno, Bun), export default handler is the whole mount; put Host/Origin checks in front as described in the web-standard docs

Authentication

The handler verifies no tokens. For a server exposed beyond localhost, put requireBearerAuth in front of the route:

import { createMcpExpressApp, requireBearerAuth, type OAuthTokenVerifier } from "@modelcontextprotocol/express";
import { OAuthError, OAuthErrorCode, type AuthInfo } from "@modelcontextprotocol/server";

const serverUrl = new URL("https://mcp.example.com/mcp");

const verifier: OAuthTokenVerifier = {
  async verifyAccessToken(token): Promise<AuthInfo> {
    // Replace with JWT verification or token introspection against your authorization server
    const claims = await verifyWithYourAuthServer(token);
    if (!claims) throw new OAuthError(OAuthErrorCode.InvalidToken, "Unknown or expired token");
    return {
      token,
      clientId: claims.clientId,
      scopes: claims.scopes,
      expiresAt: claims.exp, // Required: tokens without an expiry are rejected
      resource: new URL(claims.aud) // Compared with expectedResource below
    };
  }
};

const app = createMcpExpressApp({ host: "0.0.0.0", allowedHosts: ["mcp.example.com"] });
const requireAuth = requireBearerAuth({ verifier, requiredScopes: ["mcp"], expectedResource: serverUrl });
app.all("/mcp", requireAuth, (req, res) => void nodeHandler(req, res, req.body));
  • expectedResource accepts only tokens issued for this server, so a token meant for another service is refused

  • The verified AuthInfo reaches the factory as createMcpHandler(({ authInfo }) => ...) and tool handlers as ctx.http?.authInfo (undefined on stdio)

  • Serving OAuth discovery metadata and per-tool scopes are covered by the oauth and scoped-tools examples and the authorization docs

Transport selection: stdio for local tools a host launches as a subprocess; Streamable HTTP for remote services and multiple clients.


Resources and Prompts

Resources expose read-only data at a URI that the client application reads and attaches as context. Prompts are message templates a user invokes by name. Tools are for anything the model should call, including operations with side effects.

import { completable, ProtocolError, ProtocolErrorCode, ResourceNotFoundError, ResourceTemplate } from "@modelcontextprotocol/server";

// A resource at a fixed URI
server.registerResource(
  "example-teams",
  "example://teams",
  { title: "Teams", description: "Every team in the Example workspace", mimeType: "application/json" },
  async (uri, ctx) => {
    const teams = await apiRequest<unknown>("teams", {}, ctx.mcpReq.signal);
    return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(teams) }] };
  }
);

// A URI template: the matched variables arrive as the second argument
server.registerResource(
  "example-user",
  new ResourceTemplate("example://users/{userId}", { list: undefined }),
  { title: "User profile", description: "One Example user by ID", mimeType: "application/json" },
  async (uri, { userId }, ctx) => {
    if (!/^U\d+$/.test(String(userId))) {
      throw new ProtocolError(ProtocolErrorCode.InvalidParams, `User IDs look like U123, got "${userId}"`);
    }
    const user = await apiRequest<unknown>(`users/${userId}`, {}, ctx.mcpReq.signal).catch((error: unknown) => {
      // Only a 404 means the resource is missing; a 401, 429, or timeout keeps its own error
      if (error instanceof ApiError && error.status === 404) throw new ResourceNotFoundError(uri.href);
      throw error;
    });
    return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(user) }] };
  }
);

// A prompt; completable() lets clients autocomplete the argument as the user types
const TEAMS = ["engineering", "marketing", "sales"];

server.registerPrompt(
  "example_team_report",
  {
    title: "Team report",
    description: "Summarize who is on a team",
    argsSchema: z.object({
      team: completable(z.string().describe("Team name"), value => TEAMS.filter(team => team.startsWith(value)))
    })
  },
  async ({ team }) => ({
    messages: [
      {
        role: "user",
        content: { type: "text", text: `Use example_search_users to find everyone on the ${team} team, then summarize the team in a short table.` }
      }
    ]
  })
);
  • Give a template a list callback that returns { resources: [...] } to make its instances appear in resources/list; { list: undefined } leaves them readable but unlisted

  • If a template variable becomes a filesystem path, resolve it with realpath and reject anything outside your root before reading

Notifications

The handles returned by registerTool, registerResource, and registerPrompt send the matching list_changed notification when you change them:

const exportTool = server.registerTool(
  "example_export_data",
  { description: "Export workspace data (export-enabled plans only)" },
  async () => ({ content: [{ type: "text", text: "Export started" }] })
);
exportTool.disable(); // Sends notifications/tools/list_changed
exportTool.enable(); // So do enable(), update(), and remove()

Behind createMcpHandler each request has its own server instance, so publish changes through the handler instead: handler.notify.toolsChanged(), handler.notify.resourcesChanged(). Send notifications only when the server's capabilities actually change.


Verify Your Server

Write a self-check script that starts the built server, calls its tools as a client would, and exits non-zero on any failure. Run it after every change, along with npm run build; adapt the tool names and assertions to your server.

/**
 * Self-check: starts the built server over stdio, calls its tools, and exits non-zero on any failure.
 * Run with: npm run build && npm run smoke
 */
import { strict as assert } from "node:assert";
import { Client } from "@modelcontextprotocol/client";
import { getDefaultEnvironment, StdioClientTransport } from "@modelcontextprotocol/client/stdio";

const client = new Client({ name: "smoke-test", version: "1.0.0" });
await client.connect(
  new StdioClientTransport({
    command: "node",
    args: ["dist/index.js"],
    // The child gets only a safe default environment; pass the server's own variables explicitly
    env: { ...getDefaultEnvironment(), EXAMPLE_API_KEY: process.env.EXAMPLE_API_KEY ?? "" }
  })
);

const { tools } = await client.listTools();
assert.deepEqual(tools.map(tool => tool.name).sort(), ["example_get_user", "example_search_users"]);

const search = await client.callTool({
  name: "example_search_users",
  arguments: { query: "john", limit: 5, response_format: "json" }
});
assert.ok(!search.isError, `search failed: ${JSON.stringify(search.content)}`);
const page = search.structuredContent as { users: { id: string }[]; total: number };
assert.ok(Array.isArray(page.users), "search should return a users array");

if (page.users.length > 0) {
  const user = await client.callTool({ name: "example_get_user", arguments: { user_id: page.users[0].id } });
  assert.ok(!user.isError, `get_user failed: ${JSON.stringify(user.content)}`);
}

// Invalid arguments must come back as a tool error the model can read
const invalid = await client.callTool({ name: "example_search_users", arguments: { query: "j" } });
assert.equal(invalid.isError, true, "a one-character query should be rejected");

await client.close();
console.error("Smoke test passed");
  • Call read-only tools with realistic arguments; check that one invalid input is rejected

  • StdioClientTransport gives the child process only a safe default environment (PATH, HOME, and similar), so pass API keys and other settings in env

  • For an HTTP server, start it (TRANSPORT=http npm start) and connect with new StreamableHTTPClientTransport(new URL("http://127.0.0.1:3000/mcp")) from @modelcontextprotocol/client instead

  • For manual exploration, npx @modelcontextprotocol/inspector node dist/index.js opens an interactive UI

  • More test patterns, including in-process tests without a subprocess: https://ts.sdk.modelcontextprotocol.io/v2/testing.md


More Features

Each linked example is a runnable server and client pair from the SDK repository:

To...See
Report progress, send log messages, and handle cancellation in long-running toolsstreaming
Ask the user for input mid-call (elicitation)elicitation
Notify subscribed clients when resources changesubscriptions
Add OAuth login (authorization code flow)oauth
Accept machine-to-machine OAuth (client credentials)oauth-client-credentials
Require different scopes for different toolsscoped-tools
Serve on Hono or another web-standard runtimehono, bearer-auth-web
Run sessionful 2025-era HTTP deploymentsstandalone-get, sse-polling
See every server feature in one reference servertodos-server

Migrating a v1 Server

To move an existing @modelcontextprotocol/sdk server to v2, run the official codemod at the package root, then fix what it marks:

npx @modelcontextprotocol/codemod@latest v1-to-v2 .
grep -rn '@mcp-codemod-error' .   # Spots the codemod could not rewrite safely
npx tsc --noEmit

The codemod rewrites imports, package.json, registration calls, and handler context access. Check that zod ends up at ^4.2.0 or later, then run your formatter and tests. The full guide is at https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2.md.


Quality Checklist

Tool Design
  • [ ] Tools enable complete workflows, not just API endpoint wrappers

  • [ ] Tool names are snake_case with a service prefix

  • [ ] Every tool has a title, a description that says when to use it, and correct annotations

  • [ ] Inputs are Zod object schemas with constraints and .describe() text; z.strictObject where unknown arguments should be rejected

  • [ ] List and search tools paginate with limit / offset and return next_offset

  • [ ] Responses stay under CHARACTER_LIMIT by returning fewer items

  • [ ] Results with structuredContent also include that data serialized as JSON text; outputSchema only on always-JSON tools

  • [ ] Error messages say what went wrong and how to recover

Implementation
  • [ ] createServer() returns a new McpServer on every call, with everything registered inside it; shared clients live at module scope

  • [ ] Upstream requests pass ctx.mcpReq.signal and have a timeout

  • [ ] Strict TypeScript; no any; upstream data validated where it feeds structuredContent

  • [ ] Nothing writes to stdout in a stdio server

  • [ ] HTTP servers keep Host/Origin validation, and add bearer auth when exposed beyond localhost

Project
  • [ ] package.json depends on @modelcontextprotocol/server (not @modelcontextprotocol/sdk) and zod ^4.2.0 or later

  • [ ] No imports from @modelcontextprotocol/sdk/... or zod/v3

  • [ ] bin points at dist/index.js, and src/index.ts starts with #!/usr/bin/env node

Verification
  • [ ] npm run build completes without errors

  • [ ] The self-check script passes against the built server

Requirements and compatibility

Written for: Claude Code, Claude apps

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Needs: Names Claude Code tools, Needs an MCP server, Needs network access, Needs Python, Needs Node.js

Security

What our scanner noticed· Low risk

  • Instructions that use the network
  • Scripts that your agent may run

This skill includes scripts that your agent may run. Read them before you use it.

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Published by
Anthropic
Publisher
Anthropic (Official)
Source
anthropics/skills · skills/mcp-builder · commit 2c5958e
Licence
Apache License 2.0 (redistribution allowed)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by Anthropic or Anthropic.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download mcp-builder.zip