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
14 changes: 11 additions & 3 deletions packages/mcp-core/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,8 +375,15 @@ describe("buildServer", () => {
context: baseContext,
tools: {
structured_tool: createMockTool("structured_tool", {
outputSchema: z.object({ preview: z.string() }),
handler: async () => structuredResult({ preview: cutText }),
outputSchema: z.object({
title: z.string(),
previews: z.array(z.object({ preview: z.string() })),
}),
handler: async () =>
structuredResult({
title: "unchanged",
previews: [{ preview: "unchanged" }, { preview: cutText }],
}),
}),
failing_tool: createMockTool("failing_tool", {
handler: async () => {
Expand All @@ -392,7 +399,8 @@ describe("buildServer", () => {
{},
);
expect(getStructuredContent(structured)).toEqual({
preview: replacedText,
title: "unchanged",
previews: [{ preview: "unchanged" }, { preview: replacedText }],
});
expect(getTextContent(structured)).toBe(
getGeneratedTextFromStructuredContent(structured),
Expand Down
46 changes: 35 additions & 11 deletions packages/mcp-core/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,26 +113,50 @@ function structuredOutputToCallToolResult(
* 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.
*
* Runs on every tool call, so it copies on write: it returns the input itself
* when nothing changes, and copies only the arrays and objects on the path to
* a replaced string. V8 returns the same string from `toWellFormed()` when it
* is already well formed, so the `!==` checks are pointer comparisons.
*/
function toWellFormedOutput<T>(value: T): T {
if (typeof value === "string") {
return value.toWellFormed() as T;
}
if (value === null || typeof value !== "object") {
return value;
}
if (Array.isArray(value)) {
return value.map(toWellFormedOutput) as T;
let copy: unknown[] | undefined;
for (let index = 0; index < value.length; index++) {
const next = toWellFormedOutput(value[index]);
if (next !== value[index]) {
copy ??= [...value];
copy[index] = next;
}
}
return (copy ?? value) as T;
}
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) {
return value;
}
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;
const record = value as Record<string, unknown>;
// `for...in` avoids allocating a key list per object on the common path.
let entries: [string, unknown][] | undefined;
let index = 0;
for (const key in record) {
if (!Object.hasOwn(record, key)) continue;
const item = record[key];
const nextKey = key.toWellFormed();
const next = toWellFormedOutput(item);
if (!entries && (nextKey !== key || next !== item)) {
entries = Object.entries(record).slice(0, index);
}
entries?.push([nextKey, next]);
index++;
}
return value;
return (entries ? Object.fromEntries(entries) : value) as T;
}

/**
Expand Down
Loading