From 7ccbd6655c9aa71401faf581852835f44da4dfe6 Mon Sep 17 00:00:00 2001 From: Huzaifa Farooq Date: Mon, 14 Sep 2026 06:15:25 +0500 Subject: [PATCH 1/2] fix(middleware-node): accumulate SSE reads in tests instead of assuming one chunk streamableHttp.test.ts asserted on multiple SSE events after a single reader.read() call, assuming Node's fetch would coalesce them into one chunk. That assumption doesn't hold on newer Node versions, where closely-timed events can arrive as separate chunks across separate reads, failing the test before the later event is read. Adds a readUntilContains helper that accumulates decoded text across reads until the expected content appears (or times out), and uses it in the two tests that were asserting on multiple SSE events from a single read. --- .../node/test/streamableHttp.test.ts | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/packages/middleware/node/test/streamableHttp.test.ts b/packages/middleware/node/test/streamableHttp.test.ts index 140717c6bb..209f06875e 100644 --- a/packages/middleware/node/test/streamableHttp.test.ts +++ b/packages/middleware/node/test/streamableHttp.test.ts @@ -34,6 +34,29 @@ async function getFreePort() { }); } +/** + * Reads chunks from an SSE stream reader until the accumulated decoded text contains every + * needle, or `timeoutMs` elapses. Node's fetch may deliver events emitted close together as + * separate chunks across separate `read()` calls (rather than coalesced into one), so tests + * asserting on multiple SSE events must accumulate across reads instead of reading once. + */ +async function readUntilContains(reader: ReadableStreamDefaultReader, needles: string[], timeoutMs = 2000): Promise { + const decoder = new TextDecoder(); + let text = ''; + const deadline = Date.now() + timeoutMs; + + while (!needles.every(needle => text.includes(needle))) { + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for [${needles.join(', ')}] in SSE stream. Received so far:\n${text}`); + } + const { value, done } = await reader.read(); + if (done) break; + text += decoder.decode(value, { stream: true }); + } + + return text; +} + /** * Test server configuration for NodeStreamableHTTPServerTransport tests */ @@ -727,9 +750,9 @@ describe('Zod v4', () => { const reader = response.body?.getReader(); - // The responses may come in any order or together in one chunk - const { value } = await reader!.read(); - const text = new TextDecoder().decode(value); + // The responses may come in any order, and Node's fetch may deliver them as + // separate chunks rather than coalesced into one, so accumulate until both arrive. + const text = await readUntilContains(reader!, ['"id":"req-1"', '"id":"req-2"']); // Check that both responses were sent on the same stream expect(text).toContain('"id":"req-1"'); @@ -1480,10 +1503,11 @@ describe('Zod v4', () => { // Send a server notification through the MCP server await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'First notification from MCP server' }); - // Read the notification from the SSE stream + // Read the notification from the SSE stream. Node's fetch may deliver the + // preceding connection event and this notification as separate chunks, so + // accumulate reads until the expected text shows up. const reader = sseResponse.body?.getReader(); - const { value } = await reader!.read(); - const text = new TextDecoder().decode(value); + const text = await readUntilContains(reader!, ['id: ', 'First notification from MCP server']); // Verify the notification was sent with an event ID expect(text).toContain('id: '); From ab9ac4c1c9fb0195536598e1fdc84c6c8e81f69d Mon Sep 17 00:00:00 2001 From: Huzaifa Farooq Date: Mon, 14 Sep 2026 06:26:50 +0500 Subject: [PATCH 2/2] docs: document v1 z.object() vs raw-shape footgun in troubleshooting + migration guide On SDK v1, server.tool() expects a raw shape ({ name: z.string() }) rather than a ZodObject, but nothing about the two failure modes points a reader at the actual mistake: - v1 <=1.21: tools/list crashes with "Cannot read properties of null (reading '_def')" - v1 1.22-1.26: registration succeeds but publishes an empty {"type":"object"} schema, so every client silently strips the tool's arguments v1 >=1.28 already throws a clear error, but the older ranges are still widely deployed and easy to hit when copying v2-style examples (which use z.object()) into v1 code. Adds a troubleshooting entry keyed on both observable symptoms, and a short callout in the v2 migration guide next to the registerTool example, so both search paths land on the explanation. Reported with full repro matrix in #2627. --- docs/migration/upgrade-to-v2.md | 9 +++++++++ docs/troubleshooting.md | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 3353fa5cb7..dd9da7e9af 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -641,6 +641,15 @@ server.registerTool('greet', { description: 'Greet a user', inputSchema: z.objec `registerResource` requires a `metadata` argument — pass `{}` if you have none. +> **Doing it backwards on v1:** if you copy a v2-style `z.object({...})` schema into a +> v1 `server.tool()` call (which expects the raw shape `{ name: z.string() }`), the +> failure is silent or cryptic depending on the v1 version: `tools/list` crashes with +> `Cannot read properties of null (reading '_def')` on v1 ≤1.21, or publishes an empty +> `{"type":"object"}` schema (arguments get stripped) on v1 1.22–1.26, with no error +> pointing at the cause either way. v1 ≥1.28 throws a clear error at registration. If +> you hit either symptom on v1, this is almost always why: migrate to `registerTool` +> with `inputSchema: z.object({...})` as shown above instead. + A tool or prompt registered **without** an `inputSchema` / `argsSchema` passes the context as its callback's single argument — v1 passed `(extra)`, v2 passes `(ctx)`: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d9e6e7824d..2b484ee913 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -147,6 +147,12 @@ METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION: subscriptions/listen requires a 2026-0 Either negotiate the era that defines the method — `versionNegotiation: { mode: 'auto' }` against a server that serves 2026-07-28, as in the previous entry — or call the surface the negotiated era does define. [Subscriptions](./clients/subscriptions.md) covers both delivery models; [Protocol versions](./protocol-versions.md) lists which methods each era defines. +## My tool's `inputSchema` is empty, or `tools/list` fails with `Cannot read properties of null (reading '_def')` + +You're on the SDK **v1** line and passed a `z.object({...})` where `server.tool()` expects a raw shape (`{ name: z.string() }`), the reverse of the v2 API. Depending on the exact v1 version this either crashes `tools/list` (≤1.21), or registers successfully but publishes an empty `{"type":"object"}` schema so every client silently strips your tool's arguments (1.22–1.26). Neither failure mode names the cause. v1 ≥1.28 throws a clear error at registration instead. + +Fix: pass the raw shape to `server.tool()` on v1, or migrate to `registerTool({ inputSchema: z.object({...}) })` on v2. See [the migration guide](./migration/upgrade-to-v2.md#server-registration-api). + ## `Module '"@modelcontextprotocol/server"' has no exported member 'SSEServerTransport'` `@modelcontextprotocol/server` no longer ships the server-side SSE transport, and the OAuth Authorization Server helpers (`mcpAuthRouter`, `ProxyOAuthServerProvider`) left with it. Both live on as a frozen v1 copy in `@modelcontextprotocol/server-legacy`. @@ -172,6 +178,7 @@ HTTP SSE streams emit a `: keepalive` comment every 15 seconds by default so cli - Every heading on this page is the exact message you searched for. - On stdio, `stdout` carries JSON-RPC; log with `console.error`. - `TS2589` means two `zod` copies in the dependency tree. +- An empty `inputSchema` or a `reading '_def'` crash on v1 means a `z.object()` was passed where a raw shape was expected. - The SDK raises `ERA_NEGOTIATION_FAILED` and `METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION` locally — neither is a wire error. - Server SSE and the Authorization Server helpers live in `@modelcontextprotocol/server-legacy`.