Skip to content

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.

  • 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
  1. Initialize the project.

    Terminal window
    mkdir my-mcp-server && cd my-mcp-server
    npm init -y
    npm install @modelcontextprotocol/sdk zod
  2. Create the server. Add this to index.mjs:

    #!/usr/bin/env node
    import { 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 call
    const 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);
  3. Make it executable.

    Terminal window
    chmod +x index.mjs
  4. Connect it to your editor.

{
"mcpServers": {
"my-server": {
"command": "node",
"args": ["/absolute/path/to/my-mcp-server/index.mjs"]
}
}
}

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 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 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.`,
},
}],
};
}
);
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),
}],
};
}
);
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,
};
}
}
);

The MCP Inspector is a web-based tool for testing MCP servers interactively:

Terminal window
npx @modelcontextprotocol/inspector node index.mjs

This opens a browser UI where you can call individual tools, view responses, and debug issues without connecting to an AI client.

test/server.test.mjs
import { describe, it, expect } from 'vitest';
// Import your tool handler directly for unit testing
import { 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);
});
});

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:

Terminal window
DEBUG=true node index.mjs

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:

Terminal window
npx -y @mycompany/mcp-internal-tools
FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
ENTRYPOINT ["node", "index.mjs"]

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);

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.