Index of every page on this site: llms.txt
All skills
anthropics avatar

/mcp-builder

@ca45fc4 official
by Anthropicanthropics/skills187k stars
22,089
Run it yourself

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).

Use this Skill: https://skilld.dev/gh/anthropics/skills/mcp-builder

Nothing lands on disk. Nothing to clean up.

Fork this Skill

Edit a local copy. It keeps the author and licence.

referencenode_mcp_server_v2.md

≈8.8k tokens on demand. Your agent reads this file only when SKILL.md points to it.

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.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"
  }
}
  • 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.

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 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 john@acme.com" -> query="john@acme.com"
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 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 --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

Source: SKILL.md on GitHub

skilld matched fixed text patterns in SKILL.md and file names. Patterns miss obfuscated code.

skilld run checks every file with the same patterns. It asks for approval before it loads a Skill with a behavior marked Needs approval.

1 warning11d5 checks · Risk SAFE
  • Gen Agent Trust Hub11d

    This skill serves as a development guide and testing toolkit for creating Model Context Protocol (MCP) servers. It includes a Python-based evaluation harness that processes user-provided test cases, which introduces a potential consideration for indirect prompt injection. The skill utilizes standard developer tools and follows security best practices for secret management and input validation.

  • Socket11d

    No alerts

  • Snyk11d

    Risk: MEDIUM · 1 issue

  • Runlayer8mo

    1/10 files flagged

  • ZeroLeaks6mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at ca45fc4. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 weeks ago.

Activeupdated 2 weeks ago

README badge

README badge for anthropics/skills/mcp-builder

Provides a structured process for building Model Context Protocol (MCP) servers that expose external APIs and services to LLMs, covering design decisions, implementation patterns in TypeScript and Python, testing with MCP Inspector, and evaluation methodology. Targets developers integrating services like GitHub, Slack, or custom APIs into Claude or other AI agents through the MCP specification.

Generated from the current SKILL.md.

Should I use TypeScript or Python for my MCP server?
TypeScript is recommended. The skill cites high-quality SDK support, good compatibility in execution environments, and strong AI code generation for TypeScript. Python is supported via FastMCP if you prefer it.
What transport mechanism should I use?
Use streamable HTTP for remote servers (stateless JSON, easier to scale) or stdio for local servers. The skill recommends HTTP for most cases due to simpler maintenance and scaling.
How do I decide between comprehensive API coverage and workflow tools?
Prioritize comprehensive API endpoint coverage, which gives agents flexibility to compose operations. Workflow tools can be convenient for specific tasks, but some clients execute code to combine basic tools efficiently.
What should error messages include?
Error messages must be actionable and guide agents toward solutions with specific suggestions and next steps, not just describe what went wrong.
Do I need to create evaluations for my MCP server?
Yes. The skill requires creating 10 complex, realistic evaluation questions to test whether LLMs can effectively use your server. The evaluation guide provides question creation and answer verification strategies.

Generated from the current SKILL.md. These answers refresh after source changes.