Skip to content

Commit d97cf3a

Browse files
committed
test(mcp): preserve optional arguments through promotion
1 parent 6d07b36 commit d97cf3a

2 files changed

Lines changed: 124 additions & 15 deletions

File tree

‎tests/integration/harness.ts‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,15 @@ import type {
2727
ContextTransform,
2828
ContextStore,
2929
InferenceSource,
30+
ToolDefinition,
3031
} from "@intx/types/runtime";
3132
import { type } from "arktype";
3233

3334
import { createAgentWithLiveToolDispatch } from "../../src/agent/live-tool-dispatch.js";
34-
import { createChatDirector } from "../../src/agent/director.js";
35+
import {
36+
createChatDirector,
37+
type ChatDirector,
38+
} from "../../src/agent/director.js";
3539
import { OPERATOR_ORIGINATED_FLAG } from "../../src/agent/message-provenance.js";
3640
import {
3741
readSourceCredentialMaterial,
@@ -82,6 +86,7 @@ export interface IntegrationSession {
8286
workdir: string;
8387
agent: Agent;
8488
toolset: Awaited<ReturnType<typeof createAgentToolset>>;
89+
updateToolDefinitions: (definitions: ToolDefinition[]) => void;
8590
}
8691

8792
export interface OpenIntegrationSessionOpts {
@@ -114,6 +119,9 @@ export async function openIntegrationSession(
114119
const storageHolder: { current: ContextStore | undefined } = {
115120
current: undefined,
116121
};
122+
const directorHolder: { current: ChatDirector | undefined } = {
123+
current: undefined,
124+
};
117125

118126
const toolset = await createAgentToolset({
119127
cwd,
@@ -140,6 +148,7 @@ export async function openIntegrationSession(
140148
},
141149
);
142150
d.setClearDenials(() => opts.permissionGate.clearDenials());
151+
directorHolder.current = d;
143152
return d;
144153
},
145154
});
@@ -262,7 +271,22 @@ export async function openIntegrationSession(
262271
? innerAgent
263272
: createPrimaryDeliveryAdmission(innerAgent, primaryArchive);
264273

265-
return { harness, cwd, workdir, agent, toolset, storage: storageForAgent };
274+
const updateToolDefinitions = (definitions: ToolDefinition[]): void => {
275+
const director = directorHolder.current;
276+
if (director === undefined)
277+
throw new Error("chat director is not available");
278+
director.updateToolDefinitions(definitions);
279+
};
280+
281+
return {
282+
harness,
283+
cwd,
284+
workdir,
285+
agent,
286+
toolset,
287+
storage: storageForAgent,
288+
updateToolDefinitions,
289+
};
266290
}
267291

268292
export async function closeIntegrationSession(

‎tests/integration/mcp-late-dispatch.test.ts‎

Lines changed: 98 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { createAgent } from "@intx/agent";
33
import type { ReactorEmittedEvent } from "@intx/inference";
44

55
import { createAgentWithLiveToolDispatch } from "../../src/agent/live-tool-dispatch.js";
6+
import type { MCPClient } from "../../src/mcp/client.js";
7+
import { mcpClientTools } from "../../src/mcp/plugin.js";
68
import { createPermissionGate } from "../../src/permission/gate.js";
79
import {
810
closeIntegrationSession,
@@ -11,6 +13,20 @@ import {
1113
} from "./harness.js";
1214

1315
const LATE_MCP = "mcp__linear__list_issues";
16+
const LATE_MCP_SCHEMA = {
17+
type: "object" as const,
18+
properties: {
19+
limit: { type: "integer" },
20+
team: { type: "string" },
21+
},
22+
};
23+
24+
interface AnthropicRequestBody {
25+
tools?: {
26+
name: string;
27+
input_schema: Record<string, unknown>;
28+
}[];
29+
}
1430

1531
function permissionGate() {
1632
return createPermissionGate({
@@ -21,20 +37,27 @@ function permissionGate() {
2137
});
2238
}
2339

24-
function lateMcpTool() {
25-
return {
26-
kind: "string" as const,
27-
definition: {
28-
name: LATE_MCP,
29-
description: "list issues",
30-
inputSchema: {
31-
type: "object" as const,
32-
properties: {},
33-
required: [] as string[],
40+
function lateMcpTools(
41+
onCall?: (toolName: string, args: Record<string, unknown>) => void,
42+
) {
43+
const client: MCPClient = {
44+
serverName: "linear",
45+
tools: [
46+
{
47+
name: "list_issues",
48+
description: "list issues",
49+
inputSchema: LATE_MCP_SCHEMA,
3450
},
51+
],
52+
async call(toolName, args) {
53+
onCall?.(toolName, args);
54+
return "ISSUE-1";
55+
},
56+
async close() {
57+
return undefined;
3558
},
36-
handler: async () => "ISSUE-1",
3759
};
60+
return mcpClientTools(client);
3861
}
3962

4063
function toolDoneContents(events: ReactorEmittedEvent[]): string[] {
@@ -62,7 +85,7 @@ describe("integration — late MCP dispatch", () => {
6285
});
6386

6487
try {
65-
session.toolset.dynamicRunner.addTools([lateMcpTool()]);
88+
session.toolset.dynamicRunner.addTools(lateMcpTools());
6689
session.harness.scenario.replyOnce("anthropic", {
6790
toolCalls: [{ name: LATE_MCP, args: {} }],
6891
});
@@ -89,7 +112,7 @@ describe("integration — late MCP dispatch", () => {
89112
});
90113

91114
try {
92-
session.toolset.dynamicRunner.addTools([lateMcpTool()]);
115+
session.toolset.dynamicRunner.addTools(lateMcpTools());
93116
session.harness.scenario.replyOnce("anthropic", {
94117
toolCalls: [{ name: LATE_MCP, args: {} }],
95118
});
@@ -106,4 +129,66 @@ describe("integration — late MCP dispatch", () => {
106129
}
107130
},
108131
);
132+
133+
test.serial(
134+
"tool_search promotion preserves optional MCP arguments",
135+
async () => {
136+
const session = await openIntegrationSession({
137+
permissionGate: permissionGate(),
138+
createAgentFn: createAgentWithLiveToolDispatch,
139+
});
140+
let receivedArgs: Record<string, unknown> | undefined;
141+
142+
try {
143+
const tools = lateMcpTools((toolName, args) => {
144+
if (toolName === "list_issues") {
145+
receivedArgs = args;
146+
}
147+
});
148+
expect(tools).toHaveLength(1);
149+
expect(tools[0]?.kind).toBe("full");
150+
session.toolset.dynamicRunner.addTools(tools);
151+
session.toolset.setToolPromoter(() => {
152+
session.updateToolDefinitions(
153+
session.toolset.dynamicRunner.currentDefinitions(),
154+
);
155+
});
156+
session.harness.scenario.replyOnce("anthropic", {
157+
toolCalls: [
158+
{ name: "tool_search", args: { query: "linear list issues" } },
159+
],
160+
});
161+
session.harness.scenario.replyOnce("anthropic", {
162+
toolCalls: [{ name: LATE_MCP, args: { limit: 1 } }],
163+
});
164+
session.harness.scenario.replyOnce("anthropic", { text: "listed" });
165+
166+
const { events } = await runUntilDone(session, "list one linear issue");
167+
const bodies = await Promise.all(
168+
session.harness.scenario
169+
.matchedRequests()
170+
.map(
171+
async (request) =>
172+
JSON.parse(
173+
await (request.clone() as unknown as Request).text(),
174+
) as AnthropicRequestBody,
175+
),
176+
);
177+
const publishedTool = bodies
178+
.flatMap((body) => body.tools ?? [])
179+
.find((tool) => tool.name === LATE_MCP);
180+
181+
expect(publishedTool?.input_schema.properties).toEqual(
182+
LATE_MCP_SCHEMA.properties,
183+
);
184+
expect(
185+
Object.hasOwn(publishedTool?.input_schema ?? {}, "required"),
186+
).toBe(false);
187+
expect(receivedArgs).toEqual({ limit: 1 });
188+
expect(toolDoneContents(events)).toContain("ISSUE-1");
189+
} finally {
190+
await closeIntegrationSession(session);
191+
}
192+
},
193+
);
109194
});

0 commit comments

Comments
 (0)