# I Rebuilt My MCP Server on the v2 SDK and Tested What console.log Actually Breaks

> Source: <https://dev.to/its-tahir/i-rebuilt-my-mcp-server-on-the-v2-sdk-and-tested-what-consolelog-actually-breaks-bcj>
> Published: 2026-11-12 05:11:19+00:00

*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 <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:

```
# 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:

``` js
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 (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](https://tools.its-tahir.com/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` ; 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](https://www.its-tahir.com/blog/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](https://www.its-tahir.com/blog/claude-code-vs-cursor-context-management): 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](https://www.its-tahir.com/blog/rag-retrieval-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` 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](https://www.its-tahir.com/blog/claude-code-troubleshooting) 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.
