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`. 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: ');