Skip to content

Building your own MCP server

A custom MCP server is a small program that exposes an internal API, database, or CLI as tools that Claude Code, Codex, and Cursor can call. In September 2026, build it on the v2 TypeScript SDK (@modelcontextprotocol/server) or PyPI mcp 2.x, and start it with an entry point that serves both MCP spec 2026-07-28 and older clients.

This page is for the developer who owns an internal system and for the tech lead who decides whether the server ships. Your feature flags live behind an internal API, your deploy history sits in a service nobody outside the company has heard of, and the agent can reach neither. So you paste JSON into the chat, and you are the integration. A custom server removes that step. The hard part is not the protocol, which the SDK handles. The hard part is a tool contract the model uses correctly, and proof that it does.

What you get from building your own MCP server

Section titled “What you get from building your own MCP server”
  • A working server on @modelcontextprotocol/server 2.1.0, started with serveStdio so that it answers a client that negotiates spec 2026-07-28 (Claude Code does by default with HTTP servers) and one that speaks the older protocol (Codex 0.157.1).
  • The mcp-builder skill installed in your agent, and a prompt that makes it scaffold on the v2 SDK instead of the v1 examples it ships with.
  • A test file and two MCP Inspector commands that prove the tool contract in both protocol eras, so that nobody reviews the server by reading it line by line.
  • Registration for Claude Code, Codex, and Cursor, and a context-cost check before and after.
  • Three ways to ship it (private npm, Streamable HTTP for the team, the public MCP Registry with mcp-publisher) and a sign-off checklist.

Checked on 2026-09-26 against @modelcontextprotocol/server 2.1.0, @modelcontextprotocol/node 2.1.0, @modelcontextprotocol/express 2.0.1, PyPI mcp 2.2.0, @modelcontextprotocol/inspector 2.8.0, Claude Code 2.1.283, and codex-cli 0.157.1. Every code block on this page was compiled and run against those versions. Cursor had no binary in the test environment, so the Cursor steps are unverified.

Do you need an MCP server, a skill, or a CLI?

Section titled “Do you need an MCP server, a skill, or a CLI?”

Build a server only when the agent needs live access to something it cannot reach otherwise. A lot of “we need an MCP server” requests are really a missing instruction or a missing script.

The agent needs…BuildWhy
Live data or actions behind auth: feature flags, deploy history, an incident systemMCP serverTyped inputs, one credential held by the server, works in every agent
A procedure: how to write a migration, your release checklistSkill (building custom skills)Costs context only when it fires; no process to run
A command that already exists: kubectl, gh, your make targetsNothing new, or a skill that documents the commandThe agent already runs shell commands; a server adds a second path to the same thing
Something an off-the-shelf server already does: GitHub, Postgres, SentryInstall that server (top MCP servers)You do not want to own someone else’s API wrapper

Tech leads deciding this for a whole team should read internal MCP servers: build only the missing interface, which covers ownership and retirement criteria.

Install the mcp-builder skill and let the agent scaffold

Section titled “Install the mcp-builder skill and let the agent scaffold”

Anthropic’s mcp-builder skill walks an agent through research, implementation, MCP Inspector testing, and a set of evaluation questions. It works in all three agents through the skills CLI (1.7.0).

Terminal window
npx skills add anthropics/skills --skill mcp-builder -a claude-code

Alternatively, install it as part of a plugin: /plugin marketplace add anthropics/skills, then /plugin install example-skills@anthropic-agent-skills. That plugin also installs 11 other example skills, so prefer the single-skill install.

Read what the agent produced against this reference. It is the smallest server that does the job correctly, with the flag lookup in its own module so that you can swap in the real API call.

  1. Create the project and install the SDK.

    Terminal window
    mkdir feature-flags-mcp && cd feature-flags-mcp
    npm init -y && npm pkg set type=module
    npm install @modelcontextprotocol/server zod
    npm install -D typescript @types/node vitest @modelcontextprotocol/client

    v2 needs Node.js 20 or later and Zod 4.2 or later. With Zod 4.0 or 4.1, the SDK falls back to its own converter and drops your .describe() text from the schema the model sees.

  2. Write the server factory in src/build-server.ts.

    src/build-server.ts
    import { McpServer } from '@modelcontextprotocol/server';
    import { z } from 'zod';
    import { getFeatureFlags } from './flags.js';
    export function buildServer() {
    const server = new McpServer({ name: 'feature-flags', version: '1.0.0' });
    server.registerTool(
    'get_feature_flags',
    {
    description:
    'Return the feature flags that are ON in one environment. Use before changing code behind a flag.',
    inputSchema: z.object({
    environment: z.enum(['dev', 'staging', 'production']).describe('Deployment environment'),
    }),
    annotations: { readOnlyHint: true },
    },
    async ({ environment }) => {
    try {
    const flags = await getFeatureFlags(environment);
    return { content: [{ type: 'text', text: JSON.stringify(flags, null, 2) }] };
    } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    return { isError: true, content: [{ type: 'text', text: `Flag lookup failed: ${message}` }] };
    }
    },
    );
    return server;
    }

    src/flags.ts exports getFeatureFlags(environment), which calls your flags API. Keeping it separate lets the tests replace it.

  3. Start it in src/server.ts.

    src/server.ts
    #!/usr/bin/env node
    import { serveStdio } from '@modelcontextprotocol/server/stdio';
    import { buildServer } from './build-server.js';
    serveStdio(buildServer);

    serveStdio pins one server instance per connection. It answers a 2026-07-28 opening and, by default, a 2025-era initialize, so the same binary works for a client on either protocol era.

  4. Add tsconfig.json and build.

    tsconfig.json
    {
    "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "types": ["node"]
    },
    "include": ["src"]
    }
    Terminal window
    npx tsc

    The build emits dist/server.js, which every client configuration below points at.

Three details decide whether the model uses the tool well. The description is the only thing the model reads when it chooses a tool, so say what it returns and when to call it. A z.enum turns a guess (“prod”?) into a validation error the model can correct. And isError: true lets the model see the failure and react, instead of receiving empty content and inventing an answer.

Prove the server works without reading every line

Section titled “Prove the server works without reading every line”

The review question for an MCP server is not “is this code nice?” but “does the model get the right tools, and does every tool fail safely?”. Answer it with three checks that run in seconds.

1. Inspect the contract in both protocol eras. The MCP Inspector CLI connects on the older protocol unless you say otherwise, so run it twice. Put the server command first: --tool-arg accepts several values and swallows anything after it.

Terminal window
npx @modelcontextprotocol/inspector --cli node dist/server.js --protocol-era modern --method tools/list --strict
npx @modelcontextprotocol/inspector --cli node dist/server.js --protocol-era legacy \
--method tools/call --tool-name get_feature_flags --tool-arg environment=staging

--strict reports tool-schema portability problems and exits with code 6 if one is an error, which makes it a CI gate. Run npx @modelcontextprotocol/inspector with no --cli for the browser UI.

2. Test the tool contract in-process. createMcpHandler serves 2026-07-28 over a fetch function, so a test can drive the real server with no socket and no child process:

test/server.test.ts
import { describe, expect, it } from 'vitest';
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { createMcpHandler } from '@modelcontextprotocol/server';
import { buildServer } from '../src/build-server.js';
async function connect() {
const handler = createMcpHandler(buildServer);
const transport = new StreamableHTTPClientTransport(new URL('http://test.local/mcp'), {
fetch: (url, init) => handler.fetch(new Request(url, init)),
});
const client = new Client({ name: 'test', version: '0.0.0' }, { versionNegotiation: { mode: { pin: '2026-07-28' } } });
await client.connect(transport);
return client;
}
describe('feature-flags MCP server', () => {
it('exposes exactly the tools the agent should see', async () => {
const { tools } = await (await connect()).listTools();
expect(tools.map((t) => t.name)).toEqual(['get_feature_flags']);
});
it('rejects an unknown environment as a tool error, not a crash', async () => {
const result = await (await connect()).callTool({ name: 'get_feature_flags', arguments: { environment: 'qa' } });
expect(result.isError).toBe(true);
});
});

The first test goes red when someone adds a write tool without meaning to. The second goes red when validation or error handling breaks. Add one test per tool for the happy path with getFeatureFlags stubbed.

3. Evaluate whether the model uses it. Tests prove the contract; they do not prove that the model picks the right tool. mcp-builder has an evaluation phase that writes 10 realistic questions whose answers need your tools. Run them in a fresh session after every change to a description or schema, and keep the question file in the repository.

Connect the server to Claude Code, Codex, and Cursor

Section titled “Connect the server to Claude Code, Codex, and Cursor”

Use an absolute path: clients start the server from their own working directory, not from your project. Pass secrets as environment variables from your shell or secret store, never as literal values in a committed file.

Terminal window
claude mcp add feature-flags -e FLAGS_API_URL="$FLAGS_API_URL" -e FLAGS_API_TOKEN="$FLAGS_API_TOKEN" \
-- node /abs/path/feature-flags-mcp/dist/server.js
claude mcp get feature-flags

claude mcp get health-checks the server and prints Connected when the process starts and answers. The default scope is local (only you, only this project). Use -s project to write .mcp.json for the team; teammates then approve it once. .mcp.json is committed, so in that case leave the token out of -e and set it in each engineer’s environment instead. For a slow upstream, raise MCP_TOOL_TIMEOUT (milliseconds) in the environment you start Claude Code from.

Check the context cost. Every tool schema sits in the agent’s context. In Claude Code, run /context before and after you add the server and compare the MCP tools line; Claude Code and Codex both defer tool schemas through tool search by default, so the cost of one small server is low, but it grows with every tool you add. Ten narrow tools are worse than three well-described ones. See reducing MCP token cost.

A tool is an input boundary: the model chooses the arguments, and a prompt injection in something the agent read earlier can choose them too. Two patterns cover most internal servers.

src/ops-tools.ts
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import type { McpServer } from '@modelcontextprotocol/server';
import { z } from 'zod';
const execFileAsync = promisify(execFile);
export function registerOpsTools(server: McpServer) {
server.registerTool(
'list_deployments',
{
description: 'List the most recent deployments of one service, newest first.',
inputSchema: z.object({
service: z.string().regex(/^[a-z0-9-]{1,64}$/),
limit: z.number().int().min(1).max(50).default(10),
}),
annotations: { readOnlyHint: true },
},
async ({ service, limit }) => {
const url = new URL('/deployments', process.env.DEPLOY_API_URL);
url.searchParams.set('service', service);
url.searchParams.set('limit', String(limit));
const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.DEPLOY_API_TOKEN}` } });
if (!res.ok) {
return { isError: true, content: [{ type: 'text', text: `Deploy API returned HTTP ${res.status} for ${service}` }] };
}
return { content: [{ type: 'text', text: JSON.stringify(await res.json(), null, 2) }] };
},
);
server.registerTool(
'kubectl_get',
{
description: 'Read Kubernetes resources as JSON. Read-only: never creates, edits or deletes.',
inputSchema: z.object({
resource: z.enum(['pods', 'services', 'deployments', 'ingresses']),
namespace: z.string().regex(/^[a-z0-9-]{1,63}$/).default('default'),
}),
annotations: { readOnlyHint: true },
},
async ({ resource, namespace }) => {
try {
const { stdout } = await execFileAsync('kubectl', ['get', resource, '-n', namespace, '-o', 'json'], {
timeout: 15_000,
maxBuffer: 5 * 1024 * 1024,
});
return { content: [{ type: 'text', text: stdout }] };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { isError: true, content: [{ type: 'text', text: `kubectl failed: ${message}` }] };
}
},
);
}

What makes these safe is the shape, not the comments. URL and searchParams encode the input instead of concatenating it into a path. execFile with an argument array starts no shell, and the z.enum and regex are the allowlist. The server holds one scoped service-account token; the agent never sees it. Call registerOpsTools(server) inside buildServer() so that the tests cover these tools too.

Start read-only. Add a write tool only when a reviewer has approved it, give it its own narrow input, and leave readOnlyHint off so that clients ask for approval. For threat modelling, see MCP security.

MCP servers expose three primitives. Most internal servers need only tools.

PrimitiveRegistered withUse it forClient behaviour
ToolregisterToolAnything the model decides to call: lookups, searches, actionsSupported by all three agents
ResourceregisterResource (v2 requires a metadata argument; pass {} if you have none)Documents the user attaches: runbooks, schemasClients differ in how resources reach the model; check each agent before you rely on one
PromptregisterPromptA reusable workflow the user starts by name, such as a postmortem templateClients differ in how they surface prompts; a skill is often the better fit

When you are unsure, make it a tool. The model can call a tool on its own; a resource or prompt usually needs a person to pick it.

A stdio server runs on each engineer’s machine with each engineer’s credentials. When several people or a CI job should share one server with one service account, serve Streamable HTTP. createMcpHandler serves 2026-07-28 and, by default, stateless 2025-era requests from the same endpoint.

src/http.ts
import { createMcpExpressApp } from '@modelcontextprotocol/express';
import { toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHandler } from '@modelcontextprotocol/server';
import { buildServer } from './build-server.js';
const handler = createMcpHandler(buildServer);
const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['flags-mcp.internal.example.com'] });
app.all('/mcp', toNodeHandler(handler));
app.listen(3000, '0.0.0.0');

Install the adapters, plus @types/express as a dev dependency so tsc compiles src/http.ts: npm install @modelcontextprotocol/express @modelcontextprotocol/node express && npm install -D @types/express. allowedHosts rejects any other Host header with HTTP 403, which blocks DNS rebinding. It is not authentication: put the endpoint behind your identity-aware proxy or gateway, or add bearer-token checks, before anyone outside your laptop can reach it. The server builds a fresh instance per request, so there is no session state to lose when a load balancer moves a request.

Register the URL instead of a command: claude mcp add --transport http feature-flags https://flags-mcp.internal.example.com/mcp, or codex mcp add feature-flags --url https://flags-mcp.internal.example.com/mcp --bearer-token-env-var FLAGS_MCP_TOKEN.

A server you already run on the v1 SDK, or with per-session state, needs a migration rather than a rewrite: see MCP 2026-07-28: what changed and how to migrate a server.

Ship it: private npm, a shared HTTP server, or the public MCP Registry

Section titled “Ship it: private npm, a shared HTTP server, or the public MCP Registry”
AudienceShip asClients run
Your companyA package on your private npm registry, with a bin entrynpx -y @acme/feature-flags-mcp
Your company, one shared credentialThe HTTP server above in a container behind your gatewayA URL
EveryoneA public npm package plus an entry in the Official MCP Registrynpx -y your package, found through registry-aware clients

The Official MCP Registry stores metadata only, so the package must be public first. The registry is still a preview (API frozen at v0.1 since 2025-10-24; breaking changes or data resets may occur). It listed 36,176 servers at version=latest on 2026-09-26 (registry API, full pagination). Never publish an internal server there; to curate internal servers, run a private catalogue as described in MCP registries, allowlists and gateways.

  1. Add mcpName to package.json. With GitHub authentication, it must start with io.github.<your-user-or-org>/.

    package.json (excerpt)
    {
    "name": "@acme/feature-flags-mcp",
    "version": "1.0.0",
    "mcpName": "io.github.acme/feature-flags",
    "type": "module",
    "bin": { "feature-flags-mcp": "dist/server.js" },
    "files": ["dist"]
    }
  2. Publish the package: npm publish --access public.

  3. Install the publisher and create server.json.

    Terminal window
    brew install mcp-publisher
    mcp-publisher init

    Set name to the same value as mcpName, and set both version fields to the version you published to npm.

  4. Validate, log in, and publish.

    Terminal window
    mcp-publisher validate
    mcp-publisher login github
    mcp-publisher publish

When you want the opposite: Claude Code as an MCP server

Section titled “When you want the opposite: Claude Code as an MCP server”

claude mcp serve runs Claude Code itself as a stdio MCP server, so another MCP client can use its tools. It is not a way to build your own server, and Codex has no equivalent in 0.157.1. Reach for it when a tool you already use speaks MCP and should reuse Claude Code’s file and shell tools; it runs with whatever permissions that Claude Code configuration grants.

The client shows the server as failed, or connected with no tools. Something wrote to stdout. On stdio, stdout is the protocol channel. v2 SDK clients skip non-JSON lines, but you do not control which client connects, so move every log to stderr (console.error) and check dependencies that print banners. Then run the Inspector tools/list command above.

Codex sees the tools, Claude Code does not (or the reverse). The server answers only one protocol era. A v2 server started with server.connect(new StdioServerTransport()) speaks only the 2025-era protocol. Switch to serveStdio(buildServer) or createMcpHandler(buildServer) and rerun the Inspector in both eras.

Every call fails validation, or field descriptions are missing. You are on Zod 3 or Zod 4.0–4.1, or you passed a raw shape ({ env: z.string() }) built with a different Zod copy. Install zod@^4.2.0 and wrap schemas in z.object().

“Cannot find module” at startup. The client starts the server from another directory, or package.json lacks "type": "module". Use an absolute path to dist/server.js and rebuild.

The tool hangs, then the agent gives up. The upstream is slow. Set a timeout on every fetch and execFile so the tool returns isError instead of hanging, then raise the client limit if the work is legitimately slow: MCP_TOOL_TIMEOUT for Claude Code, tool_timeout_sec for Codex.

The model calls the wrong tool or invents arguments. The description is vague, or two tools overlap. Rewrite the description to say what it returns and when to use it, merge overlapping tools, and rerun the evaluation questions.

mcp-publisher publish rejects the server. name in server.json does not match mcpName, the namespace does not match the account you logged in with, or the npm version is not published yet. Fix the mismatch and rerun mcp-publisher validate.

The tech lead signs off on evidence, not on a read-through of the handlers:

  • npm run verify passes in CI: type check, tests, and the Inspector --strict run in both protocol eras.
  • The test pins the exact tool list, and every tool without readOnlyHint is on an approved write list.
  • The server uses one scoped service-account credential from the environment; no secret is in a committed file.
  • Every upstream call has a timeout, and every failure returns isError.
  • The evaluation questions pass in a fresh session of the team’s main agent.
  • There is a named owner and a condition for retiring the server.

As of 2026-09-26: modelcontextprotocol/inspector has 11.0k GitHub stars, and mcp-builder shows 118,601 all-time installs in a third-party scrape of skills.sh (LinklyAI/best-skills, dated 2026-09-26; secondary, because skills.sh itself could not be read).