Blog

How to Build a Custom MCP Server for Claude Code

A step-by-step guide to building a custom MCP server for Claude Code using the TypeScript SDK, covering architecture, tool definitions, and common patterns.

Phos Team ·
claude code

Why You Would Build a Custom MCP Server

The open-source MCP ecosystem covers many common services. GitHub, Postgres, Notion, Slack, and others all have published servers. But two categories of need are not covered by public servers: proprietary internal APIs and custom internal tools.

If your company runs a bespoke CRM, an internal pricing engine, a custom data warehouse query layer, or a legacy system with no public MCP server, you need to build one. The same applies if you want Claude to interact with an internal service in a way that goes beyond what a generic server provides.

Building a custom MCP server is also how you create Claude Code tools that enforce business logic, apply access controls, or transform data before Claude sees it.

A custom MCP server is the bridge between Claude Code and any system your team owns but the public ecosystem does not cover.


MCP Server Architecture

An MCP server is a process that communicates with Claude Code over a defined protocol. The server:

  1. Starts up and registers its available tools with their names, descriptions, and input schemas
  2. Listens for tool call requests from Claude
  3. Executes the requested action (API call, database query, file operation)
  4. Returns the result to Claude

The protocol uses JSON-RPC over stdio (standard input/output) for local servers. Claude Code launches the server as a child process and communicates with it through that channel.

Your server code does not need to manage the protocol directly. The MCP TypeScript SDK handles the protocol layer, leaving you to define tools and implement handlers.


Step 1: Initialize the Project

Create a new directory and initialize a Node.js project with the MCP SDK.

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

Add a tsconfig.json:

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

Create src/index.ts as your entry point.


Step 2: Define Your Tools

Tools have three components: a name, a description Claude uses to decide when to call the tool, and an input schema defining what parameters the tool accepts.

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

const server = new Server(
  { name: "my-internal-api", version: "1.0.0" },
  { capabilities: { tools: {} } }
);

server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: "get_customer",
        description: "Fetch a customer record from the internal CRM by customer ID",
        inputSchema: {
          type: "object",
          properties: {
            customer_id: {
              type: "string",
              description: "The unique customer identifier",
            },
          },
          required: ["customer_id"],
        },
      },
      {
        name: "list_open_orders",
        description: "List all open orders for a customer",
        inputSchema: {
          type: "object",
          properties: {
            customer_id: {
              type: "string",
              description: "The unique customer identifier",
            },
            limit: {
              type: "number",
              description: "Maximum number of orders to return (default 20)",
            },
          },
          required: ["customer_id"],
        },
      },
    ],
  };
});

Write tool descriptions as if explaining to a developer what the tool does. Claude uses these descriptions to decide which tool to call and when.


Step 3: Implement the Handlers

The tool handler receives the tool name and arguments, executes the action, and returns a result.

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

  if (name === "get_customer") {
    const { customer_id } = args as { customer_id: string };
    
    const response = await fetch(
      `${process.env.CRM_BASE_URL}/customers/${customer_id}`,
      {
        headers: {
          Authorization: `Bearer ${process.env.CRM_API_KEY}`,
        },
      }
    );

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

    const customer = await response.json();
    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(customer, null, 2),
        },
      ],
    };
  }

  if (name === "list_open_orders") {
    // similar implementation
  }

  return {
    content: [{ type: "text", text: `Unknown tool: ${name}` }],
    isError: true,
  };
});

Return errors as content with isError: true rather than throwing exceptions. This gives Claude useful information about what went wrong.


Step 4: Start the Server

Add the transport initialization at the bottom of your file:

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("MCP server running on stdio");
}

main().catch(console.error);

Note that any logging should go to stderr, not stdout. The MCP protocol uses stdout for communication. Anything written to stdout outside of the protocol will corrupt the connection.


Step 5: Test Locally

Before configuring Claude Code, test your server manually. Add a dev script to package.json:

{
  "scripts": {
    "dev": "tsx src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

Run the server:

CRM_BASE_URL=https://your-crm.internal CRM_API_KEY=test123 npm run dev

The server should start without errors. You can then send test requests using the MCP inspector tool or simply configure it in Claude Code and test interactively.


Common Patterns

REST API Wrapper

The most common pattern. Your server wraps an existing REST API, translating MCP tool calls into HTTP requests.

Key considerations:

  • Store base URLs and API keys in environment variables
  • Handle pagination by either fetching all results or exposing a cursor parameter
  • Normalize error responses into readable text before returning them

Database Connector

Your server connects directly to a database and exposes query tools.

Key considerations:

  • Use read-only database users for query tools
  • Validate and sanitize all inputs before constructing queries
  • Return results as formatted JSON or markdown tables for readability
  • Expose schema inspection tools separately from data query tools

File Processor

Your server reads, transforms, or processes files in formats Claude cannot handle natively.

Key considerations:

  • Accept file paths as inputs rather than file contents (for large files)
  • Return structured summaries rather than raw file dumps when files are large
  • Consider streaming for very large outputs

Configuring Your Server in Claude Code

Once built, register your server in .mcp.json:

{
  "mcpServers": {
    "my-internal-api": {
      "command": "node",
      "args": ["/absolute/path/to/my-mcp-server/dist/index.js"],
      "env": {
        "CRM_BASE_URL": "https://your-crm.internal",
        "CRM_API_KEY": "your_key_here"
      }
    }
  }
}

Use the absolute path to your built server. Use environment variables for all secrets. Restart Claude Code to load the new server.


Frequently Asked Questions

Does my MCP server need to be written in TypeScript?

No. The MCP protocol is language-agnostic. Anthropic provides official SDKs for TypeScript and Python. Community SDKs exist for Go, Rust, and other languages. TypeScript is the most common choice because the official SDK is well-documented and the ecosystem of examples is largest.

Can my MCP server maintain state between tool calls?

Yes. The server is a long-running process for the duration of a Claude Code session. You can maintain in-memory state, connection pools, or cached data between tool calls. Be aware that the server restarts each time Claude Code restarts.

How do I handle authentication for users with different permissions?

The simplest approach is to run one server instance per permission level, each with different credentials configured in the environment. More sophisticated setups can accept a user token as a tool parameter and enforce permissions in the handler. The right approach depends on your security requirements.

Can I publish my custom server for others to use?

Yes. MCP servers are just npm packages. You can publish yours to npm, add it to the MCP servers community registry, and document it for others. If your server wraps a service others use, publishing it saves them the build time and contributes to the ecosystem.


Ready to build your custom MCP server?

You now have the full pattern: project setup, tool definitions, handlers, testing, and configuration. Whether you build it yourself or bring in help, the next step is the same, get one tool working end-to-end before expanding the server.

Path one: build it yourself. The TypeScript SDK is well-documented. Start with a single tool wrapping one endpoint, verify it works in Claude Code, then expand. The MCP setup guide covers how to configure your finished server in Claude Code.

Path two: work with Phos AI Labs. If you want a custom MCP server built for your internal APIs as part of a broader Claude Code implementation, Phos AI Labs is a CCA-F certified Claude implementation partner that handles that work. Thirty minutes, no deck. Start here.

Related articles

The fastest way to know whether we're the right fit, is a conversation.

STEP 1/2 · ABOUT YOU