# How to Build MCP Servers with the MCP SDK for TypeScript: A Complete Developer Guide

> Build MCP servers with the MCP SDK for TypeScript. Expose external services to LLMs by creating an McpServer instance, registering tools, and connecting to a transport layer for CLI integration. A complete developer guide.

- Repository: [Composio/awesome-claude-skills](https://github.com/composiohq/awesome-claude-skills)
- Tags: how-to-guide
- Published: 2026-07-24

---

**The MCP TypeScript SDK enables developers to expose external services to LLMs by initializing an `McpServer` instance, registering Zod-validated tools with metadata annotations, and connecting to a transport layer such as `StdioServerTransport` for CLI integration.**

The ComposioHQ/awesome-claude-skills repository provides comprehensive reference implementations for building Model Context Protocol (MCP) servers using the official TypeScript SDK. This guide explains how to build MCP servers with the MCP SDK for TypeScript, covering server initialization, tool registration with runtime validation, and transport configuration based on production-ready patterns from [`mcp-builder/reference/node_mcp_server.md`](https://github.com/ComposioHQ/awesome-claude-skills/blob/main/mcp-builder/reference/node_mcp_server.md).

## Server Architecture and Initialization

### Creating the McpServer Instance

Every MCP server begins with a named instance of the `McpServer` class imported from `@modelcontextprotocol/sdk/server/mcp.js`. According to the reference implementation in [`mcp-builder/reference/node_mcp_server.md`](https://github.com/ComposioHQ/awesome-claude-skills/blob/main/mcp-builder/reference/node_mcp_server.md), server names should follow the pattern `{service}-mcp-server` (e.g., `github-mcp-server`) to ensure consistency across the ecosystem.

```typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

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

```

### Configuring the Transport Layer

The server requires a transport to communicate with MCP clients. The `StdioServerTransport` is the default choice for CLI-based tools, handling JSON-RPC messages over standard input/output. Alternative transports include Server-Sent Events (SSE) or HTTP for web-based deployments.

```typescript
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("MCP server started (stdio)");
}

```

## Defining Tools with Zod Validation

### Tool Registration Pattern

Tools expose external functionality to LLMs through the `server.registerTool(name, config, handler)` method. The configuration object must include four required fields: `title`, `description`, `inputSchema`, and `annotations`. As implemented in [`mcp-builder/reference/node_mcp_server.md`](https://github.com/ComposioHQ/awesome-claude-skills/blob/main/mcp-builder/reference/node_mcp_server.md), the `inputSchema` uses Zod for runtime type safety.

```typescript
import { z } from "zod";

const UserSearchInputSchema = z.object({
  query: z.string().min(2).max(200).describe("Search term"),
  limit: z.number().int().min(1).max(100).default(20),
  offset: z.number().int().min(0).default(0),
}).strict();

server.registerTool(
  "example_search_users",
  {
    title: "Search Example Users",
    description: "Find users by name or email with pagination support.",
    inputSchema: UserSearchInputSchema,
    annotations: { 
      readOnlyHint: true, 
      destructiveHint: false, 
      idempotentHint: true, 
      openWorldHint: true 
    },
  },
  async (params) => {
    // Handler implementation
  }
);

```

### Input Schema Design with Strict Validation

The `.strict()` method on Zod schemas forbids extra fields, ensuring that LLM-provided arguments exactly match the expected interface. This prevents hallucinated parameters from reaching your API handlers. Each field should include `.describe()` calls to provide context for the LLM determining which tool to invoke.

## Error Handling and Response Management

### Centralized Error Handling

Production MCP servers implement centralized error handling to map API failures to clear, actionable messages. The reference pattern extracts this logic into a reusable `handleApiError` function that intercepts HTTP status codes (404, 403, 429) and returns formatted error strings.

```typescript
import { AxiosError } from "axios";

function handleApiError(err: unknown): string {
  if (err instanceof AxiosError && err.response) {
    switch (err.response.status) {
      case 404: return "Error: Resource not found.";
      case 403: return "Error: Permission denied.";
      case 429: return "Error: Rate limit exceeded.";
      default:  return `Error: API request failed (${err.response.status}).`;
    }
  }
  return `Error: ${err instanceof Error ? err.message : String(err)}`;
}

```

### Response Formatting and Pagination

MCP servers should support both human-readable Markdown and machine-readable JSON outputs via a `ResponseFormat` enum. To prevent context window overflow, enforce a `CHARACTER_LIMIT` constant (typically 25,000 characters) on responses. For large datasets, expose standard pagination fields including `limit`, `offset`, `has_more`, and `next_offset`.

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

const CHARACTER_LIMIT = 25_000;

// In handler: conditional formatting based on params.response_format
const result = params.response_format === ResponseFormat.MARKDOWN
  ? `# Results\n\n${data.items.map(item => `- ${item.name}`).join("\n")}`

  : JSON.stringify({
      total: data.total,
      offset: params.offset,
      has_more: data.total > params.offset + data.items.length,
      next_offset: params.offset + data.items.length,
      items: data.items,
    }, null, 2);

```

## Complete Implementation Example

The following production-ready example demonstrates the complete architecture: shared API helpers, Zod validation, error handling, and response formatting as defined in [`mcp-builder/reference/node_mcp_server.md`](https://github.com/ComposioHQ/awesome-claude-skills/blob/main/mcp-builder/reference/node_mcp_server.md).

```typescript
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import axios, { AxiosError } from "axios";

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

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

const UserSearchInputSchema = z.object({
  query: z.string().min(2).max(200).describe("Search term"),
  limit: z.number().int().min(1).max(100).default(20),
  offset: z.number().int().min(0).default(0),
  response_format: z.nativeEnum(ResponseFormat).default(ResponseFormat.MARKDOWN),
}).strict();

type UserSearchInput = z.infer<typeof UserSearchInputSchema>;

async function makeApiRequest<T>(endpoint: string, method = "GET", data?: any, params?: any): Promise<T> {
  const resp = await axios({ 
    method, 
    url: `${API_BASE_URL}/${endpoint}`, 
    data, 
    params,
    headers: { Authorization: `Bearer ${process.env.EXAMPLE_API_KEY}` }
  });
  return resp.data;
}

function handleApiError(err: unknown): string {
  if (err instanceof AxiosError && err.response) {
    switch (err.response.status) {
      case 404: return "Error: Resource not found.";
      case 403: return "Error: Permission denied.";
      case 429: return "Error: Rate limit exceeded.";
      default:  return `Error: API request failed (${err.response.status}).`;
    }
  }
  return `Error: ${err instanceof Error ? err.message : String(err)}`;
}

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

server.registerTool(
  "example_search_users",
  {
    title: "Search Example Users",
    description: "Find users by name or email with pagination and selectable output format.",
    inputSchema: UserSearchInputSchema,
    annotations: { 
      readOnlyHint: true, 
      destructiveHint: false, 
      idempotentHint: true, 
      openWorldHint: true 
    },
  },
  async (params: UserSearchInput) => {
    try {
      const data = await makeApiRequest<any>("users/search", "GET", undefined, {
        q: params.query,
        limit: params.limit,
        offset: params.offset,
      });

      if (!data.users?.length) {
        return { content: [{ type: "text", text: `No users found for '${params.query}'` }] };
      }

      const result = params.response_format === ResponseFormat.MARKDOWN
        ? `# Users matching '${params.query}'\n\n${data.users.map((u: any) => `- ${u.name} (${u.email})`).join("\n")}`

        : JSON.stringify({
            total: data.total,
            count: data.users.length,
            offset: params.offset,
            users: data.users,
            ...(data.total > params.offset + data.users.length && {
              has_more: true,
              next_offset: params.offset + data.users.length,
            }),
          }, null, 2);

      return { content: [{ type: "text", text: result }] };
    } catch (e) {
      return { content: [{ type: "text", text: handleApiError(e) }] };
    }
  },
);

async function main() {
  if (!process.env.EXAMPLE_API_KEY) {
    console.error("ERROR: EXAMPLE_API_KEY env var missing");
    process.exit(1);
  }
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("Example MCP server started (stdio)");
}

main().catch(err => { 
  console.error("Fatal:", err); 
  process.exit(1); 
});

```

## Project Configuration

### Package Dependencies

MCP servers require Node.js 18 or higher. The essential dependencies include the official SDK, Zod for validation, and axios for HTTP requests.

```json
{
  "name": "example-mcp-server",
  "version": "1.0.0",
  "type": "module",
  "main": "dist/index.js",
  "scripts": {
    "start": "node dist/index.js",
    "dev": "tsx watch src/index.ts",
    "build": "tsc"
  },
  "engines": { "node": ">=18" },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.6.1",
    "axios": "^1.7.9",
    "zod": "^3.23.8"
  },
  "devDependencies": {
    "@types/node": "^22.10.0",
    "tsx": "^4.19.2",
    "typescript": "^5.7.2"
  }
}

```

### TypeScript Compiler Settings

Configure [`tsconfig.json`](https://github.com/ComposioHQ/awesome-claude-skills/blob/main/tsconfig.json) with strict mode enabled and Node16 module resolution to ensure compatibility with the SDK's ESM structure.

```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/**/*"],
  "exclude": ["node_modules", "dist"]
}

```

## Summary

Building MCP servers with the TypeScript SDK requires understanding these core concepts:

- **Server initialization**: Create an `McpServer` instance with a `{service}-mcp-server` naming pattern and semantic versioning
- **Transport configuration**: Use `StdioServerTransport` for CLI tools, or SSE/HTTP for web-based deployments
- **Tool registration**: Define tools using `server.registerTool()` with Zod schemas (`.strict()` recommended) and metadata annotations
- **Runtime validation**: Zod enforces input constraints automatically, rejecting unexpected fields before they reach handlers
- **Error handling**: Centralize API error mapping in helper functions to return consistent, actionable error messages
- **Response management**: Support dual formats (Markdown/JSON) via enums and guard against oversized payloads with `CHARACTER_LIMIT` constants
- **Pagination**: Include `has_more` and `next_offset` fields in JSON responses to enable efficient data traversal
- **Resource exposure**: Use `registerResource` for static, URI-addressable data instead of dynamic tool calls when appropriate

## Frequently Asked Questions

### What transport options does the MCP TypeScript SDK support?

The SDK supports **stdio** (standard input/output), **SSE** (Server-Sent Events), and **HTTP** transports. The `StdioServerTransport` is the default for CLI-based tools and local integrations, while SSE and HTTP are suitable for remote deployments or web-based MCP clients.

### How does input validation work in MCP servers?

Input validation relies on **Zod schemas** passed to the `inputSchema` property during tool registration. The SDK validates LLM-provided arguments against the schema at runtime before invoking the handler. Using `.strict()` on Zod objects prevents the server from accepting extraneous fields that the API might not support.

### What are tool annotations used for?

Tool annotations provide metadata hints to the LLM about tool behavior: `readOnlyHint` indicates the tool doesn't modify state, `destructiveHint` warns of irreversible changes, `idempotentHint` signals safe retry logic, and `openWorldHint` suggests the tool interacts with external systems beyond the conversation context. These help the LLM select appropriate tools and handle errors gracefully.

### How do I handle pagination in MCP server responses?

Implement cursor-based or offset pagination by accepting `limit` and `offset` parameters in your Zod schema, then returning pagination metadata in JSON responses including `has_more` (boolean) and `next_offset` (number) fields. Guard against excessive data transfer by enforcing a `CHARACTER_LIMIT` constant (typically 25,000 characters) and truncating or paginating large responses accordingly.