Skip to content

Commit c87b9a7

Browse files
Pandah97steipete
andauthored
fix(parallel): use truncateUtf16Safe for MCP error JSON truncation (#102592)
* fix(parallel): use truncateUtf16Safe for MCP error JSON truncation The extractMcpToolPayload function and error messages use naive .slice(0, 500) on JSON-serialized errors and results which can split surrogate pairs. Replace with truncateUtf16Safe(). * test(parallel): cover UTF-16-safe MCP errors --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 1beea4b commit c87b9a7

2 files changed

Lines changed: 41 additions & 10 deletions

File tree

extensions/parallel/src/parallel-mcp-search.runtime.test.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,28 @@ function headerOf(call: EndpointCall, name: string): string | undefined {
7878
return (call.init.headers as Record<string, string>)[name];
7979
}
8080

81+
function boundaryJsonPayload(base: Record<string, unknown>): {
82+
payload: Record<string, unknown>;
83+
truncatedJson: string;
84+
} {
85+
const empty = { ...base, detail: "" };
86+
const jsonPrefix = JSON.stringify(empty).slice(0, -2);
87+
const detailPrefix = "x".repeat(499 - jsonPrefix.length);
88+
return {
89+
payload: { ...base, detail: `${detailPrefix}😀tail` },
90+
truncatedJson: `${jsonPrefix}${detailPrefix}`,
91+
};
92+
}
93+
94+
function thrownMessage(run: () => unknown): string {
95+
try {
96+
run();
97+
} catch (error) {
98+
return error instanceof Error ? error.message : String(error);
99+
}
100+
throw new Error("Expected call to throw.");
101+
}
102+
81103
describe("iterMcpMessages", () => {
82104
it("parses a single JSON object body", () => {
83105
expect(iterMcpMessages('{"id":"a","result":{}}')).toEqual([{ id: "a", result: {} }]);
@@ -149,20 +171,26 @@ describe("extractMcpToolPayload", () => {
149171
});
150172

151173
it("throws on a JSON-RPC error", () => {
152-
expect(() => extractMcpToolPayload({ error: { code: -1, message: "boom" } })).toThrow(
153-
/Parallel MCP error/,
174+
const { payload, truncatedJson } = boundaryJsonPayload({ code: -1, message: "boom" });
175+
176+
expect(thrownMessage(() => extractMcpToolPayload({ error: payload }))).toBe(
177+
`Parallel MCP error: ${truncatedJson}`,
154178
);
155179
});
156180

157181
it("throws on a tool-level isError", () => {
158-
expect(() =>
159-
extractMcpToolPayload({ result: { isError: true, content: [{ type: "text", text: "{}" }] } }),
160-
).toThrow(/Parallel MCP tool error/);
182+
const { payload, truncatedJson } = boundaryJsonPayload({ isError: true });
183+
184+
expect(thrownMessage(() => extractMcpToolPayload({ result: payload }))).toBe(
185+
`Parallel MCP tool error: ${truncatedJson}`,
186+
);
161187
});
162188

163189
it("throws when there is no parseable content", () => {
164-
expect(() => extractMcpToolPayload({ result: { content: [] } })).toThrow(
165-
/no parseable content/,
190+
const { payload, truncatedJson } = boundaryJsonPayload({ content: [] });
191+
192+
expect(thrownMessage(() => extractMcpToolPayload({ result: payload }))).toBe(
193+
`Parallel MCP returned no parseable content: ${truncatedJson}`,
166194
);
167195
});
168196
});

extensions/parallel/src/parallel-mcp-search.runtime.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from "openclaw/plugin-sdk/provider-http";
88
import { withTrustedWebSearchEndpoint } from "openclaw/plugin-sdk/provider-web-search";
99
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
10+
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
1011

1112
// Free hosted Search MCP. This keyless transport is used only after the user
1213
// explicitly selects the `parallel-free` web_search provider. Docs:
@@ -154,11 +155,13 @@ export function selectMcpEnvelope(text: string, requestId: string): JsonRpcMessa
154155
*/
155156
export function extractMcpToolPayload(envelope: JsonRpcMessage): McpToolPayload {
156157
if ("error" in envelope) {
157-
throw new Error(`Parallel MCP error: ${JSON.stringify(envelope.error).slice(0, 500)}`);
158+
throw new Error(
159+
`Parallel MCP error: ${truncateUtf16Safe(JSON.stringify(envelope.error), 500)}`,
160+
);
158161
}
159162
const result = isRecord(envelope.result) ? envelope.result : {};
160163
if (result.isError) {
161-
throw new Error(`Parallel MCP tool error: ${JSON.stringify(result).slice(0, 500)}`);
164+
throw new Error(`Parallel MCP tool error: ${truncateUtf16Safe(JSON.stringify(result), 500)}`);
162165
}
163166
if (isRecord(result.structuredContent)) {
164167
return result.structuredContent;
@@ -177,7 +180,7 @@ export function extractMcpToolPayload(envelope: JsonRpcMessage): McpToolPayload
177180
}
178181
}
179182
throw new Error(
180-
`Parallel MCP returned no parseable content: ${JSON.stringify(result).slice(0, 500)}`,
183+
`Parallel MCP returned no parseable content: ${truncateUtf16Safe(JSON.stringify(result), 500)}`,
181184
);
182185
}
183186

0 commit comments

Comments
 (0)