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.
Four steps from code to a connected server. Test it on its own before Claude Code ever sees it.
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.
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() andawait 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.
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"]
}
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.
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.
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.
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.
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.
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:
npx @modelcontextprotocol/inspector node build/index.js
npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/list
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.
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:
-- matters.--transport, --env, and --scope, from the command and arguments that run the server. Everything after --env KEY=value before it.~/.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).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.
| 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 (useclaude 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.
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 ; runnpx 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 down 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.
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:
find_overdue_invoices beats list_invoices plus filter_invoices plus get_invoice.
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.
npx tsc runs clean and produces build/index.js. console.log, no process.stdout.write, and no chatty dependency..describe(). isError: true results, not thrown errors.tools/list in the Inspector or your own client test.
| When | Change |
|---|---|
| 27 Jul 2026 | TypeScript SDK 2.0.0: @modelcontextprotocol/server and@modelcontextprotocol/client replace the single v1 package;registerTool andserveStdio replacetool() andStdioServerTransport withconnect() ; 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
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.
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.
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().
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.
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.
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.
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.
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.