Skip to content

Commit 1ff0696

Browse files
committed
refactor(agent): build reconnect stub definitions in one place
1 parent a263d38 commit 1ff0696

2 files changed

Lines changed: 79 additions & 9 deletions

File tree

‎src/agent/tools-mcp-disconnect.test.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,8 @@ const { MCP_RECONNECTING_TOOL_ERROR, isDegradedMcpState } =
380380
await import("../mcp/plugin.js");
381381
const { mcpNotice } = await import("../tui/runtime-notices.js");
382382
const { mcpReconnectDelayMs } = await import("./tools.js");
383+
const { TOOL_SEARCH_PENDING_WAIT_MS, TOOL_SEARCH_RECONNECT_WAIT_MS } =
384+
await import("./tool-search.js");
383385

384386
// connectMCPServer is mocked; tests simulate a dead transport by invoking the
385387
// onDisconnect hook the real client wires to transport.onclose.
@@ -773,6 +775,71 @@ describe("unintentional disconnect and automatic reconnect", () => {
773775
await toolset.dispose();
774776
}
775777
});
778+
779+
const widgetTool = {
780+
name: "list",
781+
description: "List widgets",
782+
inputSchema: { type: "object", required: ["q"] },
783+
};
784+
785+
test("reconnect stubs keep the server's name, description, and schema", async () => {
786+
mock.connectedTools = [widgetTool];
787+
const toolset = await makeToolset();
788+
try {
789+
await toolset.connectMCPServer(acme, callbacks([]));
790+
killTransport();
791+
const stub = toolset.dynamicRunner
792+
.currentDefinitions()
793+
.find((d) => d.name === "mcp__acme__list");
794+
expect(stub).toEqual({
795+
name: "mcp__acme__list",
796+
description: "[acme] List widgets",
797+
inputSchema: widgetTool.inputSchema,
798+
});
799+
} finally {
800+
await toolset.dispose();
801+
}
802+
});
803+
804+
// Resolves to whether tool_search was still waiting once the tier-1 miss
805+
// wait elapsed, i.e. whether a retained tool matched the query.
806+
async function searchWaitsForRedial(query: string): Promise<boolean> {
807+
mock.connectedTools = [widgetTool];
808+
const toolset = await makeToolset();
809+
jest.useFakeTimers();
810+
try {
811+
await toolset.connectMCPServer(acme, callbacks([]));
812+
mock.mode = "deferred";
813+
killTransport();
814+
await advanceUntil(() => mock.connectOptions.length === 2);
815+
let settled = false;
816+
const search = toolset.dynamicRunner
817+
.run(
818+
{ id: "s1", name: "tool_search", arguments: { query } },
819+
new AbortController().signal,
820+
)
821+
.then(() => {
822+
settled = true;
823+
});
824+
await advanceAndFlush(TOOL_SEARCH_PENDING_WAIT_MS);
825+
const waiting = !settled;
826+
await advanceAndFlush(TOOL_SEARCH_RECONNECT_WAIT_MS);
827+
await search;
828+
return waiting;
829+
} finally {
830+
mock.releaseDeferredConnect?.();
831+
jest.useRealTimers();
832+
await toolset.dispose();
833+
}
834+
}
835+
836+
test("tool_search waits for a redial whose retained tool matches by description", async () => {
837+
expect(await searchWaitsForRedial("widgets")).toBe(true);
838+
});
839+
840+
test("tool_search does not wait for a redial whose retained tools do not match", async () => {
841+
expect(await searchWaitsForRedial("zebra")).toBe(false);
842+
});
776843
});
777844

778845
describe("MCP handshake bounds", () => {

‎src/agent/tools.ts‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -833,11 +833,9 @@ export async function createAgentToolset(
833833
if (queryTokens.length === 0) return false;
834834
for (const [serverName, state] of reconnectingServers) {
835835
for (const tool of state.tools) {
836+
const definition = reconnectStubDefinition(serverName, tool);
836837
const score = scoreLexical(
837-
lexicalFields(
838-
mcpToolName(serverName, tool.name),
839-
`[${serverName}] ${tool.description}`,
840-
),
838+
lexicalFields(definition.name, definition.description),
841839
queryTokens,
842840
rawQuery,
843841
);
@@ -1031,6 +1029,15 @@ export async function createAgentToolset(
10311029
}
10321030
const reconnectingServers = new Map<string, McpReconnectState>();
10331031

1032+
const reconnectStubDefinition = (
1033+
serverName: string,
1034+
tool: McpReconnectTool,
1035+
): ToolDefinition => ({
1036+
name: mcpToolName(serverName, tool.name),
1037+
description: `[${serverName}] ${tool.description}`,
1038+
inputSchema: tool.inputSchema,
1039+
});
1040+
10341041
const mountReconnectingStubs = (
10351042
name: string,
10361043
tools: readonly McpReconnectTool[],
@@ -1039,11 +1046,7 @@ export async function createAgentToolset(
10391046
dynamicRunner.addTools(
10401047
tools.map((tool): AgentTool => ({
10411048
kind: "full",
1042-
definition: {
1043-
name: mcpToolName(name, tool.name),
1044-
description: `[${name}] ${tool.description}`,
1045-
inputSchema: tool.inputSchema,
1046-
},
1049+
definition: reconnectStubDefinition(name, tool),
10471050
handler: async (call: ToolCall) => ({
10481051
callId: call.id,
10491052
content: MCP_RECONNECTING_TOOL_ERROR,

0 commit comments

Comments
 (0)