DH
13 min read

Building MCP Servers for Claude Code: A Production Pattern for Custom Tooling

Deploy custom MCP servers that give Claude safe, structured access to your APIs and databases—a production-ready pattern from infrastructure first principles.

ainodejstypescript

Building MCP Servers for Claude Code: A Production Pattern for Custom Tooling

If you've been using Claude Code for more than a few days, you've hit the same wall I did: the built-in tools are useful, but the moment you need Claude to interact with your own internal APIs, databases, or deployment pipelines, you're copy-pasting context into the chat. The Model Context Protocol (MCP) solves this. Building MCP servers for Claude Code is a pattern worth getting right from the start.

This tutorial is for engineers who want a production-ready shape for custom MCP tooling — not a toy example that collapses under real usage.


What MCP Actually Is (and Isn't)

MCP is an open protocol that exposes tools, resources, and prompts to an LLM host like Claude Code. Think of it as a structured RPC layer: Claude calls your server, your server does something useful, Claude gets back structured data. Anthropic launched MCP in November 2024 and the community has since built thousands of server implementations across every major programming language.

It is not a plugin marketplace or a cloud service. You run the server. You control the transport. That matters for security when your tools touch production credentials.

The three primitives

Understanding what each primitive is for prevents you building the wrong thing:

PrimitivePurposeExample
ToolsCallable functions that do something (side effects allowed)create_jira_ticket, run_db_query
ResourcesRead-only data the model can inspectfile://, db://schema, a REST API response
PromptsReusable prompt templates with parametersA canned "summarise PR diff" prompt your whole team shares

Most internal tooling starts with tools. Resources and prompts are worth adding once the core loop works.


The Architecture I Reach For

For anything beyond a quick prototype, I use a three-layer structure:

claude-code MCP host


MCP server process (stdio or SSE transport)


Your internal services / APIs / databases

The MCP server sits as a thin orchestration layer. It validates inputs, calls your real services, and returns clean structured output. Keep business logic in your services, not in the MCP server. That separation saves you when testing the server without spinning up a full environment.

Stdio vs SSE transport — which to pick

For Claude Code specifically, stdio is almost always the right choice for local and per-project servers. The process is spawned by Claude Code directly, inherits a controlled environment, and dies with the session. There is no port to manage, no TLS to configure, and no firewall rule to punch.

SSE (Server-Sent Events) transport — which runs over HTTP — is the right choice when you need a shared server: one instance serving a whole team, or a server deployed behind your corporate auth layer. The trade-off is operational overhead: you now have a long-running process, a health check, and a URL to distribute.

If you are not sure, start with stdio. You can migrate to SSE later without changing any tool logic.


Bootstrapping the Server

The official @modelcontextprotocol/sdk for Node.js is the most actively maintained option (12.7k GitHub stars, 1,500+ commits as of mid-2025). Python's mcp package is a solid alternative if your team lives in that ecosystem.

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

Add a tsconfig.json targeting ESM (the SDK ships as ESM):

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

Create src/index.ts:

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

server.tool(
"get_deployment_status",
"Returns the current deployment status for a given service",
{
service: z.string().describe("The service name to query"),
environment: z.enum(["staging", "production"]).default("staging"),
},
async ({ service, environment }) => {
// Replace with your actual internal call
const status = await fetchDeploymentStatus(service, environment);
return {
content: [
{
type: "text",
text: JSON.stringify(status, null, 2),
},
],
};
}
);

async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}

main().catch(console.error);

Zod schema validation on inputs is non-negotiable in production. Claude sends malformed arguments when inference goes sideways — validate everything at the boundary.


Registering With Claude Code

Claude Code looks for MCP server configuration in two places, applied in precedence order:

  1. User-level~/.claude/settings.json (available in every project)
  2. Project-level.claude/settings.json in the project root (checked into source control, shared with your team)

For a team-shared internal tool, commit the project-level file. In your project root:

{
"mcpServers": {
"my-internal-tools": {
"command": "node",
"args": ["dist/index.js"],
"cwd": "/absolute/path/to/my-mcp-server",
"env": {
"API_BASE_URL": "https://your-internal-api.example.com"
}
}
}
}

cwd matters. If Claude Code is not finding your server binary, a missing or wrong cwd is the first thing to check. Relative paths in args are resolved relative to cwd, not the project root.

Build your TypeScript (npx tsc), restart Claude Code, and your tools appear in the tool list immediately. No daemon restart, no configuration portal.

Verifying registration worked

Run this in a Claude Code session:

/mcp

The /mcp command lists every registered server and its status. A green tick means the server process started and responded to the MCP handshake. A red cross means the process failed — check stderr output, which Claude Code surfaces inline.


Production Concerns You Can't Skip

1. Error handling with structure

Return errors as structured content, not thrown exceptions. Claude handles structured error text gracefully; uncaught exceptions crash the server process and leave Claude with a blank tool response.

async ({ service, environment }) => {
try {
const result = await fetchDeploymentStatus(service, environment);
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
// isError: true signals Claude that the tool failed — it will
// incorporate this into its reasoning rather than treating it
// as a successful empty result.
return {
content: [{ type: "text", text: `Error: ${message}` }],
isError: true,
};
}
}

A real failure scenario worth knowing: if your internal API returns a 503 and you throw an unhandled rejection, the MCP server process exits. Claude Code will show a generic "tool call failed" with no useful context. With isError: true, Claude Code displays your error message inline and Claude can reason about it: "the deployment status API is unavailable — I'll skip that step and tell the user."

2. Stdout is the protocol channel — log to stderr only

MCP over stdio means stdout is the wire protocol. A single stray console.log corrupts the JSON framing and produces the most confusing debugging session you will have all week:

SyntaxError: Unexpected token 'D', "Debug: fee"... is not valid JSON

Establish a logging convention at the top of your entry point:

const log = (...args: unknown[]) => process.stderr.write(`[mcp] ${args.join(" ")}\n`);

Claude Code captures stderr from the MCP server process and surfaces it when you run /mcp, so your debug output is accessible without additional log infrastructure.

3. Secrets management

Never hardcode credentials. Three patterns that work in practice:

Environment variables via env block (simplest):

"env": {
"DB_URL": "postgres://user:pass@localhost/mydb",
"INTERNAL_API_KEY": "sk-..."
}

Suitable for local development. Check .claude/settings.json into .gitignore when it contains secrets, or use a separate settings.local.json that is gitignored.

Shell environment inheritance (team-friendly): Omit the env block entirely. The MCP server process inherits the shell environment that launched Claude Code. If your team uses direnv or a .envrc file, secrets are available automatically and never touch version control.

Secrets manager at startup (production-grade):

import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";

async function loadSecrets() {
const client = new SecretsManagerClient({ region: "eu-west-1" });
const response = await client.send(new GetSecretValueCommand({
SecretId: "my-mcp-server/prod",
}));
return JSON.parse(response.SecretString!);
}

Pull secrets once at startup, store in a module-level object, and re-fetch on a schedule if rotation is a concern. This pattern works well when your MCP server runs as a shared SSE process for a team.

4. Tool granularity and description quality

Resist building one giant run_anything tool. Claude's tool selection is driven almost entirely by the tool name and description. Narrow, well-named tools with precise descriptions outperform broad ones by a significant margin.

Compare these two descriptions for the same underlying operation:

// Vague — Claude will hesitate or misfire
server.tool("query", "Runs a query", { sql: z.string() }, ...)

// Precise — Claude knows exactly when to invoke this
server.tool(
"search_internal_knowledge_base",
"Full-text search across internal engineering docs, RFCs, and runbooks. " +
"Returns the top 5 matching documents with their titles and excerpts. " +
"Use this before writing new documentation or when the user asks about internal processes.",
{ query: z.string().describe("Natural language search query") },
...
)

The second description tells Claude when to use the tool, what it returns, and what it is good for. That context is the difference between a tool that gets used correctly and one that never fires.

5. Rate limiting and back-pressure

Claude is an eager agent. If a tool is available, it will call it — repeatedly, in parallel if the task warrants it. Build back-pressure in from the start:

import Bottleneck from "bottleneck";

const limiter = new Bottleneck({
maxConcurrent: 3,
minTime: 200, // ms between requests
});

const result = await limiter.schedule(() => fetchFromInternalAPI(params));

Without this, a single Claude Code session exploring your codebase can saturate an internal API that was not designed for burst traffic.


Structuring a Maintainable Server

Treat your MCP server as a first-class service, not a throwaway script. The structure that scales well:

my-mcp-server/
├── src/
│ ├── index.ts # server setup + tool registration
│ ├── tools/
│ │ ├── deployment.ts # get_deployment_status, rollback_service
│ │ ├── search.ts # search_knowledge_base, search_tickets
│ │ └── database.ts # run_read_query, describe_schema
│ └── lib/
│ ├── api-client.ts # typed wrapper around your internal API
│ └── secrets.ts # secret loading logic
├── tests/
│ ├── tools/
│ │ └── deployment.test.ts
│ └── fixtures/
│ └── api-responses.json
├── dist/
├── tsconfig.json
├── package.json
└── README.md

Each tool file exports a registration function:

// src/tools/deployment.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

export function registerDeploymentTools(server: McpServer) {
server.tool(
"get_deployment_status",
"Returns the current deployment status for a given service and environment",
{
service: z.string().describe("The service name, e.g. 'payments-api'"),
environment: z.enum(["staging", "production"]).default("staging"),
},
async ({ service, environment }) => {
// implementation
}
);

server.tool(
"rollback_service",
"Rolls back a service to its previous deployment. Requires explicit confirmation.",
{
service: z.string(),
environment: z.enum(["staging", "production"]),
confirm: z.literal(true).describe("Must be explicitly set to true"),
},
async ({ service, environment }) => {
// implementation
}
);
}
// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { registerDeploymentTools } from "./tools/deployment.js";
import { registerSearchTools } from "./tools/search.js";

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

registerDeploymentTools(server);
registerSearchTools(server);

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

This keeps code review diffs small and makes it obvious where to add a new tool.


Testing Without Claude Code

You do not need a live Claude Code session to test tool logic. Unit-test your tools in isolation against a stub API:

// tests/tools/deployment.test.ts
import { describe, it, expect, vi } from "vitest";
import * as apiClient from "../../src/lib/api-client.js";

// Stub the network layer
vi.mock("../../src/lib/api-client.js");

describe("get_deployment_status", () => {
it("returns structured status on success", async () => {
vi.mocked(apiClient.fetchDeploymentStatus).mockResolvedValue({
service: "payments-api",
version: "1.4.2",
status: "healthy",
deployedAt: "2025-03-01T14:22:00Z",
});

// Call the handler function directly — no MCP transport involved
const result = await deploymentStatusHandler({
service: "payments-api",
environment: "staging",
});

expect(result.isError).toBeUndefined();
expect(result.content[0].text).toContain("payments-api");
});

it("returns isError on API failure", async () => {
vi.mocked(apiClient.fetchDeploymentStatus).mockRejectedValue(
new Error("503 Service Unavailable")
);

const result = await deploymentStatusHandler({
service: "payments-api",
environment: "production",
});

expect(result.isError).toBe(true);
expect(result.content[0].text).toMatch(/503/);
});
});

The key pattern: extract your handler function so it is directly importable and testable without instantiating an McpServer. The registration call in tools/deployment.ts just wires the handler into the server — the logic itself lives in a plain async function.

For end-to-end testing, the MCP SDK ships with an InMemoryTransport that lets you connect a test client and server without any socket or stdio:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();

const server = new McpServer({ name: "test", version: "0.0.1" });
registerDeploymentTools(server);

const client = new Client({ name: "test-client", version: "0.0.1" });
await server.connect(serverTransport);
await client.connect(clientTransport);

const result = await client.callTool({
name: "get_deployment_status",
arguments: { service: "payments-api", environment: "staging" },
});

This is the closest you get to Claude Code's real call path without running the full application.


Common Failure Modes

These are the debugging scenarios you will encounter. Knowing them upfront saves hours:

Server starts but tools don't appear Check that cwd in your settings.json points to the right directory and that the compiled dist/index.js actually exists. Claude Code won't error loudly if the process exits immediately — run /mcp to see the server status.

SyntaxError: Unexpected token in Claude Code's tool output A console.log leaked onto stdout. Search your codebase for any call that writes to process.stdout directly or via console.log. Replace with console.error or the log helper from the Logging section above.

Tool succeeds but Claude ignores the result The response shape is wrong. The content array must contain objects with a type field ("text", "image", or "resource"). Returning a bare string or a non-conforming object silently produces an empty result.

Claude calls the tool in an infinite loop Your tool description is too vague, or the tool returns data that prompts re-invocation. Add a sentence to the description like: "Call this once per user request — do not call it repeatedly to paginate." Explicit instruction in the description is the fastest fix.

Environment variables missing at runtime The env block in settings.json replaces the process environment rather than extending it on some platforms. If your tool relies on PATH or other system variables, pass them through explicitly or switch to shell environment inheritance (omit the env block entirely).


What This Unlocks

Once this pattern runs, Claude Code stops being a smart editor and becomes an agent that understands your stack. It can query internal APIs, check deployment state, search documentation, or scaffold code that conforms to your actual conventions — not generic ones.

The practical ceiling here is surprisingly high. Anthropic's own engineering team has demonstrated agents connected to hundreds of tools across dozens of MCP servers — at that scale, tool selection and context window management become the limiting factors, but for a typical internal toolset of 10–30 tools, the pattern above handles everything you need.

Start with one tool. Wire it to the real API. Get the error handling right. Then iterate.

Damian Hodgkiss

Damian Hodgkiss

Senior Staff Engineer at Sumo Group, leading development of AppSumo marketplace. Technical solopreneur with 25+ years of experience building SaaS products.

Creating Freedom

Join me on the journey from engineer to solopreneur. Learn how to build profitable SaaS products while keeping your technical edge.

    Proven strategies

    Learn the counterintuitive ways to find and validate SaaS ideas

    Technical insights

    From choosing tech stacks to building your MVP efficiently

    Founder mindset

    Transform from engineer to entrepreneur with practical steps