I Rebuilt My MCP Server on the v2 SDK and Tested What console.log Actually Breaks A developer rebuilt a weather MCP server on the v2 TypeScript SDK (@modelcontextprotocol/server 2.4.0) and tested the common claim that a single stray console.log breaks a stdio server, finding that a stray line with a trailing newline was tolerated while output without a newline was not. The writeup documents the full build path — installing the SDK and Zod, registering tools with registerTool and Zod schemas inside a factory, serving via serveStdio, and adding the server to Claude Code with `claude mcp add --transport stdio` — and notes that npm's `latest` tag for the v1 `@modelcontextprotocol/sdk` package still pointed at 1.32.2 as of 10 November 2026. Originally published at its-tahir.com https://www.its-tahir.com/blog/how-to-build-an-mcp-server . 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 https://tools.its-tahir.com/mcp-servers 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 https://www.its-tahir.com/work/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 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. 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: js /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