🦣 fix: Response Size Limits for Streamable HTTP MCP Responses#13219
Conversation
danny-avila
commented
May 21, 2026
- Added environment variables for maximum response and line sizes in streamable HTTP responses.
- Introduced functions to handle response size validation and error logging.
- Updated MCP connection logic to enforce these limits, ensuring safe handling of large responses.
- Added environment variables for maximum response and line sizes in streamable HTTP responses. - Introduced functions to handle response size validation and error logging. - Updated MCP connection logic to enforce these limits, ensuring safe handling of large responses.
|
@codex review |
There was a problem hiding this comment.
Pull request overview
This PR adds defensive limits and truncation around potentially large streaming MCP responses and tool execution errors to reduce memory pressure and improve safety when upstream systems return unexpectedly large payloads.
Changes:
- Added configurable size limits (via env vars) for streamable HTTP MCP SSE responses (total bytes + per-line bytes).
- Implemented an SSE response guard in the MCP undici fetch wrapper to validate response size and log diagnostics on rejection.
- Truncated tool error messages/stacks in agent tool execution handling to avoid propagating extremely large errors to logs and callers.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
packages/api/src/mcp/connection.ts |
Adds SSE response size/line guard and applies it to responses returned from the custom undici fetch/redirect loop. |
packages/api/src/agents/handlers.ts |
Introduces safe truncation for tool error messages/stacks and updates logging + returned errorMessage. |
.env.example |
Documents new env vars for streamable HTTP MCP SSE response limits. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const chunks: ArrayBuffer[] = []; | ||
| const reader = (response.body as unknown as ReadableStream<unknown>).getReader(); | ||
| let totalBytes = 0; | ||
| let currentLineBytes = 0; | ||
| let chunkCount = 0; |
There was a problem hiding this comment.
Fixed in 768c9aa. I replaced the buffering guard with an incremental TransformStream so SSE POST responses can be consumed as chunks arrive instead of waiting for EOF. The guard now emits a bounded JSON-RPC SSE error for the request id when an SSE cap trips. Covered by the new valid-streaming SSE test in MCPConnectionSSRF.test.ts.
| if (!isMethodPreservingRedirect || redirects >= MAX_REDIRECTS) { | ||
| return response; | ||
| return guardMCPStreamableHTTPResponse(response, responseContext); | ||
| } |
There was a problem hiding this comment.
Fixed in 768c9aa. Added focused MCPConnectionSSRF coverage for oversized JSON response bytes, oversized SSE line handling, and a valid SSE POST response proving the guarded response streams before EOF.
| const { message, logContext } = getSafeToolError(toolError); | ||
| logger.error(`[ON_TOOL_EXECUTE] Tool ${tc.name} error`, logContext); | ||
| return { | ||
| toolCallId: tc.id, | ||
| status: 'error' as const, | ||
| content: '', | ||
| errorMessage: error.message, | ||
| errorMessage: message, | ||
| }; |
There was a problem hiding this comment.
Fixed in 768c9aa. Added handlers.spec coverage for oversized thrown tool errors, asserting the returned errorMessage is truncated and the logger context reports messageTruncated: true.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: df6125b375
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const contentType = response.headers.get('content-type') ?? ''; | ||
| if (!contentType.toLowerCase().includes('text/event-stream')) { | ||
| return response; |
There was a problem hiding this comment.
Enforce non-GET byte caps for JSON responses
The new guard returns early for any non-SSE response (content-type not containing text/event-stream), so MCP_STREAMABLE_HTTP_MAX_RESPONSE_BYTES is never applied to application/json POST responses. In Streamable HTTP, POST requests can validly return JSON, which means a buggy or malicious MCP server can still send an unbounded JSON body and bypass the configured response-size protection.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 768c9aa. The guard now applies MCP_STREAMABLE_HTTP_MAX_RESPONSE_BYTES to non-GET responses regardless of content type, so application/json POST responses are capped too. Added a JSON POST byte-limit regression test.
| while (true) { | ||
| const { value, done } = await reader.read(); | ||
| if (done) { | ||
| break; |
There was a problem hiding this comment.
Preserve streaming semantics for SSE POST responses
This loop reads the entire SSE body to EOF before returning a Response, which turns a streamed POST response into a buffered one. Streamable HTTP servers may emit notifications/progress messages before the final JSON-RPC response, so delaying delivery until close breaks incremental processing and can make long-running tool calls appear stalled.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 768c9aa. The SSE path no longer buffers to EOF. It wraps the response body in a TransformStream, passes valid chunks through incrementally, and emits a bounded JSON-RPC error event if a limit trips. Added a test that reads the first SSE chunk before the server closes.
| const rawMessage = error instanceof Error ? error.message : String(error); | ||
| const message = truncateMiddle(rawMessage, MAX_TOOL_ERROR_MESSAGE_CHARS); |
There was a problem hiding this comment.
Guard error-string coercion in tool failure handler
When a tool throws a non-Error, getSafeToolError coerces it via String(error). That coercion can itself throw (e.g., custom toString/Symbol.toPrimitive), which would escape this catch block and trigger the outer fatal path instead of returning a per-tool error result. In that case, one malformed thrown object can fail the whole tool-execution batch.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 768c9aa. Added stringifyThrownValue() so non-Error thrown values with throwing toString/Symbol coercion still produce a per-tool error result instead of escaping to the fatal batch path. Covered in handlers.spec.ts.
| const parsed = Number.parseInt(raw, 10); | ||
| return Number.isFinite(parsed) && parsed >= 0 ? parsed : defaultValue; |
There was a problem hiding this comment.
Reject partially numeric env values for byte limits
getNonNegativeIntegerEnv uses parseInt, which accepts numeric prefixes from malformed values (for example "1e6" becomes 1, "16MB" becomes 16) instead of treating them as invalid. That silently applies much smaller limits than intended and can cause unexpected MCP response blocking in production when operators use common human-readable env formats.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 768c9aa. getNonNegativeIntegerEnv now accepts only all-digit strings and safe integers, so values like 1e6 or 16MB fall back to the default instead of being partially parsed.
GitNexus: 🚀 deployedThe |
|
@codex review |
|
@codex review |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 81e0c617ad
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| if (!isMethodPreservingRedirect || redirects >= MAX_REDIRECTS) { | ||
| return response; | ||
| return guardMCPStreamableHTTPResponse(response, responseContext); |
There was a problem hiding this comment.
Scope response-size guard to streamable-http transport
Apply the new size guard only for streamable HTTP requests, not every non-GET request path in createFetchFunction. This helper is also used by the legacy SSE transport (constructTransport passes createFetchFunction in both sse and streamable-http branches), so the new MCP_STREAMABLE_HTTP_* limits now affect SSE POST responses even though the config/docs describe them as streamable-http-only; that can unexpectedly reject existing SSE servers with larger POST payloads.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 45d0700. The response-size guard is now opt-in from createFetchFunction and is enabled only when constructing the streamable-http transport. Legacy SSE transport fetches keep their previous behavior; added a regression test that proves the streamable HTTP caps are not applied unless the transport opts in.
| if (isEventStream && context.requestIds.length > 0) { | ||
| controller.enqueue(buildSSEErrorEvents(context.requestIds, message)); | ||
| controller.terminate(); |
There was a problem hiding this comment.
Emit fallback SSE errors only for unresolved request IDs
The oversized-stream fallback currently emits an error event for every ID parsed from the original POST body, regardless of whether some IDs already received a response earlier in the same stream. For batched or partially completed streams, this can send duplicate responses for the same JSON-RPC ID; the SDK protocol handler drops the first response handler and treats later duplicates as unknown-response errors, creating spurious transport errors exactly when the guard is supposed to fail gracefully.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 45d0700. I removed the synthetic SSE fallback event entirely, so the guard no longer emits JSON-RPC errors for request ids that may already have completed. The guarded stream now fails with a bounded diagnostic error instead.
| message: string; | ||
| logContext: Record<string, unknown>; | ||
| } { | ||
| const rawMessage = error instanceof Error ? error.message : stringifyThrownValue(error); |
There was a problem hiding this comment.
Preserve message field when non-Error objects are thrown
Handle thrown plain objects with a message property before falling back to String(error). The new logic now converts non-Error throws via String(error), so a common throw shape like { message: 'timeout' } becomes "[object Object]" in errorMessage, which is a regression from the previous behavior (toolError as Error would surface .message) and makes tool failures much harder for users and agents to diagnose.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 45d0700. getSafeToolError now preserves a message property from thrown plain objects before falling back to safe String coercion, with handlers.spec coverage for { message: ... } throws.
| controller.enqueue(buildSSEErrorEvents(context.requestIds, message)); | ||
| controller.terminate(); |
There was a problem hiding this comment.
Prevent fallback SSE error from appending to partial line
When an oversized SSE line spans multiple chunks, earlier chunks from that same line are already forwarded, and then this fallback appends a synthetic data: {...} error event mid-line. Because SSE parsers buffer incomplete lines across chunks, the injected fallback is merged into malformed line content instead of becoming a valid event, so the intended bounded JSON-RPC error may never be parsed by the client.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 45d0700. The guard no longer appends a synthetic SSE event after partial oversized-line bytes. It throws/errors the guarded stream with a bounded message, preventing eventsource-parser from continuing to accumulate the oversized line or receiving malformed injected data.
|
@codex review |
|
Codex Review: Didn't find any major issues. Bravo. ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
GitNexus: 🚀 deployedThe |
GitNexus: 🚀 deployedThe |
|
@codex review |
|
Codex Review: Didn't find any major issues. Nice work! ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
GitNexus: 🚀 deployedThe |
…avila#13219) * fix: implement response size limits for streamable HTTP MCP responses - Added environment variables for maximum response and line sizes in streamable HTTP responses. - Introduced functions to handle response size validation and error logging. - Updated MCP connection logic to enforce these limits, ensuring safe handling of large responses. * fix: address MCP response guard review findings * fix: satisfy logger spy typings * fix: clarify blocked MCP response errors * fix: harden MCP guard review edge cases * test: cover MCP oversized SSE call failures