Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/migration/upgrade-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`:

Expand Down
7 changes: 7 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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`.

Expand Down
36 changes: 30 additions & 6 deletions packages/middleware/node/test/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uint8Array>, needles: string[], timeoutMs = 2000): Promise<string> {
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
*/
Expand Down Expand Up @@ -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"');
Expand Down Expand Up @@ -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: ');
Expand Down
Loading