Skip to content

Commit c4b9f64

Browse files
committed
fix(mcp): strip empty placeholder arguments before forwarding
1 parent 4ed79b6 commit c4b9f64

4 files changed

Lines changed: 189 additions & 1 deletion

File tree

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

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,4 +369,61 @@ describe("integration — late MCP dispatch", () => {
369369
});
370370
},
371371
);
372+
373+
test.serial(
374+
"padded optional MCP arguments reach the server as only the meaningful filters",
375+
async () => {
376+
const received: Record<string, unknown>[] = [];
377+
await withSession(async (session) => {
378+
session.toolset.dynamicRunner.addTools(
379+
mcpClientTools(
380+
linearClient({
381+
inputSchema: {
382+
type: "object",
383+
properties: {
384+
customView: { type: "string", minLength: 1 },
385+
cursor: { type: "string" },
386+
query: { type: "string" },
387+
team: { type: "string" },
388+
fields: { type: "array", items: { type: "string" } },
389+
priority: { type: "number" },
390+
assignee: { type: ["string", "null"] },
391+
release: { type: "string" },
392+
},
393+
},
394+
call: async (_toolName, args) => {
395+
received.push(args);
396+
return "ISSUE-1";
397+
},
398+
}),
399+
),
400+
);
401+
// The padding gpt-5.6-terra sent on a real release lookup.
402+
replyScript(
403+
session,
404+
[
405+
{
406+
name: LATE_MCP,
407+
args: {
408+
customView: " ",
409+
cursor: "",
410+
query: "",
411+
team: "",
412+
fields: [],
413+
priority: 0,
414+
assignee: null,
415+
release: "v0345-ef53e73667fa",
416+
},
417+
},
418+
],
419+
"listed",
420+
);
421+
422+
await runUntilDone(session, "list the issues in this release");
423+
});
424+
expect(received).toEqual([
425+
{ priority: 0, assignee: null, release: "v0345-ef53e73667fa" },
426+
]);
427+
},
428+
);
372429
});

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { stripPlaceholderArguments } from "./placeholder-args.js";
3+
4+
const schema = {
5+
type: "object",
6+
properties: {
7+
query: { type: "string" },
8+
customView: { type: "string", minLength: 1 },
9+
fields: { type: "array", items: { type: "string" } },
10+
team: { type: "string" },
11+
assignee: { type: ["string", "null"] },
12+
parentId: { anyOf: [{ type: "string" }, { type: "null" }] },
13+
cycle: { type: "string", nullable: true },
14+
stage: { type: "string", enum: ["", "planned"] },
15+
priority: { type: "number" },
16+
includeArchived: { type: "boolean" },
17+
id: { type: "string" },
18+
},
19+
required: ["id"],
20+
};
21+
22+
describe("stripPlaceholderArguments", () => {
23+
test("drops optional empty strings, whitespace, empty arrays, and disallowed null", () => {
24+
expect(
25+
stripPlaceholderArguments(
26+
{ id: "CL-1", query: "", customView: " ", fields: [], team: null },
27+
schema,
28+
),
29+
).toEqual({ id: "CL-1" });
30+
});
31+
32+
test("never changes required arguments", () => {
33+
expect(stripPlaceholderArguments({ id: "" }, schema)).toEqual({ id: "" });
34+
expect(stripPlaceholderArguments({ id: null }, schema)).toEqual({
35+
id: null,
36+
});
37+
});
38+
39+
test("keeps null where the property schema allows it", () => {
40+
const args = { assignee: null, parentId: null, cycle: null };
41+
expect(stripPlaceholderArguments(args, schema)).toEqual(args);
42+
});
43+
44+
test("keeps zero, false, and enum values", () => {
45+
const args = { priority: 0, includeArchived: false, stage: "" };
46+
expect(stripPlaceholderArguments(args, schema)).toEqual(args);
47+
});
48+
49+
test("keeps meaningful values and leaves the caller's object intact", () => {
50+
const args = { query: "release", fields: ["title"], team: "" };
51+
expect(stripPlaceholderArguments(args, schema)).toEqual({
52+
query: "release",
53+
fields: ["title"],
54+
});
55+
expect(args).toEqual({ query: "release", fields: ["title"], team: "" });
56+
});
57+
58+
test("treats a key the schema does not declare as optional", () => {
59+
expect(
60+
stripPlaceholderArguments({ id: "CL-1", extra: "" }, schema),
61+
).toEqual({ id: "CL-1" });
62+
});
63+
64+
test("forwards arguments unchanged when the schema shape is unusable", () => {
65+
const args = { query: "" };
66+
expect(
67+
stripPlaceholderArguments(args, { type: "object", required: "query" }),
68+
).toEqual(args);
69+
});
70+
});

‎src/mcp/placeholder-args.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { type } from "arktype";
2+
3+
const ObjectSchema = type({
4+
"properties?": "Record<string, unknown>",
5+
"required?": "string[]",
6+
});
7+
8+
const PropertySchema = type({
9+
"type?": "string | string[]",
10+
"nullable?": "boolean",
11+
"enum?": "unknown[]",
12+
"const?": "unknown",
13+
"anyOf?": "unknown[]",
14+
"oneOf?": "unknown[]",
15+
});
16+
17+
function allowsNull(property: unknown): boolean {
18+
const parsed = PropertySchema(property);
19+
if (parsed instanceof type.errors) return false;
20+
if (parsed.nullable === true) return true;
21+
if (parsed.type === "null") return true;
22+
if (Array.isArray(parsed.type) && parsed.type.includes("null")) return true;
23+
return [...(parsed.anyOf ?? []), ...(parsed.oneOf ?? [])].some(allowsNull);
24+
}
25+
26+
// A value the schema names explicitly is a deliberate choice, even when empty.
27+
function isListedValue(property: unknown, value: unknown): boolean {
28+
const parsed = PropertySchema(property);
29+
if (parsed instanceof type.errors) return false;
30+
return parsed.enum?.includes(value) === true || parsed.const === value;
31+
}
32+
33+
function isPlaceholder(value: unknown, property: unknown): boolean {
34+
if (isListedValue(property, value)) return false;
35+
if (typeof value === "string") return value.trim() === "";
36+
if (Array.isArray(value)) return value.length === 0;
37+
return value === null && !allowsNull(property);
38+
}
39+
40+
/**
41+
* Drops optional arguments a model filled with an empty placeholder (`""`,
42+
* whitespace, `[]`, or `null` where the property does not allow it).
43+
* Required arguments and any other value are forwarded unchanged.
44+
*/
45+
export function stripPlaceholderArguments(
46+
args: Record<string, unknown>,
47+
inputSchema: Record<string, unknown>,
48+
): Record<string, unknown> {
49+
const schema = ObjectSchema(inputSchema);
50+
if (schema instanceof type.errors) return args;
51+
const required = new Set(schema.required ?? []);
52+
return Object.fromEntries(
53+
Object.entries(args).filter(
54+
([key, value]) =>
55+
required.has(key) || !isPlaceholder(value, schema.properties?.[key]),
56+
),
57+
);
58+
}

‎src/mcp/plugin.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
type MCPClient,
1919
type MCPContentBlock,
2020
} from "./client.js";
21+
import { stripPlaceholderArguments } from "./placeholder-args.js";
2122
import { mcpToolName } from "./tool-name.js";
2223
import { LOG_NAMESPACE_ROOT } from "../branding.js";
2324

@@ -107,9 +108,11 @@ export function mcpClientTools(
107108
signal: AbortSignal,
108109
): Promise<ToolResult> => {
109110
try {
111+
// Some models pad every optional field; servers reject the empty
112+
// values (customView minLength) or read them as filters.
110113
const envelope = await client.callResult(
111114
tool.name,
112-
call.arguments,
115+
stripPlaceholderArguments(call.arguments, tool.inputSchema),
113116
signal,
114117
);
115118
const authorizedBlocks = applyPolicyToBlocks(envelope.blocks);

0 commit comments

Comments
 (0)