From 663aa06017dc5e88bdcc2ca4f84f016957fd1c7a Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Fri, 30 Oct 2026 16:48:03 -0400 Subject: [PATCH 1/4] fix(server): t3_thread_list clamps an oversized limit instead of failing Codex does not show the model the `maximum` of a tool parameter, so an agent asking t3_thread_list for more than 100 threads got an invalid-parameters error and had to retry. The limit now clamps to 100 on decode, as t3_thread_read's page sizes already do, and its description states the range. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/McpHttpServer.test.ts | 28 ++++++++++++++++++++ packages/contracts/src/orchestratorMcp.ts | 32 ++++++++++++----------- 2 files changed, 45 insertions(+), 15 deletions(-) diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 1f77dbfcc087d..94f5cedfcb709 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -143,6 +143,7 @@ const callSnapshot = (args: Record) => callTool("preview_snapsh const layerRecordingTools = (calls: Array) => { const RecordingToolkit = Toolkit.make( OrchestratorToolkit.tools.delegate_task, + OrchestratorToolkit.tools.t3_thread_list, OrchestratorToolkit.tools.t3_thread_read, OrchestratorToolkit.tools.t3_thread_wait, ProjectToolkit.tools.t3_project_update, @@ -158,6 +159,7 @@ const layerRecordingTools = (calls: Array) => { RecordingToolkit, McpToolAccess.toLayer(RecordingToolkit, { delegate_task: record, + t3_thread_list: record, t3_thread_read: record, t3_thread_wait: record, t3_project_update: record, @@ -463,6 +465,32 @@ it.effect.each(["limit", "runLimit", "maxCharsPerItem"])( }, ); +it.effect("clamps a t3_thread_list limit above its maximum instead of rejecting the call", () => { + const calls: Array = []; + return Effect.gen(function* () { + // Codex never shows the model this bound, so an over-large page must still list. + yield* callTool("t3_thread_list", { allProjects: true, limit: 200 }); + yield* callTool("t3_thread_list", { allProjects: true, limit: 7 }); + + expect(calls).toEqual([ + { allProjects: true, limit: 100 }, + { allProjects: true, limit: 7 }, + ]); + }).pipe(Effect.provide(layerRecordingTools(calls))); +}); + +it.effect("still rejects a t3_thread_list limit below its minimum", () => { + const calls: Array = []; + return Effect.gen(function* () { + const rejected = yield* callTool("t3_thread_list", { allProjects: true, limit: 0 }).pipe( + Effect.flip, + ); + + expect(String(rejected)).toContain("greater than or equal to 1"); + expect(calls).toEqual([]); + }).pipe(Effect.provide(layerRecordingTools(calls))); +}); + it("keeps cross-tool routing out of the delegate_task description", () => { const description = OrchestratorToolkit.tools.delegate_task.description ?? ""; expect(description).toContain("Start one child agent with only `task`"); diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 1474a5d6e87b6..224206a0faa65 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -327,6 +327,20 @@ export const OrchestratorMcpEnvironmentTarget = Schema.optional( }), ); +// 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 OrchestratorMcpThreadListInput = Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, projectId: OrchestratorMcpProjectTarget, @@ -339,7 +353,9 @@ export const OrchestratorMcpThreadListInput = Schema.Struct({ snoozed: Schema.optional(Schema.Boolean), includeSubagents: Schema.optional(Schema.Boolean), cursor: Schema.optional(NonNegativeInt), - limit: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(100))), + limit: Schema.optional(clampedPositiveInt(100)).annotate({ + description: "Max threads, 1 to 100.", + }), }); export type OrchestratorMcpThreadListInput = typeof OrchestratorMcpThreadListInput.Type; @@ -389,20 +405,6 @@ 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({ environmentId: OrchestratorMcpEnvironmentTarget, threadId: ThreadId, From e762b2f774bc8655e13d0a24b93aad4595b0cca8 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Fri, 30 Oct 2026 16:55:44 -0400 Subject: [PATCH 2/4] test(server): record the t3_thread_list limit description in the agent context budget Co-Authored-By: Claude Opus 5.5 --- apps/server/src/provider/agentContextBudget.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 1711e148d4ca4..db2acdf12c24c 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5135, "codexAdditionalContextBytes": 6865, - "mcpToolListJsonBytes": 46007 + "mcpToolListJsonBytes": 46032 } From 4e3d6a618d869b583d5d7edb4aedb41f481bd550 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Fri, 30 Oct 2026 17:02:43 -0400 Subject: [PATCH 3/4] fix(server): keep the t3_thread_list clamp without growing the agent context Co-Authored-By: Claude Opus 5.5 --- apps/server/src/provider/agentContextBudget.json | 2 +- packages/contracts/src/orchestratorMcp.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index db2acdf12c24c..9c5e152d6a3d7 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5135, "codexAdditionalContextBytes": 6865, - "mcpToolListJsonBytes": 46032 + "mcpToolListJsonBytes": 45993 } diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 224206a0faa65..f54baaffbbfd6 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -353,9 +353,7 @@ export const OrchestratorMcpThreadListInput = Schema.Struct({ snoozed: Schema.optional(Schema.Boolean), includeSubagents: Schema.optional(Schema.Boolean), cursor: Schema.optional(NonNegativeInt), - limit: Schema.optional(clampedPositiveInt(100)).annotate({ - description: "Max threads, 1 to 100.", - }), + limit: Schema.optional(clampedPositiveInt(100)), }); export type OrchestratorMcpThreadListInput = typeof OrchestratorMcpThreadListInput.Type; From fa013b50ebcdbe9d899228c0619b0cb335e160c0 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 31 Oct 2026 03:44:32 +0000 Subject: [PATCH 4/4] test(server): remove redundant thread-list clamp comment --- apps/server/src/mcp/McpHttpServer.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 146cc142b6805..07a08115cee91 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -551,7 +551,6 @@ it.effect.each(["limit", "runLimit", "maxCharsPerItem"])( it.effect("clamps a t3_thread_list limit above its maximum instead of rejecting the call", () => { const calls: Array = []; return Effect.gen(function* () { - // Codex never shows the model this bound, so an over-large page must still list. yield* callTool("t3_thread_list", { allProjects: true, limit: 200 }); yield* callTool("t3_thread_list", { allProjects: true, limit: 7 });