diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 6e54be84ef863..85ccc3ab926e4 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -375,6 +375,53 @@ 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( "registers the pull request toolkit and surfaces a missing capability as a tool error", () => 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..75db3e4d59786 --- /dev/null +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts @@ -0,0 +1,16 @@ +import { expect, it } from "@effect/vitest"; +import { Tool } from "effect/ai"; + +import { OrchestratorToolkit } from "./tools.ts"; + +// 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>; + }; + const limits = { limit: "100", runLimit: "50", maxCharsPerItem: "50000" }; + for (const [name, maximum] of Object.entries(limits)) { + expect(schema.properties[name]?.description, name).toContain(maximum); + } +}); diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 7d643be910789..f6ef8712eb621 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -367,15 +367,35 @@ export const OrchestratorMcpThreadListResult = Schema.Struct({ }); export type OrchestratorMcpThreadListResult = typeof OrchestratorMcpThreadListResult.Type; +// 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({ + 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)).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;