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/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-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,