Skip to content

Commit 7506833

Browse files
Merge pull request #996 from corbitsdev/cl-7865-tool_search-answers-no-tools-matched-while-mcp-servers-are
Wait briefly for connecting servers before answering an empty tool search
2 parents 35c8cd6 + 119fb6d commit 7506833

3 files changed

Lines changed: 162 additions & 2 deletions

File tree

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

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,81 @@ describe("createToolSearchTool", () => {
287287
"No tools matched",
288288
);
289289
});
290+
291+
test("mid-handshake search waits for a connecting server instead of reporting no match", async () => {
292+
const live: ToolDefinition[] = [];
293+
let resolveConnect!: () => void;
294+
const connected = new Promise<void>((resolve) => {
295+
resolveConnect = resolve;
296+
});
297+
const tool = createToolSearchTool({
298+
search: (query) => createToolIndex(() => live).search(query),
299+
lookup: (name) => live.find((def) => def.name === name),
300+
promote: () => undefined,
301+
awaitPendingConnections: async (timeoutMs?: number) => {
302+
await Promise.race([
303+
connected,
304+
new Promise((resolve) => setTimeout(resolve, timeoutMs ?? 50)),
305+
]);
306+
return live.length === 0 ? 1 : 0;
307+
},
308+
});
309+
const pending = call(tool, { query: "linear tracker" });
310+
live.push({
311+
name: "mcp__linear__create_issue",
312+
description: "Create an issue in the tracker",
313+
inputSchema: { type: "object", properties: {}, required: [] },
314+
});
315+
resolveConnect();
316+
const out = await pending;
317+
expect(out).toContain("mcp__linear__create_issue");
318+
expect(out).not.toContain("No tools matched");
319+
});
320+
321+
test("a hung connection never hangs the search — bounded wait, then a retry signal", async () => {
322+
const tool = createToolSearchTool({
323+
search: () => [],
324+
lookup: () => undefined,
325+
promote: () => undefined,
326+
awaitPendingConnections: () =>
327+
new Promise<number>(() => {
328+
// Never settles: simulates a hung authorization handshake.
329+
}),
330+
});
331+
const out = await call(tool, { query: "linear" });
332+
expect(out).toContain("No tools matched");
333+
expect(out).toMatch(/starting up|still connecting/);
334+
expect(out).toMatch(/retry.*shortly/i);
335+
expect(out).not.toContain("different keywords");
336+
});
337+
338+
test("two pending connectors report the plural connecting copy", async () => {
339+
const tool = createToolSearchTool({
340+
search: () => [],
341+
lookup: () => undefined,
342+
promote: () => undefined,
343+
awaitPendingConnections: async () => 2,
344+
});
345+
const out = await call(tool, { query: "linear" });
346+
expect(out).toContain("2 connectors are still connecting");
347+
expect(out).toMatch(/retry.*shortly/i);
348+
expect(out).not.toContain("different keywords");
349+
});
350+
351+
test("a genuine miss keeps the keyword advice and omits the retry caveat", async () => {
352+
const tool = createToolSearchTool({
353+
search: () => [],
354+
lookup: () => undefined,
355+
promote: () => undefined,
356+
awaitPendingConnections: async () => 0,
357+
});
358+
const out = await call(tool, { query: "nonsense" });
359+
expect(out).toContain("No tools matched");
360+
expect(out).toContain("different keywords");
361+
expect(out).not.toMatch(
362+
/still connecting|still starting up|retry shortly/i,
363+
);
364+
});
290365
});
291366

292367
describe("advertisedTools", () => {

‎src/agent/tool-search.ts‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,8 +283,18 @@ export interface ToolSearchDeps {
283283
// invoke them this turn. The next inference also declares them on the wire
284284
// for strict providers.
285285
promote: (names: string[]) => void;
286+
// Resolves to the remaining in-flight MCP handshake count after waiting up
287+
// to `timeoutMs`. The toolset bounds its own wait; the handler re-races
288+
// below so even a stuck dependency can never hang the call. Omitted callers
289+
// (tests, ad-hoc indexes) have no pending handshakes to wait for.
290+
awaitPendingConnections?: (timeoutMs?: number) => Promise<number>;
286291
}
287292

293+
// Brief bound a tool_search miss waits for in-flight MCP handshakes before
294+
// answering. A hung authorization must never hang the call, so both the
295+
// toolset wait and the handler race below are capped by this.
296+
export const TOOL_SEARCH_PENDING_WAIT_MS = 1_000;
297+
288298
const ToolSearchArgs = type({ query: "string" });
289299

290300
// Render one discovered tool as name, description, and pretty-printed input
@@ -305,6 +315,28 @@ function indent(text: string, pad: string): string {
305315
.join("\n");
306316
}
307317

318+
// Race the dependency's pending-count wait against the same bound, so a
319+
// stuck dependency (hung OAuth that never settles) cannot hang the call.
320+
// Resolves undefined when this race itself times out.
321+
async function racePendingCount(
322+
awaitPending: (timeoutMs?: number) => Promise<number>,
323+
): Promise<number | undefined> {
324+
let timer: ReturnType<typeof setTimeout> | undefined;
325+
try {
326+
return await Promise.race([
327+
awaitPending(TOOL_SEARCH_PENDING_WAIT_MS),
328+
new Promise<undefined>((resolve) => {
329+
timer = setTimeout(
330+
() => resolve(undefined),
331+
TOOL_SEARCH_PENDING_WAIT_MS,
332+
);
333+
}),
334+
]);
335+
} finally {
336+
if (timer !== undefined) clearTimeout(timer);
337+
}
338+
}
339+
308340
export function createToolSearchTool(deps: ToolSearchDeps): AgentTool {
309341
return stringTool({
310342
definition: toolSearchDefinition,
@@ -316,7 +348,25 @@ export function createToolSearchTool(deps: ToolSearchDeps): AgentTool {
316348
const query = parsed.query.trim();
317349
if (query.length === 0)
318350
return "Error: tool_search requires a non-empty query.";
319-
const names = deps.search(query);
351+
let names = deps.search(query);
352+
if (names.length === 0 && deps.awaitPendingConnections !== undefined) {
353+
// Miss while connectors start up: wait briefly, then re-search so
354+
// late-mounting tools land. The race bounds even a stuck dependency
355+
// (hung OAuth) — undefined means the wait itself timed out.
356+
const stillPending = await racePendingCount(
357+
deps.awaitPendingConnections,
358+
);
359+
names = deps.search(query);
360+
if (names.length === 0 && (stillPending ?? 1) > 0) {
361+
const detail =
362+
stillPending === undefined
363+
? "a connector may still be starting up"
364+
: stillPending === 1
365+
? "1 connector is still connecting"
366+
: `${stillPending} connectors are still connecting`;
367+
return `No tools matched "${query}" yet — ${detail}. Retry this search shortly.`;
368+
}
369+
}
320370
if (names.length === 0) {
321371
return `No tools matched "${query}". Try different keywords describing the capability.`;
322372
}

‎src/agent/tools.ts‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,11 @@ import {
103103
} from "../tools/web-search.js";
104104
import { createUseSkillTool } from "./use-skill.js";
105105
import { createSkillSearchTool } from "./skill-search.js";
106-
import { createToolIndex, createToolSearchTool } from "./tool-search.js";
106+
import {
107+
createToolIndex,
108+
createToolSearchTool,
109+
TOOL_SEARCH_PENDING_WAIT_MS,
110+
} from "./tool-search.js";
107111
import { createSearchAgentsTool } from "./agent-search.js";
108112
import { createReadAgentTraceTool } from "../subagent/trace-tool.js";
109113
import {
@@ -319,6 +323,10 @@ export interface AgentToolset {
319323
// second add of an active name; failed rows retry through connectMCPServer
320324
// without a second persist. Still true while disable is in progress.
321325
hasMCPServer: (name: string) => boolean;
326+
// Bounded wait for in-flight MCP handshakes; resolves to the remaining
327+
// count. Capped by `timeoutMs` so a hung authorization never hangs the
328+
// caller — the tool_search bound passes briefly by default.
329+
awaitPendingMcpConnections: (timeoutMs?: number) => Promise<number>;
322330
// Catalog unshadow can change local → global/none without rebuilding the
323331
// toolset; connectOne reads this on every late connect.
324332
setMcpServersSource: (source: "local" | "global" | "none") => void;
@@ -719,6 +727,12 @@ export async function createAgentToolset(
719727
lookup: (name) =>
720728
runnerHolder.current?.currentDefinitions().find((d) => d.name === name),
721729
promote: (names) => promoter.promote(names),
730+
// Misses wait briefly for in-flight MCP handshakes (bounded, so hung
731+
// OAuth cannot hang the call) and re-search before answering. Reads the
732+
// connection map live — declared below, populated by the time any
733+
// search runs.
734+
awaitPendingConnections: (timeoutMs = TOOL_SEARCH_PENDING_WAIT_MS) =>
735+
awaitPendingMcpConnections(timeoutMs),
722736
}),
723737
);
724738

@@ -744,6 +758,26 @@ export async function createAgentToolset(
744758
const connectedClients = new Map<string, MCPClient>();
745759
const inFlightConnections = new Map<string, Promise<void>>();
746760
const inFlightEpochs = new Map<string, number>();
761+
// Bounded wait for in-flight handshakes; resolves to the remaining count.
762+
// Capped by `timeoutMs` so a hung authorization never hangs the caller.
763+
const awaitPendingMcpConnections = async (
764+
timeoutMs = TOOL_SEARCH_PENDING_WAIT_MS,
765+
): Promise<number> => {
766+
if (inFlightConnections.size === 0) return 0;
767+
const pending = [...inFlightConnections.values()];
768+
let timer: ReturnType<typeof setTimeout> | undefined;
769+
try {
770+
await Promise.race([
771+
Promise.allSettled(pending),
772+
new Promise<void>((resolve) => {
773+
timer = setTimeout(resolve, timeoutMs);
774+
}),
775+
]);
776+
} finally {
777+
if (timer !== undefined) clearTimeout(timer);
778+
}
779+
return inFlightConnections.size;
780+
};
747781
const disabledNames = new Set<string>();
748782
const serverAborts = new Map<string, AbortController>();
749783
const serverEpochs = new Map<string, number>();
@@ -1218,6 +1252,7 @@ export async function createAgentToolset(
12181252
disconnectMCPServer: publicDisconnectMCPServer,
12191253
hasMCPServer: (name) =>
12201254
connectedClients.has(name) || inFlightConnections.has(name),
1255+
awaitPendingMcpConnections,
12211256
setMcpServersSource: (source) => {
12221257
mcpServersSource = source;
12231258
},

0 commit comments

Comments
 (0)