From 59d6bda4d61fa55e73678815cce5241539fc7797 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:00:54 +0000 Subject: [PATCH 1/2] fix(mcp): Avoid splitting emoji when truncating tool output 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 --- packages/mcp-core/src/internal/formatting.ts | 3 +- .../src/internal/tool-helpers/breadcrumbs.ts | 6 +- .../catalog/get-agent-conversation-details.ts | 3 +- .../catalog/get-issue-breadcrumbs.test.ts | 44 +++++++++++++++ .../catalog/search-agent-conversations.ts | 3 +- .../src/tools/support/issue-linking/index.ts | 3 +- .../src/tools/support/profile/formatter.ts | 3 +- .../src/tools/support/search-events/utils.ts | 7 ++- .../tools/support/traces/semantic-display.ts | 3 +- packages/toolkit-core/package.json | 4 ++ packages/toolkit-core/src/text.test.ts | 55 +++++++++++++++++++ packages/toolkit-core/src/text.ts | 45 +++++++++++++++ 12 files changed, 168 insertions(+), 11 deletions(-) create mode 100644 packages/toolkit-core/src/text.test.ts create mode 100644 packages/toolkit-core/src/text.ts diff --git a/packages/mcp-core/src/internal/formatting.ts b/packages/mcp-core/src/internal/formatting.ts index 6ce811051..154823929 100644 --- a/packages/mcp-core/src/internal/formatting.ts +++ b/packages/mcp-core/src/internal/formatting.ts @@ -45,6 +45,7 @@ import { getStatusDisplayName, isTerminalStatus, } from "./tool-helpers/seer"; +import { sliceAtCodePointBoundary } from "@sentry/toolkit-core/text"; import { formatToolCallInstruction } from "./tool-helpers/tool-call-formatting"; import { isPlainObject } from "./type-guards"; import { formatUserGeoSummary } from "./user-formatting"; @@ -867,7 +868,7 @@ function formatVariableValue(value: unknown, maxLength = 80): string { if (stringified.length > maxLength) { // Leave room for ", ...]" or ", ...}" const truncateAt = maxLength - 6; - let truncated = stringified.substring(0, truncateAt); + let truncated = sliceAtCodePointBoundary(stringified, 0, truncateAt); // Find the last complete element by looking for the last comma const lastComma = truncated.lastIndexOf(","); diff --git a/packages/mcp-core/src/internal/tool-helpers/breadcrumbs.ts b/packages/mcp-core/src/internal/tool-helpers/breadcrumbs.ts index 0bb7f8bdf..62d14735f 100644 --- a/packages/mcp-core/src/internal/tool-helpers/breadcrumbs.ts +++ b/packages/mcp-core/src/internal/tool-helpers/breadcrumbs.ts @@ -1,3 +1,4 @@ +import { sliceAtCodePointBoundary } from "@sentry/toolkit-core/text"; import type { SentryApiService } from "../../api-client/index"; /** @@ -93,10 +94,11 @@ export function formatBreadcrumbData( } /** - * Strips newlines and truncates a string to maxLen characters. + * Strips newlines and truncates a string to maxLen UTF-16 code units without + * splitting a surrogate pair. */ export function cleanAndTruncate(str: string, maxLen: number): string { const clean = str.replace(/[\r\n]+/g, " "); if (clean.length <= maxLen) return clean; - return `${clean.slice(0, maxLen)}...`; + return `${sliceAtCodePointBoundary(clean, 0, maxLen)}...`; } diff --git a/packages/mcp-core/src/tools/catalog/get-agent-conversation-details.ts b/packages/mcp-core/src/tools/catalog/get-agent-conversation-details.ts index 601ba33c9..b9bf66b8d 100644 --- a/packages/mcp-core/src/tools/catalog/get-agent-conversation-details.ts +++ b/packages/mcp-core/src/tools/catalog/get-agent-conversation-details.ts @@ -1,6 +1,7 @@ // Catalog-only agent conversation detail lookup. This owns the MCP-facing // transcript projection and keeps detail output chronological and debugger // oriented instead of mirroring every raw span attribute. +import { sliceAtCodePointBoundary } from "@sentry/toolkit-core/text"; import { z } from "zod"; import type { AIConversationSpan, SentryApiService } from "../../api-client"; import { UserInputError } from "../../errors"; @@ -251,7 +252,7 @@ function truncateAttribute( } truncatedFields.push(field); - return `${value.slice(0, MAX_RAW_GEN_AI_ATTRIBUTE_LENGTH)}... [truncated]`; + return `${sliceAtCodePointBoundary(value, 0, MAX_RAW_GEN_AI_ATTRIBUTE_LENGTH)}... [truncated]`; } function getOperationType(span: AIConversationSpan): string | undefined { diff --git a/packages/mcp-core/src/tools/catalog/get-issue-breadcrumbs.test.ts b/packages/mcp-core/src/tools/catalog/get-issue-breadcrumbs.test.ts index 61f78842e..86aa39d33 100644 --- a/packages/mcp-core/src/tools/catalog/get-issue-breadcrumbs.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-issue-breadcrumbs.test.ts @@ -109,6 +109,50 @@ describe("get_issue_breadcrumbs", () => { ); }); + it("does not split an emoji at the message truncation boundary", async () => { + // The lock emoji starts at index 119, so a raw 120-unit cut keeps only + // its high surrogate. Strict JSON parsers reject that lone half. + const message = `${"x".repeat(119)}\u{1F512} required`; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/events/latest/", + () => + HttpResponse.json({ + ...eventFixture, + entries: [ + { + type: "breadcrumbs", + data: { + values: [ + { + timestamp: "2025-04-08T21:14:52.000Z", + category: "console", + level: "info", + message, + }, + ], + }, + }, + ], + }), + { once: true }, + ), + ); + + const result = await getIssueBreadcrumbs.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + issueId: "CLOUDFLARE-MCP-41", + eventId: "latest", + }, + context, + ); + + expect(result).toContain(`[console] ${"x".repeat(119)}...`); + expect(result).not.toMatch(/[\ud800-\udbff](?![\udc00-\udfff])/); + }); + it("returns breadcrumbs for an explicit event ID", async () => { mswServer.use( http.get( diff --git a/packages/mcp-core/src/tools/catalog/search-agent-conversations.ts b/packages/mcp-core/src/tools/catalog/search-agent-conversations.ts index bd3168401..8fd5074cf 100644 --- a/packages/mcp-core/src/tools/catalog/search-agent-conversations.ts +++ b/packages/mcp-core/src/tools/catalog/search-agent-conversations.ts @@ -1,6 +1,7 @@ // Catalog-only Agent Conversations search. This owns the MCP-facing projection for // Sentry's conversation list endpoint and intentionally exposes backend default // ordering until alternate sorting is applied by the API. +import { sliceAtCodePointBoundary } from "@sentry/toolkit-core/text"; import { z } from "zod"; import type { AIConversationSummary, SentryApiService } from "../../api-client"; import { UserInputError } from "../../errors"; @@ -114,7 +115,7 @@ function previewText(value: string | null): string | null { if (value == null || value.length <= PREVIEW_LENGTH) { return value; } - return `${value.slice(0, PREVIEW_LENGTH - 3)}...`; + return `${sliceAtCodePointBoundary(value, 0, PREVIEW_LENGTH - 3)}...`; } function formatLLMCost(cost: number): string { diff --git a/packages/mcp-core/src/tools/support/issue-linking/index.ts b/packages/mcp-core/src/tools/support/issue-linking/index.ts index e3e0f7cb2..1bff25984 100644 --- a/packages/mcp-core/src/tools/support/issue-linking/index.ts +++ b/packages/mcp-core/src/tools/support/issue-linking/index.ts @@ -1,4 +1,5 @@ /** Select native integrations locally; Sentry owns URL validation and link mutations. */ +import { sliceAtCodePointBoundary } from "@sentry/toolkit-core/text"; import type { SentryApiService } from "../../../api-client"; import type { IssueIntegration } from "../../../api-client/types"; import { UserInputError } from "../../../errors"; @@ -219,7 +220,7 @@ function describeIntegrations(integrations: IssueIntegration[]): string { .slice(0, 5) .map( (integration) => - `${integration.id} (${integration.name.slice(0, 60)}, ${integration.provider.key})`, + `${integration.id} (${sliceAtCodePointBoundary(integration.name, 0, 60)}, ${integration.provider.key})`, ) .join("; "); } diff --git a/packages/mcp-core/src/tools/support/profile/formatter.ts b/packages/mcp-core/src/tools/support/profile/formatter.ts index 2a8bb5f0b..2a371f68b 100644 --- a/packages/mcp-core/src/tools/support/profile/formatter.ts +++ b/packages/mcp-core/src/tools/support/profile/formatter.ts @@ -7,6 +7,7 @@ * - Raw profile chunk analysis (get_profile_details) */ +import { sliceAtCodePointBoundary } from "@sentry/toolkit-core/text"; import type { Flamegraph, ProfileChunk, @@ -52,7 +53,7 @@ function determineComparisonStatus( */ function truncateLocation(location: string, maxLength = 30): string { if (location.length <= maxLength) return location; - return `...${location.slice(-(maxLength - 3))}`; + return `...${sliceAtCodePointBoundary(location, -(maxLength - 3))}`; } /** diff --git a/packages/mcp-core/src/tools/support/search-events/utils.ts b/packages/mcp-core/src/tools/support/search-events/utils.ts index 564aacf22..9054aead6 100644 --- a/packages/mcp-core/src/tools/support/search-events/utils.ts +++ b/packages/mcp-core/src/tools/support/search-events/utils.ts @@ -4,6 +4,7 @@ import { type ParseResultToken, parseSearch, } from "@sentry/search-syntax/parser"; +import { sliceAtCodePointBoundary } from "@sentry/toolkit-core/text"; import { TermOperator, Token } from "@sentry/search-syntax/tokens"; import { getActiveSpan } from "@sentry/core"; import { z } from "zod"; @@ -626,10 +627,10 @@ function truncateString(value: string, maxLength: number): string { } if (maxLength <= 3) { - return value.slice(0, maxLength); + return sliceAtCodePointBoundary(value, 0, maxLength); } - return `${value.slice(0, maxLength - 3)}...`; + return `${sliceAtCodePointBoundary(value, 0, maxLength - 3)}...`; } function safeJsonStringify(value: unknown): string { @@ -992,7 +993,7 @@ function truncateValidationOutput(output: string): string { if (output.length <= VALIDATION_OUTPUT_MAX_LENGTH) { return output; } - return `${output.slice(0, VALIDATION_OUTPUT_MAX_LENGTH)}…`; + return `${sliceAtCodePointBoundary(output, 0, VALIDATION_OUTPUT_MAX_LENGTH)}…`; } export function recordEventsSearchValidationTelemetry({ diff --git a/packages/mcp-core/src/tools/support/traces/semantic-display.ts b/packages/mcp-core/src/tools/support/traces/semantic-display.ts index 212ce7d47..62a88ac9b 100644 --- a/packages/mcp-core/src/tools/support/traces/semantic-display.ts +++ b/packages/mcp-core/src/tools/support/traces/semantic-display.ts @@ -1,3 +1,4 @@ +import { sliceAtCodePointBoundary } from "@sentry/toolkit-core/text"; import type { TraceSpan } from "../../../api-client"; interface SemanticSpanDisplay { @@ -767,5 +768,5 @@ function formatDisplayPart( return normalized; } - return `${normalized.slice(0, maxLength - 3)}...`; + return `${sliceAtCodePointBoundary(normalized, 0, maxLength - 3)}...`; } diff --git a/packages/toolkit-core/package.json b/packages/toolkit-core/package.json index 2465f3871..80bec53b4 100644 --- a/packages/toolkit-core/package.json +++ b/packages/toolkit-core/package.json @@ -67,6 +67,10 @@ "types": "./src/sentry-origin.ts", "default": "./src/sentry-origin.ts" }, + "./text": { + "types": "./src/text.ts", + "default": "./src/text.ts" + }, "./api-client/response-cache": { "types": "./src/api-client/response-cache/index.ts", "default": "./src/api-client/response-cache/index.ts" diff --git a/packages/toolkit-core/src/text.test.ts b/packages/toolkit-core/src/text.test.ts new file mode 100644 index 000000000..584e78ad9 --- /dev/null +++ b/packages/toolkit-core/src/text.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { sliceAtCodePointBoundary } from "./text.js"; + +const LOCK = "\u{1F512}"; + +function isWellFormed(text: string): boolean { + return !/[\ud800-\udbff](?![\udc00-\udfff])|(? { + it("backs off when the end lands between the halves of an emoji", () => { + const text = `${"a".repeat(119)}${LOCK}tail`; + + const result = sliceAtCodePointBoundary(text, 0, 120); + + expect(result).toBe("a".repeat(119)); + expect(isWellFormed(result)).toBe(true); + }); + + it("keeps a whole emoji that ends exactly at the bound", () => { + const text = `${"a".repeat(118)}${LOCK}tail`; + + expect(sliceAtCodePointBoundary(text, 0, 120)).toBe( + `${"a".repeat(118)}${LOCK}`, + ); + }); + + it("moves a negative start past the low half of an emoji", () => { + const text = `/src/${LOCK}/file.ts`; + const lowHalfIndex = text.indexOf(LOCK) + 1; + + const result = sliceAtCodePointBoundary(text, lowHalfIndex - text.length); + + expect(result).toBe("/file.ts"); + expect(isWellFormed(result)).toBe(true); + }); + + it("matches String.prototype.slice when no pair is split", () => { + const text = `hello ${LOCK} world`; + + expect(sliceAtCodePointBoundary(text, 0, 5)).toBe(text.slice(0, 5)); + expect(sliceAtCodePointBoundary(text, -5)).toBe(text.slice(-5)); + expect(sliceAtCodePointBoundary(text, 0, 100)).toBe(text); + }); + + it("never returns a lone surrogate for any end bound", () => { + const text = `${LOCK}a${LOCK}${LOCK}b`; + + for (let end = 0; end <= text.length; end += 1) { + expect(isWellFormed(sliceAtCodePointBoundary(text, 0, end))).toBe(true); + } + }); +}); diff --git a/packages/toolkit-core/src/text.ts b/packages/toolkit-core/src/text.ts new file mode 100644 index 000000000..c4e81659f --- /dev/null +++ b/packages/toolkit-core/src/text.ts @@ -0,0 +1,45 @@ +function isHighSurrogate(code: number): boolean { + return code >= 0xd8_00 && code <= 0xdb_ff; +} + +function isLowSurrogate(code: number): boolean { + return code >= 0xdc_00 && code <= 0xdf_ff; +} + +/** + * Slice `text` like `String.prototype.slice` without splitting a surrogate pair. + * + * Truncating user text (log messages, breadcrumbs, prompts) by UTF-16 index can + * cut an emoji in half. The lone half serializes as `\ud83d`, and strict JSON + * parsers such as serde_json reject the whole response. When a bound lands + * inside a pair, this drops the partial character: `end` backs off one unit + * and `start` moves forward one unit. + */ +export function sliceAtCodePointBoundary( + text: string, + start: number, + end: number = text.length, +): string { + const length = text.length; + let from = start < 0 ? Math.max(length + start, 0) : Math.min(start, length); + let to = end < 0 ? Math.max(length + end, 0) : Math.min(end, length); + + if ( + from > 0 && + from < length && + isLowSurrogate(text.charCodeAt(from)) && + isHighSurrogate(text.charCodeAt(from - 1)) + ) { + from += 1; + } + if ( + to > 0 && + to < length && + isHighSurrogate(text.charCodeAt(to - 1)) && + isLowSurrogate(text.charCodeAt(to)) + ) { + to -= 1; + } + + return text.slice(from, to); +} From 896f2c8cb8763225cfba04f86e1a465017e49229 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 09:31:14 +0000 Subject: [PATCH 2/2] fix(mcp): Replace lone surrogates once at the tool response boundary 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 Claude-Session: https://claude.ai/code/session_017M6odTSxZ1TzZm58HvggVB --- docs/contributing/tool-responses.md | 4 + packages/mcp-core/src/internal/formatting.ts | 3 +- .../src/internal/tool-helpers/breadcrumbs.ts | 6 +- packages/mcp-core/src/server.test.ts | 93 +++++++++++++++++++ packages/mcp-core/src/server.ts | 50 ++++++++-- .../catalog/get-agent-conversation-details.ts | 3 +- .../catalog/get-issue-breadcrumbs.test.ts | 44 --------- .../catalog/search-agent-conversations.ts | 3 +- .../src/tools/support/issue-linking/index.ts | 3 +- .../src/tools/support/profile/formatter.ts | 3 +- .../src/tools/support/search-events/utils.ts | 7 +- .../tools/support/traces/semantic-display.ts | 3 +- .../mcp-server-tsconfig/tsconfig.base.json | 2 +- packages/toolkit-core/package.json | 4 - packages/toolkit-core/src/text.test.ts | 55 ----------- packages/toolkit-core/src/text.ts | 45 --------- 16 files changed, 151 insertions(+), 177 deletions(-) delete mode 100644 packages/toolkit-core/src/text.test.ts delete mode 100644 packages/toolkit-core/src/text.ts diff --git a/docs/contributing/tool-responses.md b/docs/contributing/tool-responses.md index 2805c164c..020321803 100644 --- a/docs/contributing/tool-responses.md +++ b/docs/contributing/tool-responses.md @@ -107,6 +107,10 @@ Translate upstream API shapes into user-facing terms: identifiers needed for follow-up. - Drop empty, nullish, or unknown placeholder values before rendering details. - Cap long repeated lists and say how many additional items were omitted. +- Truncate long text with plain `slice`. A cut can leave half of an emoji, and + strict JSON clients such as Codex reject that lone surrogate, so the server + replaces it with U+FFFD in every tool response. Formatters do not need + surrogate-aware slicing. - Prefer shared helpers for dates, actors, IDs, and unknown values. If a helper emits noisy placeholders for a domain-specific field, filter before calling it. diff --git a/packages/mcp-core/src/internal/formatting.ts b/packages/mcp-core/src/internal/formatting.ts index 154823929..6ce811051 100644 --- a/packages/mcp-core/src/internal/formatting.ts +++ b/packages/mcp-core/src/internal/formatting.ts @@ -45,7 +45,6 @@ import { getStatusDisplayName, isTerminalStatus, } from "./tool-helpers/seer"; -import { sliceAtCodePointBoundary } from "@sentry/toolkit-core/text"; import { formatToolCallInstruction } from "./tool-helpers/tool-call-formatting"; import { isPlainObject } from "./type-guards"; import { formatUserGeoSummary } from "./user-formatting"; @@ -868,7 +867,7 @@ function formatVariableValue(value: unknown, maxLength = 80): string { if (stringified.length > maxLength) { // Leave room for ", ...]" or ", ...}" const truncateAt = maxLength - 6; - let truncated = sliceAtCodePointBoundary(stringified, 0, truncateAt); + let truncated = stringified.substring(0, truncateAt); // Find the last complete element by looking for the last comma const lastComma = truncated.lastIndexOf(","); diff --git a/packages/mcp-core/src/internal/tool-helpers/breadcrumbs.ts b/packages/mcp-core/src/internal/tool-helpers/breadcrumbs.ts index 62d14735f..0bb7f8bdf 100644 --- a/packages/mcp-core/src/internal/tool-helpers/breadcrumbs.ts +++ b/packages/mcp-core/src/internal/tool-helpers/breadcrumbs.ts @@ -1,4 +1,3 @@ -import { sliceAtCodePointBoundary } from "@sentry/toolkit-core/text"; import type { SentryApiService } from "../../api-client/index"; /** @@ -94,11 +93,10 @@ export function formatBreadcrumbData( } /** - * Strips newlines and truncates a string to maxLen UTF-16 code units without - * splitting a surrogate pair. + * Strips newlines and truncates a string to maxLen characters. */ export function cleanAndTruncate(str: string, maxLen: number): string { const clean = str.replace(/[\r\n]+/g, " "); if (clean.length <= maxLen) return clean; - return `${sliceAtCodePointBoundary(clean, 0, maxLen)}...`; + return `${clean.slice(0, maxLen)}...`; } diff --git a/packages/mcp-core/src/server.test.ts b/packages/mcp-core/src/server.test.ts index 69667785e..c158986f8 100644 --- a/packages/mcp-core/src/server.test.ts +++ b/packages/mcp-core/src/server.test.ts @@ -3,6 +3,7 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { McpServer as ModernMcpServer } from "@modelcontextprotocol/server"; import { type Span, setUser, startSpan } from "@sentry/core"; import { + eventFixture, issueFixture, mswServer, projectFixture, @@ -316,6 +317,98 @@ describe("buildServer", () => { }, ); + describe("lone surrogates in tool output", () => { + // Cutting 119 characters plus an emoji at 120 UTF-16 units keeps only the + // emoji's high surrogate. Strict JSON parsers such as serde_json in Codex + // reject that lone half and close the transport. + const lock = "\u{1F512}"; + const cutText = `${"x".repeat(119)}${lock}`.slice(0, 120); + const replacedText = `${"x".repeat(119)}�`; + + it("replaces a breadcrumb message cut inside an emoji", async () => { + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/events/latest/", + () => + HttpResponse.json({ + ...eventFixture, + entries: [ + { + type: "breadcrumbs", + data: { + values: [ + { + timestamp: "2025-04-08T21:14:52.000Z", + category: "console", + level: "info", + message: `${"x".repeat(119)}${lock} required`, + }, + ], + }, + }, + ], + }), + { once: true }, + ), + ); + + const result = await callRegisteredTool( + buildServer({ context: baseContext }), + "execute_sentry_tool", + { + name: "get_issue_breadcrumbs", + arguments: { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + }, + }, + ); + + const text = getTextContent(result); + expect(text).toContain(`[console] ${replacedText}...`); + expect(text.isWellFormed()).toBe(true); + }); + + it("replaces them in structured and error output", async () => { + const createServer = () => + buildServer({ + context: baseContext, + tools: { + structured_tool: createMockTool("structured_tool", { + outputSchema: z.object({ preview: z.string() }), + handler: async () => structuredResult({ preview: cutText }), + }), + failing_tool: createMockTool("failing_tool", { + handler: async () => { + throw new UserInputError(cutText); + }, + }), + }, + }); + + const structured = await callRegisteredTool( + createServer(), + "structured_tool", + {}, + ); + expect(getStructuredContent(structured)).toEqual({ + preview: replacedText, + }); + expect(getTextContent(structured)).toBe( + getGeneratedTextFromStructuredContent(structured), + ); + + const failed = await callRegisteredTool( + createServer(), + "failing_tool", + {}, + ); + expect(failed).toMatchObject({ isError: true }); + expect(getTextContent(failed)).toContain(replacedText); + expect(getTextContent(failed).isWellFormed()).toBe(true); + }); + }); + describe("telemetry context", () => { it("generates compatibility text for structured-only tool output", async () => { const server = buildServer({ diff --git a/packages/mcp-core/src/server.ts b/packages/mcp-core/src/server.ts index 2c99ba6e3..52aa7ea37 100644 --- a/packages/mcp-core/src/server.ts +++ b/packages/mcp-core/src/server.ts @@ -105,6 +105,36 @@ function structuredOutputToCallToolResult( }; } +/** + * Replaces lone UTF-16 surrogates in tool output with U+FFFD. + * + * Formatters truncate upstream text by UTF-16 index, so a cut can keep only + * half of an emoji. `JSON.stringify` writes that half as `\ud83d`, and strict + * JSON parsers such as serde_json in Codex reject the message and close the + * transport. Normalizing handler output here covers every tool, including + * structuredContent and the compatibility text generated from it. + */ +function toWellFormedOutput(value: T): T { + if (typeof value === "string") { + return value.toWellFormed() as T; + } + if (Array.isArray(value)) { + return value.map(toWellFormedOutput) as T; + } + if (value !== null && typeof value === "object") { + const prototype = Object.getPrototypeOf(value); + if (prototype === Object.prototype || prototype === null) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key.toWellFormed(), + toWellFormedOutput(item), + ]), + ) as T; + } + } + return value; +} + /** * Creates and configures a complete MCP server with Sentry instrumentation. * @@ -349,11 +379,13 @@ function configureServer({ } } - const output = await executeToolHandler({ - tool, - params: rawParams, - context: contextWithToolAvailability, - }); + const output = toWellFormedOutput( + await executeToolHandler({ + tool, + params: rawParams, + context: contextWithToolAvailability, + }), + ); if (activeSpan) { activeSpan.setStatus({ @@ -435,9 +467,11 @@ function configureServer({ content: [ { type: "text" as const, - text: await formatErrorForUser(error, { - transport: context.transport, - }), + text: ( + await formatErrorForUser(error, { + transport: context.transport, + }) + ).toWellFormed(), }, ], isError: true, diff --git a/packages/mcp-core/src/tools/catalog/get-agent-conversation-details.ts b/packages/mcp-core/src/tools/catalog/get-agent-conversation-details.ts index b9bf66b8d..601ba33c9 100644 --- a/packages/mcp-core/src/tools/catalog/get-agent-conversation-details.ts +++ b/packages/mcp-core/src/tools/catalog/get-agent-conversation-details.ts @@ -1,7 +1,6 @@ // Catalog-only agent conversation detail lookup. This owns the MCP-facing // transcript projection and keeps detail output chronological and debugger // oriented instead of mirroring every raw span attribute. -import { sliceAtCodePointBoundary } from "@sentry/toolkit-core/text"; import { z } from "zod"; import type { AIConversationSpan, SentryApiService } from "../../api-client"; import { UserInputError } from "../../errors"; @@ -252,7 +251,7 @@ function truncateAttribute( } truncatedFields.push(field); - return `${sliceAtCodePointBoundary(value, 0, MAX_RAW_GEN_AI_ATTRIBUTE_LENGTH)}... [truncated]`; + return `${value.slice(0, MAX_RAW_GEN_AI_ATTRIBUTE_LENGTH)}... [truncated]`; } function getOperationType(span: AIConversationSpan): string | undefined { diff --git a/packages/mcp-core/src/tools/catalog/get-issue-breadcrumbs.test.ts b/packages/mcp-core/src/tools/catalog/get-issue-breadcrumbs.test.ts index 86aa39d33..61f78842e 100644 --- a/packages/mcp-core/src/tools/catalog/get-issue-breadcrumbs.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-issue-breadcrumbs.test.ts @@ -109,50 +109,6 @@ describe("get_issue_breadcrumbs", () => { ); }); - it("does not split an emoji at the message truncation boundary", async () => { - // The lock emoji starts at index 119, so a raw 120-unit cut keeps only - // its high surrogate. Strict JSON parsers reject that lone half. - const message = `${"x".repeat(119)}\u{1F512} required`; - mswServer.use( - http.get( - "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/events/latest/", - () => - HttpResponse.json({ - ...eventFixture, - entries: [ - { - type: "breadcrumbs", - data: { - values: [ - { - timestamp: "2025-04-08T21:14:52.000Z", - category: "console", - level: "info", - message, - }, - ], - }, - }, - ], - }), - { once: true }, - ), - ); - - const result = await getIssueBreadcrumbs.handler( - { - organizationSlug: "sentry-mcp-evals", - regionUrl: null, - issueId: "CLOUDFLARE-MCP-41", - eventId: "latest", - }, - context, - ); - - expect(result).toContain(`[console] ${"x".repeat(119)}...`); - expect(result).not.toMatch(/[\ud800-\udbff](?![\udc00-\udfff])/); - }); - it("returns breadcrumbs for an explicit event ID", async () => { mswServer.use( http.get( diff --git a/packages/mcp-core/src/tools/catalog/search-agent-conversations.ts b/packages/mcp-core/src/tools/catalog/search-agent-conversations.ts index 8fd5074cf..bd3168401 100644 --- a/packages/mcp-core/src/tools/catalog/search-agent-conversations.ts +++ b/packages/mcp-core/src/tools/catalog/search-agent-conversations.ts @@ -1,7 +1,6 @@ // Catalog-only Agent Conversations search. This owns the MCP-facing projection for // Sentry's conversation list endpoint and intentionally exposes backend default // ordering until alternate sorting is applied by the API. -import { sliceAtCodePointBoundary } from "@sentry/toolkit-core/text"; import { z } from "zod"; import type { AIConversationSummary, SentryApiService } from "../../api-client"; import { UserInputError } from "../../errors"; @@ -115,7 +114,7 @@ function previewText(value: string | null): string | null { if (value == null || value.length <= PREVIEW_LENGTH) { return value; } - return `${sliceAtCodePointBoundary(value, 0, PREVIEW_LENGTH - 3)}...`; + return `${value.slice(0, PREVIEW_LENGTH - 3)}...`; } function formatLLMCost(cost: number): string { diff --git a/packages/mcp-core/src/tools/support/issue-linking/index.ts b/packages/mcp-core/src/tools/support/issue-linking/index.ts index 1bff25984..e3e0f7cb2 100644 --- a/packages/mcp-core/src/tools/support/issue-linking/index.ts +++ b/packages/mcp-core/src/tools/support/issue-linking/index.ts @@ -1,5 +1,4 @@ /** Select native integrations locally; Sentry owns URL validation and link mutations. */ -import { sliceAtCodePointBoundary } from "@sentry/toolkit-core/text"; import type { SentryApiService } from "../../../api-client"; import type { IssueIntegration } from "../../../api-client/types"; import { UserInputError } from "../../../errors"; @@ -220,7 +219,7 @@ function describeIntegrations(integrations: IssueIntegration[]): string { .slice(0, 5) .map( (integration) => - `${integration.id} (${sliceAtCodePointBoundary(integration.name, 0, 60)}, ${integration.provider.key})`, + `${integration.id} (${integration.name.slice(0, 60)}, ${integration.provider.key})`, ) .join("; "); } diff --git a/packages/mcp-core/src/tools/support/profile/formatter.ts b/packages/mcp-core/src/tools/support/profile/formatter.ts index 2a371f68b..2a8bb5f0b 100644 --- a/packages/mcp-core/src/tools/support/profile/formatter.ts +++ b/packages/mcp-core/src/tools/support/profile/formatter.ts @@ -7,7 +7,6 @@ * - Raw profile chunk analysis (get_profile_details) */ -import { sliceAtCodePointBoundary } from "@sentry/toolkit-core/text"; import type { Flamegraph, ProfileChunk, @@ -53,7 +52,7 @@ function determineComparisonStatus( */ function truncateLocation(location: string, maxLength = 30): string { if (location.length <= maxLength) return location; - return `...${sliceAtCodePointBoundary(location, -(maxLength - 3))}`; + return `...${location.slice(-(maxLength - 3))}`; } /** diff --git a/packages/mcp-core/src/tools/support/search-events/utils.ts b/packages/mcp-core/src/tools/support/search-events/utils.ts index 9054aead6..564aacf22 100644 --- a/packages/mcp-core/src/tools/support/search-events/utils.ts +++ b/packages/mcp-core/src/tools/support/search-events/utils.ts @@ -4,7 +4,6 @@ import { type ParseResultToken, parseSearch, } from "@sentry/search-syntax/parser"; -import { sliceAtCodePointBoundary } from "@sentry/toolkit-core/text"; import { TermOperator, Token } from "@sentry/search-syntax/tokens"; import { getActiveSpan } from "@sentry/core"; import { z } from "zod"; @@ -627,10 +626,10 @@ function truncateString(value: string, maxLength: number): string { } if (maxLength <= 3) { - return sliceAtCodePointBoundary(value, 0, maxLength); + return value.slice(0, maxLength); } - return `${sliceAtCodePointBoundary(value, 0, maxLength - 3)}...`; + return `${value.slice(0, maxLength - 3)}...`; } function safeJsonStringify(value: unknown): string { @@ -993,7 +992,7 @@ function truncateValidationOutput(output: string): string { if (output.length <= VALIDATION_OUTPUT_MAX_LENGTH) { return output; } - return `${sliceAtCodePointBoundary(output, 0, VALIDATION_OUTPUT_MAX_LENGTH)}…`; + return `${output.slice(0, VALIDATION_OUTPUT_MAX_LENGTH)}…`; } export function recordEventsSearchValidationTelemetry({ diff --git a/packages/mcp-core/src/tools/support/traces/semantic-display.ts b/packages/mcp-core/src/tools/support/traces/semantic-display.ts index 62a88ac9b..212ce7d47 100644 --- a/packages/mcp-core/src/tools/support/traces/semantic-display.ts +++ b/packages/mcp-core/src/tools/support/traces/semantic-display.ts @@ -1,4 +1,3 @@ -import { sliceAtCodePointBoundary } from "@sentry/toolkit-core/text"; import type { TraceSpan } from "../../../api-client"; interface SemanticSpanDisplay { @@ -768,5 +767,5 @@ function formatDisplayPart( return normalized; } - return `${sliceAtCodePointBoundary(normalized, 0, maxLength - 3)}...`; + return `${normalized.slice(0, maxLength - 3)}...`; } diff --git a/packages/mcp-server-tsconfig/tsconfig.base.json b/packages/mcp-server-tsconfig/tsconfig.base.json index 943b1ffe7..96f158583 100644 --- a/packages/mcp-server-tsconfig/tsconfig.base.json +++ b/packages/mcp-server-tsconfig/tsconfig.base.json @@ -3,7 +3,7 @@ "display": "Default", "compilerOptions": { "target": "ES2022", - "lib": ["ES2023"], + "lib": ["ES2023", "ES2024.String"], "module": "ESNext", "preserveWatchOutput": true, diff --git a/packages/toolkit-core/package.json b/packages/toolkit-core/package.json index 80bec53b4..2465f3871 100644 --- a/packages/toolkit-core/package.json +++ b/packages/toolkit-core/package.json @@ -67,10 +67,6 @@ "types": "./src/sentry-origin.ts", "default": "./src/sentry-origin.ts" }, - "./text": { - "types": "./src/text.ts", - "default": "./src/text.ts" - }, "./api-client/response-cache": { "types": "./src/api-client/response-cache/index.ts", "default": "./src/api-client/response-cache/index.ts" diff --git a/packages/toolkit-core/src/text.test.ts b/packages/toolkit-core/src/text.test.ts deleted file mode 100644 index 584e78ad9..000000000 --- a/packages/toolkit-core/src/text.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { sliceAtCodePointBoundary } from "./text.js"; - -const LOCK = "\u{1F512}"; - -function isWellFormed(text: string): boolean { - return !/[\ud800-\udbff](?![\udc00-\udfff])|(? { - it("backs off when the end lands between the halves of an emoji", () => { - const text = `${"a".repeat(119)}${LOCK}tail`; - - const result = sliceAtCodePointBoundary(text, 0, 120); - - expect(result).toBe("a".repeat(119)); - expect(isWellFormed(result)).toBe(true); - }); - - it("keeps a whole emoji that ends exactly at the bound", () => { - const text = `${"a".repeat(118)}${LOCK}tail`; - - expect(sliceAtCodePointBoundary(text, 0, 120)).toBe( - `${"a".repeat(118)}${LOCK}`, - ); - }); - - it("moves a negative start past the low half of an emoji", () => { - const text = `/src/${LOCK}/file.ts`; - const lowHalfIndex = text.indexOf(LOCK) + 1; - - const result = sliceAtCodePointBoundary(text, lowHalfIndex - text.length); - - expect(result).toBe("/file.ts"); - expect(isWellFormed(result)).toBe(true); - }); - - it("matches String.prototype.slice when no pair is split", () => { - const text = `hello ${LOCK} world`; - - expect(sliceAtCodePointBoundary(text, 0, 5)).toBe(text.slice(0, 5)); - expect(sliceAtCodePointBoundary(text, -5)).toBe(text.slice(-5)); - expect(sliceAtCodePointBoundary(text, 0, 100)).toBe(text); - }); - - it("never returns a lone surrogate for any end bound", () => { - const text = `${LOCK}a${LOCK}${LOCK}b`; - - for (let end = 0; end <= text.length; end += 1) { - expect(isWellFormed(sliceAtCodePointBoundary(text, 0, end))).toBe(true); - } - }); -}); diff --git a/packages/toolkit-core/src/text.ts b/packages/toolkit-core/src/text.ts deleted file mode 100644 index c4e81659f..000000000 --- a/packages/toolkit-core/src/text.ts +++ /dev/null @@ -1,45 +0,0 @@ -function isHighSurrogate(code: number): boolean { - return code >= 0xd8_00 && code <= 0xdb_ff; -} - -function isLowSurrogate(code: number): boolean { - return code >= 0xdc_00 && code <= 0xdf_ff; -} - -/** - * Slice `text` like `String.prototype.slice` without splitting a surrogate pair. - * - * Truncating user text (log messages, breadcrumbs, prompts) by UTF-16 index can - * cut an emoji in half. The lone half serializes as `\ud83d`, and strict JSON - * parsers such as serde_json reject the whole response. When a bound lands - * inside a pair, this drops the partial character: `end` backs off one unit - * and `start` moves forward one unit. - */ -export function sliceAtCodePointBoundary( - text: string, - start: number, - end: number = text.length, -): string { - const length = text.length; - let from = start < 0 ? Math.max(length + start, 0) : Math.min(start, length); - let to = end < 0 ? Math.max(length + end, 0) : Math.min(end, length); - - if ( - from > 0 && - from < length && - isLowSurrogate(text.charCodeAt(from)) && - isHighSurrogate(text.charCodeAt(from - 1)) - ) { - from += 1; - } - if ( - to > 0 && - to < length && - isHighSurrogate(text.charCodeAt(to - 1)) && - isLowSurrogate(text.charCodeAt(to)) - ) { - to -= 1; - } - - return text.slice(from, to); -}