Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/contributing/tool-responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
93 changes: 93 additions & 0 deletions packages/mcp-core/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down
50 changes: 42 additions & 8 deletions packages/mcp-core/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(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.
*
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-server-tsconfig/tsconfig.base.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"display": "Default",
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"lib": ["ES2023", "ES2024.String"],
"module": "ESNext",
"preserveWatchOutput": true,

Expand Down
Loading