Building Your Own MCP Server
Your company has an internal API for feature flags, an incident management system nobody else uses, and a custom deployment pipeline built on top of Kubernetes. No MCP server on the marketplace covers any of these. Every time the AI needs context from these systems, you manually paste API responses into the chat. You are the human MCP server, and you are the bottleneck.
Building a custom MCP server is simpler than it sounds. The MCP SDK handles the protocol negotiation, transport layer, and client communication. You write the tool logic — the part that actually talks to your API or system — and the SDK handles everything else.
What You’ll Walk Away With
Section titled “What You’ll Walk Away With”- A working MCP server in TypeScript from scratch, connected to all three tools
- Understanding of tools, resources, and prompts — the three MCP primitives
- Patterns for connecting to internal APIs, databases, and CLI tools
- Testing and debugging strategies for MCP servers
- Deployment options: local npm package, Docker, and remote HTTP
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 { Server } from '@modelcontextprotocol/sdk/server/index.js';import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';import { z } from 'zod';const server = new Server({name: 'my-first-mcp',version: '1.0.0',});server.tool('get_feature_flags','Returns active feature flags for a given environment',{environment: z.enum(['dev', 'staging', 'production']).describe('Target environment'),},async ({ environment }) => {// Replace with your actual API callconst flags = {dev: { darkMode: true, newCheckout: true, betaSearch: true },staging: { darkMode: true, newCheckout: true, betaSearch: false },production: { darkMode: true, newCheckout: false, betaSearch: false },};return {content: [{type: 'text',text: JSON.stringify(flags[environment], null, 2),}],};});const transport = new StdioServerTransport();await server.connect(transport); -
Make it executable.
Terminal window chmod +x index.mjs -
Connect it to your editor.
{ "mcpServers": { "my-server": { "command": "node", "args": ["/absolute/path/to/my-mcp-server/index.mjs"] } }}claude mcp add my-server -- node /absolute/path/to/my-mcp-server/index.mjsOr in .claude/settings.json:
{ "mcpServers": { "my-server": { "command": "node", "args": ["/absolute/path/to/my-mcp-server/index.mjs"] } }}[mcp.my-server]transport = "stdio"command = "node"args = ["/absolute/path/to/my-mcp-server/index.mjs"]The Three MCP Primitives
Section titled “The Three MCP Primitives”MCP servers expose three types of capabilities:
Tools are functions the AI can call. They take structured input, perform an operation, and return results. This is what you will use most often.
server.tool( 'search_incidents', // Tool name 'Search the incident database', // Description for the AI { // Input schema (Zod) 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
Section titled “Resources”Resources are data the AI can browse and read, like files in a filesystem. Use them when you want the AI to discover available data rather than query for specific items.
server.resource( 'runbooks', 'Operational runbooks for production services', async () => { const runbooks = await listRunbooks(); return runbooks.map(r => ({ uri: `runbook:///${r.id}`, name: r.title, description: r.summary, mimeType: 'text/markdown', })); }, async (uri) => { const id = uri.replace('runbook:///', ''); const content = await getRunbook(id); return { contents: [{ uri, mimeType: 'text/markdown', text: content, }], }; });Prompts
Section titled “Prompts”Prompts are pre-built templates the AI can use. They are useful for standardizing workflows across your team.
server.prompt( 'incident_postmortem', 'Generate a postmortem document for an incident', { 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.`, }, }], }; });Real-World Patterns
Section titled “Real-World Patterns”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.tool( 'list_deployments', 'List recent deployments for a service', { 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
Section titled “Wrapping a CLI Tool”import { execFile } from 'child_process';import { promisify } from 'util';
const execFileAsync = promisify(execFile);
server.tool( 'kubectl_get', 'Get Kubernetes resources (read-only)', { 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 Your Server
Section titled “Testing Your Server”Manual Testing with MCP Inspector
Section titled “Manual Testing with MCP Inspector”The MCP Inspector is a web-based tool for testing MCP servers interactively:
npx @modelcontextprotocol/inspector node index.mjsThis opens a browser UI where you can call individual tools, view responses, and debug issues without connecting to an AI client.
Automated Testing
Section titled “Automated Testing”import { describe, it, expect } from 'vitest';
// Import your tool handler directly for unit testingimport { getFeatureFlags } from '../tools/feature-flags.mjs';
describe('get_feature_flags', () => { it('returns flags for the dev environment', async () => { const result = await getFeatureFlags({ environment: 'dev' }); const parsed = JSON.parse(result.content[0].text); expect(parsed.darkMode).toBe(true); expect(parsed.betaSearch).toBe(true); });
it('returns conservative flags for production', async () => { const result = await getFeatureFlags({ environment: 'production' }); const parsed = JSON.parse(result.content[0].text); expect(parsed.betaSearch).toBe(false); });});Debugging
Section titled “Debugging”MCP servers communicate over stdio, so console.log goes to the transport and can corrupt the protocol. Use console.error for debug output instead:
const DEBUG = process.env.DEBUG === 'true';
function debug(...args) { if (DEBUG) { console.error('[MCP Debug]', ...args); }}Run with debugging enabled:
DEBUG=true node index.mjsDeployment Options
Section titled “Deployment Options”npm Package (Recommended for Teams)
Section titled “npm Package (Recommended for Teams)”Package your server for easy 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 MCP servers that multiple developers or CI pipelines should access, deploy as an HTTP service with Streamable HTTP transport:
import { createServer } from 'http';import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamablehttp.js';
const httpServer = createServer(async (req, res) => { if (req.url === '/mcp') { const transport = new StreamableHTTPServerTransport({ req, res }); await server.connect(transport); } else { res.writeHead(404); res.end(); }});
httpServer.listen(3000);When This Breaks
Section titled “When This Breaks”Server starts but no tools appear. The tool registration must complete before the transport connects. Make sure all server.tool() calls happen before server.connect(transport).
“Cannot find module” errors. Ensure "type": "module" is in your package.json when using ES module syntax. Or use .mjs extension.
Server crashes on first tool call. Check that async functions properly await their promises. Unhandled promise rejections crash the server silently.
AI cannot find the server. Verify the absolute path in your MCP configuration. Relative paths resolve differently depending on the editor’s working directory.
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.