From 05c26b327a9c410136f04ad9d9ff3075d0b6ac19 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 4 Dec 2025 14:04:37 +0000 Subject: [PATCH 1/4] fix: disable priming events to fix backwards compatibility Priming events (SEP-1699) have empty SSE data which clients in the 1.23.x line cannot handle - they crash trying to JSON.parse(""). This patch disables priming events entirely to prevent breaking existing clients. Priming events will be re-enabled in 1.24.x with proper protocol version gating to only send them to clients that can handle empty SSE data. --- src/server/streamableHttp.test.ts | 133 +++--------------------------- src/server/streamableHttp.ts | 21 ++--- 2 files changed, 18 insertions(+), 136 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 80ee04d67..1b93cae0f 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -1592,12 +1592,13 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { }); // Test SSE priming events for POST streams - describe('StreamableHTTPServerTransport POST SSE priming events', () => { + // NOTE: Priming events are DISABLED in 1.23.1 to fix backwards compatibility issues. + // Clients in the 1.23.x line cannot handle empty SSE data (they crash on JSON.parse("")). + describe('StreamableHTTPServerTransport POST SSE priming events (DISABLED in 1.23.1)', () => { let server: Server; let transport: StreamableHTTPServerTransport; let baseUrl: URL; let sessionId: string; - let mcpServer: McpServer; // Simple eventStore for priming event tests const createEventStore = (): EventStore => { @@ -1641,7 +1642,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { } }); - it('should send priming event with retry field on POST SSE stream', async () => { + it('should NOT send priming events (disabled in 1.23.1 for backwards compatibility)', async () => { const result = await createTestServer({ sessionIdGenerator: () => randomUUID(), eventStore: createEventStore(), @@ -1650,7 +1651,6 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { server = result.server; transport = result.transport; baseUrl = result.baseUrl; - mcpServer = result.mcpServer; // Initialize to get session ID const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); @@ -1671,7 +1671,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { 'Content-Type': 'application/json', Accept: 'text/event-stream, application/json', 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26' + 'mcp-protocol-version': '2025-06-18' }, body: JSON.stringify(toolCallRequest) }); @@ -1679,128 +1679,15 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { expect(postResponse.status).toBe(200); expect(postResponse.headers.get('content-type')).toBe('text/event-stream'); - // Read the priming event + // Read the first chunk - should be the actual response, not a priming event const reader = postResponse.body?.getReader(); const { value } = await reader!.read(); const text = new TextDecoder().decode(value); - // Verify priming event has id and retry field - expect(text).toContain('id: '); - expect(text).toContain('retry: 5000'); - expect(text).toContain('data: '); - }); - - it('should send priming event without retry field when retryInterval is not configured', async () => { - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - eventStore: createEventStore() - // No retryInterval - }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; - mcpServer = result.mcpServer; - - // Initialize to get session ID - const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - sessionId = initResponse.headers.get('mcp-session-id') as string; - expect(sessionId).toBeDefined(); - - // Send a tool call request - const toolCallRequest: JSONRPCMessage = { - jsonrpc: '2.0', - id: 100, - method: 'tools/call', - params: { name: 'greet', arguments: { name: 'Test' } } - }; - - const postResponse = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'text/event-stream, application/json', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26' - }, - body: JSON.stringify(toolCallRequest) - }); - - expect(postResponse.status).toBe(200); - - // Read the priming event - const reader = postResponse.body?.getReader(); - const { value } = await reader!.read(); - const text = new TextDecoder().decode(value); - - // Priming event should have id field but NOT retry field - expect(text).toContain('id: '); - expect(text).toContain('data: '); - expect(text).not.toContain('retry:'); - }); - - it('should close POST SSE stream when closeSseStream is called', async () => { - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - eventStore: createEventStore(), - retryInterval: 1000 - }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; - mcpServer = result.mcpServer; - - // Track tool execution state - let toolResolve: () => void; - const toolPromise = new Promise(resolve => { - toolResolve = resolve; - }); - - // Register a blocking tool - mcpServer.tool('blocking-tool', 'A blocking tool', {}, async () => { - await toolPromise; - return { content: [{ type: 'text', text: 'Done' }] }; - }); - - // Initialize to get session ID - const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - sessionId = initResponse.headers.get('mcp-session-id') as string; - expect(sessionId).toBeDefined(); - - // Send a tool call request - const toolCallRequest: JSONRPCMessage = { - jsonrpc: '2.0', - id: 100, - method: 'tools/call', - params: { name: 'blocking-tool', arguments: {} } - }; - - const postResponse = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'text/event-stream, application/json', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26' - }, - body: JSON.stringify(toolCallRequest) - }); - - expect(postResponse.status).toBe(200); - - const reader = postResponse.body?.getReader(); - - // Read the priming event - await reader!.read(); - - // Close the SSE stream - transport.closeSSEStream(100); - - // Stream should now be closed - const { done } = await reader!.read(); - expect(done).toBe(true); - - // Clean up - resolve the tool promise - toolResolve!(); + // Should NOT contain a priming event (which would have empty data) + // The first message should be the actual tool result with event: message + expect(text).toContain('event: message'); + expect(text).toContain('"result"'); }); }); diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 4514e619c..dc295b4e3 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -270,20 +270,15 @@ export class StreamableHTTPServerTransport implements Transport { /** * Writes a priming event to establish resumption capability. - * Only sends if eventStore is configured (opt-in for resumability). + * + * DISABLED in 1.23.1: Priming events have empty SSE data which clients + * in the 1.23.x line cannot handle (they crash trying to JSON.parse("")). + * This feature is disabled to prevent breaking existing clients. + * Priming events will be re-enabled in 1.24.x with proper protocol version gating. */ - private async _maybeWritePrimingEvent(res: ServerResponse, streamId: string): Promise { - if (!this._eventStore) { - return; - } - - const primingEventId = await this._eventStore.storeEvent(streamId, {} as JSONRPCMessage); - - let primingEvent = `id: ${primingEventId}\ndata: \n\n`; - if (this._retryInterval !== undefined) { - primingEvent = `id: ${primingEventId}\nretry: ${this._retryInterval}\ndata: \n\n`; - } - res.write(primingEvent); + private async _maybeWritePrimingEvent(_res: ServerResponse, _streamId: string): Promise { + // Priming events disabled in 1.23.x - see docstring above + return; } /** From e98eece19c9e9d830b432721077ec71c117f7a4a Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 4 Dec 2025 14:25:11 +0000 Subject: [PATCH 2/4] chore: bump version to 1.23.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index b6720b978..d5313e7a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.23.0", + "version": "1.23.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.23.0", + "version": "1.23.1", "license": "MIT", "dependencies": { "ajv": "^8.17.1", diff --git a/package.json b/package.json index 9aa77ff2e..e61f9a7df 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.23.0", + "version": "1.23.1", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 0224934384219432197151b5b13e44efd362b287 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 4 Dec 2025 18:18:40 +0000 Subject: [PATCH 3/4] fix: use versioned npm tag for non-main branch releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When publishing from maintenance branches (e.g., patch-1), use a major.minor tag like "v1.23" instead of defaulting to "latest". This prevents old patches from becoming the latest version on npm. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/workflows/main.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 911c08bdf..89673834d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -67,8 +67,18 @@ jobs: id: npm-tag run: | VERSION=$(node -p "require('./package.json').version") + # Check if this is a beta release if [[ "$VERSION" == *"-beta"* ]]; then echo "tag=--tag beta" >> $GITHUB_OUTPUT + # Check if this release is from a non-main branch (patch/maintenance release) + elif [[ "${{ github.event.release.target_commitish }}" != "main" ]]; then + # Use major.minor as tag for old branch releases (e.g., "v1.23" for 1.23.x) + # npm tags are mutable pointers to versions (like "latest" pointing to 1.24.3). + # Using "v1.23" means users can `npm install @modelcontextprotocol/sdk@v1.23` + # to get the latest patch on that minor version, and the tag updates if we + # release 1.23.2, 1.23.3, etc. + MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1,2) + echo "tag=--tag v${MAJOR_MINOR}" >> $GITHUB_OUTPUT else echo "tag=" >> $GITHUB_OUTPUT fi From bd3c461f01f2b1f8cc948a14d38857aa450e1cea Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 4 Dec 2025 18:26:51 +0000 Subject: [PATCH 4/4] fix: use release-X.Y tag format to avoid semver range conflict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm rejects tags like "v1.23" because they look like semver ranges. Use "release-1.23" format instead. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/workflows/main.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 89673834d..60144add1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -72,13 +72,14 @@ jobs: echo "tag=--tag beta" >> $GITHUB_OUTPUT # Check if this release is from a non-main branch (patch/maintenance release) elif [[ "${{ github.event.release.target_commitish }}" != "main" ]]; then - # Use major.minor as tag for old branch releases (e.g., "v1.23" for 1.23.x) + # Use "release-X.Y" as tag for old branch releases (e.g., "release-1.23" for 1.23.x) # npm tags are mutable pointers to versions (like "latest" pointing to 1.24.3). - # Using "v1.23" means users can `npm install @modelcontextprotocol/sdk@v1.23` + # Using "release-1.23" means users can `npm install @modelcontextprotocol/sdk@release-1.23` # to get the latest patch on that minor version, and the tag updates if we # release 1.23.2, 1.23.3, etc. + # Note: Can't use "v1.23" because npm rejects tags that look like semver ranges. MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1,2) - echo "tag=--tag v${MAJOR_MINOR}" >> $GITHUB_OUTPUT + echo "tag=--tag release-${MAJOR_MINOR}" >> $GITHUB_OUTPUT else echo "tag=" >> $GITHUB_OUTPUT fi