Skip to content

Commit ae133d3

Browse files
committed
test(mcp): harden late-dispatch guards for loud failure and fidelity
1 parent a38e29c commit ae133d3

1 file changed

Lines changed: 117 additions & 11 deletions

File tree

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

Lines changed: 117 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,16 @@ describe("integration — late MCP dispatch", () => {
9292
session.harness.scenario.replyOnce("anthropic", { text: "listed" });
9393

9494
const { events } = await runUntilDone(session, "list linear issues");
95-
expect(
96-
toolDoneContents(events).some((content) =>
97-
content.includes(`unknown tool: ${LATE_MCP}`),
98-
),
99-
).toBe(true);
95+
const loud = events.find(
96+
(
97+
event,
98+
): event is Extract<ReactorEmittedEvent, { type: "tool.done" }> =>
99+
event.type === "tool.done" &&
100+
typeof event.data.result.content === "string" &&
101+
event.data.result.content.includes(`unknown tool: ${LATE_MCP}`),
102+
);
103+
expect(loud).toBeDefined();
104+
expect(loud?.data.result.isError).toBe(true);
100105
} finally {
101106
await closeIntegrationSession(session);
102107
}
@@ -130,6 +135,96 @@ describe("integration — late MCP dispatch", () => {
130135
},
131136
);
132137

138+
test.serial(
139+
"unadvertised MCP tool dispatch fails loudly instead of altering results",
140+
async () => {
141+
const session = await openIntegrationSession({
142+
permissionGate: permissionGate(),
143+
createAgentFn: createAgentWithLiveToolDispatch,
144+
});
145+
146+
try {
147+
session.toolset.dynamicRunner.addTools(lateMcpTools());
148+
const missing = "mcp__linear__no_such_tool";
149+
session.harness.scenario.replyOnce("anthropic", {
150+
toolCalls: [{ name: missing, args: {} }],
151+
});
152+
session.harness.scenario.replyOnce("anthropic", { text: "refused" });
153+
154+
const { events } = await runUntilDone(session, "delete everything");
155+
const loud = events.find(
156+
(
157+
event,
158+
): event is Extract<ReactorEmittedEvent, { type: "tool.done" }> =>
159+
event.type === "tool.done" &&
160+
typeof event.data.result.content === "string" &&
161+
event.data.result.content.includes(`unknown tool: ${missing}`),
162+
);
163+
expect(loud).toBeDefined();
164+
expect(loud?.data.result.isError).toBe(true);
165+
} finally {
166+
await closeIntegrationSession(session);
167+
}
168+
},
169+
);
170+
171+
test.serial(
172+
"out-of-scope MCP arguments fail loudly with an actionable error",
173+
async () => {
174+
const session = await openIntegrationSession({
175+
permissionGate: permissionGate(),
176+
createAgentFn: createAgentWithLiveToolDispatch,
177+
});
178+
179+
try {
180+
const client: MCPClient = {
181+
serverName: "linear",
182+
tools: [
183+
{
184+
name: "list_issues",
185+
description: "list issues",
186+
inputSchema: LATE_MCP_SCHEMA,
187+
},
188+
],
189+
async call(toolName, args) {
190+
if (toolName === "list_issues" && args.team === "rogue") {
191+
throw new Error(
192+
'scope denied: team "rogue" is not in scope for this connection',
193+
);
194+
}
195+
return "ISSUE-1";
196+
},
197+
async close() {
198+
return undefined;
199+
},
200+
};
201+
session.toolset.dynamicRunner.addTools(mcpClientTools(client));
202+
session.harness.scenario.replyOnce("anthropic", {
203+
toolCalls: [{ name: LATE_MCP, args: { limit: 1, team: "rogue" } }],
204+
});
205+
session.harness.scenario.replyOnce("anthropic", { text: "refused" });
206+
207+
const { events } = await runUntilDone(
208+
session,
209+
"list rogue team issues",
210+
);
211+
const loud = events.find(
212+
(
213+
event,
214+
): event is Extract<ReactorEmittedEvent, { type: "tool.done" }> =>
215+
event.type === "tool.done" &&
216+
typeof event.data.result.content === "string" &&
217+
event.data.result.content.includes("scope denied"),
218+
);
219+
expect(loud).toBeDefined();
220+
expect(loud?.data.result.isError).toBe(true);
221+
expect(loud?.data.result.content).toContain("not in scope");
222+
} finally {
223+
await closeIntegrationSession(session);
224+
}
225+
},
226+
);
227+
133228
test.serial(
134229
"tool_search promotion preserves optional MCP arguments",
135230
async () => {
@@ -147,6 +242,7 @@ describe("integration — late MCP dispatch", () => {
147242
});
148243
expect(tools).toHaveLength(1);
149244
expect(tools[0]?.kind).toBe("full");
245+
const expectedSchema = structuredClone(LATE_MCP_SCHEMA);
150246
session.toolset.dynamicRunner.addTools(tools);
151247
session.toolset.setToolPromoter(() => {
152248
session.updateToolDefinitions(
@@ -159,7 +255,7 @@ describe("integration — late MCP dispatch", () => {
159255
],
160256
});
161257
session.harness.scenario.replyOnce("anthropic", {
162-
toolCalls: [{ name: LATE_MCP, args: { limit: 1 } }],
258+
toolCalls: [{ name: LATE_MCP, args: { limit: 1, team: "eng" } }],
163259
});
164260
session.harness.scenario.replyOnce("anthropic", { text: "listed" });
165261

@@ -179,12 +275,12 @@ describe("integration — late MCP dispatch", () => {
179275
.find((tool) => tool.name === LATE_MCP);
180276

181277
expect(publishedTool?.input_schema.properties).toEqual(
182-
LATE_MCP_SCHEMA.properties,
278+
expectedSchema.properties,
183279
);
184280
expect(
185281
Object.hasOwn(publishedTool?.input_schema ?? {}, "required"),
186282
).toBe(false);
187-
expect(receivedArgs).toEqual({ limit: 1 });
283+
expect(receivedArgs).toEqual({ limit: 1, team: "eng" });
188284
expect(toolDoneContents(events)).toContain("ISSUE-1");
189285
} finally {
190286
await closeIntegrationSession(session);
@@ -230,6 +326,7 @@ describe("integration — late MCP dispatch", () => {
230326
return undefined;
231327
},
232328
};
329+
const expectedSchema = structuredClone(schema);
233330
session.toolset.dynamicRunner.addTools(mcpClientTools(client));
234331
session.toolset.setToolPromoter(() => {
235332
session.updateToolDefinitions(
@@ -242,7 +339,12 @@ describe("integration — late MCP dispatch", () => {
242339
],
243340
});
244341
session.harness.scenario.replyOnce("anthropic", {
245-
toolCalls: [{ name: LATE_MCP, args: { limit: 1 } }],
342+
toolCalls: [
343+
{
344+
name: LATE_MCP,
345+
args: { limit: 1, team: "eng", customView: "mine" },
346+
},
347+
],
246348
});
247349
session.harness.scenario.replyOnce("anthropic", { text: "listed" });
248350

@@ -261,8 +363,12 @@ describe("integration — late MCP dispatch", () => {
261363
.flatMap((body) => body.tools ?? [])
262364
.find((tool) => tool.name === LATE_MCP);
263365

264-
expect(publishedTool?.input_schema).toEqual(schema);
265-
expect(receivedArgs).toEqual({ limit: 1 });
366+
expect(publishedTool?.input_schema).toEqual(expectedSchema);
367+
expect(receivedArgs).toEqual({
368+
limit: 1,
369+
team: "eng",
370+
customView: "mine",
371+
});
266372
expect(toolDoneContents(events)).toContain("ISSUE-1");
267373
} finally {
268374
await closeIntegrationSession(session);

0 commit comments

Comments
 (0)