tutorial
Featured

Build MCP Server with TypeScript: Complete Tutorial (2026)

Learn to build type-safe MCP servers with TypeScript. Includes testing, error handling, deployment to MCPize, and monetization. Full code examples.

MCPize Team
MCPize TeamCore Team
November 18, 202516 min read
TypeScript MCP Server development with Zod validation and type-safe patterns

Build MCP Server with TypeScript: Complete Tutorial

TL;DR: Use @modelcontextprotocol/sdk with Zod for type-safe validation. Set moduleResolution: NodeNext in tsconfig. Use console.error() for logging (never stdout). TypeScript catches bugs Python misses.

I've shipped MCP servers in both Python and TypeScript. Here's the thing. TypeScript caught bugs that would have cost me hours of debugging in production.

Last month I was building an MCP server that processed user data. A tool expected a userId string, but Claude passed a number. In JavaScript, this would have silently corrupted data. In TypeScript with Zod? The error surfaced before I even ran the code. That's the difference.

TypeScript for MCP development means catching mistakes before they reach production. Combined with Zod for runtime validation, you get defense in depth that makes AI interactions predictable and safe.

This guide takes you from zero to a production-ready MCP server. Not the basic "hello world" that other tutorials offer. We're covering real error handling, actual testing patterns, and deployment strategies used by production MCP servers.

What you'll build: A complete, type-safe MCP server with tools, resources, error handling, and tests.

Time to complete: About 30 minutes if you follow along.

Prerequisites: Node.js 18+ and basic TypeScript knowledge.

See TypeScript MCP Servers in action

Why TypeScript for MCP Servers?#

Let's be real about when TypeScript wins over Python for MCP development.

Developer Experience: TypeScript vs Python (%)
FactorPython (FastMCP)TypeScript (SDK)
Type safetyRuntime onlyCompile-time + runtime
Schema definitionAutomatic from hintsExplicit with Zod
Error discoveryAt executionBefore execution
Best forPrototypes, data scienceProduction apps, teams
IDE supportGoodExcellent

The official @modelcontextprotocol/sdk package is maintained by Anthropic. First-class TypeScript support. Types are documentation that never goes stale.

Choose TypeScript when:

  • You're building for a team (types become documentation)
  • The server will live in production for years
  • You need strict input validation (financial, healthcare, anything sensitive)
  • Your existing codebase is already TypeScript
  • You want excellent IDE autocomplete

Choose Python when:

  • You're prototyping fast
  • Heavy data science or ML integration
  • You already have Python infrastructure

For most production MCP servers? TypeScript is the safer bet.

Project Setup#

The official MCP TypeScript SDK requires Node.js 18+ and proper TypeScript configuration. Here's the complete setup:

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

Now the critical part. Your tsconfig.json needs specific settings:

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

The NodeNext setting is essential. The SDK uses ESM with .js extensions in imports. Using Node instead will cause resolution failures that are painful to debug. I've seen developers waste hours on this.

Add these scripts to your package.json:

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

Create your source directory:

mkdir src
touch src/index.ts

Basic Server Structure#

Every TypeScript MCP server follows the same core pattern. Here's the foundation:

// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

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

// Tool registrations will go here

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

main().catch(console.error);

Critical mistake to avoid: Using console.log(). MCP communicates via stdout. Any regular logging corrupts the JSON-RPC stream. Always use console.error() for diagnostics.

The McpServer class is the main entry point. It handles all the protocol complexity. You just register tools and resources.

The StdioServerTransport is what you'll use for local development and Claude Desktop integration. For remote deployments, you'd use StreamableHTTPServerTransport instead. More on that later.

Here's what the architecture looks like:

Claude Desktop ←→ stdio ←→ Your Server ←→ External APIs

The transport layer handles serialization, framing, and message routing. You focus on your business logic.

Adding Tools with Zod Validation#

Tools are the core of any MCP server. They let Claude perform actions. Here's how to build them properly with Zod for type-safe validation:

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: "calculator-server",
  version: "1.0.0",
});

// Register a tool with Zod schemas for input and output
server.registerTool(
  "calculate",
  {
    title: "Calculator",
    description: "Perform basic math operations",
    inputSchema: {
      operation: z.enum(["add", "subtract", "multiply", "divide"]),
      a: z.number().describe("First number"),
      b: z.number().describe("Second number"),
    },
    outputSchema: {
      result: z.number(),
      operation: z.string(),
    },
  },
  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) throw new Error("Division by zero");
        result = a / b;
        break;
    }

    const output = { result, operation };
    return {
      content: [{ type: "text", text: JSON.stringify(output) }],
      structuredContent: output,
    };
  }
);

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

main().catch(console.error);

Why Zod matters for MCP servers:

Without ZodWith Zod
Hope AI sends valid dataGuarantee valid data
Runtime crashes on bad inputStructured error messages
Manual type assertionsAutomatic type inference
Trust the clientVerify everything

The inputSchema object takes Zod schemas directly. The SDK converts them to JSON Schema for Claude, and validates inputs at runtime. Your handler receives fully typed, validated data. No defensive coding needed inside the function.

The outputSchema is optional but recommended. It documents what your tool returns and enables structured output for clients that support it.

Pro tip: The .describe() method on Zod fields becomes the field description in JSON Schema. Claude uses these to understand what each parameter does.

Adding Resources#

Resources expose read-only data to Claude. Think configuration, documentation, or any context the AI should have access to:

import { z } from "zod";

// Register a static resource
server.registerResource(
  "config://settings",
  {
    title: "Server Configuration",
    description: "Current server settings and environment",
    mimeType: "application/json",
  },
  async () => ({
    contents: [
      {
        uri: "config://settings",
        mimeType: "application/json",
        text: JSON.stringify(
          {
            version: "1.0.0",
            environment: process.env.NODE_ENV || "development",
            features: ["calculator", "converter"],
          },
          null,
          2
        ),
      },
    ],
  })
);

// Register a dynamic resource template
server.registerResourceTemplate(
  "docs://{section}",
  {
    title: "Documentation",
    description: "API documentation sections",
  },
  async (uri, { section }) => ({
    contents: [
      {
        uri: uri.href,
        mimeType: "text/markdown",
        text: getDocumentation(section),
      },
    ],
  })
);

function getDocumentation(section: string): string {
  const docs: Record<string, string> = {
    "getting-started": "# Getting Started\n\nWelcome to the API...",
    "api-reference": "# API Reference\n\n## calculate\n\nPerforms math...",
    "examples": "# Examples\n\n```typescript\nawait client.callTool...",
  };
  return docs[section] || `# ${section}\n\nDocumentation not found.`;
}

When to use resources vs tools:

  • Resources: Static or semi-static data Claude can read and reference
  • Tools: Actions that compute results or have side effects

Resources are great for giving Claude context about your system. Configuration, documentation, schema definitions. Anything that helps the AI make better decisions about which tools to use and how.

Error Handling Best Practices#

Most tutorials show try { } catch (e) { throw e; }. That's not error handling. Here's what production MCP servers actually need:

import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";

// Custom application error class
class AppError extends Error {
  constructor(
    message: string,
    public userMessage: string,
    public code: string
  ) {
    super(message);
    this.name = "AppError";
  }
}

// Centralized error handler
function handleError(error: unknown): never {
  // Zod validation errors - give specific feedback
  if (error instanceof z.ZodError) {
    const messages = error.errors
      .map((e) => `${e.path.join(".")}: ${e.message}`)
      .join(", ");
    throw new McpError(
      ErrorCode.InvalidParams,
      `Validation failed: ${messages}`
    );
  }

  // Already an MCP error - rethrow
  if (error instanceof McpError) {
    throw error;
  }

  // Known application errors - use user-friendly message
  if (error instanceof AppError) {
    throw new McpError(ErrorCode.InternalError, error.userMessage);
  }

  // Standard JavaScript errors
  if (error instanceof Error) {
    // Log the full error for debugging
    console.error("[ERROR]", error.message, error.stack);
    // But don't expose internals to the AI
    throw new McpError(ErrorCode.InternalError, "An unexpected error occurred");
  }

  // Unknown error type
  console.error("[ERROR] Unknown error type:", error);
  throw new McpError(ErrorCode.InternalError, "An unexpected error occurred");
}

Key principle: Never expose internal error details to Claude. Log them for debugging, but return user-friendly messages.

Error codes reference:

CodeWhen to use
InvalidParamsBad input from client
InvalidRequestMalformed request structure
MethodNotFoundUnknown tool or resource
InternalErrorServer-side failures

Now use it in your tool handlers:

server.registerTool(
  "fetch-data",
  {
    title: "Fetch Data",
    description: "Fetch data from external API",
    inputSchema: {
      endpoint: z.string().url(),
    },
  },
  async ({ endpoint }) => {
    try {
      const response = await fetch(endpoint);
      if (!response.ok) {
        throw new AppError(
          `HTTP ${response.status}`,
          `Failed to fetch data from endpoint`,
          "FETCH_ERROR"
        );
      }
      const data = await response.json();
      return {
        content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
      };
    } catch (error) {
      handleError(error);
    }
  }
);

This pattern gives you:

  1. Detailed internal logging for debugging
  2. User-friendly messages for Claude
  3. Proper MCP error codes for clients
  4. Type-safe error handling throughout

Testing Your MCP Server#

Here's the section no other TypeScript MCP tutorial covers. Testing is critical for production servers.

Unit tests with Vitest:

First, refactor your tools to be testable. Extract the business logic:

// src/tools/calculator.ts
import { z } from "zod";

export const CalculateSchema = z.object({
  operation: z.enum(["add", "subtract", "multiply", "divide"]),
  a: z.number(),
  b: z.number(),
});

export type CalculateInput = z.infer<typeof CalculateSchema>;

export function performCalculation(input: CalculateInput): number {
  switch (input.operation) {
    case "add":
      return input.a + input.b;
    case "subtract":
      return input.a - input.b;
    case "multiply":
      return input.a * input.b;
    case "divide":
      if (input.b === 0) throw new Error("Division by zero");
      return input.a / input.b;
  }
}

Now write tests:

// src/__tests__/calculator.test.ts
import { describe, it, expect } from "vitest";
import { CalculateSchema, performCalculation } from "../tools/calculator";

describe("Calculator Tool", () => {
  it("adds numbers correctly", () => {
    const input = CalculateSchema.parse({ operation: "add", a: 2, b: 3 });
    expect(performCalculation(input)).toBe(5);
  });

  it("subtracts numbers correctly", () => {
    const input = CalculateSchema.parse({ operation: "subtract", a: 10, b: 3 });
    expect(performCalculation(input)).toBe(7);
  });

  it("rejects division by zero", () => {
    const input = CalculateSchema.parse({ operation: "divide", a: 10, b: 0 });
    expect(() => performCalculation(input)).toThrow("Division by zero");
  });

  it("validates input schema", () => {
    expect(() =>
      CalculateSchema.parse({ operation: "invalid", a: 1, b: 2 })
    ).toThrow();
  });

  it("requires all fields", () => {
    expect(() => CalculateSchema.parse({ operation: "add" })).toThrow();
  });
});

Run tests:

npm install -D vitest
npx vitest

Integration testing with MCP Inspector:

The official inspector lets you test the full server interactively:

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

This opens a browser UI where you can:

  • See all registered tools and their schemas
  • Call tools with test inputs
  • Inspect JSON-RPC messages
  • Debug response formatting

Complete test workflow:

  1. Unit test individual tool functions
  2. Integration test the full server with Inspector
  3. Manual test with Claude Desktop

Don't skip step 1. Those unit tests will save you when refactoring.

Local Development#

Development workflow for TypeScript MCP servers:

# Run in development with hot reload
npx tsx watch src/index.ts

# Build for production
npx tsc

# Run production build
node dist/index.js

Claude Desktop configuration:

Create or edit ~/Library/Application Support/Claude/claude_desktop_config.json on macOS:

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

Other platforms:

  • Windows: %APPDATA%\Claude\claude_desktop_config.json
  • Linux: ~/.config/Claude/claude_desktop_config.json

Critical: Use absolute paths. Relative paths fail silently and you'll waste time debugging.

After editing the config, restart Claude Desktop completely. Check the MCP section in settings to verify your server appears and shows "Running" status.

Debug tips:

# Enable debug logging
DEBUG=mcp:* npx tsx src/index.ts

# Check if server starts correctly
npx tsx src/index.ts

# If you see output, ctrl+c and check Claude Desktop logs

Troubleshooting Common Issues#

I've hit all these issues. Here's the fix for each:

IssueCauseSolution
"Cannot find module"Wrong moduleResolutionSet moduleResolution: NodeNext in tsconfig
Server doesn't respondLogging to stdoutUse console.error() only
Type errors with SDKOutdated SDK versionnpm update @modelcontextprotocol/sdk
Tools not appearingMissing capabilityServer capabilities are auto-detected now
Zod parse failsType mismatchCheck AI input format, add .describe() to fields
"Unknown tool" errorTypo in tool nameVerify exact string match in registration
Connection timeoutServer crash on startRun manually first to see error output

Checking Claude Desktop logs:

On macOS, open Console.app and filter for "Claude". You'll see MCP-related messages including connection errors and server output.

Common gotcha with the new API:

The SDK has evolved. If you're reading old tutorials, they might use server.setRequestHandler(). The current recommended approach is server.registerTool() which is cleaner and handles more for you.

Complete Example: Weather API Server#

Let's build a real-world example. A weather API server that fetches current conditions:

// src/index.ts
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: "weather-server",
  version: "1.0.0",
});

// Tool: Get current weather
server.registerTool(
  "get-weather",
  {
    title: "Get Weather",
    description: "Get current weather conditions for a location",
    inputSchema: {
      location: z.string().describe("City name or coordinates"),
      units: z
        .enum(["metric", "imperial"])
        .default("metric")
        .describe("Temperature units"),
    },
  },
  async ({ location, units }) => {
    // Using Open-Meteo API (free, no key required)
    const geocodeUrl = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(location)}&count=1`;

    const geoResponse = await fetch(geocodeUrl);
    const geoData = await geoResponse.json();

    if (!geoData.results?.length) {
      return {
        content: [{ type: "text", text: `Location not found: ${location}` }],
        isError: true,
      };
    }

    const { latitude, longitude, name, country } = geoData.results[0];

    const tempUnit = units === "imperial" ? "fahrenheit" : "celsius";
    const weatherUrl = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&current=temperature_2m,relative_humidity_2m,wind_speed_10m&temperature_unit=${tempUnit}`;

    const weatherResponse = await fetch(weatherUrl);
    const weatherData = await weatherResponse.json();

    const current = weatherData.current;
    const result = {
      location: `${name}, ${country}`,
      temperature: `${current.temperature_2m}°${units === "imperial" ? "F" : "C"}`,
      humidity: `${current.relative_humidity_2m}%`,
      windSpeed: `${current.wind_speed_10m} km/h`,
    };

    return {
      content: [
        {
          type: "text",
          text: `Weather in ${result.location}:\n- Temperature: ${result.temperature}\n- Humidity: ${result.humidity}\n- Wind: ${result.windSpeed}`,
        },
      ],
    };
  }
);

// Tool: Get forecast
server.registerTool(
  "get-forecast",
  {
    title: "Get Forecast",
    description: "Get weather forecast for upcoming days",
    inputSchema: {
      location: z.string().describe("City name"),
      days: z.number().min(1).max(7).default(3).describe("Number of days"),
    },
  },
  async ({ location, days }) => {
    const geocodeUrl = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(location)}&count=1`;
    const geoResponse = await fetch(geocodeUrl);
    const geoData = await geoResponse.json();

    if (!geoData.results?.length) {
      return {
        content: [{ type: "text", text: `Location not found: ${location}` }],
        isError: true,
      };
    }

    const { latitude, longitude, name } = geoData.results[0];

    const forecastUrl = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&daily=temperature_2m_max,temperature_2m_min,precipitation_probability_max&forecast_days=${days}`;

    const forecastResponse = await fetch(forecastUrl);
    const forecastData = await forecastResponse.json();

    const daily = forecastData.daily;
    let forecastText = `${days}-day forecast for ${name}:\n\n`;

    for (let i = 0; i < days; i++) {
      forecastText += `${daily.time[i]}: ${daily.temperature_2m_min[i]}°C - ${daily.temperature_2m_max[i]}°C, ${daily.precipitation_probability_max[i]}% chance of rain\n`;
    }

    return {
      content: [{ type: "text", text: forecastText }],
    };
  }
);

// Resource: API info
server.registerResource(
  "weather://api-info",
  {
    title: "API Information",
    description: "Information about the weather API",
    mimeType: "application/json",
  },
  async () => ({
    contents: [
      {
        uri: "weather://api-info",
        mimeType: "application/json",
        text: JSON.stringify(
          {
            provider: "Open-Meteo",
            documentation: "https://open-meteo.com/en/docs",
            features: ["current weather", "7-day forecast", "no API key required"],
          },
          null,
          2
        ),
      },
    ],
  })
);

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

main().catch(console.error);

This example shows:

  • Multiple tools with different schemas
  • External API integration
  • Error handling for bad locations
  • Default parameter values
  • Resource for metadata

Test it with MCP Inspector, then add it to Claude Desktop. Ask Claude: "What's the weather in San Francisco?"

Deploy and Publish Your Server#

Your TypeScript MCP server is ready for the world. Here are your options.

Option 1: Docker deployment

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY dist ./dist
USER node
CMD ["node", "dist/index.js"]

Build and run:

npm run build
docker build -t my-mcp-server .
docker run my-mcp-server

Option 2: Publish to MCPize marketplace

This is where it gets interesting. You can monetize your work.

# Install MCPize CLI
npm install -g mcpize

# Login
mcpize login

# Initialize your project
mcpize init

# Deploy
mcpize deploy

Create mcpize.yaml in your project root:

name: my-typescript-server
version: 1.0.0
description: "Type-safe calculator and weather MCP server"
runtime: node
entrypoint: dist/index.js
build:
  command: npm run build

Share for free:

mcpize publish --free

Or monetize your work:

mcpize publish --price 5.00

Earn 85% revenue on every subscription.

Full Deployment Guide

Next Steps: Publish and Monetize#

You've built a production-ready TypeScript MCP server. The gap between knowing and shipping is action.

Your action plan:

  1. Run the setup commands from this guide
  2. Implement one tool that solves a real problem for you
  3. Test with MCP Inspector until it works perfectly
  4. Connect to Claude Desktop and use it daily
  5. When it's solid, publish to the marketplace

The MCP ecosystem is growing fast. There's real demand for well-built, type-safe servers. Developers are making money from their tools.

Start Earning with Monetization Python Alternative Browse MCP Examples

FAQ#

How do I build my own MCP server with TypeScript?#

Install @modelcontextprotocol/sdk, create a McpServer instance, register tools with Zod validation using server.registerTool(), and connect via StdioServerTransport. The SDK handles all the protocol complexity. You focus on your business logic.

What is the difference between SDK and MCP server?#

The SDK (@modelcontextprotocol/sdk) is a library that helps you build MCP servers. An MCP server is the actual service that exposes tools and resources to AI clients like Claude. Think of it like Express.js (library) vs your web app (what you build with it).

Is MCP SSE deprecated?#

SSE (Server-Sent Events) transport has been superseded by Streamable HTTP as of the 2025-03-26 MCP spec. SSE still works for backwards compatibility, but Streamable HTTP is the recommended transport for remote servers. For local development, stdio remains the standard.

TypeScript or Python for my first MCP server?#

If you're comfortable with both, choose based on what you're building. Data processing and ML integrations lean Python. Long-term production services lean TypeScript. The type safety and IDE support make TypeScript easier to maintain over time.

Do I need to learn the MCP protocol details?#

No. The SDK handles JSON-RPC framing, message parsing, capability negotiation, and transport management. Focus on your tool logic. The protocol details only matter if you're building custom transport layers or debugging edge cases.

Can I use async/await in tools?#

Yes, fully supported. All tool handlers are async. Use await freely for database queries, API calls, or any I/O operations. The SDK manages the async flow correctly.

What's the maximum response size?#

No hard limit in the protocol, but large responses slow down the AI's processing. For big datasets, consider pagination, streaming via multiple tool calls, or returning a summary with a resource link to the full data.

How do I handle secrets?#

Environment variables. Never hardcode credentials:

const apiKey = process.env.API_KEY;
if (!apiKey) throw new Error("API_KEY environment variable not set");

For Claude Desktop, you can add environment variables in the server config.

Related guides:

Building TypeScript MCP servers? Check out TypeScript servers on the marketplace for inspiration, or join our Discord to connect with other developers.

Enjoyed this article?

Share it with your network

MCPize Team

MCPize Team

Core Team

The team behind MCPize - building the future of MCP server monetization.

Stay Updated

Get the latest MCP tutorials, product updates, and developer tips delivered to your inbox.

No spam, ever. Unsubscribe anytime.

Related Articles

Continue exploring similar topics

View all articles