Skip to content

Chapter 14: Building Custom MCP Servers ​

First edition (March 2026) · archived

You are reading the first edition; some of it is out of date. Read the second edition →

When to Build Your Own ​

The MCP registry contains hundreds of servers for common tools: GitHub, Slack, Jira, Notion, Stripe, databases, and more. For most integrations, the right move is to use an existing server.

Build a custom MCP server when:

  • Your company has internal APIs or services that no public server covers
  • You need to wrap proprietary tooling or a private data store
  • You want to encapsulate complex business logic as a tool Claude can call
  • An existing server exists but does not expose the specific operations you need
  • You want to give Claude access to a command-line tool with a specific interface

Custom MCP servers are also the right solution when you want to package reusable capabilities for your team — a server that connects to your internal monitoring system, queries your feature flag service, or wraps your deployment pipeline.


MCP Architecture Overview ​

Every MCP server, regardless of what it does, has the same basic structure:

  • It listens for requests over a transport (stdio or HTTP)
  • It responds to an initialize handshake that declares its capabilities
  • It exposes tools (callable functions), resources (readable data), and/or prompts (reusable instruction templates)
  • Claude Code discovers and calls these through the MCP protocol

The simplest mental model: an MCP server is a local or remote service that Claude treats like a programmable API. You define what tools it has, what parameters those tools accept, and what they return. Claude decides when to use them.


TypeScript SDK Walkthrough ​

The official MCP SDK for TypeScript (@modelcontextprotocol/sdk) is the most ergonomic way to build a server. Start with a minimal working example and expand from there.

Setup:

bash
mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk
npm install --save-dev typescript @types/node tsx
npx tsc --init

Minimal server (src/index.ts):

typescript
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-company-tools",
  version: "1.0.0",
});

// Define a tool
server.tool(
  "get_deploy_status",
  "Get the current deployment status of a service",
  {
    service: z.string().describe("The service name to check"),
    environment: z.enum(["dev", "staging", "prod"]).describe("The target environment"),
  },
  async ({ service, environment }) => {
    // Your actual implementation here
    const status = await checkDeployStatus(service, environment);
    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(status, null, 2),
        },
      ],
    };
  }
);

// Connect to stdio transport and start listening
const transport = new StdioServerTransport();
await server.connect(transport);

Add a script to package.json:

json
{
  "scripts": {
    "start": "tsx src/index.ts"
  }
}

Register the server with Claude Code:

bash
claude mcp add --transport stdio my-tools -- npm --prefix /absolute/path/to/my-mcp-server run start

Defining Tools, Resources, and Prompts ​

Tools ​

Tools are callable functions — the most common MCP primitive. Each tool has a name, description, input schema, and handler function.

The description is critical: it tells Claude when to use the tool. Write it as you would write documentation for a human developer.

typescript
server.tool(
  "search_internal_docs",
  "Search the company's internal documentation and knowledge base. " +
  "Use this when the user asks questions about internal processes, " +
  "company policies, or internal APIs.",
  {
    query: z.string().describe("The search query"),
    category: z.enum(["engineering", "product", "hr", "all"])
      .optional()
      .describe("Limit results to a specific category"),
    limit: z.number().min(1).max(20).default(5)
      .describe("Number of results to return"),
  },
  async ({ query, category, limit }) => {
    const results = await searchDocs(query, { category, limit });
    return {
      content: [
        {
          type: "text",
          text: results.map(r => `**${r.title}**\n${r.excerpt}\n${r.url}`).join("\n\n"),
        },
      ],
    };
  }
);

Resources ​

Resources expose readable data that Claude can reference, similar to how files work. They are identified by URIs and can be static or dynamic.

typescript
server.resource(
  "deployment-runbook",
  "runbook://deployments/standard",
  async (uri) => {
    return {
      contents: [
        {
          uri: uri.href,
          mimeType: "text/markdown",
          text: await fetchRunbook("deployments/standard"),
        },
      ],
    };
  }
);

// Resource with dynamic listing
server.resourceTemplate(
  "service-runbook",
  new ResourceTemplate("runbook://{service}", { list: undefined }),
  async (uri, { service }) => {
    return {
      contents: [
        {
          uri: uri.href,
          mimeType: "text/markdown",
          text: await fetchRunbook(`services/${service}`),
        },
      ],
    };
  }
);

Prompts ​

Prompts are reusable instruction templates that become slash commands in Claude Code, prefixed as /mcp__servername__promptname.

typescript
server.prompt(
  "incident_response",
  "Start an incident response workflow for a production issue",
  {
    service: z.string().describe("The affected service"),
    severity: z.enum(["sev1", "sev2", "sev3"]),
  },
  async ({ service, severity }) => {
    return {
      messages: [
        {
          role: "user",
          content: {
            type: "text",
            text: `You are the on-call engineer responding to a ${severity} incident affecting ${service}.

Start by checking the service status, recent deployments, and error rates.
Then guide me through the standard incident response checklist.`,
          },
        },
      ],
    };
  }
);

Users invoke this in Claude Code as /mcp__my-company-tools__incident_response.


Transport Options: stdio vs HTTP ​

stdio (standard input/output): The server runs as a child process. Claude Code spawns it, communicates over stdin/stdout, and terminates it when done. This is the right choice for:

  • Local tools that need filesystem or local network access
  • Tools wrapping command-line utilities
  • Tools that should not be accessible over the network
bash
# stdio registration
claude mcp add --transport stdio my-tools -- node /path/to/server/dist/index.js

HTTP (Streamable HTTP): The server runs as a persistent HTTP service. This is the right choice for:

  • Tools that need to serve multiple Claude Code instances
  • Tools deployed on remote infrastructure
  • Tools that need to maintain persistent state between calls
  • Team-shared tools running on internal company servers
typescript
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";

const app = express();
const transport = new StreamableHTTPServerTransport({ path: "/mcp" });

app.use("/mcp", transport.handler());

server.connect(transport);

app.listen(3001, () => {
  console.log("MCP server running on http://localhost:3001/mcp");
});
bash
# HTTP registration
claude mcp add --transport http my-remote-tools http://localhost:3001/mcp

For team-deployed HTTP servers, add authentication:

bash
claude mcp add --transport http my-remote-tools https://internal.company.com/mcp \
  --header "Authorization: Bearer ${INTERNAL_MCP_TOKEN}"

Testing and Debugging MCP Servers ​

Local testing with the MCP Inspector:

bash
npx @modelcontextprotocol/inspector tsx src/index.ts

This opens a browser-based interface where you can browse your server's tools, call them with test inputs, and inspect responses — without needing Claude Code running.

Within Claude Code:

text
/mcp
# Shows all connected servers and their tools

# Test a specific tool
Use the get_deploy_status tool to check the "api-gateway" service in "staging".

Enable verbose logging:

bash
MCP_TIMEOUT=30000 claude --mcp-debug

This shows MCP communication details: what tools Claude is calling, what parameters it passes, and what responses come back.

Common debugging patterns:

Add structured logging in your tool handlers:

typescript
server.tool("my_tool", "Description", { param: z.string() }, async ({ param }) => {
  console.error(`[my_tool] Called with param: ${param}`); // stderr goes to MCP logs

  try {
    const result = await doWork(param);
    return { content: [{ type: "text", text: JSON.stringify(result) }] };
  } catch (error) {
    // Return errors as tool results, not thrown exceptions
    return {
      content: [{ type: "text", text: `Error: ${error.message}` }],
      isError: true,
    };
  }
});

Use console.error (not console.log) for debug output in stdio servers — stdout is reserved for MCP protocol messages.


Deployment and Distribution ​

For personal use: Keep the server source in a local directory and register it with local scope. No special deployment needed.

For team use (stdio):

  • Check the server code into a shared repository
  • Have team members clone it and register it locally
  • Or package it as an npm package and install via npx

For team use (HTTP):

  • Deploy to your internal infrastructure (same options as any Node.js service)
  • Use project scope and commit the .mcp.json with the HTTP URL (not credentials)
  • Store auth tokens in environment variables on each developer's machine

As an npm package: If you want others to use your server via npx, publish it to npm:

json
{
  "name": "@yourcompany/mcp-internal-tools",
  "version": "1.0.0",
  "bin": {
    "mcp-internal-tools": "dist/index.js"
  }
}

Then team members install with:

bash
claude mcp add --transport stdio company-tools -- npx -y @yourcompany/mcp-internal-tools

Plugin bundling: For the tightest integration, bundle your MCP server with a Claude Code plugin (.mcp.json at the plugin root). This way, enabling the plugin automatically starts the server — no per-developer registration required.


A Complete Working Example ​

Here is a minimal but complete MCP server that wraps an internal feature flag service:

typescript
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: "feature-flags",
  version: "1.0.0",
});

const FLAGS_API = process.env.FLAGS_API_URL ?? "http://localhost:8080";
const FLAGS_TOKEN = process.env.FLAGS_API_TOKEN;

server.tool(
  "list_flags",
  "List all feature flags and their current enabled/disabled status",
  { environment: z.enum(["dev", "staging", "prod"]) },
  async ({ environment }) => {
    const response = await fetch(`${FLAGS_API}/flags?env=${environment}`, {
      headers: { Authorization: `Bearer ${FLAGS_TOKEN}` },
    });
    const flags = await response.json();
    const lines = flags.map((f: any) => `${f.enabled ? "ON " : "OFF"} ${f.name}: ${f.description}`);
    return { content: [{ type: "text", text: lines.join("\n") }] };
  }
);

server.tool(
  "get_flag",
  "Check whether a specific feature flag is enabled",
  {
    flag_name: z.string(),
    environment: z.enum(["dev", "staging", "prod"]),
  },
  async ({ flag_name, environment }) => {
    const response = await fetch(`${FLAGS_API}/flags/${flag_name}?env=${environment}`, {
      headers: { Authorization: `Bearer ${FLAGS_TOKEN}` },
    });
    if (!response.ok) {
      return { content: [{ type: "text", text: `Flag not found: ${flag_name}` }], isError: true };
    }
    const flag = await response.json();
    return {
      content: [{ type: "text", text: `Flag "${flag_name}" is ${flag.enabled ? "ENABLED" : "DISABLED"} in ${environment}` }],
    };
  }
);

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

Register it:

bash
claude mcp add --transport stdio --scope project feature-flags \
  --env FLAGS_API_URL=http://flags.internal.company.com \
  --env FLAGS_API_TOKEN=your_token_here \
  -- npx tsx /path/to/feature-flags/src/index.ts

Now any developer on the team can ask:

text
Which feature flags are currently enabled in staging?
Is the "new-checkout-flow" flag enabled in production?

Next up: Chapter 15 — Context Window Management — Understanding and managing the context window for long sessions.

Released under CC BY-SA 4.0.