-
Notifications
You must be signed in to change notification settings - Fork 3k
fix(core): add streamable HTTP SSE POST fallback #4344
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -10,7 +10,10 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; | |||||||||||||||||||||||||||||||||||||||||
| import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; | ||||||||||||||||||||||||||||||||||||||||||
| import type { StreamableHTTPClientTransportOptions } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; | ||||||||||||||||||||||||||||||||||||||||||
| import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; | ||||||||||||||||||||||||||||||||||||||||||
| import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; | ||||||||||||||||||||||||||||||||||||||||||
| import type { | ||||||||||||||||||||||||||||||||||||||||||
| FetchLike, | ||||||||||||||||||||||||||||||||||||||||||
| Transport, | ||||||||||||||||||||||||||||||||||||||||||
| } from '@modelcontextprotocol/sdk/shared/transport.js'; | ||||||||||||||||||||||||||||||||||||||||||
| import type { | ||||||||||||||||||||||||||||||||||||||||||
| GetPromptResult, | ||||||||||||||||||||||||||||||||||||||||||
| JSONRPCMessage, | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -62,6 +65,9 @@ export const MCP_DEFAULT_TIMEOUT_MSEC = 10 * 60 * 1000; // default to 10 minutes | |||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const debugLogger = createDebugLogger('MCP'); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const STREAMABLE_HTTP_SSE_ACCEPT = 'text/event-stream'; | ||||||||||||||||||||||||||||||||||||||||||
| const STREAMABLE_HTTP_POST_ACCEPT = 'text/event-stream, application/json'; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| export type DiscoveredMCPPrompt = Prompt & { | ||||||||||||||||||||||||||||||||||||||||||
| serverName: string; | ||||||||||||||||||||||||||||||||||||||||||
| invoke: (params: Record<string, unknown>) => Promise<GetPromptResult>; | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -506,7 +512,8 @@ async function createTransportWithOAuth( | |||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| return new StreamableHTTPClientTransport( | ||||||||||||||||||||||||||||||||||||||||||
| return createStreamableHTTPClientTransport( | ||||||||||||||||||||||||||||||||||||||||||
| mcpServerName, | ||||||||||||||||||||||||||||||||||||||||||
| new URL(mcpServerConfig.httpUrl), | ||||||||||||||||||||||||||||||||||||||||||
| oauthTransportOptions, | ||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -531,6 +538,77 @@ async function createTransportWithOAuth( | |||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| function isStreamableHttpSseGet(init?: RequestInit): boolean { | ||||||||||||||||||||||||||||||||||||||||||
| const method = init?.method?.toUpperCase() ?? 'GET'; | ||||||||||||||||||||||||||||||||||||||||||
| if (method !== 'GET') { | ||||||||||||||||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const acceptHeader = new Headers(init?.headers).get('accept'); | ||||||||||||||||||||||||||||||||||||||||||
| return acceptHeader?.includes(STREAMABLE_HTTP_SSE_ACCEPT) ?? false; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| function createStreamableHttpFallbackFetch( | ||||||||||||||||||||||||||||||||||||||||||
| mcpServerName: string, | ||||||||||||||||||||||||||||||||||||||||||
| baseFetch?: FetchLike, | ||||||||||||||||||||||||||||||||||||||||||
| ): FetchLike { | ||||||||||||||||||||||||||||||||||||||||||
| let hasWarned = false; | ||||||||||||||||||||||||||||||||||||||||||
| const fetchImpl = baseFetch ?? fetch; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| return async (url, init) => { | ||||||||||||||||||||||||||||||||||||||||||
| const response = await fetchImpl(url, init); | ||||||||||||||||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||||||||||||||||
| !isStreamableHttpSseGet(init) || | ||||||||||||||||||||||||||||||||||||||||||
| (response.status !== 400 && response.status !== 405) | ||||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||||
| return response; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| if (!hasWarned) { | ||||||||||||||||||||||||||||||||||||||||||
| hasWarned = true; | ||||||||||||||||||||||||||||||||||||||||||
| debugLogger.warn( | ||||||||||||||||||||||||||||||||||||||||||
| `MCP server '${mcpServerName}' rejected the spec-compliant Streamable HTTP SSE GET with HTTP ${response.status}; retrying with POST for compatibility. ` + | ||||||||||||||||||||||||||||||||||||||||||
| `This usually indicates a spec-divergent server (for example Spring AI 1.1.x). Please update the server to support GET SSE when possible.`, | ||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const retryHeaders = new Headers(init?.headers); | ||||||||||||||||||||||||||||||||||||||||||
| retryHeaders.set('accept', STREAMABLE_HTTP_POST_ACCEPT); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const retryInit: RequestInit = { | ||||||||||||||||||||||||||||||||||||||||||
| ...init, | ||||||||||||||||||||||||||||||||||||||||||
| method: 'POST', | ||||||||||||||||||||||||||||||||||||||||||
| headers: retryHeaders, | ||||||||||||||||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] Keep the original 405 response body intact until you know it will not be returned. Right now this cancels the original GET response before the POST retry; if that retry also fails, the 405 branch returns the same original
Suggested change
— gpt-5.5 via Qwen Code /review |
||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||
| delete retryInit.body; | ||||||||||||||||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] Wrap the retry Two robustness improvements:
Suggested change
— qwen-latest-series-invite-beta-v34 via Qwen Code /review |
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| await response.body?.cancel(); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const retryResponse = await fetchImpl(url, retryInit); | ||||||||||||||||||||||||||||||||||||||||||
| if (retryResponse.ok) { | ||||||||||||||||||||||||||||||||||||||||||
| return retryResponse; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| if (response.status === 405) { | ||||||||||||||||||||||||||||||||||||||||||
| await retryResponse.body?.cancel(); | ||||||||||||||||||||||||||||||||||||||||||
| return response; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| return retryResponse; | ||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| function createStreamableHTTPClientTransport( | ||||||||||||||||||||||||||||||||||||||||||
| mcpServerName: string, | ||||||||||||||||||||||||||||||||||||||||||
| url: URL, | ||||||||||||||||||||||||||||||||||||||||||
| options: StreamableHTTPClientTransportOptions = {}, | ||||||||||||||||||||||||||||||||||||||||||
| ): StreamableHTTPClientTransport { | ||||||||||||||||||||||||||||||||||||||||||
| return new StreamableHTTPClientTransport(url, { | ||||||||||||||||||||||||||||||||||||||||||
| ...options, | ||||||||||||||||||||||||||||||||||||||||||
| fetch: createStreamableHttpFallbackFetch(mcpServerName, options.fetch), | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||
| * Discovers tools from all configured MCP servers and registers them with the tool registry. | ||||||||||||||||||||||||||||||||||||||||||
| * It orchestrates the connection and discovery process for each server defined in the | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -1332,7 +1410,8 @@ export async function createTransport( | |||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| if (mcpServerConfig.httpUrl) { | ||||||||||||||||||||||||||||||||||||||||||
| return new StreamableHTTPClientTransport( | ||||||||||||||||||||||||||||||||||||||||||
| return createStreamableHTTPClientTransport( | ||||||||||||||||||||||||||||||||||||||||||
| mcpServerName, | ||||||||||||||||||||||||||||||||||||||||||
| new URL(mcpServerConfig.httpUrl), | ||||||||||||||||||||||||||||||||||||||||||
| transportOptions, | ||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -1358,7 +1437,8 @@ export async function createTransport( | |||||||||||||||||||||||||||||||||||||||||
| authProvider: provider, | ||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||
| if (mcpServerConfig.httpUrl) { | ||||||||||||||||||||||||||||||||||||||||||
| return new StreamableHTTPClientTransport( | ||||||||||||||||||||||||||||||||||||||||||
| return createStreamableHTTPClientTransport( | ||||||||||||||||||||||||||||||||||||||||||
| mcpServerName, | ||||||||||||||||||||||||||||||||||||||||||
| new URL(mcpServerConfig.httpUrl), | ||||||||||||||||||||||||||||||||||||||||||
| transportOptions, | ||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -1430,7 +1510,8 @@ export async function createTransport( | |||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| return new StreamableHTTPClientTransport( | ||||||||||||||||||||||||||||||||||||||||||
| return createStreamableHTTPClientTransport( | ||||||||||||||||||||||||||||||||||||||||||
| mcpServerName, | ||||||||||||||||||||||||||||||||||||||||||
| new URL(mcpServerConfig.httpUrl), | ||||||||||||||||||||||||||||||||||||||||||
| transportOptions, | ||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion] Add test coverage for POST retry failure paths.
The three existing tests cover happy paths (400→POST ok, 405→POST ok, 5xx no-retry), but neither "POST retry also fails" branch is tested. These paths contain the most nuanced logic — asymmetric body cancellation and different return-value semantics for 400 vs 405.
Suggested additional tests:
These pin down the asymmetric failure semantics and guard against regressions in the body-cancellation logic.
— qwen-latest-series-invite-beta-v34 via Qwen Code /review