From 1dd56c0b7707ca84072cc84587b2af6effc42072 Mon Sep 17 00:00:00 2001 From: fixfon <12512757+fixfon@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:16:15 +0300 Subject: [PATCH 1/4] fix(server): clamp t3_thread_read page sizes instead of rejecting them Codex hides the minimum/maximum bounds on `limit`, `runLimit` and `maxCharsPerItem`, so models sent values like `maxCharsPerItem: 60000` and the call failed with -32602 (19% of calls in one report). Values above the maximum now clamp to it on decode, and the existing nextPosition/nextTextOffset pagination covers the rest. Each field's description states its bound. Values below 1 still fail. Fixes #22618 --- apps/server/src/mcp/McpHttpServer.test.ts | 62 +++++++++++++++++++++++ packages/contracts/src/orchestratorMcp.ts | 36 +++++++++++-- 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 6e54be84ef863..b5a27a01a111d 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -375,6 +375,68 @@ it.effect("decodes null optional arguments the way it decodes omitted ones", () }).pipe(Effect.provide(layerRecordingTools(calls))); }); +it.effect( + "clamps t3_thread_read page sizes above their maximum instead of rejecting the call", + () => { + const calls: Array = []; + return Effect.gen(function* () { + const thread = { threadId: "thread-clamped-arguments" }; + // Codex never shows the model these bounds, so an over-large page must still read. + yield* callTool("t3_thread_read", { + ...thread, + limit: 150, + runLimit: 80, + maxCharsPerItem: 60_000, + }); + yield* callTool("t3_thread_read", { + ...thread, + limit: 100, + runLimit: 50, + maxCharsPerItem: 50_000, + }); + yield* callTool("t3_thread_read", { ...thread, limit: 7, runLimit: 1, maxCharsPerItem: 1 }); + + expect(calls).toEqual([ + { ...thread, limit: 100, runLimit: 50, maxCharsPerItem: 50_000 }, + { ...thread, limit: 100, runLimit: 50, maxCharsPerItem: 50_000 }, + { ...thread, limit: 7, runLimit: 1, maxCharsPerItem: 1 }, + ]); + }).pipe(Effect.provide(layerRecordingTools(calls))); + }, +); + +it.effect.each(["limit", "runLimit", "maxCharsPerItem"])( + "still rejects a t3_thread_read %s below its minimum", + (field) => { + const calls: Array = []; + return Effect.gen(function* () { + // Zero is a different request than one item, so it fails rather than answering another question. + const rejected = yield* callTool("t3_thread_read", { + threadId: "thread-below-minimum", + [field]: 0, + }).pipe(Effect.flip); + + expect(String(rejected)).toContain("greater than or equal to 1"); + expect(calls).toEqual([]); + }).pipe(Effect.provide(layerRecordingTools(calls))); + }, +); + +it.effect("tells the model each t3_thread_read page-size bound in the field description", () => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const schema = server.tools.find(({ tool }) => tool.name === "t3_thread_read")?.tool + .inputSchema as { properties?: Record } | undefined; + const advertised = (field: string) => JSON.stringify(schema?.properties?.[field]); + + expect(advertised("limit")).toContain("1 to 100. Larger values are clamped to 100"); + expect(advertised("runLimit")).toContain("1 to 50. Larger values are clamped to 50"); + expect(advertised("maxCharsPerItem")).toContain( + "1 to 50000. Larger values are clamped to 50000", + ); + }).pipe(Effect.provide(layerRecordingTools([]))), +); + it.effect( "registers the pull request toolkit and surfaces a missing capability as a tool error", () => diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 7d643be910789..92b20fda6b7f1 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -367,15 +367,45 @@ export const OrchestratorMcpThreadListResult = Schema.Struct({ }); export type OrchestratorMcpThreadListResult = typeof OrchestratorMcpThreadListResult.Type; +/** + * Codex drops `minimum` and `maximum` from the declaration it shows the model, so it cannot see + * these bounds and a rejected call only costs a retry. The description carries the bound instead, + * and a larger value clamps on decode (the advertised schema therefore has no such maximum). + * Values below 1 still fail: 0 asks for nothing, and answering it with one item would be a + * different request. + */ +const clampedPositiveInt = (maximum: number, description: string) => + PositiveInt.annotate({ description }).pipe( + Schema.decodeTo( + PositiveInt.check(Schema.isLessThanOrEqualTo(maximum)), + SchemaTransformation.transform({ + decode: (value) => Math.min(value, maximum), + encode: (value) => value, + }), + ), + ); + export const OrchestratorMcpThreadReadInput = Schema.Struct({ threadId: ThreadId, itemId: Schema.optional(TurnItemId), textOffset: Schema.optional(NonNegativeInt), view: Schema.optional(Schema.Literals(["messages", "activity"])), afterPosition: Schema.optional(NonNegativeInt), - limit: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(100))), - runLimit: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(50))), - maxCharsPerItem: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(50_000))), + limit: Schema.optional( + clampedPositiveInt( + 100, + "Timeline items per page, 1 to 100. Larger values are clamped to 100; continue with afterPosition=nextPosition.", + ), + ), + runLimit: Schema.optional( + clampedPositiveInt(50, "Recent runs to include, 1 to 50. Larger values are clamped to 50."), + ), + maxCharsPerItem: Schema.optional( + clampedPositiveInt( + 50_000, + "Characters of text per timeline item, 1 to 50000. Larger values are clamped to 50000; recover the rest with itemId and textOffset=nextTextOffset.", + ), + ), }); export type OrchestratorMcpThreadReadInput = typeof OrchestratorMcpThreadReadInput.Type; From eef7ce77dcdb53063eebe8959ff6bc564f64f8df Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 21 Oct 2026 06:20:58 -0700 Subject: [PATCH 2/4] fix(server): leave t3_thread_read bound copy to field descriptions Drop the field descriptions and the test that only asserted their copy, so this change is only the clamp. #22652 states the bounds in the descriptions. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/McpHttpServer.test.ts | 15 ----------- packages/contracts/src/orchestratorMcp.ts | 32 ++++++----------------- 2 files changed, 8 insertions(+), 39 deletions(-) diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index b5a27a01a111d..85ccc3ab926e4 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -422,21 +422,6 @@ it.effect.each(["limit", "runLimit", "maxCharsPerItem"])( }, ); -it.effect("tells the model each t3_thread_read page-size bound in the field description", () => - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - const schema = server.tools.find(({ tool }) => tool.name === "t3_thread_read")?.tool - .inputSchema as { properties?: Record } | undefined; - const advertised = (field: string) => JSON.stringify(schema?.properties?.[field]); - - expect(advertised("limit")).toContain("1 to 100. Larger values are clamped to 100"); - expect(advertised("runLimit")).toContain("1 to 50. Larger values are clamped to 50"); - expect(advertised("maxCharsPerItem")).toContain( - "1 to 50000. Larger values are clamped to 50000", - ); - }).pipe(Effect.provide(layerRecordingTools([]))), -); - it.effect( "registers the pull request toolkit and surfaces a missing capability as a tool error", () => diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 92b20fda6b7f1..5c83120d4ba16 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -367,15 +367,11 @@ export const OrchestratorMcpThreadListResult = Schema.Struct({ }); export type OrchestratorMcpThreadListResult = typeof OrchestratorMcpThreadListResult.Type; -/** - * Codex drops `minimum` and `maximum` from the declaration it shows the model, so it cannot see - * these bounds and a rejected call only costs a retry. The description carries the bound instead, - * and a larger value clamps on decode (the advertised schema therefore has no such maximum). - * Values below 1 still fail: 0 asks for nothing, and answering it with one item would be a - * different request. - */ -const clampedPositiveInt = (maximum: number, description: string) => - PositiveInt.annotate({ description }).pipe( +// Codex hides `minimum` and `maximum` from the model, so a value above the maximum clamps on +// decode instead of failing the call; pagination returns the rest. Values below 1 still fail: +// 0 asks for nothing, and answering it with one item would be a different request. +const clampedPositiveInt = (maximum: number) => + PositiveInt.pipe( Schema.decodeTo( PositiveInt.check(Schema.isLessThanOrEqualTo(maximum)), SchemaTransformation.transform({ @@ -391,21 +387,9 @@ export const OrchestratorMcpThreadReadInput = Schema.Struct({ textOffset: Schema.optional(NonNegativeInt), view: Schema.optional(Schema.Literals(["messages", "activity"])), afterPosition: Schema.optional(NonNegativeInt), - limit: Schema.optional( - clampedPositiveInt( - 100, - "Timeline items per page, 1 to 100. Larger values are clamped to 100; continue with afterPosition=nextPosition.", - ), - ), - runLimit: Schema.optional( - clampedPositiveInt(50, "Recent runs to include, 1 to 50. Larger values are clamped to 50."), - ), - maxCharsPerItem: Schema.optional( - clampedPositiveInt( - 50_000, - "Characters of text per timeline item, 1 to 50000. Larger values are clamped to 50000; recover the rest with itemId and textOffset=nextTextOffset.", - ), - ), + limit: Schema.optional(clampedPositiveInt(100)), + runLimit: Schema.optional(clampedPositiveInt(50)), + maxCharsPerItem: Schema.optional(clampedPositiveInt(50_000)), }); export type OrchestratorMcpThreadReadInput = typeof OrchestratorMcpThreadReadInput.Type; From 155a88bd5b339d6f1d3b9337c3206b4ded0a294a Mon Sep 17 00:00:00 2001 From: grey-tsosie <45133231+grey-tsosie@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:15:25 -0700 Subject: [PATCH 3/4] fix(server): state t3_thread_read limits in its parameter descriptions Codex shows models a TypeScript view of each MCP tool that drops minimum and maximum, so limit, runLimit and maxCharsPerItem looked unbounded and calls above 100, 50 and 50000 failed validation. The bounds now appear in each property's description, on the outer property because Codex also drops descriptions inside anyOf members. Assisted by: Claude (Claude Code) --- .../mcp/toolkits/orchestrator/tools.test.ts | 27 +++++++++++++++++++ packages/contracts/src/orchestratorMcp.ts | 12 ++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/mcp/toolkits/orchestrator/tools.test.ts diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts new file mode 100644 index 0000000000000..35a09717819bc --- /dev/null +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts @@ -0,0 +1,27 @@ +import { expect, it } from "@effect/vitest"; +import { Tool } from "effect/ai"; + +import { OrchestratorToolkit } from "./tools.ts"; + +interface PropertySchema { + readonly maximum?: number; + readonly description?: string; + readonly anyOf?: ReadonlyArray; +} + +// Codex shows models a TypeScript view of each tool that drops minimum, maximum and any +// description inside anyOf, so an enforced bound must be stated on the property itself. +it("states t3_thread_read numeric bounds in the parameter descriptions", () => { + const schema = Tool.getJsonSchema(OrchestratorToolkit.tools.t3_thread_read) as { + readonly properties: Readonly>; + }; + const bounded = Object.entries(schema.properties).flatMap(([name, property]) => + [property, ...(property.anyOf ?? [])] + .filter((member) => member.maximum !== undefined && member.maximum < Number.MAX_SAFE_INTEGER) + .map((member) => [name, property.description, member.maximum] as const), + ); + expect(bounded.map(([name]) => name)).toEqual(["limit", "runLimit", "maxCharsPerItem"]); + for (const [name, description, maximum] of bounded) { + expect(description, name).toContain(String(maximum)); + } +}); diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 5c83120d4ba16..f6ef8712eb621 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -387,9 +387,15 @@ export const OrchestratorMcpThreadReadInput = Schema.Struct({ textOffset: Schema.optional(NonNegativeInt), view: Schema.optional(Schema.Literals(["messages", "activity"])), afterPosition: Schema.optional(NonNegativeInt), - limit: Schema.optional(clampedPositiveInt(100)), - runLimit: Schema.optional(clampedPositiveInt(50)), - maxCharsPerItem: Schema.optional(clampedPositiveInt(50_000)), + limit: Schema.optional(clampedPositiveInt(100)).annotate({ + description: "Maximum timeline items to return, from 1 to 100.", + }), + runLimit: Schema.optional(clampedPositiveInt(50)).annotate({ + description: "Maximum recent runs to return, from 1 to 50.", + }), + maxCharsPerItem: Schema.optional(clampedPositiveInt(50_000)).annotate({ + description: "Maximum text characters per item, from 1 to 50000.", + }), }); export type OrchestratorMcpThreadReadInput = typeof OrchestratorMcpThreadReadInput.Type; From 3b5e459bcd10f7f60e428ce94626c68f9b1fadbb Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 21 Oct 2026 07:07:41 -0700 Subject: [PATCH 4/4] test(server): check t3_thread_read limits in descriptions now that sizes clamp Clamping removes maximum from the tool schema, so the description is the only place a model sees each page size limit. Co-Authored-By: Claude Opus 5.5 --- .../mcp/toolkits/orchestrator/tools.test.ts | 25 ++++++------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts index 35a09717819bc..75db3e4d59786 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts @@ -3,25 +3,14 @@ import { Tool } from "effect/ai"; import { OrchestratorToolkit } from "./tools.ts"; -interface PropertySchema { - readonly maximum?: number; - readonly description?: string; - readonly anyOf?: ReadonlyArray; -} - -// Codex shows models a TypeScript view of each tool that drops minimum, maximum and any -// description inside anyOf, so an enforced bound must be stated on the property itself. -it("states t3_thread_read numeric bounds in the parameter descriptions", () => { +// Oversized page sizes clamp on decode, so the schema has no `maximum`; Codex also drops +// bounds from its TypeScript view. The description is the only place a model sees a limit. +it("states t3_thread_read page size limits in the parameter descriptions", () => { const schema = Tool.getJsonSchema(OrchestratorToolkit.tools.t3_thread_read) as { - readonly properties: Readonly>; + readonly properties: Readonly>; }; - const bounded = Object.entries(schema.properties).flatMap(([name, property]) => - [property, ...(property.anyOf ?? [])] - .filter((member) => member.maximum !== undefined && member.maximum < Number.MAX_SAFE_INTEGER) - .map((member) => [name, property.description, member.maximum] as const), - ); - expect(bounded.map(([name]) => name)).toEqual(["limit", "runLimit", "maxCharsPerItem"]); - for (const [name, description, maximum] of bounded) { - expect(description, name).toContain(String(maximum)); + const limits = { limit: "100", runLimit: "50", maxCharsPerItem: "50000" }; + for (const [name, maximum] of Object.entries(limits)) { + expect(schema.properties[name]?.description, name).toContain(maximum); } });