Building Your Own MCP Server
A custom MCP server is a small program on @modelcontextprotocol/sdk and Zod that exposes an internal API, database, or CLI as tools an AI can call through server.registerTool(), while the SDK handles protocol negotiation, transport, and client communication. It can carry three primitives — tools, resources, and prompts — runs over stdio or HTTP, and ships as an npm package, a Docker image, or a remote service.
The off-the-shelf MCP catalog is huge, but it stops at your firewall. Your company has an internal API for feature flags, an incident system nobody else uses, a deploy pipeline built on top of Kubernetes, and a wiki where every architectural decision lives. The AI can search GitHub and read Postgres; it cannot touch any of those. So you paste API responses into the chat by hand. You are the human MCP server, and you are the bottleneck.
A custom server closes that gap, and it is simpler than it sounds. The SDK handles the protocol; you write the part that actually talks to your system. This guide builds one end to end, registers it in Cursor, Claude Code, and Codex, and then hardens and ships it.
What you will get from building your own MCP server
Section titled “What you will get from building your own MCP server”- A runnable server skeleton on
@modelcontextprotocol/sdkand Zod, registering a real tool with input validation - The current
server.registerTool()API, and why the oldserver.tool()shape silently breaks tool discovery - The exact config to connect the server to Cursor, Claude Code, and Codex
- Tools, resources, and prompts — the three MCP primitives, and when each one is the right shape
- Patterns for wrapping internal REST APIs and CLI tools without opening a shell-injection hole
- Testing, debugging, and three deployment options: npm package, Docker, and remote HTTP
- Copy-paste prompts that have the AI scaffold the server and add tools for you
- A failure-mode checklist for the bugs every first MCP server hits
Let the agent write the boilerplate first
Section titled “Let the agent write the boilerplate first”You do not have to hand-write the skeleton. The fastest path is to have your agent scaffold it, then review and harden what comes back.
The rest of this guide is what you need to know to review that output — and to write it yourself when the scaffold is wrong.
Your first MCP server in 10 minutes
Section titled “Your first MCP server in 10 minutes”-
Initialize the project.
Terminal window mkdir my-mcp-server && cd my-mcp-servernpm init -ynpm install @modelcontextprotocol/sdk zod -
Create the server. Add this to
index.mjs:#!/usr/bin/env nodeimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';import { z } from 'zod';import { getFeatureFlags } from './tools/feature-flags.mjs';const server = new McpServer({name: 'my-first-mcp',version: '1.0.0',});server.registerTool('get_feature_flags',{description: 'Returns active feature flags for a given environment',inputSchema: {environment: z.enum(['dev', 'staging', 'production']).describe('Target environment'),},},async ({ environment }) => ({content: [{type: 'text',text: JSON.stringify(getFeatureFlags({ environment }), null, 2),}],}));const transport = new StdioServerTransport();await server.connect(transport);Keep the flag logic in its own module so you can unit-test it without spinning up the transport. Add
tools/feature-flags.mjs:tools/feature-flags.mjs const FLAGS = {dev: { darkMode: true, newCheckout: true, betaSearch: true },staging: { darkMode: true, newCheckout: true, betaSearch: false },production: { darkMode: true, newCheckout: false, betaSearch: false },};// Replace the lookup below with your actual feature-flag API call.export function getFeatureFlags({ environment }) {return FLAGS[environment];} -
Make it executable.
Terminal window chmod +x index.mjs -
Connect it to your editor, using the configuration below.
Registering a tool the way the current SDK expects
Section titled “Registering a tool the way the current SDK expects”Tool registration is the part most tutorials get wrong. The low-level server.tool(name, shape, handler) call is deprecated in the current SDK — use server.registerTool(name, { description, inputSchema }, handler) with a Zod shape. The description is what the model reads to decide when to call the tool, so make it specific.
The TypeScript version of the same idea, with the error handling the quickstart leaves out:
npm install @modelcontextprotocol/sdk zod turndownnpm install -D typescript @types/node @types/turndownnpx tsc --initimport { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";import { z } from "zod";import TurndownService from "turndown";
const server = new McpServer({ name: "internal-docs", version: "1.0.0" });const turndown = new TurndownService();
server.registerTool( "get_doc", { description: "Fetch an internal Confluence page and return it as Markdown", inputSchema: { url: z.string().url() }, }, async ({ url }) => { try { const response = await fetch(url); if (!response.ok) { return { isError: true, content: [{ type: "text", text: `Fetch failed: HTTP ${response.status}` }], }; } const html = await response.text(); return { content: [{ type: "text", text: turndown.turndown(html) }] }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { isError: true, content: [{ type: "text", text: `Error scraping ${url}: ${message}` }], }; } },);
const transport = new StdioServerTransport();await server.connect(transport);Three details that bite. inputSchema takes the raw Zod shape object ({ url: z.string().url() }), not a pre-built z.object(...). Under strict TypeScript error is typed unknown, so you must narrow it (error instanceof Error ? ...) before reading .message. And returning isError: true lets the model see the failure and react instead of silently receiving empty content.
Build it:
npx tscThat emits dist/server.js, which is what every client config below points at. Keep console.log out of the server entirely — on stdio transport, stdout is the JSON-RPC channel, and one stray log line corrupts the protocol stream. Log to stderr (console.error) instead.
Connecting the server to Cursor, Claude Code and Codex
Section titled “Connecting the server to Cursor, Claude Code and Codex”Registration is one of the places where the three tools genuinely converge. Cursor and Claude Code read the same mcpServers JSON shape, so those two blocks are intentionally identical and you can paste one into the other — Claude Code just also offers a CLI command that writes the file for you. Only Codex differs, and only in format: TOML under [mcp_servers.<id>], where stdio transport is implied by the presence of command (there is no transport key).
Add to .cursor/mcp.json (project) or ~/.cursor/mcp.json (global):
{ "mcpServers": { "internal-docs": { "command": "node", "args": ["/abs/path/to/dist/server.js"] } }}Either add the same JSON to .mcp.json at your project root, or register it from the terminal:
claude mcp add internal-docs -- node /abs/path/to/dist/server.jsVerify with claude mcp list, and debug a non-loading server with claude --debug "mcp".
Add to ~/.codex/config.toml:
[mcp_servers.internal-docs]command = "node"args = ["/abs/path/to/dist/server.js"]No transport key is needed — stdio is inferred from command (an HTTP server would use url instead).
Restart the client, then ask it to call one of your tools to confirm the wiring.
Tools, resources and prompts: the three MCP primitives
Section titled “Tools, resources and prompts: the three MCP primitives”MCP servers expose three types of capability, and picking the wrong one is why some servers feel unusable.
Tools: functions the AI calls
Section titled “Tools: functions the AI calls”Tools take structured input, perform an operation, and return results. This is what you will use most often.
server.registerTool( 'search_incidents', // Tool name { description: 'Search the incident database', // Shown to the AI inputSchema: { // Zod raw shape query: z.string(), severity: z.enum(['P1', 'P2', 'P3', 'P4']).optional(), status: z.enum(['open', 'resolved', 'investigating']).optional(), }, }, async ({ query, severity, status }) => { const results = await searchIncidents({ query, severity, status }); return { content: [{ type: 'text', text: JSON.stringify(results, null, 2), }], }; });Resources: data the AI browses
Section titled “Resources: data the AI browses”Resources are data the AI can browse and read, like files in a filesystem. Use them when you want the AI to discover what is available rather than query for a specific item.
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
server.registerResource( 'runbooks', // A templated URI plus a `list` callback so the AI can browse available runbooks. new ResourceTemplate('runbook:///{id}', { list: async () => { const runbooks = await listRunbooks(); return { resources: runbooks.map(r => ({ uri: `runbook:///${r.id}`, name: r.title, description: r.summary, mimeType: 'text/markdown', })), }; }, }), { title: 'Runbooks', description: 'Operational runbooks for production services', mimeType: 'text/markdown', }, // Read handler. The matched `{id}` arrives in the second argument. async (uri, { id }) => { const content = await getRunbook(id); return { contents: [{ uri: uri.href, mimeType: 'text/markdown', text: content, }], }; });Prompts: templates that standardize a workflow
Section titled “Prompts: templates that standardize a workflow”Prompts are pre-built templates the AI can invoke. They are how you stop five engineers writing five different postmortems.
server.registerPrompt( 'incident_postmortem', { description: 'Generate a postmortem document for an incident', argsSchema: { incidentId: z.string().describe('Incident ID to generate postmortem for'), }, }, async ({ incidentId }) => { const incident = await getIncident(incidentId); return { messages: [{ role: 'user', content: { type: 'text', text: `Write a postmortem for incident ${incident.id}: "${incident.title}". Timeline: ${JSON.stringify(incident.timeline)} Root cause: ${incident.rootCause || 'Unknown'} Impact: ${incident.impact}
Follow our postmortem template: title, summary, timeline, root cause analysis, action items with owners and due dates.`, }, }], }; });Growing the server: one tool at a time
Section titled “Growing the server: one tool at a time”Once the first read-only tool works, each addition is another registerTool call. Let the AI extend its own toolset.
Wrapping an internal REST API
Section titled “Wrapping an internal REST API”const API_BASE = process.env.INTERNAL_API_URL;const API_KEY = process.env.INTERNAL_API_KEY;
if (!API_KEY) { console.error('INTERNAL_API_KEY is required'); process.exit(1);}
async function apiCall(path, options = {}) { const response = await fetch(`${API_BASE}${path}`, { ...options, headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json', ...options.headers, }, });
if (!response.ok) { throw new Error(`API error: ${response.status} ${response.statusText}`); }
return response.json();}
server.registerTool( 'list_deployments', { description: 'List recent deployments for a service', inputSchema: { service: z.string(), limit: z.number().optional().default(10), }, }, async ({ service, limit }) => { const deployments = await apiCall(`/deployments?service=${service}&limit=${limit}`); return { content: [{ type: 'text', text: JSON.stringify(deployments, null, 2), }], }; });Wrapping a CLI tool without opening a shell
Section titled “Wrapping a CLI tool without opening a shell”import { execFile } from 'child_process';import { promisify } from 'util';
const execFileAsync = promisify(execFile);
server.registerTool( 'kubectl_get', { description: 'Get Kubernetes resources (read-only)', inputSchema: { resource: z.enum(['pods', 'services', 'deployments', 'configmaps', 'ingresses']), namespace: z.string().optional().default('default'), name: z.string().optional(), }, }, async ({ resource, namespace, name }) => { const args = ['get', resource, '-n', namespace, '-o', 'json']; if (name) args.push(name);
try { const { stdout } = await execFileAsync('kubectl', args, { timeout: 15000 }); return { content: [{ type: 'text', text: stdout, }], }; } catch (error) { return { content: [{ type: 'text', text: `kubectl error: ${error.message}`, }], isError: true, }; } });Testing and debugging an MCP server
Section titled “Testing and debugging an MCP server”Interactive testing with MCP Inspector
Section titled “Interactive testing with MCP Inspector”The MCP Inspector is a web-based tool for testing servers without an AI client in the loop:
npx @modelcontextprotocol/inspector node index.mjsIt opens a browser UI where you can call individual tools, view responses, and debug issues before you wire anything to an editor.
Automated testing
Section titled “Automated testing”Because the flag logic lives in tools/feature-flags.mjs (extracted in the hello-world step), you can test it directly — no transport, no MCP client:
import { describe, it, expect } from 'vitest';
// Import the pure handler extracted in tools/feature-flags.mjsimport { getFeatureFlags } from '../tools/feature-flags.mjs';
describe('getFeatureFlags', () => { it('returns flags for the dev environment', () => { const flags = getFeatureFlags({ environment: 'dev' }); expect(flags.darkMode).toBe(true); expect(flags.betaSearch).toBe(true); });
it('returns conservative flags for production', () => { const flags = getFeatureFlags({ environment: 'production' }); expect(flags.betaSearch).toBe(false); });});Debug output that does not corrupt the protocol
Section titled “Debug output that does not corrupt the protocol”Since stdout carries JSON-RPC, debug output goes to stderr:
const DEBUG = process.env.DEBUG === 'true';
function debug(...args) { if (DEBUG) { console.error('[MCP Debug]', ...args); }}Run with debugging enabled:
DEBUG=true node index.mjsShipping it: npm, Docker, or remote HTTP
Section titled “Shipping it: npm, Docker, or remote HTTP”npm package (recommended for teams)
Section titled “npm package (recommended for teams)”Package the server for npx installation:
{ "name": "@mycompany/mcp-internal-tools", "version": "1.0.0", "type": "module", "bin": { "mycompany-mcp": "./index.mjs" }}Publish to your private npm registry, then connect:
npx -y @mycompany/mcp-internal-toolsDocker (for isolation)
Section titled “Docker (for isolation)”FROM node:22-slimWORKDIR /appCOPY package*.json ./RUN npm ci --productionCOPY . .ENTRYPOINT ["node", "index.mjs"]Remote HTTP server (for shared access)
Section titled “Remote HTTP server (for shared access)”For servers that multiple developers or CI pipelines should reach, deploy as an HTTP service with Streamable HTTP transport. Clients then point at a url instead of launching a command:
import express from 'express';import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
const app = express();app.use(express.json());
// Stateless transport: a fresh one per request, no session tracking.// For stateful sessions, pass sessionIdGenerator: () => randomUUID() instead.app.post('/mcp', async (req, res) => { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); res.on('close', () => transport.close()); await server.connect(transport); await transport.handleRequest(req, res, req.body);});
app.listen(3000);When your custom MCP server breaks
Section titled “When your custom MCP server breaks”The client shows the server as “failed”, or it connects with no tools at all. Almost always a stdout problem: any console.log, startup banner, or dependency that prints to stdout corrupts the JSON-RPC stream on stdio transport. Move all logging to console.error and rebuild. If stdout is already clean, check that every server.registerTool() call runs before server.connect(transport).
JSON-RPC parse errors, or tools that never appear despite a successful connection. You are likely on a stale SDK or still calling server.tool(). Confirm npm view @modelcontextprotocol/sdk version is 1.29+ and that you migrated to registerTool. Mixing the deprecated and the new API in one server is a common cause of silent tool-list failures.
“Cannot find module” errors. Ensure "type": "module" is in your package.json when using ES module syntax, or use the .mjs extension.
Zod validation rejects every call. Pass the raw shape to inputSchema ({ url: z.string().url() }), not a pre-built z.object(...), and make sure the tool description tells the model what each argument is — a vague description leads the model to send malformed arguments.
“Transport mismatch”, or the HTTP server is never reached. stdio and HTTP are configured differently. For stdio, the client launches your process via command/args. For a remote server you expose Streamable HTTP and point the client at a url. Do not add a transport key to Codex TOML — it is inferred.
The AI cannot find the server, or it fails at launch. MCP clients spawn your server from their own working directory, not your project root. Use an absolute path to dist/server.js (or index.mjs) in every config.
Server crashes on the first tool call. Check that async functions properly await their promises. Unhandled promise rejections crash the server silently.
Timeout on long operations. MCP clients have default timeouts, usually 30-60 seconds. For long-running operations, return a progress message first, then the final result.