Production-grade MCP servers
EN
Tutorials

MCP SDK for TypeScript: Build Production MCP Servers with Node.js

Step-by-step tutorial for building MCP servers with TypeScript and the official @modelcontextprotocol/sdk package. Covers project setup, tool and resource definitions, Zod validation, HTTP transport, testing, and deployment.

Author
Engineering Team
April 14, 2026
MCP SDK for TypeScript: Build Production MCP Servers with Node.js
Try Vinkius Free

The official MCP SDK for TypeScript (@modelcontextprotocol/sdk) is the reference implementation of the Model Context Protocol. It provides a type-safe, ergonomic API for building MCP servers that work with Claude, Cursor, VS Code, and any other MCP-compatible client. This guide covers everything from project setup to production deployment — with working TypeScript code at every step.

Prerequisites

  • Node.js 20+ (LTS recommended)
  • TypeScript 5.4+
  • A package manager: npm, pnpm, or bun
  • An AI client for testing: Claude Desktop, Cursor, or VS Code

Project Setup

Initialize a new TypeScript MCP server project:

mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "dist",
    "rootDir": "src",
    "declaration": true,
    "sourceMap": true
  },
  "include": ["src/**/*"]
}

Add scripts to package.json:

{
  "type": "module",
  "scripts": {
    "build": "tsc",
    "dev": "tsx watch src/index.ts",
    "start": "node dist/index.js"
  }
}

Your First MCP Server

Create src/index.ts:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

// Create the server
const server = new McpServer({
  name: "my-first-server",
  version: "1.0.0",
});

// Define a tool
server.tool(
  "greet",
  "Greet someone by name",
  { name: z.string().describe("The person's name") },
  async ({ name }) => ({
    content: [{ type: "text", text: `Hello, ${name}! Welcome to MCP.` }],
  })
);

// Define another tool
server.tool(
  "calculate",
  "Perform basic arithmetic",
  {
    operation: z.enum(["add", "subtract", "multiply", "divide"]).describe("The math operation"),
    a: z.number().describe("First number"),
    b: z.number().describe("Second number"),
  },
  async ({ operation, a, b }) => {
    let result: number;
    switch (operation) {
      case "add": result = a + b; break;
      case "subtract": result = a - b; break;
      case "multiply": result = a * b; break;
      case "divide":
        if (b === 0) {
          return { content: [{ type: "text", text: "Error: Division by zero" }], isError: true };
        }
        result = a / b;
        break;
    }
    return { content: [{ type: "text", text: `${a} ${operation} ${b} = ${result}` }] };
  }
);

// Start with stdio transport
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Server started on stdio");

Run it:

npx tsx src/index.ts

Connecting to Claude Desktop

Add to your claude_desktop_config.json:

{
  "mcpServers": {
    "my-first-server": {
      "command": "npx",
      "args": ["tsx", "/absolute/path/to/src/index.ts"]
    }
  }
}

Restart Claude Desktop. The tools appear automatically.

The Three Primitives in TypeScript

Tools — Actions the AI Can Execute

Tools are the primary primitive. Define them with Zod schemas for type-safe parameter validation:

import { z } from "zod";

// Simple tool with inline schema
server.tool(
  "search_users",
  "Search for users by email, name, or role",
  {
    query: z.string().min(1).describe("Search query"),
    role: z.enum(["admin", "user", "guest"]).optional().describe("Filter by role"),
    limit: z.number().int().min(1).max(100).default(10).describe("Max results"),
  },
  async ({ query, role, limit }) => {
    // Your API call here
    const results = await searchUsers(query, role, limit);
    return {
      content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
    };
  }
);

Type safety: Zod schemas are validated at runtime. If the AI sends invalid parameters, the SDK returns a proper error — you never need to manually validate inputs.

Resources — Data the AI Can Read

Resources provide contextual data without requiring tool calls:

server.resource(
  "config://app-settings",
  "config://app-settings",
  async () => ({
    contents: [{
      uri: "config://app-settings",
      text: JSON.stringify({
        environment: "production",
        version: "2.4.1",
        features: { dark_mode: true, beta: false },
      }, null, 2),
      mimeType: "application/json",
    }],
  })
);

// Dynamic resource with URI template
server.resource(
  "docs://api/{endpoint}",
  "docs://api/{endpoint}",
  async (uri) => {
    const endpoint = uri.pathname.split("/").pop();
    const docs = await loadEndpointDocs(endpoint);
    return {
      contents: [{
        uri: uri.href,
        text: docs,
        mimeType: "text/markdown",
      }],
    };
  }
);

Prompts — Instruction Templates

Pre-built prompts standardize workflows:

server.prompt(
  "code-review",
  "Generate a structured code review",
  {
    code: z.string().describe("The code to review"),
    language: z.string().default("typescript").describe("Programming language"),
  },
  async ({ code, language }) => ({
    messages: [{
      role: "user",
      content: {
        type: "text",
        text: `Review this ${language} code for bugs, performance, security, and style:\n\n\`\`\`${language}\n${code}\n\`\`\``,
      },
    }],
  })
);

Building a Complete REST API Server

Here’s a production-pattern server wrapping a REST API:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

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

// Configuration from environment
const API_BASE = process.env.API_BASE_URL ?? "https://api.example.com";
const API_KEY = process.env.API_KEY ?? "";

// Shared HTTP client logic
async function apiRequest<T>(
  method: string,
  path: string,
  body?: Record<string, unknown>
): Promise<T> {
  const response = await fetch(`${API_BASE}${path}`, {
    method,
    headers: {
      "Authorization": `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
    },
    body: body ? JSON.stringify(body) : undefined,
  });

  if (!response.ok) {
    const error = await response.text();
    throw new Error(`API ${response.status}: ${error.slice(0, 500)}`);
  }

  return response.json() as T;
}

// Tool: List projects
server.tool(
  "list_projects",
  "List all projects with optional status filter",
  {
    status: z.enum(["active", "archived", "all"]).default("active"),
    limit: z.number().int().min(1).max(50).default(20),
  },
  async ({ status, limit }) => {
    try {
      const params = new URLSearchParams({ limit: String(limit) });
      if (status !== "all") params.set("status", status);

      const data = await apiRequest<{ projects: unknown[] }>(
        "GET",
        `/projects?${params}`
      );

      return {
        content: [{ type: "text", text: JSON.stringify(data.projects, null, 2) }],
      };
    } catch (error) {
      return {
        content: [{ type: "text", text: `Error: ${(error as Error).message}` }],
        isError: true,
      };
    }
  }
);

// Tool: Create project
server.tool(
  "create_project",
  "Create a new project",
  {
    name: z.string().min(1).max(200).describe("Project name"),
    description: z.string().optional().describe("Project description"),
    team_id: z.string().optional().describe("Team to assign the project to"),
  },
  async ({ name, description, team_id }) => {
    try {
      const project = await apiRequest<Record<string, unknown>>(
        "POST",
        "/projects",
        { name, description, team_id }
      );

      return {
        content: [{ type: "text", text: JSON.stringify(project, null, 2) }],
      };
    } catch (error) {
      return {
        content: [{ type: "text", text: `Error: ${(error as Error).message}` }],
        isError: true,
      };
    }
  }
);

// Tool: Search issues
server.tool(
  "search_issues",
  "Search for issues across all projects",
  {
    query: z.string().min(1).describe("Search query"),
    status: z.enum(["open", "in_progress", "closed", "all"]).default("open"),
    priority: z.enum(["low", "medium", "high", "critical", "all"]).default("all"),
  },
  async ({ query, status, priority }) => {
    try {
      const params = new URLSearchParams({ q: query });
      if (status !== "all") params.set("status", status);
      if (priority !== "all") params.set("priority", priority);

      const data = await apiRequest<{ issues: unknown[] }>(
        "GET",
        `/issues/search?${params}`
      );

      return {
        content: [{ type: "text", text: JSON.stringify(data.issues, null, 2) }],
      };
    } catch (error) {
      return {
        content: [{ type: "text", text: `Error: ${(error as Error).message}` }],
        isError: true,
      };
    }
  }
);

// Resource: API schema documentation
server.resource(
  "docs://api-schema",
  "docs://api-schema",
  async () => ({
    contents: [{
      uri: "docs://api-schema",
      text: `# Project Manager API Schema

## Project
- id: string (UUID)
- name: string (1-200 chars)
- description: string (optional)
- status: "active" | "archived"
- team_id: string (UUID)
- created_at: ISO 8601 datetime

## Issue
- id: string (UUID)
- title: string (1-500 chars)
- status: "open" | "in_progress" | "closed"
- priority: "low" | "medium" | "high" | "critical"
- project_id: string (UUID)
- assignee_id: string (UUID, optional)`,
      mimeType: "text/markdown",
    }],
  })
);

// Connect
const transport = new StdioServerTransport();
await server.connect(transport);

HTTP Transport for Remote Deployment

Switch from stdio to HTTP+SSE for remote access:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import express from "express";

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

// ... define tools, resources, prompts ...

// Express app for HTTP transport
const app = express();
const transports = new Map<string, SSEServerTransport>();

app.get("/sse", async (req, res) => {
  const transport = new SSEServerTransport("/messages", res);
  transports.set(transport.sessionId, transport);
  await server.connect(transport);
});

app.post("/messages", async (req, res) => {
  const sessionId = req.query.sessionId as string;
  const transport = transports.get(sessionId);
  if (transport) {
    await transport.handlePostMessage(req, res);
  } else {
    res.status(404).send("Session not found");
  }
});

app.listen(8080, () => {
  console.log("MCP server listening on http://localhost:8080");
});

Testing with MCP Inspector

The official Inspector is the fastest way to test:

npx @modelcontextprotocol/inspector npx tsx src/index.ts

For programmatic testing:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

const transport = new StdioClientTransport({
  command: "npx",
  args: ["tsx", "src/index.ts"],
});

const client = new Client({ name: "test-client", version: "1.0.0" });
await client.connect(transport);

// List tools
const tools = await client.listTools();
console.log("Tools:", tools.tools.map(t => t.name));

// Call a tool
const result = await client.callTool("greet", { name: "Test" });
console.log("Result:", result.content);

await client.close();

Error Handling Patterns

// Pattern: Wrap every tool in try-catch, return structured errors
server.tool(
  "safe_operation",
  "An operation with robust error handling",
  { input: z.string() },
  async ({ input }) => {
    try {
      const result = await riskyOperation(input);
      return { content: [{ type: "text", text: JSON.stringify(result) }] };
    } catch (error) {
      const message = error instanceof Error ? error.message : "Unknown error";
      return {
        content: [{ type: "text", text: `Operation failed: ${message}` }],
        isError: true,
      };
    }
  }
);

Security Checklist for TypeScript Servers

  1. Environment variables for secrets — use process.env, never hardcode
  2. Zod validation — the SDK validates inputs, but add business logic constraints
  3. Timeout on HTTP requests — use AbortController with a 30-second timeout
  4. Sanitize outputs — don’t leak internal errors, stack traces, or credentials
  5. Least privilege — only expose the tools your use case actually needs
  6. Audit logging — log every tool call with parameters and results

For production, deploy through a managed MCP platform that adds credential isolation, DLP, and semantic classification.

Frequently Asked Questions

Can I use JavaScript instead of TypeScript?

Yes. The SDK works with plain JavaScript. You’ll lose type safety and Zod integration, but the API is the same. We strongly recommend TypeScript for production servers.

How do I handle environment variables for different deployments?

Use dotenv for local development, container environment variables for Docker, and a secrets manager (AWS Secrets Manager, Vault) for production.

Can I use Bun or Deno instead of Node.js?

The SDK is designed for Node.js but works with Bun. Deno support is community-maintained. For production stability, use Node.js 20+.

What’s the maximum payload size?

The MCP protocol doesn’t define a hard limit, but practical limits are set by the transport. stdio handles large payloads well. For HTTP, keep responses under 10 MB and paginate larger datasets.

How do I version my MCP server?

Use semantic versioning. Bump the version field in your McpServer constructor. Breaking changes to tool schemas should be a major version bump too.


Ready to deploy your TypeScript MCP server? Publish it on our managed registry — we handle infrastructure, security, and governance. Browse 2,500+ existing servers →


Hardened & governed from day one

Your agents need tools. We make them safe.

Pick an MCP server from the catalog. Subscribe. Copy the URL. Paste it into Claude, Cursor, or any client. One URL — DLP, audit trail, and kill switch included.

V8 sandbox isolation · Semantic DLP · Cryptographic audit trail · Emergency kill switch

Share this article