Skip to content

Commit 5a5837c

Browse files
committed
fix(agent): advertise null on optional MCP properties for GPT models
1 parent 321e32a commit 5a5837c

5 files changed

Lines changed: 419 additions & 7 deletions

File tree

‎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
}

‎src/mcp/placeholder-args.test.ts‎

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { describe, expect, test } from "bun:test";
2-
import { stripPlaceholderArguments } from "./placeholder-args.js";
2+
import {
3+
advertiseNullForOptionalProperties,
4+
stripPlaceholderArguments,
5+
} from "./placeholder-args.js";
36

47
const schema = {
58
type: "object",
@@ -213,3 +216,173 @@ describe("stripPlaceholderArguments with nullMeansUnset", () => {
213216
).toEqual({ extra: null });
214217
});
215218
});
219+
220+
function propertiesOf(
221+
inputSchema: Record<string, unknown>,
222+
): Record<string, unknown> {
223+
const properties = inputSchema["properties"];
224+
if (typeof properties !== "object" || properties === null)
225+
throw new Error("schema has no properties");
226+
return Object.fromEntries(Object.entries(properties));
227+
}
228+
229+
describe("advertiseNullForOptionalProperties", () => {
230+
// Shapes from Linear's list_issues and save_issue.
231+
const linear = {
232+
type: "object",
233+
properties: {
234+
id: { type: "string" },
235+
priority: { type: "number", description: "0=None, 1=Urgent" },
236+
customView: { type: "string", minLength: 1 },
237+
orderBy: { type: "string", enum: ["createdAt", "updatedAt"] },
238+
fields: { type: "array", items: { type: "string" } },
239+
assignee: { type: ["string", "null"] },
240+
slaType: {
241+
anyOf: [{ enum: ["all", "onlyBusinessDays"] }, { type: "null" }],
242+
},
243+
label: { oneOf: [{ type: "string" }, { type: "number" }] },
244+
filter: { type: "object", properties: { state: { type: "string" } } },
245+
scope: { type: "string", const: "all" },
246+
anything: { description: "untyped" },
247+
},
248+
required: ["id"],
249+
additionalProperties: false,
250+
};
251+
252+
test("adds null to optional properties that reject it", () => {
253+
const out = advertiseNullForOptionalProperties(linear);
254+
expect(out["properties"]).toEqual({
255+
...linear.properties,
256+
priority: { type: ["number", "null"], description: "0=None, 1=Urgent" },
257+
customView: { type: ["string", "null"], minLength: 1 },
258+
orderBy: {
259+
type: ["string", "null"],
260+
enum: ["createdAt", "updatedAt", null],
261+
},
262+
fields: { type: ["array", "null"], items: { type: "string" } },
263+
label: {
264+
oneOf: [{ type: "string" }, { type: "number" }, { type: "null" }],
265+
},
266+
filter: {
267+
type: ["object", "null"],
268+
properties: { state: { type: "string" } },
269+
},
270+
});
271+
expect(out["required"]).toEqual(["id"]);
272+
expect(out["additionalProperties"]).toBe(false);
273+
});
274+
275+
test("leaves required, already-nullable, untyped, and const properties as they are", () => {
276+
const before = propertiesOf(linear);
277+
const after = propertiesOf(advertiseNullForOptionalProperties(linear));
278+
for (const key of ["id", "assignee", "slaType", "scope", "anything"])
279+
expect(after[key]).toBe(before[key]);
280+
});
281+
282+
test("leaves shapes the flat form cannot widen validly as they are", () => {
283+
const ambiguous = {
284+
type: "object",
285+
properties: {
286+
due: {
287+
type: "string",
288+
oneOf: [{ format: "date" }, { format: "time" }],
289+
},
290+
id: { type: "string", allOf: [{ type: "string" }] },
291+
code: { type: "string", not: { const: "x" } },
292+
ref: { type: "string", $ref: "#/$defs/S" },
293+
branchy: {
294+
type: "string",
295+
oneOf: [{ type: "string" }, { minLength: 1 }],
296+
},
297+
conditional: { type: "string", if: true, then: { type: "string" } },
298+
dynamic: { type: "string", $dynamicRef: "#/$defs/S" },
299+
exact: { const: "all" },
300+
openAPI: {
301+
type: "object",
302+
oneOf: [{ type: "string", nullable: true }, { type: "number" }],
303+
},
304+
strict: { type: "string", nullable: false },
305+
},
306+
};
307+
expect(advertiseNullForOptionalProperties(ambiguous)).toBe(ambiguous);
308+
});
309+
310+
test("adds a null branch to anyOf whatever its other branches hold", () => {
311+
const opaque = {
312+
type: "object",
313+
properties: {
314+
either: { type: "string", anyOf: [{ $ref: "#/$defs/S" }] },
315+
nested: { type: "string", anyOf: [{ allOf: [{ type: "string" }] }] },
316+
},
317+
};
318+
expect(propertiesOf(advertiseNullForOptionalProperties(opaque))).toEqual({
319+
either: {
320+
type: ["string", "null"],
321+
anyOf: [{ $ref: "#/$defs/S" }, { type: "null" }],
322+
},
323+
nested: {
324+
type: ["string", "null"],
325+
anyOf: [{ allOf: [{ type: "string" }] }, { type: "null" }],
326+
},
327+
});
328+
});
329+
330+
test("does not repeat a null that a keyword already lists", () => {
331+
const partial = {
332+
type: "object",
333+
properties: {
334+
state: { type: "string", enum: ["open", null] },
335+
view: {
336+
type: "string",
337+
anyOf: [{ type: "string", minLength: 1 }, { type: "null" }],
338+
},
339+
},
340+
};
341+
expect(propertiesOf(advertiseNullForOptionalProperties(partial))).toEqual({
342+
state: { type: ["string", "null"], enum: ["open", null] },
343+
view: {
344+
type: ["string", "null"],
345+
anyOf: [{ type: "string", minLength: 1 }, { type: "null" }],
346+
},
347+
});
348+
});
349+
350+
test("leaves the input schema intact", () => {
351+
const before = structuredClone(linear);
352+
advertiseNullForOptionalProperties(linear);
353+
expect(linear).toEqual(before);
354+
});
355+
356+
test("returns the same object when no property changes", () => {
357+
const nullable = {
358+
type: "object",
359+
properties: { assignee: { type: ["string", "null"] } },
360+
};
361+
expect(advertiseNullForOptionalProperties(nullable)).toBe(nullable);
362+
const empty = { type: "object", properties: {} };
363+
expect(advertiseNullForOptionalProperties(empty)).toBe(empty);
364+
const bare = { type: "object" };
365+
expect(advertiseNullForOptionalProperties(bare)).toBe(bare);
366+
const unusable = { type: "object", required: "id" };
367+
expect(advertiseNullForOptionalProperties(unusable)).toBe(unusable);
368+
});
369+
370+
test("adds null only where the strip rule drops it again", () => {
371+
const before = propertiesOf(schema);
372+
const after = propertiesOf(advertiseNullForOptionalProperties(schema));
373+
const added = Object.keys(before).filter(
374+
(key) => after[key] !== before[key],
375+
);
376+
const dropped = Object.keys(before).filter(
377+
(key) =>
378+
!(key in stripPlaceholderArguments({ [key]: null }, schema, keepNull)),
379+
);
380+
expect(added).not.toEqual([]);
381+
// A const cannot be widened to null, so its null is dropped unadvertised.
382+
expect(dropped).toEqual(
383+
Object.keys(before).filter(
384+
(key) => added.includes(key) || key === "scope",
385+
),
386+
);
387+
});
388+
});

0 commit comments

Comments
 (0)