Skip to content
Open
27 changes: 27 additions & 0 deletions apps/server/src/mcp/McpHttpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ it.effect.each([
const layerRecordingTools = (calls: Array<unknown>) => {
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,
Expand All @@ -241,6 +242,7 @@ const layerRecordingTools = (calls: Array<unknown>) => {
RecordingToolkit,
McpToolAccess.toLayer(RecordingToolkit, {
delegate_task: record,
t3_thread_list: record,
t3_thread_read: record,
t3_thread_wait: record,
t3_project_update: record,
Expand Down Expand Up @@ -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<unknown> = [];
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<unknown> = [];
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`");
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/provider/agentContextBudget.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"claudeSystemAppendBytes": 5153,
"codexAdditionalContextBytes": 6883,
"mcpToolListJsonBytes": 47538
"mcpToolListJsonBytes": 47524
}
30 changes: 15 additions & 15 deletions packages/contracts/src/orchestratorMcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;

Expand Down Expand Up @@ -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,
Expand Down
Loading