Originally published at its-tahir.com.

I rebuilt the weather MCP server from my August tutorial on the v2 TypeScript SDK (@modelcontextprotocol/server 2.4.0), added it to Claude Code, and tested the advice everyone repeats: that one console.log breaks a stdio server. A stray line was forgiven. Text with no newline was not. Here's the full build, the real output, and the table. What's the strangest thing that has broken your MCP server?

A custom MCP server is a small program that gives an AI client such as Claude Code or Cursor new tools to call. To build one in TypeScript: install @modelcontextprotocol/server and zod, register each tool with registerTool and a Zod schema inside a factory, serve the factory with serveStdio, compile it, test it with the MCP Inspector, then add it to Claude Code with claude mcp add --transport stdio weather -- node /absolute/path/build/index.js and check that claude mcp list shows ✔ Connected.

This guide is TypeScript only; the Python SDK works differently and isn't covered here.

Step Command or code What you should see
Install npm install @modelcontextprotocol/server zod SDK 2.4.0, Zod 4
Build npx tsc build/index.js, no errors
Test The MCP Inspector, or a short client script tools/list returns your tools (mine returned [ 'get_alerts' ])
Add claude mcp add --transport stdio weather -- node /absolute/path/build/index.js Added stdio MCP server weather ... to local config
Check claude mcp list weather: node ... - ✔ Connected

Last reviewed 10 November 2026. I built the server below on Windows 11 with Node 22.22.3, @modelcontextprotocol/server 2.4.0, Zod 4.6.7 and TypeScript 7.0.2, drove it with the official 2.4.0 client, and added it to Claude Code 2.1.302 in a throwaway config folder. The outputs quoted are from those runs. I didn't run the MCP Inspector itself; that section follows its docs.

Build, test and add a custom MCP server to Claude Code, tested 10 Nov 2026 with @modelcontextprotocol/server 2.4.0 and Claude Code 2.1.302. 1 Build: registerTool with a Zod inputSchema, serveStdio(createServer), npm run build. 2 Test: the MCP Inspector (npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/list, per its docs) or the official client, which negotiated protocol 2026-07-28 in my run. 3 Add: claude mcp add --transport stdio weather -- node /abs/path/build/index.js. 4 Check: claude mcp list shows weather ✔ Connected. Never write to stdout: text with no newline made the call fail with Connection closed.

Four steps from code to a connected server. Test it on its own before Claude Code ever sees it.

What is a custom MCP server?

It's a normal program that speaks the Model Context Protocol, so any MCP client can discover and call the tools it offers. The protocol is JSON-RPC over stdio (the client starts your program and talks to it through stdin and stdout) or over streamable HTTP. A server can offer tools (functions the model calls), resources (data the client reads) and prompts (reusable templates). Most custom servers are tools only, and that's what this post builds (if an existing server already does the job, check this reviewed MCP servers list first): a get_alerts tool that returns US weather alerts from the National Weather Service.

There's nothing model-specific in your code. The client (Claude Code, Cursor, VS Code, ChatGPT) handles the model; your job is to describe each tool precisely and return useful text. I spend a lot of time on the client side of this: ContextZero, the VS Code extension I maintain, exists because every agent stores its rules and memory somewhere different. MCP fixes that for tools: one server works in every client that supports the protocol.

Which MCP SDK should I use?

Use @modelcontextprotocol/server, v2 of the TypeScript SDK. Its README calls it "the stable release line", implementing the 2026-07-28 MCP spec, and it replaces the single @modelcontextprotocol/sdk package from v1. One trap: on 10 November 2026, npm's latest tag for @modelcontextprotocol/sdk still pointed at 1.32.2, so tutorials that say npm install @modelcontextprotocol/sdk give you v1. If you're upgrading, the two changes you'll meet first:

v1 (@modelcontextprotocol/sdk) v2 (@modelcontextprotocol/server)
server.tool(...) server.registerTool(...)
new StdioServerTransport() and await server.connect(transport) serveStdio(createServer) with a factory
Zod 3 Zod 4 (the docs import zod/v4)

The SDK docs point to a codemod and an upgrade guide for the rest.

How do I set up a TypeScript MCP server project?

You need @modelcontextprotocol/server, zod, "type": "module" in package.json, and Node 20 or later.

mkdir weather-mcp && cd weather-mcp npm init -y npm install @modelcontextprotocol/server zod npm install -D typescript @types/node mkdir src 

Two things in package.json matter: "type": "module", because the SDK is ESM, and a bin entry if you'll publish the server as a command:

{ "type": "module", "bin": { "weather": "./build/index.js" }, "scripts": { "build": "tsc" }, "files": ["build"] } 

And a tsconfig.json. The "types": ["node"] line is new: the SDK's README notes that TypeScript 6.0 and later no longer include @types/* automatically, and its type definitions reference Buffer. I compiled with TypeScript 7.0.2.

{ "compilerOptions": { "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", "outDir": "build", "rootDir": "src", "strict": true, "types": ["node"], "skipLibCheck": true }, "include": ["src"] } 

How do I register a tool?

Call registerTool with a name, a config holding a description and a Zod inputSchema, and an async handler that returns a content array. In v2 you do it inside a factory function, which serveStdio calls for each connection. This is src/index.ts, exactly as I compiled and ran it:

#!/usr/bin/env node import { McpServer } from "@modelcontextprotocol/server"; import { serveStdio } from "@modelcontextprotocol/server/stdio"; import * as z from "zod/v4"; const NWS_API_BASE = "https://api.weather.gov"; function createServer() { const server = new McpServer({ name: "weather", version: "1.0.0" }); server.registerTool( "get_alerts", { description: "Get weather alerts in force right now for a US state. Only works for US states.", inputSchema: z.object({ state: z.string().length(2).describe("Two-letter US state code, for example CA or NY"), }), }, async ({ state }) => { const code = state.toUpperCase(); const res = await fetch(`${NWS_API_BASE}/alerts/active?area=${code}`, { headers: { "User-Agent": "weather-mcp/1.0", Accept: "application/geo+json" }, }); if (!res.ok) { return { content: [{ type: "text", text: `Alert lookup failed: HTTP ${res.status}` }], isError: true, }; } const data = (await res.json()) as { features?: { properties: { event: string; headline: string } }[]; }; const features = data.features ?? []; if (features.length === 0) { return { content: [{ type: "text", text: `No active alerts for ${code}` }] }; } return { content: [ { type: "text", text: features.map((f) => `${f.properties.event}: ${f.properties.headline}`).join("\n"), }, ], }; }, ); return server; } serveStdio(createServer); console.error("weather MCP server running on stdio"); 

From that one Zod schema, the SDK builds the JSON Schema the model sees, checks arguments before your handler runs, and types the handler's arguments. Here's what my test client got back from tools/list:

{ "name": "get_alerts", "description": "Get weather alerts in force right now for a US state. Only works for US states.", "inputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { "state": { "type": "string", "minLength": 2, "maxLength": 2, "description": "Two-letter US state code, for example CA or NY" } }, "required": ["state"] } } 

A call for Texas on 10 November returned Dense Fog Advisory: Dense Fog Advisory issued November 10 at 1:06AM CST until November 10 at 10:00AM CST by NWS Lubbock TX, followed by the other advisories in force. Wyoming returned No active alerts for WY.

Write descriptions for the model, not for humans

The description and every .describe() are the only things the model reads when deciding whether to call your tool. "Get weather alerts in force right now for a US state" gets called correctly; "Weather utility" doesn't. Say when to call it, not just what it does: "Only works for US states" stops the model calling it for Lahore and wasting a round trip.

Keep the description true to the code, too. /alerts and /alerts/active differ by one path segment and, for Wyoming on the day I first wrote this, by 119 results against 7. The first is every alert on file; the second is the ones in force. A tool that promises "in force right now" and calls /alerts makes the model report expired warnings as current, with full confidence. Nothing type-checks a description.

How should an MCP tool report errors?

Return a normal result with isError: true, not an exception. When the weather service fails, the handler returns a normal result with isError: true instead of throwing. The model reads that text and can retry or route around it. The SDK does the same for bad arguments. When my client sent state: "California", the handler never ran, and the result was:

Input validation error: Invalid arguments for tool get_alerts: state: Too big: expected string to have exactly 2 characters 

with isError: true, so the model sees what was wrong and can fix its next call.

How do I serve it over stdio?

serveStdio(createServer) is the whole transport. It owns stdin and stdout, calls your factory for the connection, and, per the SDK docs, also serves older clients from the same factory. My test client and this server negotiated protocol version 2026-07-28.

Then compile:

npx tsc 

For a remote server that many people share, serve the same factory over streamable HTTP instead (the SDK's serving guide covers it) and add it with claude mcp add --transport http <name> <url>. I only tested stdio here.

Never write to stdout

In my test with SDK 2.4.0, a stray console.log line was skipped and calls still worked, but process.stdout.write("fetching...") with no newline made the next call fail with Connection closed. On a stdio server, stdout is the JSON-RPC channel: the client reads every line of it as a protocol message. The usual advice is that one console.log breaks everything. What actually happened with the 2.4.0 client and Claude Code 2.1.302:

What the server wrote to stdout Official 2.4.0 client Claude Code 2.1.302
A console.log line at startup Skipped; calls worked Still ✔ Connected
A console.log line inside the tool Skipped; the call worked Not tested
A console.log of a JSON object Raised an invalid_union error event; the call still worked Not tested
Text with no newline (process.stdout.write("fetching...")) The call failed: Connection closed Not tested

So current clients forgive a stray full line, but not output without a newline: it glues itself to the front of the next JSON message, which no longer parses, and the connection drops. Other clients may be stricter than these two. The rule stands: log with console.error, which goes to stderr, and check what your dependencies print at import time. If a server connects and then dies on its first call, look for stray output before anything else.

HTTP servers don't have this problem; stdout is free there.

How do I test an MCP server before adding it to Claude Code?

Run it in the MCP Inspector, the reference test tool. It ships as one package with three clients, needs Node 22.19.0 or newer, and runs through npx with nothing to install:

# web UI, connected to your server over stdio npx @modelcontextprotocol/inspector node build/index.js # scriptable: list the tools and exit (good for CI) npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/list # terminal UI npx @modelcontextprotocol/inspector --tui node build/index.js 

In the web client, click Connect, open Tools, and call yours with test arguments. One thing to know first, from its docs: the Inspector stores OAuth tokens and stdio env: values in the OS keychain, and on machines without one (headless Linux, SSH sessions, containers) it writes them to ~/.mcp-inspector/secrets.json, unencrypted unless you supply a key.

You can also test from code. The SDK's own guide drives a server through the official Client; I connected mine over stdio with a few lines:

import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; const client = new Client({ name: "test-harness", version: "1.0.0" }, { versionNegotiation: { mode: "auto" } }); await client.connect(new StdioClientTransport({ command: process.execPath, args: ["build/index.js"] })); console.log((await client.listTools()).tools.map((t) => t.name)); // [ 'get_alerts' ] console.log(await client.callTool({ name: "get_alerts", arguments: { state: "TX" } })); await client.close(); 

That's also a cheap test to keep in CI next to your unit tests.

How do I add a custom MCP server to Claude Code?

Build it, then register the compiled file with claude mcp add, putting the launch command after --.

claude mcp add --transport stdio weather -- node /absolute/path/to/weather-mcp/build/index.js 

On Windows I ran it from PowerShell with a C:\ path:

claude mcp add --transport stdio weather -- node C:\path\to\weather-mcp\build\index.js 

Claude Code 2.1.302 answered:

Added stdio MCP server weather with command: node C:\path\to\weather-mcp\build\index.js to local config 

and claude mcp list health-checked it:

Checking MCP server health… weather: node C:\path\to\weather-mcp\build\index.js - ✔ Connected 

claude mcp get weather shows the details, including Scope: Local config (private to you in this project) and how to remove it (claude mcp remove weather -s local).

Four things worth knowing:

  • The -- matters. Claude Code's docs: it "separates Claude's own options, such as --transport, --env, and --scope, from the command and arguments that run the server. Everything after -- is passed to the server untouched." Put --env KEY=value before it.
  • Use absolute paths. Relative paths resolve against the folder you start Claude Code in, not the config file.
  • Local scope is the default, and it means this project only. It's stored in ~/.claude.json under the current project's path. Use --scope user for all your projects, or --scope project to write .mcp.json at the project root and share it through git (teammates then approve it once).
  • Slow to start? MCP_TIMEOUT=10000 claude gives servers 10 seconds to start, per the docs. In PowerShell: $env:MCP_TIMEOUT=10000; claude.

If the status isn't ✔ Connected, see the troubleshooting table below.

Scope and transport, side by side

Claude Code Cursor (per its docs, Aug 2026)
This project, shared with the team .mcp.json in the project root (--scope project); commit it .cursor/mcp.json; commit it
This project only, private to you ~/.claude.json (--scope local, the default) not documented as of August 2026
All your projects, private to you ~/.claude.json (--scope user) ~/.cursor/mcp.json
Transports stdio, streamable HTTP, SSE (deprecated), WebSocket stdio, streamable HTTP, SSE
Registration claude mcp add edit the JSON file
Health check claude mcp list, except WebSocket servers (use claude mcp get <name> or /mcp) MCP Logs in the Output panel

To get ready-to-paste JSON for Claude Code, Cursor or VS Code instead of hand-writing it, use this MCP config generator. If you do hand-write JSON for Claude Code, an entry with a url but no type is read as a stdio server and skipped. The type field accepts streamable-http as an alias for http, so configs copied from a server's docs work as they are.

Why isn't my custom MCP server connecting?

Run the exact command after -- yourself first. If it doesn't start in your shell, it won't start in Claude Code.

You see Likely cause Fix
✘ Failed to connect The command fails: wrong path, not built, missing dependency Run it yourself; use an absolute path to build/index.js; run npx tsc
Connects, then a call fails with Connection closed Output on stdout without a newline, or the process crashed Log with console.error; check stderr for a crash
⏸ Pending approval A project-scope server in .mcp.json that you haven't approved Run claude in the project and accept it
Not in the list at all Added at another scope, or in another project claude mcp get <name>; re-add with --scope user
Times out at startup Slow first run, for example npx downloading a package MCP_TIMEOUT=10000 claude (PowerShell: $env:MCP_TIMEOUT=10000; claude)

Each status and its fix in detail, including the Windows npx case, is in MCP server not showing up in Claude Code.

How many tools should an MCP server have?

As few as cover your users' intents. Every tool's name, description and schema load into the model's context on every request. Twenty tools is a standing tax on every turn, and more near-synonyms make tool choice harder. Claude Code's output limits push the same way, per its docs: it warns when a tool's output exceeds 10,000 tokens, caps it at 25,000 by default (MAX_MCP_OUTPUT_TOKENS changes that), and saves text results longer than 50,000 characters to a file unless the tool declares its own anthropic/maxResultSizeChars.

Three rules I apply:

  • One tool per user intent, not per API endpoint. find_overdue_invoices beats list_invoices plus filter_invoices plus get_invoice.
  • Return the answer, not the payload. Terse text, not your API's envelope, pagination cursors and empty fields.
  • Delete tools nobody calls. They cost context for nothing.

It's the same discipline as in Claude Code vs Cursor: how each one decides what your agent knows: context is a budget, and tool schemas spend from it whether you use them or not. If your tool fronts a search index, what it retrieves spends from the same budget, and RAG retrieval that returns the wrong chunks covers when those results are off.

A checklist before you ship

  • npx tsc runs clean and produces build/index.js.
  • Nothing writes to stdout: no console.log, no process.stdout.write, and no chatty dependency.
  • Every tool's description says when to call it, and every schema field has .describe().
  • Failures return isError: true results, not thrown errors.
  • Secrets come from environment variables passed with --env, never hardcoded.
  • It passes tools/list in the Inspector or your own client test.
  • You've reviewed what it can reach. A server that fetches outside content is a prompt-injection surface; Claude Code's docs carry an explicit warning about this, and it applies to servers you write as much as ones you install.

What changed for MCP server authors recently?

When Change
27 Jul 2026 TypeScript SDK 2.0.0: @modelcontextprotocol/server and @modelcontextprotocol/client replace the single v1 package; registerTool and serveStdio replace tool() and StdioServerTransport with connect(); Zod 4
28 Jul 2026 MCP specification 2026-07-28
23 Oct 2026 @modelcontextprotocol/server 2.4.0, the version tested here
Claude Code 2.1.91 Tools can raise their own result limit with _meta["anthropic/maxResultSizeChars"] (up to 500K characters); today's docs save other text results over 50,000 characters to a file

I tested with Claude Code 2.1.302, the version installed here; the current release is 2.1.322. Since 2.1.302 the changelog lists fixes to these commands, so your output may differ slightly: in 2.1.311, claude mcp add and remove name the right project entry when run below a repository root, and failed-connection lines in claude mcp list and get no longer repeat the error code; in 2.1.318, add and remove wait for the config lock; in 2.1.321, claude mcp list no longer prints nothing when a stored MCP sign-in record is null.

If Claude Code itself is what's failing once the server is registered, every Claude Code error and its fix maps each message to the post that solves it.

Key takeaways

  • A custom MCP server is an ordinary program that speaks JSON-RPC over stdio or HTTP; the client handles the model.
  • Use @modelcontextprotocol/server (SDK v2, protocol 2026-07-28). npm install @modelcontextprotocol/sdk still gives you v1.
  • Register tools with registerTool and a Zod schema inside a factory, and serve it with serveStdio(createServer).
  • Test before Claude Code sees it: the MCP Inspector (Node 22.19+) or a few lines with the official client.
  • Add it with claude mcp add --transport stdio -- node /absolute/path/build/index.js, then check claude mcp list for ✔ Connected.
  • Never write to stdout. In my test a stray full line was forgiven, but text with no newline broke the next message and the call failed with Connection closed.

FAQ

FAQ

How do I build a custom MCP server?

In TypeScript: install @modelcontextprotocol/server and zod, create an McpServer inside a factory function, register each tool with registerTool and a Zod inputSchema, and serve the factory with serveStdio from @modelcontextprotocol/server/stdio. Compile it, test it with the MCP Inspector, then add it to your client. That is the whole shape; the rest is writing good tool descriptions and keeping stdout clean.

How do I add a custom MCP server to Claude Code?

Build it first, then run claude mcp add --transport stdio -- node /absolute/path/to/build/index.js. Everything after -- is passed to your server untouched. The server is added to local scope by default, which means this project only; add --scope user for all your projects or --scope project to share it through .mcp.json. Check it with claude mcp list, which should show ✔ Connected.

Should I use @modelcontextprotocol/sdk or @modelcontextprotocol/server?

For a new server, @modelcontextprotocol/server. It is v2 of the TypeScript SDK, the stable line, and implements the 2026-07-28 protocol. @modelcontextprotocol/sdk is the v1 package; npm's latest tag for it still pointed at 1.32.2 on 10 November 2026, so npm install @modelcontextprotocol/sdk still gives you v1. Coming from v1, registerTool replaces tool() and serveStdio replaces new StdioServerTransport() plus server.connect().

How do I test an MCP server?

Use the MCP Inspector, which needs Node 22.19.0 or newer. npx @modelcontextprotocol/inspector node build/index.js opens the web client; add --cli and --method tools/list for a scriptable check in CI. You can also drive the server from a few lines of code with the official client, which is what I did for this post.

Why does my MCP server say Connection closed?

On a stdio server, a common cause is output on stdout that corrupts the JSON-RPC stream. In my test with SDK 2.4.0, a full stray line from console.log was skipped by the client, but text written without a newline glued onto the next message and the call failed with Connection closed. Log with console.error, and check what your dependencies print. If the server never starts at all, run its command yourself to see the real error.

Do I need a different MCP server for Claude Code and Cursor?

No. MCP is a protocol, not a vendor integration. The same stdio server registers with Claude Code through claude mcp add and with Cursor through an entry in .cursor/mcp.json. Only the registration differs.

Should I use stdio or HTTP transport?

Use stdio when the server runs locally and needs machine access, such as files, local databases or CLIs. Use streamable HTTP when the server is remote and shared across a team, because HTTP supports OAuth. Claude Code's docs deprecate SSE in favor of HTTP.

How many tools should one MCP server expose?

Fewer than you think. Every tool's name, description and schema load into the model's context on every request, and Claude Code warns when a tool's output exceeds 10,000 tokens. A few sharply described tools beat many vague ones.

Sources

Originally published at its-tahir.com.