Building an MCP server in TypeScript

Kashish Hora

Kashish Hora

Co-founder of MCPcat

Try out MCPcat

The Quick Answer

Create a TypeScript MCP server that exposes tools to AI assistants in under 5 minutes:

$npm init -y
$npm install @modelcontextprotocol/sdk zod
$npm install -D @types/node tsx typescript
// index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio";
import { z } from "zod";

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

server.tool("add_numbers", 
  { a: z.number(), b: z.number() },
  async ({ a, b }) => ({
    content: [{ type: "text", text: `${a + b}` }]
  })
);

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

This creates a working MCP server that AI assistants can call to perform calculations. The SDK handles protocol compliance, type safety through Zod schemas, and transport management automatically.

Prerequisites

  • Node.js 18 or higher installed
  • TypeScript knowledge (basic to intermediate)
  • Understanding of async/await patterns
  • Familiarity with JSON-RPC concepts (helpful but not required)

Installation

# Create new project
$mkdir my-mcp-server && cd my-mcp-server
$npm init -y
 
# Install core dependencies
$npm install @modelcontextprotocol/sdk zod
 
# Install dev dependencies
$npm install -D @types/node tsx typescript
 
# Initialize TypeScript
$npx tsc --init
// Update package.json scripts
{
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "tsx watch src/index.ts"
  }
}

Configuration

MCP servers require proper TypeScript configuration for optimal type safety and compatibility. The SDK uses modern ES modules and requires specific compiler settings.

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

Configure your server metadata and capabilities. This information is exposed to clients during the initial handshake:

// src/config.ts
export const SERVER_CONFIG = {
  name: "my-mcp-server",
  version: "1.0.0",
  capabilities: {
    tools: true,
    resources: true,
    prompts: true
  }
};

For Claude Desktop integration, add your server to the configuration file:

// ~/Library/Application Support/Claude/claude_desktop_config.json (macOS)
{
  "mcpServers": {
    "my-server": {
      "command": "node",
      "args": ["/path/to/your/dist/index.js"]
    }
  }
}

Usage

Creating Tools

Tools are the primary way MCP servers expose functionality. Each tool requires a name, Zod schema for validation, and an async handler function.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp";
import { z } from "zod";

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

// Simple calculation tool
server.tool(
  "calculate",
  {
    operation: z.enum(["add", "subtract", "multiply", "divide"]),
    a: z.number(),
    b: z.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": result = a / b; break;
    }
    
    return {
      content: [{
        type: "text",
        text: `Result: ${result}`
      }]
    };
  }
);

Tools can return multiple content types including text, images, and embedded resources. The SDK automatically handles serialization and protocol compliance.

Managing Resources

Resources provide structured data access with URI-based addressing. Use ResourceTemplates for dynamic content generation:

import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp";

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

// Dynamic resource with URI parameters
server.resource(
  "user-profile",
  new ResourceTemplate("user://{username}", {
    list: async () => [
      { uri: "user://alice", name: "Alice's Profile" },
      { uri: "user://bob", name: "Bob's Profile" }
    ]
  }),
  async (uri, { username }) => ({
    contents: [{
      uri: uri.href,
      mimeType: "application/json",
      text: JSON.stringify({
        username,
        created: new Date().toISOString()
      })
    }]
  })
);

Resources support pagination, caching headers, and streaming for large datasets. The template system enables REST-like patterns while maintaining protocol compliance.

Implementing Prompts

Prompts provide reusable templates for common AI interactions. They help standardize how your server communicates with language models:

server.prompt(
  "code-review",
  {
    language: z.enum(["typescript", "javascript", "python"]),
    code: z.string(),
    focus: z.enum(["security", "performance", "style"]).optional()
  },
  ({ language, code, focus }) => ({
    messages: [{
      role: "user",
      content: `Review this ${language} code${focus ? ` focusing on ${focus}` : ''}:\n\n\`\`\`${language}\n${code}\n\`\`\`\n\nProvide specific suggestions for improvement.`
    }]
  })
);

Common Issues

Error: Cannot find module '@modelcontextprotocol/sdk'

Module resolution failures often occur with incorrect TypeScript configuration. The SDK requires Node16 or NodeNext module resolution.

// Fix: Update tsconfig.json
{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext"
  }
}
# Verify installation
$npm list @modelcontextprotocol/sdk

This error can also occur when running TypeScript files directly without compilation. Use tsx for development or compile before running in production.

Error: MCP error -32000: Connection closed

Transport initialization failures cause immediate connection drops. This typically happens when the server crashes during startup or when stdio streams are incorrectly configured.

// Add error handling to prevent crashes
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk";

server.onerror = (error) => {
  console.error("Server error:", error);
  // Log but don't exit - let MCP handle reconnection
};

process.on('SIGINT', async () => {
  await server.close();
  process.exit(0);
});

Error: Tool not found (code -32602)

Missing tool registration or naming mismatches cause tool execution failures. Tools must be registered before connecting the transport.

// Register all tools before connecting
server.tool("my_tool", schema, handler);
server.tool("another_tool", schema2, handler2);

// Only connect after all tools are registered
const transport = new StdioServerTransport();
await server.connect(transport);

Tool names must match exactly when called by clients. Use consistent naming conventions (snake_case is recommended) across your server.

Examples

File System Explorer

A practical MCP server that provides safe file system access with proper error handling and security constraints:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio";
import { z } from "zod";
import * as fs from "fs/promises";
import * as path from "path";

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

// Sandbox to user's home directory for security
const SANDBOX = process.env.HOME || "/home/user";

server.tool(
  "list_files",
  {
    directory: z.string().default("."),
    pattern: z.string().optional()
  },
  async ({ directory, pattern }) => {
    const fullPath = path.resolve(SANDBOX, directory);
    
    // Security: Ensure path stays within sandbox
    if (!fullPath.startsWith(SANDBOX)) {
      throw new Error("Access denied: Path outside sandbox");
    }
    
    const entries = await fs.readdir(fullPath, { withFileTypes: true });
    const files = entries
      .filter(e => !pattern || e.name.includes(pattern))
      .map(e => ({
        name: e.name,
        type: e.isDirectory() ? "dir" : "file"
      }));
    
    return {
      content: [{
        type: "text",
        text: JSON.stringify(files, null, 2)
      }]
    };
  }
);

server.tool(
  "read_file",
  {
    filePath: z.string(),
    encoding: z.enum(["utf8", "base64"]).default("utf8")
  },
  async ({ filePath, encoding }) => {
    const fullPath = path.resolve(SANDBOX, filePath);
    
    if (!fullPath.startsWith(SANDBOX)) {
      throw new Error("Access denied");
    }
    
    const content = await fs.readFile(fullPath, encoding);
    
    return {
      content: [{
        type: "text",
        text: content
      }]
    };
  }
);

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

This server demonstrates path sandboxing for security, flexible parameter handling with defaults, and proper error messages for debugging. Production deployments should add rate limiting, audit logging, and more granular permissions.

Database Query Interface

An MCP server that safely exposes database access with parameterized queries and connection pooling:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp";
import { z } from "zod";
import { createPool, Pool } from "mysql2/promise";

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

let pool: Pool;

// Initialize connection pool
async function initDatabase() {
  pool = createPool({
    host: process.env.DB_HOST || "localhost",
    user: process.env.DB_USER || "readonly",
    password: process.env.DB_PASSWORD,
    database: process.env.DB_NAME,
    waitForConnections: true,
    connectionLimit: 10,
    queueLimit: 0
  });
}

server.tool(
  "query_database",
  {
    query: z.string(),
    parameters: z.array(z.any()).optional(),
    limit: z.number().max(1000).default(100)
  },
  async ({ query, parameters, limit }) => {
    // Security: Only allow SELECT queries
    if (!query.trim().toUpperCase().startsWith("SELECT")) {
      throw new Error("Only SELECT queries are allowed");
    }
    
    // Add limit to prevent large result sets
    const limitedQuery = `${query} LIMIT ${limit}`;
    
    try {
      const [rows] = await pool.execute(limitedQuery, parameters);
      
      return {
        content: [{
          type: "text",
          text: JSON.stringify(rows, null, 2)
        }]
      };
    } catch (error) {
      return {
        content: [{
          type: "text",
          text: `Query error: ${error.message}`
        }]
      };
    }
  }
);

// Graceful shutdown
process.on("SIGTERM", async () => {
  await pool.end();
  await server.close();
  process.exit(0);
});

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

Key design decisions include read-only access enforcement, parameterized queries to prevent SQL injection, connection pooling for performance, and graceful shutdown handling. For production use, add query timeout controls, result caching, and comprehensive audit logging.

API Integration Gateway

A sophisticated MCP server that acts as a gateway to external APIs with authentication and caching:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp";
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp";
import { z } from "zod";

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

// In-memory cache with TTL
const cache = new Map<string, { data: any; expires: number }>();

server.tool(
  "call_api",
  {
    endpoint: z.string().url(),
    method: z.enum(["GET", "POST", "PUT", "DELETE"]).default("GET"),
    headers: z.record(z.string()).optional(),
    body: z.any().optional(),
    cacheTTL: z.number().default(300) // 5 minutes
  },
  async ({ endpoint, method, headers, body, cacheTTL }) => {
    const cacheKey = `${method}:${endpoint}:${JSON.stringify(body)}`;
    
    // Check cache
    const cached = cache.get(cacheKey);
    if (cached && cached.expires > Date.now()) {
      return {
        content: [{
          type: "text",
          text: JSON.stringify({
            cached: true,
            data: cached.data
          }, null, 2)
        }]
      };
    }
    
    // Make API request
    const response = await fetch(endpoint, {
      method,
      headers: {
        "Content-Type": "application/json",
        ...headers
      },
      body: body ? JSON.stringify(body) : undefined
    });
    
    const data = await response.json();
    
    // Cache successful responses
    if (response.ok) {
      cache.set(cacheKey, {
        data,
        expires: Date.now() + (cacheTTL * 1000)
      });
    }
    
    return {
      content: [{
        type: "text",
        text: JSON.stringify({
          status: response.status,
          data
        }, null, 2)
      }]
    };
  }
);

// Resource for API documentation
server.resource(
  "api-docs",
  new ResourceTemplate("api://docs/{service}", {
    list: async () => [
      { uri: "api://docs/weather", name: "Weather API" },
      { uri: "api://docs/geocoding", name: "Geocoding API" }
    ]
  }),
  async (uri, { service }) => ({
    contents: [{
      uri: uri.href,
      mimeType: "text/markdown",
      text: `# ${service} API Documentation\n\nEndpoints and usage examples...`
    }]
  })
);

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

This gateway pattern enables AI assistants to interact with external services while providing caching for efficiency, flexible authentication options, and structured documentation access. Production deployments should implement OAuth token management, request retry logic, and comprehensive error handling for different API failure modes.