Skip to content

Commit 40ef520

Browse files
committed
fix(tools): advertise late MCP tools on execute in tests
Search no longer grows the advertised set. The late-dispatch harness now uses the same call-gate promoter as TUI and exec so schemas land on the next infer.
1 parent d677b90 commit 40ef520

5 files changed

Lines changed: 62 additions & 20 deletions

File tree

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

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import { createAgentWithLiveToolDispatch } from "../src/agent/live-tool-dispatch
66
import type { MCPClient } from "../src/mcp/client.js";
77
import { mcpClientTools } from "../src/mcp/plugin.js";
88
import { createPermissionGate } from "../src/permission/gate.js";
9+
import { createAdvertisedToolset } from "../src/session/assemble-runtime.js";
910
import {
1011
closeIntegrationSession,
12+
INTEGRATION_SOURCE,
1113
openIntegrationSession,
1214
runUntilDone,
1315
toolDoneEvents,
@@ -108,13 +110,35 @@ function toolDoneContents(events: ReactorEmittedEvent[]): string[] {
108110
);
109111
}
110112

111-
// Registers dynamic tools as promotable so the next request advertises them.
113+
// Wires production promote-on-execute: search returns cards only; a call to a
114+
// registered-but-unadvertised name declares that one schema, then dispatches.
112115
function promoteDynamicTools(session: IntegrationSession): void {
113-
session.toolset.setToolPromoter(() => {
116+
const advertised = createAdvertisedToolset({
117+
sessionMode: "orchestrator",
118+
toolAvailability: { languageServerAvailable: false },
119+
getProvider: () => ({
120+
providerName: INTEGRATION_SOURCE.provider,
121+
model: INTEGRATION_SOURCE.model,
122+
}),
123+
});
124+
session.toolset.dynamicRunner.setCallGate(
125+
(name) => advertised.isAdvertised(name),
126+
{ isActivated: (name) => advertised.activated.has(name) },
127+
);
128+
session.toolset.setToolPromoter((names) => {
129+
if (!advertised.activated.activate(names)) return;
130+
if (!advertised.flushPromotions()) return;
114131
session.updateToolDefinitions(
115-
session.toolset.dynamicRunner.currentDefinitions(),
132+
advertised.computeAdvertised(
133+
session.toolset.dynamicRunner.currentDefinitions(),
134+
),
116135
);
117136
});
137+
session.updateToolDefinitions(
138+
advertised.computeAdvertised(
139+
session.toolset.dynamicRunner.currentDefinitions(),
140+
),
141+
);
118142
}
119143

120144
function replyScript(
@@ -318,7 +342,10 @@ describe("integration — late MCP dispatch", () => {
318342
promotionScript(session, call);
319343

320344
const { events } = await runUntilDone(session, "list one linear issue");
321-
const published = publishedTool(await requestBodies(session));
345+
const bodies = await requestBodies(session);
346+
expect(bodies.length).toBeGreaterThan(0);
347+
expect(publishedTool(bodies.slice(0, 1))).toBeUndefined();
348+
const published = publishedTool(bodies);
322349

323350
if (schema === undefined) {
324351
expect(published?.input_schema.properties).toEqual(

‎src/agent/tool-search.test.ts‎

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,10 @@ function listedToolNames(out: string): string[] {
320320
return out
321321
.split("\n")
322322
.filter((line) => line.startsWith("- "))
323-
.map((line) => line.slice(2).split(":")[0]!.trim());
323+
.flatMap((line) => {
324+
const name = line.slice(2).split(":")[0];
325+
return name === undefined ? [] : [name.trim()];
326+
});
324327
}
325328

326329
async function flushMicrotasks(rounds = 100): Promise<void> {
@@ -342,9 +345,7 @@ describe("createToolSearchTool", () => {
342345
expect(out).toContain("present");
343346
expect(out).toContain("layout");
344347
expect(out).not.toMatch(/promoted/i);
345-
expect(
346-
advertisedTools(defs).map((d) => d.name),
347-
).not.toContain("present");
348+
expect(advertisedTools(defs).map((d) => d.name)).not.toContain("present");
348349
});
349350

350351
test("default tool_search returns at most 5 matches and does not promote them", async () => {
@@ -666,7 +667,11 @@ describe("createToolSearchTool", () => {
666667
});
667668

668669
describe("promote-on-execute", () => {
669-
const stringTool = (name: string, reply: string, description: string): AgentTool => ({
670+
const stringTool = (
671+
name: string,
672+
reply: string,
673+
description: string,
674+
): AgentTool => ({
670675
kind: "string",
671676
definition: {
672677
name,
@@ -678,7 +683,9 @@ describe("promote-on-execute", () => {
678683

679684
function wirePromoteOnExecute() {
680685
const tools = [
681-
...wideCatalog.map((d) => stringTool(d.name, `ran:${d.name}`, d.description)),
686+
...wideCatalog.map((d) =>
687+
stringTool(d.name, `ran:${d.name}`, d.description),
688+
),
682689
stringTool(
683690
"mcp__never__searched",
684691
"ran:never",
@@ -753,7 +760,8 @@ describe("promote-on-execute", () => {
753760
);
754761
expect(listed).toHaveLength(3);
755762
expect(promoted).toEqual([]);
756-
const called = listed[0]!;
763+
const called = listed[0];
764+
if (called === undefined) throw new Error("expected a listed tool");
757765
const others = listed.slice(1);
758766
const result = await dispatch(runner, called);
759767
expect(result.content).toBe(`ran:${called}`);
@@ -772,7 +780,9 @@ describe("promote-on-execute", () => {
772780
expect(result.content).toBe("ran:never");
773781
expect(result.isError).toBeUndefined();
774782
expect(promoted).toEqual(["mcp__never__searched"]);
775-
expect(advertisedNames(advertised, runner)).toContain("mcp__never__searched");
783+
expect(advertisedNames(advertised, runner)).toContain(
784+
"mcp__never__searched",
785+
);
776786
});
777787

778788
test("executing list_dir does not join the advertised tail", async () => {

‎src/agent/tool-search.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -372,8 +372,7 @@ function parseToolSearchArgs(
372372
rawArgs: Record<string, unknown>,
373373
): { error: string } | { query: string; limit: number } {
374374
const parsed = ToolSearchArgs(rawArgs);
375-
const queryRaw =
376-
parsed instanceof type.errors ? rawArgs.query : parsed.query;
375+
const queryRaw = parsed instanceof type.errors ? rawArgs.query : parsed.query;
377376
if (typeof queryRaw !== "string") {
378377
return { error: "Error: tool_search requires query (string)." };
379378
}

‎src/auth/store.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { tmpdir } from "node:os";
1111
import { join } from "node:path";
1212
import { type } from "arktype";
1313

14-
import { withMockedModule } from "../../tests/helpers/mock-module.js";
14+
import { withMockedModule } from "../../testkit/mock-module.js";
1515
import type { BaseTokens } from "./store.js";
1616

1717
let failingUnlinkPath: string | undefined;

‎src/session/assemble-runtime.test.ts‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -157,18 +157,24 @@ describe("createAdvertisedToolset", () => {
157157
});
158158

159159
test("pruneIdlePromotions drops execute-promoted schemas and keeps the frozen prefix", () => {
160-
const { activated, computeAdvertised, flushPromotions, pruneIdlePromotions } =
161-
createAdvertisedToolset(
162-
wiring({ pinnedTools: ["mcp__linear__save_issue"] }),
163-
);
160+
const {
161+
activated,
162+
computeAdvertised,
163+
flushPromotions,
164+
pruneIdlePromotions,
165+
} = createAdvertisedToolset(
166+
wiring({ pinnedTools: ["mcp__linear__save_issue"] }),
167+
);
164168
const defs = [
165169
def("read_file"),
166170
def("mcp__linear__save_issue"),
167171
def("mcp__acme__do"),
168172
];
169173
activated.activate(["mcp__acme__do"]);
170174
expect(flushPromotions()).toBe(true);
171-
expect(computeAdvertised(defs).map((d) => d.name)).toContain("mcp__acme__do");
175+
expect(computeAdvertised(defs).map((d) => d.name)).toContain(
176+
"mcp__acme__do",
177+
);
172178
expect(pruneIdlePromotions()).toBe(true);
173179
const names = computeAdvertised(defs).map((d) => d.name);
174180
expect(names).not.toContain("mcp__acme__do");

0 commit comments

Comments
 (0)