All posts
MCP ServersMay 5, 20256 min read

Building an MCP Server in TypeScript: A Complete Guide

The Model Context Protocol (MCP) lets you give Claude and other LLMs access to custom tools, data sources, and APIs. Here's how to build, test, and deploy your own MCP server from scratch.

mcpclaudetypescriptapideveloper tools
Arjun KayalMoni
Arjun KayalMoni

@fullstack-spiderman

Updated May 16, 2026

Building an MCP Server in TypeScript: A Complete Guide

The Model Context Protocol (MCP) is Anthropic's open standard for connecting LLMs to external tools and data sources. Instead of building tool integrations separately for every AI product, you write one MCP server and it works with Claude Desktop, Claude Code, and any other MCP-compatible client.

Think of it as a universal plugin system for AI assistants.

What MCP Gives You

Without MCP, giving Claude access to a custom API requires prompt engineering hacks or bespoke integrations. With MCP, you define a server that exposes:

  • Tools — functions the LLM can call (like search_database, create_ticket, send_email)
  • Resources — read-only data sources the LLM can access (like files, database records, API responses)
  • Prompts — reusable prompt templates your server exposes

The LLM negotiates with your server over a well-defined protocol. You write the server once; it works everywhere.

Project Setup

mkdir my-mcp-server && cd my-mcp-server
bun init -y
bun add @modelcontextprotocol/sdk zod
bun add -d typescript @types/node
// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true
  },
  "include": ["src"]
}

Building a Task Manager MCP Server

We'll build an MCP server that manages a simple task list — realistic enough to show the patterns, simple enough to understand in one reading.

// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
 
// In-memory task store (replace with a DB in production)
interface Task {
  id: string;
  title: string;
  done: boolean;
  createdAt: string;
}
 
const tasks: Task[] = [];
let nextId = 1;
 
// Create the MCP server
const server = new McpServer({
  name: "task-manager",
  version: "1.0.0",
});
 
// Tool: Add a task
server.tool(
  "add_task",
  "Add a new task to the list",
  { title: z.string().describe("The task description") },
  async ({ title }) => {
    const task: Task = {
      id: String(nextId++),
      title,
      done: false,
      createdAt: new Date().toISOString(),
    };
    tasks.push(task);
    return {
      content: [{ type: "text", text: `Created task #${task.id}: "${title}"` }],
    };
  }
);
 
// Tool: List tasks
server.tool(
  "list_tasks",
  "List all tasks, optionally filtered by status",
  {
    filter: z
      .enum(["all", "pending", "done"])
      .default("all")
      .describe("Which tasks to show"),
  },
  async ({ filter }) => {
    const filtered = tasks.filter((t) => {
      if (filter === "pending") return !t.done;
      if (filter === "done") return t.done;
      return true;
    });
 
    if (filtered.length === 0) {
      return { content: [{ type: "text", text: "No tasks found." }] };
    }
 
    const list = filtered
      .map((t) => `${t.done ? "✅" : "⬜"} [${t.id}] ${t.title}`)
      .join("\n");
 
    return { content: [{ type: "text", text: list }] };
  }
);
 
// Tool: Complete a task
server.tool(
  "complete_task",
  "Mark a task as done",
  { id: z.string().describe("Task ID to mark as complete") },
  async ({ id }) => {
    const task = tasks.find((t) => t.id === id);
    if (!task) {
      return {
        content: [{ type: "text", text: `Task #${id} not found.` }],
        isError: true,
      };
    }
    task.done = true;
    return {
      content: [{ type: "text", text: `Marked task #${id} as done: "${task.title}"` }],
    };
  }
);
 
// Expose tasks as a resource
server.resource(
  "tasks",
  "tasks://all",
  async (uri) => ({
    contents: [
      {
        uri: uri.href,
        mimeType: "application/json",
        text: JSON.stringify(tasks, null, 2),
      },
    ],
  })
);
 
// Start the server over stdio (standard for local MCP servers)
const transport = new StdioServerTransport();
await server.connect(transport);

Testing Locally with Claude Desktop

Add your server to Claude Desktop's config (~/Library/Application Support/Claude/claude_desktop_config.json on macOS):

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

Or with Bun (no build step needed):

{
  "mcpServers": {
    "task-manager": {
      "command": "bun",
      "args": ["run", "/absolute/path/to/my-mcp-server/src/index.ts"]
    }
  }
}

Restart Claude Desktop. You'll see a hammer icon (🔨) in the chat interface — click it to see your tools listed. Now ask Claude: "Add a task: finish the MCP blog post" and watch it call your tool.

Adding Input Validation and Error Handling

Zod handles validation automatically — if the LLM passes a bad input, it gets a descriptive error instead of a crash. But you still need to handle errors from your business logic:

server.tool(
  "get_task",
  "Get details of a specific task",
  { id: z.string() },
  async ({ id }) => {
    try {
      const task = await db.tasks.findById(id); // your real DB call
      if (!task) {
        return {
          content: [{ type: "text", text: `Task ${id} does not exist.` }],
          isError: true, // signals to the LLM this is an error state
        };
      }
      return {
        content: [{ type: "text", text: JSON.stringify(task, null, 2) }],
      };
    } catch (error) {
      return {
        content: [
          { type: "text", text: `Database error: ${(error as Error).message}` },
        ],
        isError: true,
      };
    }
  }
);

Always use isError: true rather than throwing — this lets the LLM recover gracefully instead of crashing the conversation.

Connecting to Real APIs

Here's the same pattern connecting to a real external API — a GitHub issues tool:

import { Octokit } from "@octokit/rest";
 
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
 
server.tool(
  "create_github_issue",
  "Create a GitHub issue in the specified repository",
  {
    owner: z.string().describe("Repository owner"),
    repo: z.string().describe("Repository name"),
    title: z.string().describe("Issue title"),
    body: z.string().optional().describe("Issue description"),
    labels: z.array(z.string()).optional().describe("Labels to apply"),
  },
  async ({ owner, repo, title, body, labels }) => {
    const response = await octokit.issues.create({
      owner,
      repo,
      title,
      body,
      labels,
    });
    return {
      content: [
        {
          type: "text",
          text: `Created issue #${response.data.number}: ${response.data.html_url}`,
        },
      ],
    };
  }
);

Using with Claude Code

Add your server to Claude Code's settings (~/.claude/settings.json):

{
  "mcpServers": {
    "task-manager": {
      "command": "bun",
      "args": ["run", "/path/to/my-mcp-server/src/index.ts"],
      "env": {
        "DATABASE_URL": "postgresql://localhost/mydb"
      }
    }
  }
}

The env field lets you pass secrets without hardcoding them in your server.

What to Build Next

Some MCP server ideas that are immediately useful:

  • Codebase search — expose grep, AST search, or semantic search over your repo
  • Database explorer — let Claude read schema and run read-only queries
  • Deployment tools — wrap your CI/CD CLI in MCP tools
  • Documentation fetcher — scrape and cache docs from libraries you use
  • Issue tracker — bidirectional Linear/Jira integration

The pattern is always the same: take a thing you want Claude to do, wrap it in an MCP tool with good Zod validation and error handling, and wire it up. Once it's an MCP server, it works in every context automatically.

📚

Enjoyed this article?

If it saved you time or sparked an idea, consider buying me a book.

📚 Buy me a book