← Back to MCP Tutorials

How to Build an MCP Server with TypeScript

Build a production-ready MCP server from scratch. TypeScript is what Anthropic uses for their official examples—here's how to do it right.

February 3, 2026·15 min read

Prerequisites

  • Node.js 18+
  • npm or pnpm
  • Basic TypeScript knowledge
  • Claude Desktop or Cursor IDE (for testing)

Quick Start: Minimal MCP Server

First, set up your project:

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

Update your tsconfig.json:

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

Update package.json:

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

Now create your server in src/index.ts:

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

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

// Define a simple tool
server.tool(
  "get_weather",
  "Get current weather for a city",
  {
    city: z.string().describe("City name"),
  },
  async ({ city }) => {
    // In production, call a real weather API
    return {
      content: [
        {
          type: "text",
          text: `Weather in ${city}: 72°F, sunny`,
        },
      ],
    };
  }
);

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

Build and test:

npm run build
echo '{"jsonrpc":"2.0","method":"tools/list","id":1}' | node dist/index.js

Adding Resources

Resources let Claude read data from your server:

// Static resource
server.resource(
  "config",
  "config://settings",
  async (uri) => ({
    contents: [
      {
        uri: uri.href,
        mimeType: "application/json",
        text: JSON.stringify({ theme: "dark", debug: false }),
      },
    ],
  })
);

// Dynamic resource with template
server.resource(
  "user-profile",
  new ResourceTemplate("user://{userId}/profile", { list: undefined }),
  async (uri, { userId }) => ({
    contents: [
      {
        uri: uri.href,
        mimeType: "application/json",
        text: JSON.stringify({ id: userId, name: "Test User" }),
      },
    ],
  })
);

Adding Prompts

Prompts are reusable templates that help Claude handle specific tasks:

server.prompt(
  "code-review",
  "Review code for issues and improvements",
  {
    language: z.string().describe("Programming language"),
    code: z.string().describe("Code to review"),
  },
  ({ language, code }) => ({
    messages: [
      {
        role: "user",
        content: {
          type: "text",
          text: `Review this ${language} code for bugs, security issues, and improvements:\n\n\`\`\`${language}\n${code}\n\`\`\``,
        },
      },
    ],
  })
);

Connecting to Claude Desktop

Add your server to Claude Desktop's config:

macOS: ~/Library/Application Support/Claude/claude_desktop_config.json

Windows: %APPDATA%\Claude\claude_desktop_config.json

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

Restart Claude Desktop. Your tools will appear in the tools menu.

Error Handling Best Practices

Always handle errors gracefully:

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

server.tool(
  "fetch_data",
  "Fetch data from external API",
  { endpoint: z.string() },
  async ({ endpoint }) => {
    try {
      const response = await fetch(endpoint);
      if (!response.ok) {
        throw new McpError(
          ErrorCode.InternalError,
          `API returned ${response.status}`
        );
      }
      const data = await response.json();
      return {
        content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
      };
    } catch (error) {
      if (error instanceof McpError) throw error;
      throw new McpError(
        ErrorCode.InternalError,
        `Failed to fetch: ${error.message}`
      );
    }
  }
);

Full Example: File System Server

Here's a more complete example—a server that lets Claude read and write files:

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

const ALLOWED_DIR = process.env.MCP_FILES_DIR || "./files";

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

server.tool(
  "read_file",
  "Read contents of a file",
  { path: z.string().describe("Relative file path") },
  async ({ path }) => {
    const fullPath = join(ALLOWED_DIR, path);
    const content = await readFile(fullPath, "utf-8");
    return {
      content: [{ type: "text", text: content }],
    };
  }
);

server.tool(
  "write_file",
  "Write content to a file",
  {
    path: z.string().describe("Relative file path"),
    content: z.string().describe("Content to write"),
  },
  async ({ path, content }) => {
    const fullPath = join(ALLOWED_DIR, path);
    await writeFile(fullPath, content, "utf-8");
    return {
      content: [{ type: "text", text: `Wrote ${content.length} bytes to ${path}` }],
    };
  }
);

server.tool(
  "list_files",
  "List files in directory",
  { path: z.string().optional().describe("Relative directory path") },
  async ({ path = "." }) => {
    const fullPath = join(ALLOWED_DIR, path);
    const files = await readdir(fullPath, { withFileTypes: true });
    const list = files.map(f => `${f.isDirectory() ? "📁" : "📄"} ${f.name}`);
    return {
      content: [{ type: "text", text: list.join("\n") }],
    };
  }
);

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

Testing Your Server

Use the MCP Inspector for interactive testing:

npx @modelcontextprotocol/inspector node dist/index.js

This opens a web UI where you can:

  • List and call tools
  • Browse resources
  • Test prompts
  • See raw JSON-RPC messages

Deployment Options

As a local process (most common)

Configure in Claude Desktop or Cursor as shown above.

As a Docker container

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

As an HTTP server (for remote access)

Use HttpServerTransport instead of StdioServerTransport:

import { HttpServerTransport } from "@modelcontextprotocol/sdk/server/http.js";

const transport = new HttpServerTransport({ port: 3000 });
await server.connect(transport);

Key Takeaways

  1. Start simple - Get one tool working before adding complexity
  2. Use Zod schemas - They provide validation and generate descriptions
  3. Handle errors - Always wrap external calls in try/catch
  4. Test iteratively - Use MCP Inspector during development
  5. Security first - Validate inputs and restrict file system access

Next Steps

Now that you have a working TypeScript MCP server, explore these related guides:

Get More MCP Tutorials

Weekly deep dives on building AI tools. No spam, unsubscribe anytime.

Get updates in your inbox

Tutorials, updates, and best practices for Model Context Protocol.

No spam. Unsubscribe anytime.