Chapter 14: Building Custom MCP Servers
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
initializehandshake 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:
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 --initMinimal server (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-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:
{
"scripts": {
"start": "tsx src/index.ts"
}
}Register the server with Claude Code:
claude mcp add --transport stdio my-tools -- npm --prefix /absolute/path/to/my-mcp-server run startDefining 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.
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.
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.
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
# stdio registration
claude mcp add --transport stdio my-tools -- node /path/to/server/dist/index.jsHTTP (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
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");
});# HTTP registration
claude mcp add --transport http my-remote-tools http://localhost:3001/mcpFor team-deployed HTTP servers, add authentication:
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:
npx @modelcontextprotocol/inspector tsx src/index.tsThis 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:
/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:
MCP_TIMEOUT=30000 claude --mcp-debugThis 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:
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.jsonwith 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:
{
"name": "@yourcompany/mcp-internal-tools",
"version": "1.0.0",
"bin": {
"mcp-internal-tools": "dist/index.js"
}
}Then team members install with:
claude mcp add --transport stdio company-tools -- npx -y @yourcompany/mcp-internal-toolsPlugin 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:
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:
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.tsNow any developer on the team can ask:
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.