Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter 8: Building MCP Servers in TypeScript

Your First Server

Enough theory. Let’s build something.

We’re going to build an MCP server in TypeScript, from “empty directory” to “working tool that an LLM can use.” By the end of this chapter, you’ll have a server that you can connect to Claude Desktop, Claude Code, or any other MCP-compatible host.

Setting Up

First, create a new project and install the MCP SDK:

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

Create a tsconfig.json:

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

Update package.json:

{
  "name": "my-mcp-server",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "my-mcp-server": "./dist/index.js"
  },
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

The Minimal Server

Create src/index.ts:

#!/usr/bin/env node

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",
});

// Register a tool
server.tool(
  "greet",
  "Generates a greeting for the given name",
  {
    name: z.string().describe("The name to greet"),
  },
  async ({ name }) => ({
    content: [
      {
        type: "text",
        text: `Hello, ${name}! Welcome to the world of MCP.`,
      },
    ],
  })
);

// Start the server with stdio transport
const transport = new StdioServerTransport();
await server.connect(transport);

That’s it. Thirty lines, counting the imports. Build and run it:

npm run build
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0.0"}}}' | node dist/index.js

You’ll see the server respond with its capabilities. You have a working MCP server.

Wait—where’s Zod? The SDK uses Zod for schema validation. Install it:

npm install zod

Anatomy of the McpServer API

The McpServer class is the high-level API. It handles protocol details so you can focus on your tools, resources, and prompts.

Registering Tools

The server.tool() method registers a tool. It has several overloads:

// Basic: name, description, schema, handler
server.tool(
  "tool_name",
  "Description of the tool",
  {
    param1: z.string(),
    param2: z.number().optional(),
  },
  async (args) => ({
    content: [{ type: "text", text: "result" }],
  })
);

// With annotations
server.tool(
  "tool_name",
  "Description",
  {
    param1: z.string(),
  },
  async (args) => ({
    content: [{ type: "text", text: "result" }],
  }),
  {
    annotations: {
      readOnlyHint: true,
      openWorldHint: false,
    },
  }
);

The schema uses Zod, which the SDK converts to JSON Schema automatically. This means you get both TypeScript type inference and runtime validation for free.

Registering Resources

// Static resource
server.resource(
  "config",
  "file:///app/config.json",
  "Application configuration",
  async () => ({
    contents: [
      {
        uri: "file:///app/config.json",
        mimeType: "application/json",
        text: JSON.stringify(config, null, 2),
      },
    ],
  })
);

// Resource template
server.resource(
  "user-profile",
  "users://{userId}/profile",
  "Get a user's profile",
  async (uri, { userId }) => ({
    contents: [
      {
        uri: uri.href,
        mimeType: "application/json",
        text: JSON.stringify(await getUser(userId)),
      },
    ],
  })
);

Registering Prompts

server.prompt(
  "debug-error",
  "Help debug an error message",
  {
    error: z.string().describe("The error message"),
    language: z.string().optional().describe("Programming language"),
  },
  async ({ error, language }) => ({
    messages: [
      {
        role: "user",
        content: {
          type: "text",
          text: `I encountered this error${language ? ` in ${language}` : ""}:\n\n${error}\n\nPlease explain what this error means, what likely caused it, and how to fix it.`,
        },
      },
    ],
  })
);

A Real-World Example: Weather Server

Let’s build something actually useful. A weather server that provides current weather data:

#!/usr/bin/env node

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.tool(
  "get_weather",
  "Get current weather information for a city. Returns temperature, conditions, humidity, and wind speed.",
  {
    city: z.string().describe("City name (e.g., 'London', 'New York', 'Tokyo')"),
    units: z
      .enum(["celsius", "fahrenheit"])
      .optional()
      .default("celsius")
      .describe("Temperature units"),
  },
  async ({ city, units }) => {
    try {
      const apiKey = process.env.WEATHER_API_KEY;
      if (!apiKey) {
        return {
          content: [
            {
              type: "text",
              text: "Error: WEATHER_API_KEY environment variable not set",
            },
          ],
          isError: true,
        };
      }

      const unitParam = units === "fahrenheit" ? "imperial" : "metric";
      const response = await fetch(
        `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(city)}&units=${unitParam}&appid=${apiKey}`
      );

      if (!response.ok) {
        if (response.status === 404) {
          return {
            content: [
              {
                type: "text",
                text: `City not found: "${city}". Please check the spelling and try again.`,
              },
            ],
            isError: true,
          };
        }
        throw new Error(`API error: ${response.status}`);
      }

      const data = await response.json();
      const tempUnit = units === "fahrenheit" ? "°F" : "°C";
      const speedUnit = units === "fahrenheit" ? "mph" : "m/s";

      const result = [
        `Weather for ${data.name}, ${data.sys.country}:`,
        `Temperature: ${data.main.temp}${tempUnit} (feels like ${data.main.feels_like}${tempUnit})`,
        `Conditions: ${data.weather[0].description}`,
        `Humidity: ${data.main.humidity}%`,
        `Wind: ${data.wind.speed} ${speedUnit}`,
      ].join("\n");

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

// Tool: Get forecast
server.tool(
  "get_forecast",
  "Get a 5-day weather forecast for a city. Returns daily high/low temperatures and conditions.",
  {
    city: z.string().describe("City name"),
    units: z
      .enum(["celsius", "fahrenheit"])
      .optional()
      .default("celsius")
      .describe("Temperature units"),
  },
  async ({ city, units }) => {
    try {
      const apiKey = process.env.WEATHER_API_KEY;
      if (!apiKey) {
        return {
          content: [{ type: "text", text: "Error: WEATHER_API_KEY not set" }],
          isError: true,
        };
      }

      const unitParam = units === "fahrenheit" ? "imperial" : "metric";
      const response = await fetch(
        `https://api.openweathermap.org/data/2.5/forecast?q=${encodeURIComponent(city)}&units=${unitParam}&appid=${apiKey}`
      );

      if (!response.ok) {
        return {
          content: [{ type: "text", text: `Error: ${response.statusText}` }],
          isError: true,
        };
      }

      const data = await response.json();
      const tempUnit = units === "fahrenheit" ? "°F" : "°C";

      // Group by day
      const days = new Map<string, any[]>();
      for (const item of data.list) {
        const date = item.dt_txt.split(" ")[0];
        if (!days.has(date)) days.set(date, []);
        days.get(date)!.push(item);
      }

      const forecast = Array.from(days.entries())
        .slice(0, 5)
        .map(([date, items]) => {
          const temps = items.map((i: any) => i.main.temp);
          const high = Math.max(...temps);
          const low = Math.min(...temps);
          const conditions = items[Math.floor(items.length / 2)].weather[0].description;
          return `${date}: ${low.toFixed(1)}–${high.toFixed(1)}${tempUnit}, ${conditions}`;
        })
        .join("\n");

      return {
        content: [
          {
            type: "text",
            text: `5-day forecast for ${data.city.name}:\n${forecast}`,
          },
        ],
      };
    } catch (error) {
      return {
        content: [
          {
            type: "text",
            text: `Error: ${error instanceof Error ? error.message : "Unknown error"}`,
          },
        ],
        isError: true,
      };
    }
  }
);

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

Using the Low-Level API

The McpServer class is convenient, but sometimes you need more control. The SDK also provides a low-level Server class:

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import {
  ListToolsRequestSchema,
  CallToolRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

const server = new Server(
  {
    name: "low-level-server",
    version: "1.0.0",
  },
  {
    capabilities: {
      tools: {},
    },
  }
);

// Handle tool listing
server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: "calculate",
      description: "Performs basic arithmetic",
      inputSchema: {
        type: "object" as const,
        properties: {
          expression: {
            type: "string",
            description: "Math expression to evaluate (e.g., '2 + 3 * 4')",
          },
        },
        required: ["expression"],
      },
    },
  ],
}));

// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;

  if (name === "calculate") {
    try {
      // WARNING: In production, use a safe math parser, not eval!
      const expression = args?.expression as string;
      // Using Function constructor as a slightly safer eval alternative
      const result = new Function(`return (${expression})`)();
      return {
        content: [{ type: "text" as const, text: String(result) }],
      };
    } catch (e) {
      return {
        content: [
          {
            type: "text" as const,
            text: `Error evaluating expression: ${e instanceof Error ? e.message : "Unknown error"}`,
          },
        ],
        isError: true,
      };
    }
  }

  throw new Error(`Unknown tool: ${name}`);
});

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

The low-level API gives you direct control over request handling, response formatting, and error handling. Use it when McpServer’s convenience methods don’t fit your needs.

Adding an HTTP Transport

To make your server available remotely, swap the transport:

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

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

// Register your tools, resources, prompts...

// Set up the HTTP transport
const transport = new StreamableHTTPServerTransport({
  sessionIdGenerator: () => crypto.randomUUID(),
});

app.post("/mcp", async (req, res) => {
  await transport.handleRequest(req, res);
});

app.get("/mcp", async (req, res) => {
  await transport.handleRequest(req, res);
});

app.delete("/mcp", async (req, res) => {
  await transport.handleRequest(req, res);
});

await server.connect(transport);

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

Now your server is accessible via HTTP at http://localhost:3000/mcp.

Patterns and Best Practices

Environment Variables for Secrets

Never hardcode API keys or secrets. Use environment variables:

const apiKey = process.env.MY_API_KEY;
if (!apiKey) {
  console.error("MY_API_KEY environment variable is required");
  process.exit(1);
}

Configure them in the client:

{
  "mcpServers": {
    "my-server": {
      "command": "node",
      "args": ["dist/index.js"],
      "env": {
        "MY_API_KEY": "sk-..."
      }
    }
  }
}

Error Handling Strategy

Return tool execution errors (not protocol errors) for failures the LLM can act on:

server.tool("read_file", "Read a file's contents", { path: z.string() }, async ({ path }) => {
  try {
    const content = await fs.readFile(path, "utf-8");
    return { content: [{ type: "text", text: content }] };
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === "ENOENT") {
      // Helpful error message the LLM can act on
      const dir = pathModule.dirname(path);
      const files = await fs.readdir(dir).catch(() => []);
      return {
        content: [
          {
            type: "text",
            text: `File not found: ${path}\nFiles in ${dir}: ${files.join(", ") || "(directory not found)"}`,
          },
        ],
        isError: true,
      };
    }
    return {
      content: [
        {
          type: "text",
          text: `Error reading ${path}: ${(error as Error).message}`,
        },
      ],
      isError: true,
    };
  }
});

Progress Reporting

For long-running tools, report progress:

server.tool(
  "bulk_process",
  "Process a large dataset",
  { dataPath: z.string() },
  async ({ dataPath }, { progressToken, sendProgress }) => {
    const items = await loadData(dataPath);

    for (let i = 0; i < items.length; i++) {
      await processItem(items[i]);

      // Report progress
      if (progressToken) {
        await sendProgress(i + 1, items.length, `Processing item ${i + 1} of ${items.length}`);
      }
    }

    return {
      content: [{ type: "text", text: `Processed ${items.length} items` }],
    };
  }
);

Graceful Shutdown

Handle process signals properly:

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

process.on("SIGINT", async () => {
  console.error("Shutting down...");
  await server.close();
  process.exit(0);
});

Publishing Your Server

To share your server with the world:

  1. Add a shebang to your entry point: #!/usr/bin/env node
  2. Set the bin field in package.json
  3. Publish to npm: npm publish

Users can then run your server with:

npx your-server-name

Or configure it in their MCP client:

{
  "mcpServers": {
    "your-server": {
      "command": "npx",
      "args": ["-y", "your-server-name"]
    }
  }
}

The -y flag auto-confirms the npx installation prompt. It’s the convention in MCP land.

Summary

Building an MCP server in TypeScript is straightforward:

  1. Install @modelcontextprotocol/sdk and zod
  2. Create an McpServer instance
  3. Register tools, resources, and/or prompts
  4. Connect a transport (stdio for local, Streamable HTTP for remote)
  5. Handle errors gracefully, report progress, and shut down cleanly

The TypeScript SDK gives you two API levels: the high-level McpServer for most cases, and the low-level Server for when you need full control. Both produce compliant MCP servers that work with any host.

Next: let’s build the same thing in Python.