Skip to content

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.

  • A runnable MCP server skeleton built on @modelcontextprotocol/sdk and Zod, registering a real tool with input validation
  • The current server.registerTool() API (the old server.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)

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.

Initialize a Node project and install the SDK, Zod, and an HTML-to-Markdown converter.

Terminal window
npm init -y
npm install @modelcontextprotocol/sdk zod turndown
npm install -D typescript @types/node @types/turndown
npx tsc --init

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.

src/server.ts
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.

Terminal window
npx tsc

That 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.

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"]
}
}
}

Restart the client, then ask it to call get_doc on an internal URL to confirm the tool is wired up.

Once the read-only scraper works, grow the server by adding tools — each is another registerTool call. Let the AI extend its own toolset.

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.