# 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](./node_mcp_server.md) or migrate first (see [Migrating a v1 Server](#migrating-a-v1-server)).

Code patterns are adapted from the [MCP TypeScript SDK examples](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/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

```bash
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

```typescript
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

```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

```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](#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](#complete-example)'s helper:

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

```typescript
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:

```typescript
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.

```typescript
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](#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](#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`.

```typescript
#!/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:

```typescript
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](https://ts.sdk.modelcontextprotocol.io/v2/serving/web-standard.md)

### Authentication

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

```typescript
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](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/oauth) and [scoped-tools](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/scoped-tools) examples and the [authorization docs](https://ts.sdk.modelcontextprotocol.io/v2/serving/authorization.md)

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

```typescript
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:

```typescript
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.

```typescript
/**
 * 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](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/streaming) |
| Ask the user for input mid-call (elicitation) | [elicitation](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/elicitation) |
| Notify subscribed clients when resources change | [subscriptions](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/subscriptions) |
| Add OAuth login (authorization code flow) | [oauth](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/oauth) |
| Accept machine-to-machine OAuth (client credentials) | [oauth-client-credentials](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/oauth-client-credentials) |
| Require different scopes for different tools | [scoped-tools](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/scoped-tools) |
| Serve on Hono or another web-standard runtime | [hono](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/hono), [bearer-auth-web](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/bearer-auth-web) |
| Run sessionful 2025-era HTTP deployments | [standalone-get](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/standalone-get), [sse-polling](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/sse-polling) |
| See every server feature in one reference server | [todos-server](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/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:

```bash
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
