diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 3ddccf9eb07bc..61fe11a63e6d6 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -226,6 +226,7 @@ it.effect.each([ 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, @@ -241,6 +242,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, @@ -546,6 +548,31 @@ 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* () { + 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/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 1719d3ca6bd19..a008927a62e4c 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5153, "codexAdditionalContextBytes": 6883, - "mcpToolListJsonBytes": 47538 + "mcpToolListJsonBytes": 47524 } diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 438a3fe2def98..0b9f1828ddf63 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,7 @@ 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)), }); export type OrchestratorMcpThreadListInput = typeof OrchestratorMcpThreadListInput.Type; @@ -389,20 +403,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,