MCP Builder
AnthropicOfficialPublished by Anthropic
- Skill
- Tool integration
- Software development
- Apache-2.0
- Low risk
- Has scripts
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
Download the skill as a ZIP:
https://autogineer.com/agent-skills/anthropics/mcp-builder/downloadUnzip 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:
MCP Best Practices: 📋 View Best Practices - Core guidelines
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'spackage.jsonalready depends on@modelcontextprotocol/sdk, or migrate that project withnpx @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 ishttps://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.mdand 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:
⚡ TypeScript Guide (v2) - Project structure, package.json, tsconfig.json (v1 for existing v1 projects)
🐍 Python Guide - Module organization, dependencies
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
outputSchemawhere possible for structured dataUse
structuredContentin 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/falsedestructiveHint: true/falseidempotentHint: true/falseopenWorldHint: 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 buildto verify compilationv2 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.pyTest 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:
Tool Inspection: List available tools and understand their capabilities
Content Exploration: Use READ-ONLY operations to explore available data
Question Generation: Create 10 complex, realistic questions
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.mdsuffix📋 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.mdTypeScript SDK (v2): Fetch from
https://raw.githubusercontent.com/modelcontextprotocol/typescript-sdk/main/README.md; full docs index athttps://ts.sdk.modelcontextprotocol.io/v2/llms.txtTypeScript 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.toolComplete working examples
Quality checklist
⚡ TypeScript Implementation Guide (SDK v2) - Complete TypeScript guide for
@modelcontextprotocol/serverwith: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/sdkprojects
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
- ATTRIBUTION.mdAdded by AutoGineer865 B
- LICENSE.txt11.1 KiB
- SKILL.md10.9 KiB
- reference/evaluation.md21.2 KiB
- reference/mcp_best_practices.md7.2 KiB
- reference/node_mcp_server.md28.1 KiB
- reference/node_mcp_server_v2.md34.3 KiB
- reference/python_mcp_server.md24.5 KiB
- scripts/connections.pyScript4.8 KiB
- scripts/evaluation.pyScript12.3 KiB
- scripts/example_evaluation.xml1.2 KiB
- scripts/requirements.txt29 B
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/expressA 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 protocolv1 → 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.authInfo | Handler ctx.mcpReq.signal, ctx.http?.authInfo |
McpError, ErrorCode | ProtocolError, 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"
}
}binplus the#!/usr/bin/env nodeline at the top ofsrc/index.tslet hosts launch the server withnpx -y {service}-mcp-serverA stdio-only server can drop
express,@types/express, and the two adapter packagesKeep
zodat^4.2.0or later: v2 does not support Zod 3, and Zod 4.0–4.1 schemas fail to type-check inregisterTool. If the server runs anyway (tsxskips 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 connectioncreateMcpHandler(createServer)calls it once per HTTP request, so the HTTP server keeps no state between requests and scales horizontally as-isRegister everything inside the factory, never on a shared instance outside it
Return a new
McpServerfrom 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'sonerroroptionKeep 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 placeTool handlers pass
ctx.mcpReq.signal, so a cancelled call also cancels its upstream requestMap HTTP statuses and timeouts to messages that say how to recover (
httpErrorMessagein the complete example): "Rate limit exceeded. Wait a minute before making more requests."ApiErrortakes its message fromhttpErrorMessageand keeps the HTTP status, so a caller can treat a 404 differently from a 401 or a 429Tool handlers can let these errors propagate: the SDK turns anything a tool handler throws into an
isError: trueresult whose text is the error'smessage
Designing Tools
Names and Descriptions
Use snake_case, action-oriented names with a service prefix:
slack_send_message,github_create_issue,asana_list_tasksThe
descriptionis 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 extractedGive every tool a
title(display name),description,inputSchema(if it takes arguments), andannotations
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.
| Annotation | Set to true when the tool... |
|---|---|
readOnlyHint | does not modify anything |
destructiveHint | may delete or overwrite data |
idempotentHint | can be repeated with the same arguments with no extra effect |
openWorldHint | talks 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 Schemadescription, the only documentation the model gets for itFields with
.default()are optional for the caller and always present in the handler's argumentsArguments that fail validation never reach the handler; the client gets an
isError: trueresult such asInput validation error: Invalid arguments for tool example_search_users: query: Too small: expected string to have >=2 charactersZod 4 replaced some v3 APIs:
z.strictObject({...})instead of.strict(),z.enum(MyTsEnum)instead ofz.nativeEnum, andz.email()/z.url()/z.uuid()instead ofz.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
structuredContentand itsJSON.stringifyas the textIn Markdown mode, return only the Markdown text
Declare
outputSchemaonly on tools whose output is always JSON. With anoutputSchema, every successful result must include matchingstructuredContent, or the SDK replaces it with an output validation errorThe advertised
outputSchemadisallows extra fields, so buildstructuredContentfrom 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 returningisError: true, or by throwing: the thrown error'smessagebecomes the result textProtocol errors are JSON-RPC error responses handled by the client application; the model never sees them. Resource and prompt callbacks have no
isErrorchannel, so they throwProtocolError(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()installsexpress.json()and validatesHostandOriginheaders 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/honoand@modelcontextprotocol/fastifyOn web-standard runtimes (Cloudflare Workers, Deno, Bun),
export default handleris 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));expectedResourceaccepts only tokens issued for this server, so a token meant for another service is refusedThe verified
AuthInforeaches the factory ascreateMcpHandler(({ authInfo }) => ...)and tool handlers asctx.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
listcallback that returns{ resources: [...] }to make its instances appear inresources/list;{ list: undefined }leaves them readable but unlistedIf a template variable becomes a filesystem path, resolve it with
realpathand 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
StdioClientTransportgives the child process only a safe default environment (PATH,HOME, and similar), so pass API keys and other settings inenvFor an HTTP server, start it (
TRANSPORT=http npm start) and connect withnew StreamableHTTPClientTransport(new URL("http://127.0.0.1:3000/mcp"))from@modelcontextprotocol/clientinsteadFor manual exploration,
npx @modelcontextprotocol/inspector node dist/index.jsopens an interactive UIMore 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 tools | streaming |
| Ask the user for input mid-call (elicitation) | elicitation |
| Notify subscribed clients when resources change | subscriptions |
| Add OAuth login (authorization code flow) | oauth |
| Accept machine-to-machine OAuth (client credentials) | oauth-client-credentials |
| Require different scopes for different tools | scoped-tools |
| Serve on Hono or another web-standard runtime | hono, bearer-auth-web |
| Run sessionful 2025-era HTTP deployments | standalone-get, sse-polling |
| See every server feature in one reference server | todos-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 --noEmitThe 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, adescriptionthat says when to use it, and correctannotations[ ] Inputs are Zod object schemas with constraints and
.describe()text;z.strictObjectwhere unknown arguments should be rejected[ ] List and search tools paginate with
limit/offsetand returnnext_offset[ ] Responses stay under
CHARACTER_LIMITby returning fewer items[ ] Results with
structuredContentalso include that data serialized as JSON text;outputSchemaonly on always-JSON tools[ ] Error messages say what went wrong and how to recover
Implementation
[ ]
createServer()returns a newMcpServeron every call, with everything registered inside it; shared clients live at module scope[ ] Upstream requests pass
ctx.mcpReq.signaland have a timeout[ ] Strict TypeScript; no
any; upstream data validated where it feedsstructuredContent[ ] Nothing writes to stdout in a stdio server
[ ] HTTP servers keep Host/Origin validation, and add bearer auth when exposed beyond localhost
Project
[ ]
package.jsondepends on@modelcontextprotocol/server(not@modelcontextprotocol/sdk) andzod^4.2.0or later[ ] No imports from
@modelcontextprotocol/sdk/...orzod/v3[ ]
binpoints atdist/index.js, andsrc/index.tsstarts with#!/usr/bin/env node
Verification
[ ]
npm run buildcompletes 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