tutorial12 min read

How to Build an MCP Server in TypeScript (Step-by-Step)

Build a working MCP server in TypeScript with the official SDK. Covers tools with Zod schemas, resources, prompts, testing, and configuration for Claude Code and Cursor.

WI
William Wang · Apr 9, 2026

William Wang — Founder of TokRepo & GEOScore AI. Building tools for AI developer productivity and search visibility.

How to Build an MCP Server in TypeScript (Step-by-Step)
Table of Contents

Learn how to build a production-ready MCP server in TypeScript using the official @modelcontextprotocol/sdk, validate inputs with Zod, and configure it in Claude Code, Claude Desktop, and Cursor.

Prerequisites

  • Node.js 16+ (18+ recommended)
  • npm or pnpm
  • Basic TypeScript knowledge
  • One of: Claude Code, Claude Desktop, or Cursor

Step 1 — Set Up Your Project

mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod@3
npm install -D @types/node typescript
mkdir src && touch src/index.ts

Create tsconfig.json:

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

Update package.json:

{
  "type": "module",
  "scripts": {
    "build": "tsc && chmod 755 build/index.js",
    "start": "node build/index.js"
  }
}

The current SDK version is @modelcontextprotocol/sdk v1.29.0.

Step 2 — Create a Basic 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";

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

server.registerTool(
  "add",
  {
    description: "Add two numbers together",
    inputSchema: {
      a: z.number().describe("First number"),
      b: z.number().describe("Second number"),
    },
  },
  async ({ a, b }) => ({
    content: [{ type: "text", text: String(a + b) }],
  }),
);

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

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

Build and test:

npm run build

Step 3 — Add Structured Output with Zod

server.registerTool(
  "calculate-bmi",
  {
    title: "BMI Calculator",
    description: "Calculate Body Mass Index",
    inputSchema: z.object({
      weightKg: z.number().describe("Weight in kilograms"),
      heightM: z.number().describe("Height in meters"),
    }),
    outputSchema: z.object({
      bmi: z.number(),
      category: z.string(),
    }),
  },
  async ({ weightKg, heightM }) => {
    const bmi = weightKg / (heightM * heightM);
    let category = "Normal";
    if (bmi < 18.5) category = "Underweight";
    else if (bmi >= 25) category = "Overweight";

    const output = { bmi: Math.round(bmi * 10) / 10, category };
    return {
      content: [{ type: "text", text: JSON.stringify(output) }],
      structuredContent: output,
    };
  },
);

Step 4 — Add Resources

server.registerResource(
  "app-config",
  "config://app",
  {
    title: "Application Configuration",
    description: "Current app settings",
    mimeType: "application/json",
  },
  async (uri) => ({
    contents: [{
      uri: uri.href,
      text: JSON.stringify({ theme: "dark", version: "2.0" }, null, 2),
    }],
  }),
);

Step 5 — Add Prompts

server.registerPrompt(
  "code-review",
  {
    title: "Code Review",
    description: "Review code for best practices",
    argsSchema: z.object({
      code: z.string().describe("The code to review"),
      language: z.string().default("typescript"),
    }),
  },
  ({ code, language }) => ({
    messages: [{
      role: "user" as const,
      content: {
        type: "text" as const,
        text: `Review this ${language} code:\n\`\`\`${language}\n${code}\n\`\`\``,
      },
    }],
  }),
);

Step 6 — Tool Annotations

Annotations give clients metadata about tool behavior:

server.registerTool(
  "delete-file",
  {
    description: "Delete a file from the project",
    inputSchema: z.object({
      path: z.string().describe("Path to file"),
    }),
    annotations: {
      title: "Delete File",
      destructiveHint: true,
      idempotentHint: true,
    },
  },
  async ({ path }) => ({
    content: [{ type: "text", text: `Deleted ${path}` }],
  }),
);

Step 7 — Error Handling

server.registerTool(
  "fetch-data",
  {
    description: "Fetch data from a URL",
    inputSchema: z.object({
      url: z.string().url().describe("URL to fetch"),
    }),
  },
  async ({ url }) => {
    try {
      const response = await fetch(url);
      if (!response.ok) {
        return {
          content: [{ type: "text", text: `HTTP ${response.status}` }],
          isError: true,
        };
      }
      return { content: [{ type: "text", text: await response.text() }] };
    } catch (error) {
      return {
        content: [{
          type: "text",
          text: `Failed: ${error instanceof Error ? error.message : String(error)}`,
        }],
        isError: true,
      };
    }
  },
);

Step 8 — Complete Example: File Analyzer

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { readFile, readdir, stat } from "fs/promises";
import { join, extname } from "path";

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

server.registerTool(
  "analyze-file",
  {
    description: "Analyze a text file and return statistics",
    inputSchema: {
      filePath: z.string().describe("Absolute path to the file"),
    },
  },
  async ({ filePath }) => {
    try {
      const content = await readFile(filePath, "utf-8");
      const lines = content.split("\n");
      const words = content.split(/\s+/).filter(Boolean);

      const result = [
        `File: ${filePath}`,
        `Lines: ${lines.length}`,
        `Words: ${words.length}`,
        `Characters: ${content.length}`,
        `Extension: ${extname(filePath)}`,
      ].join("\n");

      return { content: [{ type: "text", text: result }] };
    } catch (error) {
      return {
        content: [{
          type: "text",
          text: `Error: ${error instanceof Error ? error.message : String(error)}`,
        }],
        isError: true,
      };
    }
  },
);

server.registerTool(
  "list-directory",
  {
    description: "List files in a directory with sizes",
    inputSchema: {
      dirPath: z.string().describe("Absolute path to directory"),
      extension: z.string().optional().describe("Filter by extension"),
    },
  },
  async ({ dirPath, extension }) => {
    let entries = await readdir(dirPath);
    if (extension) entries = entries.filter((e) => e.endsWith(extension));

    const details = await Promise.all(
      entries.slice(0, 50).map(async (entry) => {
        const stats = await stat(join(dirPath, entry));
        const size = stats.isDirectory()
          ? "[DIR]"
          : `${(stats.size / 1024).toFixed(1)} KB`;
        return `${entry} (${size})`;
      }),
    );

    return {
      content: [{ type: "text", text: details.join("\n") || "Empty directory" }],
    };
  },
);

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

main().catch(console.error);

Step 9 — Test with MCP Inspector

npm run build
npx @modelcontextprotocol/inspector node build/index.js

Opens a web UI at http://localhost:6274 for visual debugging.

Step 10 — Configure in Claude Code

claude mcp add --transport stdio my-server \
  -- node /absolute/path/to/my-mcp-server/build/index.js

Step 11 — Configure in Claude Desktop / Cursor

Claude Desktop (~/Library/Application Support/Claude/claude_desktop_config.json):

{
  "mcpServers": {
    "my-server": {
      "command": "node",
      "args": ["/absolute/path/to/build/index.js"]
    }
  }
}

Cursor (.cursor/mcp.json):

{
  "mcpServers": {
    "my-server": {
      "command": "node",
      "args": ["/absolute/path/to/build/index.js"]
    }
  }
}
⚠️

FAQ

Q: Which TypeScript SDK version should I use? A: Use @modelcontextprotocol/sdk v1.29.0 (latest stable). A v2 SDK is in pre-alpha — stick with v1.x for production.

Q: Can I use plain JavaScript instead of TypeScript? A: Yes, the SDK works with JavaScript. You lose type safety for tool schemas, but Zod validation still works at runtime.

Q: How do I publish my MCP server to npm? A: Add "bin": { "my-server": "./build/index.js" } to package.json, add a #!/usr/bin/env node shebang to index.ts, then npm publish. Users install via npx my-server.

Next Steps