Skip to content

fix(server): escape U+2028 / U+2029 in SSE data lines (v2)#1925

Open
tonxxd wants to merge 1 commit intomodelcontextprotocol:mainfrom
tonxxd:fix-sse-u2028-u2029-escaping
Open

fix(server): escape U+2028 / U+2029 in SSE data lines (v2)#1925
tonxxd wants to merge 1 commit intomodelcontextprotocol:mainfrom
tonxxd:fix-sse-u2028-u2029-escaping

Conversation

@tonxxd
Copy link
Copy Markdown

@tonxxd tonxxd commented Apr 17, 2026

This PR has a V1 equivalent https://github.com/modelcontextprotocol/typescript-sdk/pull/1926

Escape U+2028 (LINE SEPARATOR) and U+2029 (PARAGRAPH SEPARATOR) in SSE data: lines emitted by WebStandardStreamableHTTPServerTransport.

Motivation and Context

JSON.stringify leaves U+2028 / U+2029 unescaped, they are valid inside JSON strings. But many SSE client parsers (including in Claude Desktop and ChatGPT) treat them as line terminators. When a tool response contains either codepoint, the receiver truncates the data: line mid-JSON, fails to parse silently, and the tool call appears to hang forever on the client side.
The SSE spec (WHATWG HTML) only defines LF/CR/CRLF as line terminators, so strictly this is a client parser bug. The Python SDK hit the same issue (modelcontextprotocol/python-sdk#1356) and shipped a fix in its client parser (httpx-sse 0.4.2).
Reproduced end-to-end against an mcp-use server whose Algolia-backed tool returned a candidate whose skills field contained a literal U+2028

How Has This Been Tested?

  • New regression test in packages/server/test/server/streamableHttp.test.ts: registers a tool returning "before\u2028middle\u2029after", asserts the literal codepoints do not appear on the wire, asserts the escaped forms do, and asserts JSON.parse of the data: line round-trips to the original string.
  • pnpm --filter @modelcontextprotocol/server test → 41/41 in streamableHttp.test.ts, 56/56 across the package.
  • pnpm test:all → green across all packages and the 422-test integration suite. (One pre-existing better-sqlite3 native-binding failure in examples/shared on my machine is reproducible on clean origin/main and unrelated to this PR.)
  • pnpm lint:all clean.
  • Manually reproduced end-to-end against the affected mcp-use server: before → widget hangs at isPending: true; after → tool result surfaces and widget renders normally.

Breaking Changes

None. The escape is invisible to SSE-spec-compliant clients (JSON.parse reinflates \u2028 / \u2029 back to the original codepoints).

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Scope intentionally narrow: only the SSE framing in WebStandardStreamableHTTPServerTransport.writeSSEEvent is touched. The JSON-response path (enableJsonResponse) is unaffected

Related:

JSON.stringify leaves LINE SEPARATOR (U+2028) and PARAGRAPH SEPARATOR
(U+2029) unescaped inside JSON strings. Many real-world SSE client
parsers — browser EventSource historically, webview-embedded parsers
used by Claude Desktop / ChatGPT app widgets, and libraries that split
on a broader line-terminator regex — treat those codepoints as line
terminators, truncating the `data:` line mid-JSON. The client then
silently fails to parse the message, and the tool call never resolves
on the client (the widget / UI stays stuck in an "invoking" state).

The SSE spec (WHATWG HTML) only defines LF/CR/CRLF as line
terminators, so strictly this is a client bug, but defensive
server-side escaping of U+2028/U+2029 is a long-established practice
for JSON over SSE. The Python SDK hit the same pathology (see
modelcontextprotocol/python-sdk#1356) and needed a fix in its client
SSE parser (httpx-sse 0.4.2).

Fixed in WebStandardStreamableHTTPServerTransport#writeSSEEvent.
Added a regression test that registers a tool which returns text
containing both codepoints and asserts the literal characters do not
appear on the wire (they are escaped to `\u2028` / `\u2029`) while
JSON.parse of the `data:` line still reproduces the original text.

Made-with: Cursor
@tonxxd tonxxd requested a review from a team as a code owner April 17, 2026 17:31
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 17, 2026

🦋 Changeset detected

Latest commit: 91906db

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 5 packages
Name Type
@modelcontextprotocol/server Patch
@modelcontextprotocol/express Patch
@modelcontextprotocol/fastify Patch
@modelcontextprotocol/hono Patch
@modelcontextprotocol/node Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 17, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@1925

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@1925

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@1925

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@1925

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@1925

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@1925

commit: 91906db

@tonxxd tonxxd changed the title fix(server): escape U+2028 / U+2029 in SSE data lines fix(server): escape U+2028 / U+2029 in SSE data lines (v2) Apr 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Incorrect newline parsing

1 participant