Building Custom MCP Servers
The off-the-shelf MCP catalog is huge, but it stops at your firewall. Your AI can search GitHub and read Postgres, yet it cannot touch the internal wiki where every architectural decision lives, or the homegrown deploy API that only your platform team understands. The moment a teammate asks “why does the AI keep ignoring our runbook?”, the answer is always the same: nothing connects it to your runbook.
A custom MCP server fixes that. It is a small program that exposes your internal systems as tools the AI can call — a Confluence scraper, a wrapper around your internal REST API, a query helper for a proprietary data store. This guide walks through building one end to end in TypeScript with the official SDK, then registering it in Cursor, Claude Code, and Codex.
What You’ll Walk Away With
Section titled “What You’ll Walk Away With”- A runnable MCP server skeleton built on
@modelcontextprotocol/sdkand Zod, registering a real tool with input validation - The current
server.registerTool()API (the oldserver.tool()shape is deprecated in SDK v1.29+) - The exact config to connect your server to Cursor, Claude Code, and Codex
- Copy-paste prompts that have your AI scaffold the server and add new tools for you
- A failure-mode checklist for the bugs every first MCP server hits (stdout pollution, JSON-RPC framing, schema validation)
The Workflow
Section titled “The Workflow”You do not have to hand-write the boilerplate. The fastest path is to have your AI agent scaffold the server, then you review and harden it. Start with this prompt.
1. Set up the project
Section titled “1. Set up the project”Initialize a Node project and install the SDK, Zod, and an HTML-to-Markdown converter.
npm init -ynpm install @modelcontextprotocol/sdk zod turndownnpm install -D typescript @types/node @types/turndownnpx tsc --init2. Write the server
Section titled “2. Write the server”The 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 object schema. The description is what the model reads to decide when to call the tool, so make it specific.
import { 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);Two details that bite people: inputSchema takes the raw Zod shape object ({ url: z.string().url() }), and error is typed unknown under strict TypeScript, so you must narrow it (error instanceof Error ? ...) before reading .message. Returning isError: true lets the model see the failure and react instead of silently getting empty content.
3. Build it
Section titled “3. Build it”npx tscThat emits dist/server.js, which is what every client below points at. Keep console.log out of the server — on stdio transport, stdout is the JSON-RPC channel, and a stray log line corrupts the protocol stream. Log to stderr (console.error) instead.
Connect your server
Section titled “Connect your server”Cursor and Claude Code both read the same mcpServers JSON shape, so those two tabs are intentionally identical — if you prefer, Claude Code can register the server with a single CLI command instead. Codex uses 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, 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 get_doc on an internal URL to confirm the tool is wired up.
Adding more tools
Section titled “Adding more tools”Once the read-only scraper works, grow the server by adding tools — each is another registerTool call. Let the AI extend its own toolset.
When This Breaks
Section titled “When This Breaks”The client shows the server as “failed” with no tools. Almost always a stdout problem. Any console.log, banner, or dependency that prints to stdout corrupts the JSON-RPC stream on stdio transport. Move all logging to console.error (stderr) and rebuild.
JSON-RPC parse errors / the server connects but tools never appear. 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 new APIs in one server is a common cause of silent tool-list failures.
“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 instead expose Streamable HTTP and point the client at a url. Do not put a transport key in Codex TOML — it is inferred.
Zod validation rejects every call. Pass the raw shape to inputSchema ({ url: z.string().url() }), not a pre-built z.object(...), and make sure your tool description tells the model what each argument is — a vague description leads the model to send malformed arguments.
Relative paths fail at launch. MCP clients spawn your server from their own working directory, not your project root. Use an absolute path to dist/server.js in every config above.