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

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.

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, server names should follow the pattern {service}-mcp-server (e.g., github-mcp-server) to ensure consistency across the ecosystem.

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.

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, the inputSchema uses Zod for runtime type safety.

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.

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.

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.

#!/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.

{
  "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 with strict mode enabled and Node16 module resolution to ensure compatibility with the SDK's ESM structure.

{
  "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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →