Skip to content

fix(mcp): Replace lone surrogates in tool responses - #1790

Merged
betegon merged 2 commits into
mainfrom
fix/mcp-truncate-surrogate-pairs
Nov 2, 2026
Merged

betegon merged 2 commits into
mainfrom
fix/mcp-truncate-surrogate-pairs

Conversation

@sentry-junior

@sentry-junior sentry-junior Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

get_issue_breadcrumbs returned only "Transport closed" in Codex. Breadcrumb messages are cut at 120 UTF-16 code units, so a message with an emoji at index 119 keeps only the emoji's high surrogate. JSON.stringify writes that lone half as \ud83d, and Codex's JSON parser (serde_json) rejects the whole message and closes the connection.

Any .slice() truncation of user text can do this, and mcp-core truncates in about a dozen places. Rather than patch each one, server.ts now runs every tool response through the native String.prototype.toWellFormed(), which replaces a lone surrogate with U+FFFD:

  • Handler output, before it becomes a CallToolResult: text, content arrays, and structuredContent (keys and values of plain objects and arrays). This runs before the structured compatibility text is generated, so content and structuredContent stay equivalent.
  • The error text from formatErrorForUser.

toWellFormed() is ES2024 (Node 20+, Workers, Bun), so the shared tsconfig adds ES2024.String to lib. docs/contributing/tool-responses.md now notes that formatters can truncate with plain slice.

Before / after

get_issue_breadcrumbs called through execute_sentry_tool for an event whose breadcrumb message has 🔒 at index 119 (example message, reproduced locally). This is the breadcrumb line inside result.content[0].text of the serialized JSON-RPC response.

Before:

2025-04-08T21:14:52.000Z info    [auth] Session refreshed for user-123 after 2 retries, restoring cart and redirecting to /checkout/payments with strict scope \ud83d...\n

Codex rejects the response and the call ends in Transport closed.

After:

2025-04-08T21:14:52.000Z info    [auth] Session refreshed for user-123 after 2 retries, restoring cart and redirecting to /checkout/payments with strict scope �...\n

The response is valid JSON for strict parsers, and the cut emoji shows as �.

Tests

server.test.ts covers the reported case end to end (execute_sentry_tool → get_issue_breadcrumbs, emoji at index 119) and structured and error output. Both tests fail on main and pass on this branch.

Not in this PR

  • The app.search_events.validation.output span attribute is truncated the same way, but it goes to Sentry telemetry, not to an MCP client.
  • CLI formatters also truncate with raw .slice(). Terminal output already prints a lone surrogate as U+FFFD; --json output would need the same pass in writeJson.

via bete.

--

View Session [Sentry]

https://claude.ai/code/session_017M6odTSxZ1TzZm58HvggVB


Generated by Claude Code

Tool formatters cut user text (breadcrumb messages, log attributes, agent
prompts, span descriptions) by UTF-16 index. When the cut lands between
the halves of a surrogate pair, the response carries a lone high
surrogate that serializes as `\ud83d`. Strict JSON parsers such as
serde_json in Codex reject that string and drop the transport, so
get_issue_breadcrumbs returned only "Transport closed".

Add sliceAtCodePointBoundary to toolkit-core, which backs off one code
unit when a bound would split a pair (the same approach as the CLI grep
worker), and use it at each MCP truncation site that handles user text.

Co-Authored-By: Miguel Betegon <miguel.betegon@sentry.io>
@vercel

vercel Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
sentry-local Ready Ready Preview Nov 2, 2026 9:33am UTC

Request Review

@github-actions github-actions Bot added the risk: medium PR risk score: medium label Nov 2, 2026
Replace the per-site sliceAtCodePointBoundary helper with a single pass in
server.ts. Every tool's output (text, structuredContent, and error text)
now goes through String.prototype.toWellFormed(), which swaps a lone
surrogate for U+FFFD. That covers every truncation site, current and
future, and upstream text that is already malformed, without a new
toolkit-core export that only MCP uses.

Normalize before the structured compatibility text is generated so
content and structuredContent stay equivalent.

Add ES2024.String to the shared tsconfig lib for the native method
(Node 20+, Workers, Bun). Move the breadcrumb regression test to
server.test.ts so it exercises the response path end to end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017M6odTSxZ1TzZm58HvggVB
@betegon betegon changed the title fix(mcp): Avoid splitting emoji when truncating tool output fix(mcp): Replace lone surrogates in tool responses Nov 2, 2026
@betegon
betegon merged commit e65e68e into main Nov 2, 2026
36 checks passed
@betegon
betegon deleted the fix/mcp-truncate-surrogate-pairs branch November 2, 2026 09:49
BYK added a commit that referenced this pull request Nov 2, 2026
…1798)

Follow-up to #1790. `toWellFormedOutput` runs on every tool response,
and it rebuilt every array (`map`) and object (`Object.entries` → `map`
→ `Object.fromEntries`), even when there was nothing to replace. Almost
no response contains a lone surrogate, so nearly all of that copying was
wasted.

It now copies on write. It returns the input unchanged unless a string
or key actually changes, and then copies only the arrays and objects on
the path to that string. Objects are walked with `for...in` +
`Object.hasOwn`, so the common path allocates nothing. Output is the
same as before.

## Benchmark

Node 22.23, median time per call and bytes allocated per call (large
semi-space so no GC runs inside the measured loop). "Structured" rows
are issue-shaped objects with 12 fields, a nested project object, and a
tags array.

| payload | before (#1790) | after | `JSON.stringify` for scale |
|---|---|---|---|
| string, 45 KB | 52 µs / ~0 B | 52 µs / ~0 B | 229 µs |
| `content[]`, 1 × 45 KB text | 53 µs / 0.6 KB | 52 µs / ~0 B | 176 µs |
| structured, 50 rows | 376 µs / 210 KB | 42 µs / ~0 B | 89 µs |
| structured, 500 rows | 2.37 ms / 1.9 MB | 425 µs / ~0 B | 1.58 ms |
| structured, 500 rows + 1 lone surrogate | 2.16 ms / 1.9 MB | 451 µs /
7.4 KB | 1.28 ms |
| string, 45 KB + lone surrogate | 107 µs / 123 KB | 109 µs / 123 KB |
163 µs |

- On structured output it is about 5× faster and allocates almost
nothing; before, it allocated about 1.5× the payload size on every call.
- String-only and `content[]` output was already cheap. V8 returns the
same string from `toWellFormed()` when the string is well formed, so
most of the time left is the scan itself, which can't be skipped.
- I also tried running `JSON.stringify` once and walking only when the
JSON contains a `\uD8xx`–`\uDFxx` escape. It was slower than the walk in
every case, so it isn't used.

## Tests

The structured case in `server.test.ts` now has a lone surrogate inside
a nested array, next to strings that don't change. That covers the
partial-copy path; if the copy drops the entries before the changed one,
the test fails.

<!-- junior-request-attribution:start -->
via @BYK.
<!-- junior-request-attribution:end -->

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:local%3Aweb%3A94b2b90536e955539b32d29f -->

--

[View Session in
Sentry](https://sentry.sentry.io/explore/conversations/local%3Aweb%3A94b2b90536e955539b32d29f/?project=4510944073809921)

<!-- junior-session-footer:end -->

Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com>
Co-authored-by: BYK <126780+BYK@users.noreply.github.com>

This branch was successfully deployed

1 active and 1 inactive (outdated) deployments
Preview — 896f2c8c Deployed Nov 2, 2026 by vercel[bot]
Actions — 59d6bda4 Deployed Nov 2, 2026 by sentry-junior[bot] via eval #1452
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk: medium PR risk score: medium

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants