Skip to content

Commit 5e1631f

Browse files
Merge pull request #1578 from corbitsdev/cl-11352-drop-and-advertise-null-for-optional-mcp-arguments-to-stop
fix(mcp): keep padded optional arguments from filtering MCP reads
2 parents e8e6130 + 5a5837c commit 5e1631f

9 files changed

Lines changed: 630 additions & 48 deletions

‎e2e/mcp-late-dispatch.test.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,7 @@ describe("integration — late MCP dispatch", () => {
371371
);
372372

373373
test.serial(
374-
"padded optional MCP arguments reach the server minus the values its schema rejects",
374+
"padded optional arguments to a read MCP tool reach the server without rejected placeholders or null",
375375
async () => {
376376
const received: Record<string, unknown>[] = [];
377377
await withSession(async (session) => {
@@ -428,7 +428,6 @@ describe("integration — late MCP dispatch", () => {
428428
team: "",
429429
fields: [],
430430
priority: 0,
431-
assignee: null,
432431
release: "v0345-ef53e73667fa",
433432
},
434433
]);

‎src/agent/tool-schema-normalize.test.ts‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,78 @@ describe("normalizeToolDefinitionsForProvider", () => {
163163
}
164164
});
165165

166+
describe("optional MCP properties", () => {
167+
const listIssues: ToolDefinition = {
168+
name: "mcp__linear__list_issues",
169+
description: "[linear] list issues",
170+
inputSchema: {
171+
type: "object",
172+
properties: {
173+
release: { type: "string" },
174+
priority: { type: "number" },
175+
},
176+
required: ["release"],
177+
},
178+
};
179+
const builtin: ToolDefinition = {
180+
...listIssues,
181+
name: "list_issues",
182+
};
183+
184+
test.each([
185+
{ providerName: "codex/plus", model: "gpt-5.6-terra" },
186+
{ providerName: "opencode-go", model: "gpt-5.1" },
187+
{ providerName: "codex", model: "codex-mini-latest" },
188+
])("$providerName $model advertises null on MCP tools only", (ctx) => {
189+
const [mcp, own] = normalizeToolDefinitionsForProvider(
190+
[listIssues, builtin],
191+
ctx,
192+
);
193+
expect(mcp?.inputSchema).toEqual({
194+
type: "object",
195+
properties: {
196+
release: { type: "string" },
197+
priority: { type: ["number", "null"] },
198+
},
199+
required: ["release"],
200+
});
201+
expect(own).toBe(builtin);
202+
});
203+
204+
test.each([
205+
{ providerName: "anthropic", model: "claude-sonnet-5-5" },
206+
{ providerName: "moonshot", model: "kimi-k2" },
207+
])("$providerName leaves MCP tools as they are", (ctx) => {
208+
const defs = [listIssues];
209+
expect(normalizeToolDefinitionsForProvider(defs, ctx)).toBe(defs);
210+
});
211+
212+
test("grok sanitizes MCP tools without advertising null", () => {
213+
const [out] = normalizeToolDefinitionsForProvider([listIssues], {
214+
providerName: "xai/default-2",
215+
model: "grok-4.6",
216+
});
217+
expect(out?.inputSchema).toEqual(listIssues.inputSchema);
218+
});
219+
220+
test("returns the same array when no MCP property changes", () => {
221+
const nullable: ToolDefinition = {
222+
...listIssues,
223+
inputSchema: {
224+
type: "object",
225+
properties: { assignee: { type: ["string", "null"] } },
226+
},
227+
};
228+
const defs = [nullable, builtin];
229+
expect(
230+
normalizeToolDefinitionsForProvider(defs, {
231+
providerName: "openai",
232+
model: "gpt-5.6",
233+
}),
234+
).toBe(defs);
235+
});
236+
});
237+
166238
test("grok sanitizes untyped properties and strips $schema", () => {
167239
const untyped: ToolDefinition = {
168240
name: "submit_result",

‎src/agent/tool-schema-normalize.ts‎

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import type { ToolDefinition } from "@intx/types/runtime";
2+
import { advertiseNullForOptionalProperties } from "../mcp/placeholder-args.js";
3+
import { isMcpToolName } from "../mcp/tool-name.js";
24
import {
5+
isGptProvider,
36
isKimiLeafProvider,
47
isMuseSparkLeafProvider,
58
isXaiGrokLeafProvider,
@@ -317,33 +320,49 @@ function sanitizeToolDefForGrok(def: ToolDefinition): ToolDefinition {
317320
};
318321
}
319322

323+
// GPT pads optional arguments with real values ("priority: 0") where it
324+
// cannot write null; the MCP handler drops the null it writes instead.
325+
function advertiseNullForMcpTool(def: ToolDefinition): ToolDefinition {
326+
if (!isMcpToolName(def.name)) return def;
327+
const inputSchema = advertiseNullForOptionalProperties(def.inputSchema);
328+
return inputSchema === def.inputSchema ? def : { ...def, inputSchema };
329+
}
330+
320331
function needsAcyclicPresentSchema(ctx: NormalizeToolDefsContext): boolean {
321332
return isKimiLeafProvider(ctx) || isMuseSparkLeafProvider(ctx);
322333
}
323334

324335
/**
325336
* Family-gated wire rewrite of tool defs before they reach the
326337
* director / provider. Moonshot/kimi and Muse Spark get a non-recursive
327-
* `present` schema; Grok/xAI get schema sanitization; others pass through.
328-
* Does not alter runtime validation or the canonical `presentDefinition`.
338+
* `present` schema; Grok/xAI get schema sanitization; GPT gets `null` on
339+
* optional MCP properties; others pass through. Does not alter runtime
340+
* validation or the canonical `presentDefinition`. Returns `defs` itself
341+
* when no def changes, so an unchanged toolset keeps its cache identity.
329342
*
330-
* Call at every advertise path that may include `present`; sub-agent
331-
* toolsets omit it, so rewriting is a no-op unless one appears.
343+
* Call wherever a tools array is built for the provider. Text that quotes
344+
* a schema back to the model (tool_search cards) keeps the canonical
345+
* definition.
332346
*/
333347
export function normalizeToolDefinitionsForProvider(
334348
defs: readonly ToolDefinition[],
335349
ctx: NormalizeToolDefsContext,
336350
): ToolDefinition[] {
337351
const acyclicPresent = needsAcyclicPresentSchema(ctx);
338352
const grok = isXaiGrokLeafProvider(ctx);
339-
if (!acyclicPresent && !grok) {
353+
const gpt = isGptProvider(ctx);
354+
if (!acyclicPresent && !grok && !gpt) {
340355
return defs as ToolDefinition[];
341356
}
342-
return defs.map((def) => {
357+
const out = defs.map((def) => {
343358
let next = def;
344359
if (acyclicPresent && def.name === "present")
345360
next = rewritePresentAcyclic(next);
346361
if (grok) next = sanitizeToolDefForGrok(next);
362+
if (gpt) next = advertiseNullForMcpTool(next);
347363
return next;
348364
});
365+
return out.every((def, i) => def === defs[i])
366+
? (defs as ToolDefinition[])
367+
: out;
349368
}

0 commit comments

Comments
 (0)