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/server2.1.0, started withserveStdioso 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-builderskill 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… | Build | Why |
|---|---|---|
| Live data or actions behind auth: feature flags, deploy history, an incident system | MCP server | Typed inputs, one credential held by the server, works in every agent |
| A procedure: how to write a migration, your release checklist | Skill (building custom skills) | Costs context only when it fires; no process to run |
A command that already exists: kubectl, gh, your make targets | Nothing new, or a skill that documents the command | The 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, Sentry | Install 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).
npx skills add anthropics/skills --skill mcp-builder -a claude-codeAlternatively, 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.
npx skills add anthropics/skills --skill mcp-builder -a codexnpx skills add anthropics/skills --skill mcp-builder -a cursorBuild the server on the v2 SDK
Section titled “Build the server on the v2 SDK”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.
-
Create the project and install the SDK.
Terminal window mkdir feature-flags-mcp && cd feature-flags-mcpnpm init -y && npm pkg set type=modulenpm install @modelcontextprotocol/server zodnpm install -D typescript @types/node vitest @modelcontextprotocol/clientv2 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. -
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.tsexportsgetFeatureFlags(environment), which calls your flags API. Keeping it separate lets the tests replace it. -
Start it in
src/server.ts.src/server.ts #!/usr/bin/env nodeimport { serveStdio } from '@modelcontextprotocol/server/stdio';import { buildServer } from './build-server.js';serveStdio(buildServer);serveStdiopins one server instance per connection. It answers a 2026-07-28 opening and, by default, a 2025-erainitialize, so the same binary works for a client on either protocol era. -
Add
tsconfig.jsonand build.tsconfig.json {"compilerOptions": {"target": "ES2022","module": "NodeNext","moduleResolution": "NodeNext","outDir": "dist","rootDir": "src","strict": true,"types": ["node"]},"include": ["src"]}Terminal window npx tscThe 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.
npx @modelcontextprotocol/inspector --cli node dist/server.js --protocol-era modern --method tools/list --strictnpx @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:
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.
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.jsclaude mcp get feature-flagsclaude 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.
codex mcp add feature-flags --env FLAGS_API_URL="$FLAGS_API_URL" --env FLAGS_API_TOKEN="$FLAGS_API_TOKEN" \ -- node /abs/path/feature-flags-mcp/dist/server.jscodex mcp get feature-flagsThis writes [mcp_servers.feature-flags] to ~/.codex/config.toml. Timeouts are the startup_timeout_sec and tool_timeout_sec keys under that table. Codex 0.157.1 connects with the 2025-era protocol (mcp_2026_07_28 is under development and off in codex features list), which serveStdio also answers.
Add the server to .cursor/mcp.json in the project (or the global file in your home directory):
{ "mcpServers": { "feature-flags": { "command": "node", "args": ["/abs/path/feature-flags-mcp/dist/server.js"], "env": { "FLAGS_API_URL": "https://flags.internal.example.com" } } }}Keep the token out of this file; set FLAGS_API_TOKEN in the environment Cursor starts from. Cursor’s MCP settings show whether the server connected. Cursor’s support for spec 2026-07-28 was not verified on 2026-09-26; the server answers either protocol.
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.
Wrap an internal API or a CLI safely
Section titled “Wrap an internal API or a CLI safely”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.
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.
Tools, resources, or prompts?
Section titled “Tools, resources, or prompts?”MCP servers expose three primitives. Most internal servers need only tools.
| Primitive | Registered with | Use it for | Client behaviour |
|---|---|---|---|
| Tool | registerTool | Anything the model decides to call: lookups, searches, actions | Supported by all three agents |
| Resource | registerResource (v2 requires a metadata argument; pass {} if you have none) | Documents the user attaches: runbooks, schemas | Clients differ in how resources reach the model; check each agent before you rely on one |
| Prompt | registerPrompt | A reusable workflow the user starts by name, such as a postmortem template | Clients 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.
Serve the server over HTTP for the team
Section titled “Serve the server over HTTP for the team”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.
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”| Audience | Ship as | Clients run |
|---|---|---|
| Your company | A package on your private npm registry, with a bin entry | npx -y @acme/feature-flags-mcp |
| Your company, one shared credential | The HTTP server above in a container behind your gateway | A URL |
| Everyone | A public npm package plus an entry in the Official MCP Registry | npx -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.
-
Add
mcpNametopackage.json. With GitHub authentication, it must start withio.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"]} -
Publish the package:
npm publish --access public. -
Install the publisher and create
server.json.Terminal window brew install mcp-publishermcp-publisher initSet
nameto the same value asmcpName, and set bothversionfields to the version you published to npm. -
Validate, log in, and publish.
Terminal window mcp-publisher validatemcp-publisher login githubmcp-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.
What breaks in a custom MCP server?
Section titled “What breaks in a custom MCP server?”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.
Sign off a custom MCP server
Section titled “Sign off a custom MCP server”The tech lead signs off on evidence, not on a read-through of the handlers:
-
npm run verifypasses in CI: type check, tests, and the Inspector--strictrun in both protocol eras. - The test pins the exact tool list, and every tool without
readOnlyHintis 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).