diff --git a/e2e/mcp-late-dispatch.test.ts b/e2e/mcp-late-dispatch.test.ts index 1885e5a31..8b65c3512 100644 --- a/e2e/mcp-late-dispatch.test.ts +++ b/e2e/mcp-late-dispatch.test.ts @@ -371,7 +371,7 @@ describe("integration — late MCP dispatch", () => { ); test.serial( - "padded optional MCP arguments reach the server minus the values its schema rejects", + "padded optional arguments to a read MCP tool reach the server without rejected placeholders or null", async () => { const received: Record[] = []; await withSession(async (session) => { @@ -428,7 +428,6 @@ describe("integration — late MCP dispatch", () => { team: "", fields: [], priority: 0, - assignee: null, release: "v0345-ef53e73667fa", }, ]); diff --git a/src/agent/tool-schema-normalize.test.ts b/src/agent/tool-schema-normalize.test.ts index 45f5425c6..50ef3a293 100644 --- a/src/agent/tool-schema-normalize.test.ts +++ b/src/agent/tool-schema-normalize.test.ts @@ -163,6 +163,78 @@ describe("normalizeToolDefinitionsForProvider", () => { } }); + describe("optional MCP properties", () => { + const listIssues: ToolDefinition = { + name: "mcp__linear__list_issues", + description: "[linear] list issues", + inputSchema: { + type: "object", + properties: { + release: { type: "string" }, + priority: { type: "number" }, + }, + required: ["release"], + }, + }; + const builtin: ToolDefinition = { + ...listIssues, + name: "list_issues", + }; + + test.each([ + { providerName: "codex/plus", model: "gpt-5.6-terra" }, + { providerName: "opencode-go", model: "gpt-5.1" }, + { providerName: "codex", model: "codex-mini-latest" }, + ])("$providerName $model advertises null on MCP tools only", (ctx) => { + const [mcp, own] = normalizeToolDefinitionsForProvider( + [listIssues, builtin], + ctx, + ); + expect(mcp?.inputSchema).toEqual({ + type: "object", + properties: { + release: { type: "string" }, + priority: { type: ["number", "null"] }, + }, + required: ["release"], + }); + expect(own).toBe(builtin); + }); + + test.each([ + { providerName: "anthropic", model: "claude-sonnet-5-5" }, + { providerName: "moonshot", model: "kimi-k2" }, + ])("$providerName leaves MCP tools as they are", (ctx) => { + const defs = [listIssues]; + expect(normalizeToolDefinitionsForProvider(defs, ctx)).toBe(defs); + }); + + test("grok sanitizes MCP tools without advertising null", () => { + const [out] = normalizeToolDefinitionsForProvider([listIssues], { + providerName: "xai/default-2", + model: "grok-4.6", + }); + expect(out?.inputSchema).toEqual(listIssues.inputSchema); + }); + + test("returns the same array when no MCP property changes", () => { + const nullable: ToolDefinition = { + ...listIssues, + inputSchema: { + type: "object", + properties: { assignee: { type: ["string", "null"] } }, + }, + }; + const defs = [nullable, builtin]; + expect( + normalizeToolDefinitionsForProvider(defs, { + providerName: "openai", + model: "gpt-5.6", + }), + ).toBe(defs); + }); + }); + test("grok sanitizes untyped properties and strips $schema", () => { const untyped: ToolDefinition = { name: "submit_result", diff --git a/src/agent/tool-schema-normalize.ts b/src/agent/tool-schema-normalize.ts index 0cf727c55..1955f665f 100644 --- a/src/agent/tool-schema-normalize.ts +++ b/src/agent/tool-schema-normalize.ts @@ -1,5 +1,8 @@ import type { ToolDefinition } from "@intx/types/runtime"; +import { advertiseNullForOptionalProperties } from "../mcp/placeholder-args.js"; +import { isMcpToolName } from "../mcp/tool-name.js"; import { + isGptProvider, isKimiLeafProvider, isMuseSparkLeafProvider, isXaiGrokLeafProvider, @@ -317,6 +320,14 @@ function sanitizeToolDefForGrok(def: ToolDefinition): ToolDefinition { }; } +// GPT pads optional arguments with real values ("priority: 0") where it +// cannot write null; the MCP handler drops the null it writes instead. +function advertiseNullForMcpTool(def: ToolDefinition): ToolDefinition { + if (!isMcpToolName(def.name)) return def; + const inputSchema = advertiseNullForOptionalProperties(def.inputSchema); + return inputSchema === def.inputSchema ? def : { ...def, inputSchema }; +} + function needsAcyclicPresentSchema(ctx: NormalizeToolDefsContext): boolean { return isKimiLeafProvider(ctx) || isMuseSparkLeafProvider(ctx); } @@ -324,11 +335,14 @@ function needsAcyclicPresentSchema(ctx: NormalizeToolDefsContext): boolean { /** * Family-gated wire rewrite of tool defs before they reach the * director / provider. Moonshot/kimi and Muse Spark get a non-recursive - * `present` schema; Grok/xAI get schema sanitization; others pass through. - * Does not alter runtime validation or the canonical `presentDefinition`. + * `present` schema; Grok/xAI get schema sanitization; GPT gets `null` on + * optional MCP properties; others pass through. Does not alter runtime + * validation or the canonical `presentDefinition`. Returns `defs` itself + * when no def changes, so an unchanged toolset keeps its cache identity. * - * Call at every advertise path that may include `present`; sub-agent - * toolsets omit it, so rewriting is a no-op unless one appears. + * Call wherever a tools array is built for the provider. Text that quotes + * a schema back to the model (tool_search cards) keeps the canonical + * definition. */ export function normalizeToolDefinitionsForProvider( defs: readonly ToolDefinition[], @@ -336,14 +350,19 @@ export function normalizeToolDefinitionsForProvider( ): ToolDefinition[] { const acyclicPresent = needsAcyclicPresentSchema(ctx); const grok = isXaiGrokLeafProvider(ctx); - if (!acyclicPresent && !grok) { + const gpt = isGptProvider(ctx); + if (!acyclicPresent && !grok && !gpt) { return defs as ToolDefinition[]; } - return defs.map((def) => { + const out = defs.map((def) => { let next = def; if (acyclicPresent && def.name === "present") next = rewritePresentAcyclic(next); if (grok) next = sanitizeToolDefForGrok(next); + if (gpt) next = advertiseNullForMcpTool(next); return next; }); + return out.every((def, i) => def === defs[i]) + ? (defs as ToolDefinition[]) + : out; } diff --git a/src/mcp/placeholder-args.test.ts b/src/mcp/placeholder-args.test.ts index 99a041588..f9a23afe7 100644 --- a/src/mcp/placeholder-args.test.ts +++ b/src/mcp/placeholder-args.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { stripPlaceholderArguments } from "./placeholder-args.js"; +import { + advertiseNullForOptionalProperties, + stripPlaceholderArguments, +} from "./placeholder-args.js"; const schema = { type: "object", @@ -29,34 +32,49 @@ const schema = { required: ["id"], }; +const keepNull = { nullMeansUnset: false }; +const dropNull = { nullMeansUnset: true }; + describe("stripPlaceholderArguments", () => { test("forwards empty and whitespace strings the property schema permits", () => { const args = { query: "", note: "", team: " ", suffix: "", stage: "" }; - expect(stripPlaceholderArguments(args, schema)).toEqual(args); + expect(stripPlaceholderArguments(args, schema, keepNull)).toEqual(args); }); test("drops an empty or whitespace string where minLength is at least 1", () => { expect( - stripPlaceholderArguments({ customView: " ", query: "x" }, schema), + stripPlaceholderArguments( + { customView: " ", query: "x" }, + schema, + keepNull, + ), ).toEqual({ query: "x" }); - expect(stripPlaceholderArguments({ customView: "" }, schema)).toEqual({}); + expect( + stripPlaceholderArguments({ customView: "" }, schema, keepNull), + ).toEqual({}); }); test("drops an empty string that an enum, const, or pattern rejects", () => { expect( - stripPlaceholderArguments({ state: "", scope: " ", slug: "" }, schema), + stripPlaceholderArguments( + { state: "", scope: " ", slug: "" }, + schema, + keepNull, + ), ).toEqual({}); }); test("forwards an empty string when the pattern does not compile", () => { - expect(stripPlaceholderArguments({ malformed: "" }, schema)).toEqual({ + expect( + stripPlaceholderArguments({ malformed: "" }, schema, keepNull), + ).toEqual({ malformed: "", }); }); test("drops an empty array only where minItems is at least 1", () => { expect( - stripPlaceholderArguments({ fields: [], labels: [] }, schema), + stripPlaceholderArguments({ fields: [], labels: [] }, schema, keepNull), ).toEqual({ fields: [] }); }); @@ -65,13 +83,18 @@ describe("stripPlaceholderArguments", () => { stripPlaceholderArguments( { priority: "", includeArchived: " ", query: [] }, schema, + keepNull, ), ).toEqual({}); }); test("drops null only where the property schema disallows it", () => { expect( - stripPlaceholderArguments({ team: null, priority: null }, schema), + stripPlaceholderArguments( + { team: null, priority: null }, + schema, + keepNull, + ), ).toEqual({}); const nullable = { assignee: null, @@ -79,34 +102,42 @@ describe("stripPlaceholderArguments", () => { cycle: null, anything: null, }; - expect(stripPlaceholderArguments(nullable, schema)).toEqual(nullable); + expect(stripPlaceholderArguments(nullable, schema, keepNull)).toEqual( + nullable, + ); }); test("drops a composite value only when every branch rejects it", () => { - expect(stripPlaceholderArguments({ view: " " }, schema)).toEqual({}); - expect(stripPlaceholderArguments({ view: null }, schema)).toEqual({ - view: null, - }); - expect(stripPlaceholderArguments({ label: "" }, schema)).toEqual({ + expect(stripPlaceholderArguments({ view: " " }, schema, keepNull)).toEqual( + {}, + ); + expect(stripPlaceholderArguments({ view: null }, schema, keepNull)).toEqual( + { + view: null, + }, + ); + expect(stripPlaceholderArguments({ label: "" }, schema, keepNull)).toEqual({ label: "", }); }); test("never changes required arguments", () => { - expect(stripPlaceholderArguments({ id: "" }, schema)).toEqual({ id: "" }); - expect(stripPlaceholderArguments({ id: null }, schema)).toEqual({ + expect(stripPlaceholderArguments({ id: "" }, schema, keepNull)).toEqual({ + id: "", + }); + expect(stripPlaceholderArguments({ id: null }, schema, keepNull)).toEqual({ id: null, }); }); test("keeps zero and false", () => { const args = { priority: 0, includeArchived: false }; - expect(stripPlaceholderArguments(args, schema)).toEqual(args); + expect(stripPlaceholderArguments(args, schema, keepNull)).toEqual(args); }); test("keeps meaningful values and leaves the caller's object intact", () => { const args = { query: "release", fields: ["title"], customView: "" }; - expect(stripPlaceholderArguments(args, schema)).toEqual({ + expect(stripPlaceholderArguments(args, schema, keepNull)).toEqual({ query: "release", fields: ["title"], }); @@ -119,22 +150,239 @@ describe("stripPlaceholderArguments", () => { test("forwards a key the schema does not declare", () => { expect( - stripPlaceholderArguments({ id: "CL-1", extra: "" }, schema), + stripPlaceholderArguments({ id: "CL-1", extra: "" }, schema, keepNull), ).toEqual({ id: "CL-1", extra: "" }); }); test("returns missing or non-object arguments as-is", () => { - expect(stripPlaceholderArguments(null, schema)).toBeNull(); - expect(stripPlaceholderArguments(undefined, schema)).toBeUndefined(); - expect(stripPlaceholderArguments("query", schema)).toBe("query"); + expect(stripPlaceholderArguments(null, schema, keepNull)).toBeNull(); + expect( + stripPlaceholderArguments(undefined, schema, keepNull), + ).toBeUndefined(); + expect(stripPlaceholderArguments("query", schema, keepNull)).toBe("query"); const list = [""]; - expect(stripPlaceholderArguments(list, schema)).toBe(list); + expect(stripPlaceholderArguments(list, schema, keepNull)).toBe(list); }); test("forwards arguments unchanged when the schema shape is unusable", () => { const args = { query: "" }; expect( - stripPlaceholderArguments(args, { type: "object", required: "query" }), + stripPlaceholderArguments( + args, + { type: "object", required: "query" }, + keepNull, + ), ).toEqual(args); }); }); + +describe("stripPlaceholderArguments with nullMeansUnset", () => { + test("drops null on optional properties whose schema permits it", () => { + expect( + stripPlaceholderArguments( + { + assignee: null, + parentId: null, + cycle: null, + view: null, + anything: null, + query: "release", + }, + schema, + dropNull, + ), + ).toEqual({ query: "release" }); + }); + + test("still applies the rejected-placeholder rule to other values", () => { + expect( + stripPlaceholderArguments( + { customView: " ", fields: [], priority: 0, team: null }, + schema, + dropNull, + ), + ).toEqual({ fields: [], priority: 0 }); + }); + + test("never changes required arguments", () => { + expect(stripPlaceholderArguments({ id: null }, schema, dropNull)).toEqual({ + id: null, + }); + }); + + test("forwards null on a key the schema does not declare", () => { + expect( + stripPlaceholderArguments({ extra: null }, schema, dropNull), + ).toEqual({ extra: null }); + }); +}); + +function propertiesOf( + inputSchema: Record, +): Record { + const properties = inputSchema["properties"]; + if (typeof properties !== "object" || properties === null) + throw new Error("schema has no properties"); + return Object.fromEntries(Object.entries(properties)); +} + +describe("advertiseNullForOptionalProperties", () => { + // Shapes from Linear's list_issues and save_issue. + const linear = { + type: "object", + properties: { + id: { type: "string" }, + priority: { type: "number", description: "0=None, 1=Urgent" }, + customView: { type: "string", minLength: 1 }, + orderBy: { type: "string", enum: ["createdAt", "updatedAt"] }, + fields: { type: "array", items: { type: "string" } }, + assignee: { type: ["string", "null"] }, + slaType: { + anyOf: [{ enum: ["all", "onlyBusinessDays"] }, { type: "null" }], + }, + label: { oneOf: [{ type: "string" }, { type: "number" }] }, + filter: { type: "object", properties: { state: { type: "string" } } }, + scope: { type: "string", const: "all" }, + anything: { description: "untyped" }, + }, + required: ["id"], + additionalProperties: false, + }; + + test("adds null to optional properties that reject it", () => { + const out = advertiseNullForOptionalProperties(linear); + expect(out["properties"]).toEqual({ + ...linear.properties, + priority: { type: ["number", "null"], description: "0=None, 1=Urgent" }, + customView: { type: ["string", "null"], minLength: 1 }, + orderBy: { + type: ["string", "null"], + enum: ["createdAt", "updatedAt", null], + }, + fields: { type: ["array", "null"], items: { type: "string" } }, + label: { + oneOf: [{ type: "string" }, { type: "number" }, { type: "null" }], + }, + filter: { + type: ["object", "null"], + properties: { state: { type: "string" } }, + }, + }); + expect(out["required"]).toEqual(["id"]); + expect(out["additionalProperties"]).toBe(false); + }); + + test("leaves required, already-nullable, untyped, and const properties as they are", () => { + const before = propertiesOf(linear); + const after = propertiesOf(advertiseNullForOptionalProperties(linear)); + for (const key of ["id", "assignee", "slaType", "scope", "anything"]) + expect(after[key]).toBe(before[key]); + }); + + test("leaves shapes the flat form cannot widen validly as they are", () => { + const ambiguous = { + type: "object", + properties: { + due: { + type: "string", + oneOf: [{ format: "date" }, { format: "time" }], + }, + id: { type: "string", allOf: [{ type: "string" }] }, + code: { type: "string", not: { const: "x" } }, + ref: { type: "string", $ref: "#/$defs/S" }, + branchy: { + type: "string", + oneOf: [{ type: "string" }, { minLength: 1 }], + }, + conditional: { type: "string", if: true, then: { type: "string" } }, + dynamic: { type: "string", $dynamicRef: "#/$defs/S" }, + exact: { const: "all" }, + openAPI: { + type: "object", + oneOf: [{ type: "string", nullable: true }, { type: "number" }], + }, + strict: { type: "string", nullable: false }, + }, + }; + expect(advertiseNullForOptionalProperties(ambiguous)).toBe(ambiguous); + }); + + test("adds a null branch to anyOf whatever its other branches hold", () => { + const opaque = { + type: "object", + properties: { + either: { type: "string", anyOf: [{ $ref: "#/$defs/S" }] }, + nested: { type: "string", anyOf: [{ allOf: [{ type: "string" }] }] }, + }, + }; + expect(propertiesOf(advertiseNullForOptionalProperties(opaque))).toEqual({ + either: { + type: ["string", "null"], + anyOf: [{ $ref: "#/$defs/S" }, { type: "null" }], + }, + nested: { + type: ["string", "null"], + anyOf: [{ allOf: [{ type: "string" }] }, { type: "null" }], + }, + }); + }); + + test("does not repeat a null that a keyword already lists", () => { + const partial = { + type: "object", + properties: { + state: { type: "string", enum: ["open", null] }, + view: { + type: "string", + anyOf: [{ type: "string", minLength: 1 }, { type: "null" }], + }, + }, + }; + expect(propertiesOf(advertiseNullForOptionalProperties(partial))).toEqual({ + state: { type: ["string", "null"], enum: ["open", null] }, + view: { + type: ["string", "null"], + anyOf: [{ type: "string", minLength: 1 }, { type: "null" }], + }, + }); + }); + + test("leaves the input schema intact", () => { + const before = structuredClone(linear); + advertiseNullForOptionalProperties(linear); + expect(linear).toEqual(before); + }); + + test("returns the same object when no property changes", () => { + const nullable = { + type: "object", + properties: { assignee: { type: ["string", "null"] } }, + }; + expect(advertiseNullForOptionalProperties(nullable)).toBe(nullable); + const empty = { type: "object", properties: {} }; + expect(advertiseNullForOptionalProperties(empty)).toBe(empty); + const bare = { type: "object" }; + expect(advertiseNullForOptionalProperties(bare)).toBe(bare); + const unusable = { type: "object", required: "id" }; + expect(advertiseNullForOptionalProperties(unusable)).toBe(unusable); + }); + + test("adds null only where the strip rule drops it again", () => { + const before = propertiesOf(schema); + const after = propertiesOf(advertiseNullForOptionalProperties(schema)); + const added = Object.keys(before).filter( + (key) => after[key] !== before[key], + ); + const dropped = Object.keys(before).filter( + (key) => + !(key in stripPlaceholderArguments({ [key]: null }, schema, keepNull)), + ); + expect(added).not.toEqual([]); + // A const cannot be widened to null, so its null is dropped unadvertised. + expect(dropped).toEqual( + Object.keys(before).filter( + (key) => added.includes(key) || key === "scope", + ), + ); + }); +}); diff --git a/src/mcp/placeholder-args.ts b/src/mcp/placeholder-args.ts index 83134c532..60683782e 100644 --- a/src/mcp/placeholder-args.ts +++ b/src/mcp/placeholder-args.ts @@ -72,23 +72,38 @@ function isRejectedPlaceholder(value: unknown, property: unknown): boolean { return value === null && !admits(property, null); } +export interface StripPlaceholderOpts { + // A read tool's server takes a `null` filter literally (Linear's + // list_issues reads `assignee: null` as "unassigned"), so padding narrows + // the results; a write tool's `null` clears the field and must survive. + // The cost: a deliberate JSON-null filter on a read tool is dropped for + // every model, so asking for an unset field takes the string form a server + // documents (Linear's `"null"`). + nullMeansUnset: boolean; +} + /** * Drops optional arguments a model filled with an empty placeholder (`""`, * whitespace, `[]`, or `null`) that the property's own schema would reject, - * so one padded field does not fail the whole call. Empty values the schema - * permits, required arguments, and any other value are forwarded unchanged. + * so one padded field does not fail the whole call. With `nullMeansUnset`, + * `null` on any declared optional property is dropped even where the schema + * permits it. Other empty values the schema permits, required arguments, + * undeclared keys, and any other value are forwarded unchanged. */ export function stripPlaceholderArguments( args: Record, inputSchema: Record, + opts: StripPlaceholderOpts, ): Record; export function stripPlaceholderArguments( args: unknown, inputSchema: Record, + opts: StripPlaceholderOpts, ): unknown; export function stripPlaceholderArguments( args: unknown, inputSchema: Record, + opts: StripPlaceholderOpts, ): unknown { const parsedArgs = Arguments(args); if (parsedArgs instanceof type.errors || Array.isArray(parsedArgs)) @@ -96,11 +111,135 @@ export function stripPlaceholderArguments( const schema = ObjectSchema(inputSchema); if (schema instanceof type.errors) return args; const required = new Set(schema.required ?? []); + const properties = schema.properties ?? {}; return Object.fromEntries( - Object.entries(parsedArgs).filter( - ([key, value]) => - required.has(key) || - !isRejectedPlaceholder(value, schema.properties?.[key]), - ), + Object.entries(parsedArgs).filter(([key, value]) => { + if (required.has(key)) return true; + if ( + opts.nullMeansUnset && + value === null && + Object.hasOwn(properties, key) + ) + return false; + return !isRejectedPlaceholder(value, properties[key]); + }), + ); +} + +const SchemaObject = type("Record"); + +// Keywords that constrain only non-null values or only annotate, so adding +// null to `type`, `enum`, and the branch lists is enough for a JSON Schema +// validator to accept it. Anything else ($ref, allOf, not, if, ...) +// can still reject null, and `admits` cannot see that. +const NULL_NEUTRAL_KEYWORDS = new Set([ + "type", + "enum", + "anyOf", + "oneOf", + "title", + "description", + "default", + "examples", + "deprecated", + "readOnly", + "writeOnly", + "$comment", + "format", + "minLength", + "maxLength", + "pattern", + "contentEncoding", + "contentMediaType", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + "items", + "prefixItems", + "minItems", + "maxItems", + "uniqueItems", + "properties", + "patternProperties", + "additionalProperties", + "required", + "minProperties", + "maxProperties", + "propertyNames", +]); + +const NULL_BRANCH = { type: "null" }; + +// oneOf admits null only when exactly one branch does, so every existing +// branch must reject it on its own declared values. +function rejectsNull(branch: unknown): boolean { + const schema = PropertySchema(branch); + if (schema instanceof type.errors || schema.nullable === true) return false; + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + return ( + (schema.type !== undefined && !types.includes("null")) || + (schema.enum !== undefined && !schema.enum.includes(null)) || + (schema.const !== undefined && schema.const !== null) + ); +} + +function isNullBranch(branch: unknown): boolean { + const object = SchemaObject(branch); + if (object instanceof type.errors) return false; + const keys = Object.keys(object); + return keys.length === 1 && object["type"] === "null"; +} + +function admittingNull(property: unknown): unknown { + if (admits(property, null) || Array.isArray(property)) return property; + const schema = PropertySchema(property); + const object = SchemaObject(property); + if (schema instanceof type.errors || object instanceof type.errors) + return property; + if ( + !Object.keys(object).every((key) => NULL_NEUTRAL_KEYWORDS.has(key)) || + !(schema.oneOf ?? []).every(rejectsNull) + ) + return property; + const next: Record = { ...object }; + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + if (schema.type !== undefined && !types.includes("null")) + next["type"] = [...types, "null"]; + if (schema.enum !== undefined && !schema.enum.includes(null)) + next["enum"] = [...schema.enum, null]; + if (schema.anyOf !== undefined && !schema.anyOf.some(isNullBranch)) + next["anyOf"] = [...schema.anyOf, NULL_BRANCH]; + if (schema.oneOf !== undefined) + next["oneOf"] = [...schema.oneOf, NULL_BRANCH]; + return next; +} + +/** + * Makes optional top-level properties of a tool's input schema also admit + * `null`, for models that write `null` to mean "not set" and otherwise pick + * a real value. A property gains `null` only where `admits` rejects it, + * which is where `stripPlaceholderArguments` drops a `null` again, and only + * when the result still validates `null` under JSON Schema; other + * properties (`const`, `$ref`, `allOf`, ...) stay as they are. + * Returns the same object when no property changes. + */ +export function advertiseNullForOptionalProperties( + inputSchema: Record, +): Record { + const schema = ObjectSchema(inputSchema); + if (schema instanceof type.errors || schema.properties === undefined) + return inputSchema; + const required = new Set(schema.required ?? []); + let changed = false; + const properties = Object.fromEntries( + Object.entries(schema.properties).map(([key, property]) => { + if (required.has(key)) return [key, property]; + const next = admittingNull(property); + if (next !== property) changed = true; + return [key, next]; + }), ); + return changed ? { ...inputSchema, properties } : inputSchema; } diff --git a/src/mcp/plugin.test.ts b/src/mcp/plugin.test.ts index 5e3d4ffa2..6e8834c33 100644 --- a/src/mcp/plugin.test.ts +++ b/src/mcp/plugin.test.ts @@ -734,6 +734,63 @@ describe("mcpClientToAgentTools (production gated path)", () => { expect(result.isError).toBeUndefined(); }); + test.each([ + { name: "list_issues", annotations: undefined, forwarded: {} }, + { + name: "save_issue", + annotations: undefined, + forwarded: { assignee: null }, + }, + { name: "lookup", annotations: { readOnlyHint: true }, forwarded: {} }, + { + name: "list_issues", + annotations: { readOnlyHint: false }, + forwarded: { assignee: null }, + }, + ])( + "$name with annotations $annotations forwards $forwarded for an optional null", + async ({ name, annotations, forwarded }) => { + let capturedArgs: Record | undefined; + const client: MCPClient = { + serverName: "linear", + tools: [ + { + name, + description: "issues", + inputSchema: { + type: "object", + properties: { assignee: { type: ["string", "null"] } }, + }, + ...(annotations === undefined ? {} : { annotations }), + }, + ], + async callResult(_toolName, args) { + capturedArgs = args; + return { blocks: [{ type: "text", text: "done" }], isError: false }; + }, + async close() { + return undefined; + }, + }; + const gate = createPermissionGate({ + approvals: [], + interactive: false, + skipPermissions: true, + reactorGated: false, + }); + const tool = defined(mcpClientToAgentTools(client, gate)[0], "mcp tool"); + await tool.handler( + { + id: "c1", + name: `mcp__linear__${name}`, + arguments: { assignee: null }, + }, + new AbortController().signal, + ); + expect(capturedArgs).toEqual(forwarded); + }, + ); + test("permission gate blocks mutating MCP when not skipped", async () => { let asked = 0; const gate = createPermissionGate({ diff --git a/src/mcp/plugin.ts b/src/mcp/plugin.ts index 68b706a16..1ed268317 100644 --- a/src/mcp/plugin.ts +++ b/src/mcp/plugin.ts @@ -20,6 +20,7 @@ import { } from "./client.js"; import { stripPlaceholderArguments } from "./placeholder-args.js"; import { mcpToolName } from "./tool-name.js"; +import { isReadOnlyMcpToolDefinition } from "./tool-permissions.js"; import { LOG_NAMESPACE_ROOT } from "../branding.js"; export const MCP_RECONNECTING_TOOL_ERROR = @@ -108,11 +109,18 @@ export function mcpClientTools( signal: AbortSignal, ): Promise => { try { - // Some models pad every optional field with empty values; one the - // property schema rejects (customView minLength) fails the call. + // Some models pad every optional field with empty values: one the + // property schema rejects (customView minLength) fails the call, + // and a null on a read tool silently filters the results. const envelope = await client.callResult( tool.name, - stripPlaceholderArguments(call.arguments, tool.inputSchema), + stripPlaceholderArguments(call.arguments, tool.inputSchema, { + nullMeansUnset: isReadOnlyMcpToolDefinition( + tool.annotations, + client.serverName, + tool.name, + ), + }), signal, ); const authorizedBlocks = applyPolicyToBlocks(envelope.blocks); diff --git a/src/mcp/tool-permissions.ts b/src/mcp/tool-permissions.ts index d48eb1e60..9d0ac625d 100644 --- a/src/mcp/tool-permissions.ts +++ b/src/mcp/tool-permissions.ts @@ -51,17 +51,27 @@ function hasAnnotationHints( ); } -// With annotation hints, readOnlyHint drives allow vs ask; empty {} or -// title-only objects fall back to name-prefix heuristics (list_/get_/save_). -export function tierFromMcpTool( +// With annotation hints, readOnlyHint decides; empty {} or title-only objects +// fall back to name-prefix heuristics (list_/get_/save_). +export function isReadOnlyMcpToolDefinition( annotations: McpToolAnnotations | undefined, serverName: string, toolName: string, -): Tier { +): boolean { if (annotations !== undefined && hasAnnotationHints(annotations)) { - return annotations.readOnlyHint === true ? "allow" : "ask"; + return annotations.readOnlyHint === true; } - return isReadOnlyMcpTool(mcpToolName(serverName, toolName)) ? "allow" : "ask"; + return isReadOnlyMcpTool(mcpToolName(serverName, toolName)); +} + +export function tierFromMcpTool( + annotations: McpToolAnnotations | undefined, + serverName: string, + toolName: string, +): Tier { + return isReadOnlyMcpToolDefinition(annotations, serverName, toolName) + ? "allow" + : "ask"; } export function registerMcpClientTools( diff --git a/src/session/assemble-runtime.test.ts b/src/session/assemble-runtime.test.ts index 9abf52605..e61b16e94 100644 --- a/src/session/assemble-runtime.test.ts +++ b/src/session/assemble-runtime.test.ts @@ -108,6 +108,36 @@ describe("createAdvertisedToolset", () => { expect(flushPromotions()).toBe(false); }); + test.each([ + { model: "gpt-5", priority: { type: ["number", "null"] } }, + { model: "claude-sonnet-5-5", priority: { type: "number" } }, + ])( + "a promoted MCP tool reaches the $model wire with priority $priority", + ({ model, priority }) => { + const { activated, computeAdvertised, flushPromotions } = + createAdvertisedToolset( + wiring({ getProvider: () => ({ providerName: "test", model }) }), + ); + const listIssues: ToolDefinition = { + name: "mcp__linear__list_issues", + description: "list issues", + inputSchema: { + type: "object", + properties: { priority: { type: "number" } }, + }, + }; + activated.activate([listIssues.name]); + flushPromotions(); + const advertised = computeAdvertised([def("read_file"), listIssues]).find( + (d) => d.name === listIssues.name, + ); + expect(advertised?.inputSchema).toEqual({ + type: "object", + properties: { priority }, + }); + }, + ); + test("flushPromotions commits pending activations in order and resume re-arms them", () => { const { activated, computeAdvertised, flushPromotions } = createAdvertisedToolset(wiring());