Skip to content

Commit 0660a64

Browse files
committed
Fix same-turn MCP dispatch after tool_search
@intx/agent snapshots dispatch names at createAgent. MCP tools arrive later, and the post-connect reload waited for every server including OAuth-blocked ones. tool_search listed them; invoke returned unknown tool. Fall through to the live runner until upstream consults current definitions.
1 parent 90c3222 commit 0660a64

9 files changed

Lines changed: 266 additions & 16 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1313

1414
## [Unreleased]
1515

16+
### MCP
17+
18+
- **Late-connected MCP tools are callable the same turn they appear in `tool_search`.**
19+
`@intx/agent` snapshots dispatch names at `createAgent`, and the post-connect
20+
reload that used to rebuild that snapshot waited for every server — including
21+
one stuck on OAuth. Cataloged `mcp__*` tools then returned `unknown tool`.
22+
Construction now dispatches misses through the live runner, so Linear/Exa
23+
(and any other server that finished) work even while another server still
24+
needs auth.
25+
1626
### TUI
1727

1828
- **Settled permission and operator prompts no longer recap into the chat.**

‎docs/MCP.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ does not require project trust. Local settings **replace** global MCP entirely
2525
when present (they do not merge).
2626

2727
Tools from connected servers are not advertised to the model up front; they are
28-
registered for dispatch and surfaced on demand through dynamic tool discovery
28+
registered for dispatch as soon as the server connects (including later in the
29+
same turn) and surfaced on demand through dynamic tool discovery
2930
(`tool_search`).
3031

3132
## Server Kinds
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import { createDynamicToolRunner } from "../tui/dynamic-tool-runner.js";
4+
import {
5+
fallbackLiveToolBundle,
6+
isLiveToolBundle,
7+
withLiveToolDispatchMap,
8+
} from "./live-tool-dispatch.js";
9+
10+
const stringTool = (name: string, reply: string) => ({
11+
kind: "string" as const,
12+
definition: {
13+
name,
14+
description: name,
15+
inputSchema: { type: "object" as const, properties: {}, required: [] as string[] },
16+
},
17+
handler: async () => reply,
18+
});
19+
20+
describe("live tool dispatch fallback", () => {
21+
test("recognizes a DynamicToolRunner as the live bundle", () => {
22+
const runner = createDynamicToolRunner([stringTool("tool_search", "ok")]);
23+
expect(isLiveToolBundle(runner)).toBe(true);
24+
expect(isLiveToolBundle({ run: () => undefined })).toBe(false);
25+
expect(isLiveToolBundle(null)).toBe(false);
26+
});
27+
28+
test("falls back to the single live bundle and refuses to guess among several", () => {
29+
const live = createDynamicToolRunner([stringTool("tool_search", "ok")]);
30+
const other = createDynamicToolRunner([stringTool("present", "no")]);
31+
32+
const one = new Map<unknown, unknown>([["tool_search", live]]);
33+
expect(fallbackLiveToolBundle(one)).toBe(live);
34+
35+
const many = new Map<unknown, unknown>([
36+
["tool_search", live],
37+
["present", other],
38+
]);
39+
expect(fallbackLiveToolBundle(many)).toBeUndefined();
40+
41+
const none = new Map<unknown, unknown>([["read_file", { run: () => undefined }]]);
42+
expect(fallbackLiveToolBundle(none)).toBeUndefined();
43+
});
44+
45+
test("Map.get installed for createAgent resolves a late MCP name to the live bundle", () => {
46+
const runner = createDynamicToolRunner([stringTool("tool_search", "ok")]);
47+
48+
withLiveToolDispatchMap(() => {
49+
const byName = new Map<string, unknown>();
50+
byName.set("tool_search", runner);
51+
expect(byName.get("tool_search")).toBe(runner);
52+
expect(byName.get("mcp__linear__list_issues")).toBe(runner);
53+
expect(byName.get("read_file")).toBe(runner);
54+
});
55+
56+
const after = new Map<string, unknown>();
57+
after.set("tool_search", runner);
58+
expect(after.get("mcp__linear__list_issues")).toBeUndefined();
59+
});
60+
61+
test("restores Map even when the wrapped call throws", () => {
62+
const before = globalThis.Map;
63+
expect(() =>
64+
withLiveToolDispatchMap(() => {
65+
throw new Error("boom");
66+
}),
67+
).toThrow("boom");
68+
expect(globalThis.Map).toBe(before);
69+
});
70+
});

‎src/agent/live-tool-dispatch.ts‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import {
2+
createAgent,
3+
type Agent,
4+
type AgentDefinition,
5+
type BaseEnv,
6+
} from "@intx/agent";
7+
8+
// XXX — @intx/agent resolveTools snapshots `byName` from each bundle's
9+
// definitions at createAgent and never consults a live getter. MCP tools
10+
// arrive later via DynamicToolRunner.addTools (servers connect after the TUI
11+
// is up; one OAuth-blocked server can also stall the post-connect reload
12+
// that would rebuild the snapshot). A miss then returns `unknown tool`
13+
// even though tool_search already listed the name from the live runner.
14+
//
15+
// resolveTools is not exported. During its synchronous walk it does
16+
// `new Map()` for that snapshot; we install a Map whose get() falls back
17+
// to the single live tool bundle so late names reach DynamicToolRunner.run.
18+
// Restore Map before createAgent awaits so only that snapshot is live.
19+
// Drop this wrapper when @intx/agent dispatches through the bundle's
20+
// current definitions (the characterization test in
21+
// tests/integration/mcp-late-dispatch.test.ts will fail first).
22+
23+
const OriginalMap = globalThis.Map;
24+
25+
export function isLiveToolBundle(value: unknown): boolean {
26+
if (typeof value !== "object" || value === null) return false;
27+
const candidate = value as Record<string, unknown>;
28+
return (
29+
typeof candidate.addTools === "function" &&
30+
typeof candidate.currentDefinitions === "function" &&
31+
typeof candidate.run === "function"
32+
);
33+
}
34+
35+
export function fallbackLiveToolBundle<V>(map: Map<unknown, V>): V | undefined {
36+
let found: V | undefined;
37+
for (const value of map.values()) {
38+
if (!isLiveToolBundle(value)) continue;
39+
if (found !== undefined && found !== value) return undefined;
40+
found = value;
41+
}
42+
return found;
43+
}
44+
45+
function createLiveDispatchMap<K, V>(iterable?: Iterable<readonly [K, V]> | null): Map<K, V> {
46+
const map = new OriginalMap<K, V>(iterable ?? undefined);
47+
const protoGet = OriginalMap.prototype.get.bind(map);
48+
map.get = (key: K) => {
49+
const hit = protoGet(key);
50+
if (hit !== undefined || typeof key !== "string") return hit;
51+
return fallbackLiveToolBundle(map) ?? hit;
52+
};
53+
return map;
54+
}
55+
56+
// Compatible with `new Map()` inside published @intx/agent. Not a class —
57+
// we only need a constructable that returns a Map with a live get().
58+
const LiveDispatchMap = Object.assign(
59+
function LiveDispatchMap<K, V>(iterable?: Iterable<readonly [K, V]> | null): Map<K, V> {
60+
return createLiveDispatchMap(iterable);
61+
},
62+
{ prototype: OriginalMap.prototype },
63+
) as unknown as MapConstructor;
64+
65+
export function withLiveToolDispatchMap<T>(fn: () => T): T {
66+
const previous = globalThis.Map;
67+
globalThis.Map = LiveDispatchMap;
68+
try {
69+
return fn();
70+
} finally {
71+
globalThis.Map = previous;
72+
}
73+
}
74+
75+
export function createAgentWithLiveToolDispatch<EnvReq extends BaseEnv>(
76+
def: AgentDefinition<EnvReq>,
77+
env: EnvReq,
78+
): Promise<Agent> {
79+
return withLiveToolDispatchMap(() => createAgent(def, env));
80+
}

‎src/exec/runner.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { createInterface } from "node:readline/promises";
33
import { stdin as input, stdout as output, stderr } from "node:process";
44
import { isAbsolute, join, resolve } from "node:path";
55
import {
6-
createAgent,
76
defineAgent,
87
defineTool,
98
createDirectorRegistry,
@@ -54,6 +53,7 @@ import type {
5453
PermissionRequest,
5554
} from "../permission/types.js";
5655
import { createAgentToolset, type AgentToolset, type OperatorResult } from "../agent/tools.js";
56+
import { createAgentWithLiveToolDispatch } from "../agent/live-tool-dispatch.js";
5757
import { liveTelemetry } from "../telemetry/singleton.js";
5858
import { collectToolPlugins, resolveToolPlugins } from "../plugins/tool-plugins.js";
5959
import {
@@ -547,7 +547,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
547547
const withLiveCreds = sources.map((s) =>
548548
s.id === liveSource.id ? { ...s, apiKey: liveSource.apiKey } : s,
549549
);
550-
return createAgent(def, {
550+
return createAgentWithLiveToolDispatch(def, {
551551
sources: withLiveCreds,
552552
defaultSource,
553553
storage,

‎src/subagent/run.ts‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { liveTelemetry } from "../telemetry/singleton.js";
88
import { join } from "node:path";
99

1010
import {
11-
createAgent,
1211
defineAgent,
1312
defineTool,
1413
createDirectorRegistry,
@@ -19,6 +18,7 @@ import {
1918
import type { AgentTool } from "@intx/agent";
2019
import { noopAuditStore, permissiveAuthorize } from "@intx/agent/testing";
2120
import { createOptimizedContextStore } from "../session/optimized-context-store.js";
21+
import { createAgentWithLiveToolDispatch } from "../agent/live-tool-dispatch.js";
2222
import { type } from "arktype";
2323
import { createPosixTools } from "@intx/tools-posix";
2424
import { createDynamicToolRunner } from "../tui/dynamic-tool-runner.js";
@@ -260,7 +260,7 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> {
260260
}),
261261
});
262262

263-
let agent: Awaited<ReturnType<typeof createAgent>> | null = null;
263+
let agent: Awaited<ReturnType<typeof createAgentWithLiveToolDispatch>> | null = null;
264264
let streamPromise: Promise<void> | undefined;
265265
let closeOnAbort: (() => void) | undefined;
266266
// Declared before try (same reasoning as closeOnAbort above): assigned once
@@ -380,8 +380,7 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> {
380380
}),
381381
});
382382

383-
384-
let agentHandle: Awaited<ReturnType<typeof createAgent>> | null = null;
383+
let agentHandle: Awaited<ReturnType<typeof createAgentWithLiveToolDispatch>> | null = null;
385384
const requestContinuation = (): void => {
386385
try {
387386
agentHandle?.deliver(buildCompactionContinuationMessage());
@@ -483,7 +482,7 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> {
483482
const inferenceDeps = await createInferenceDependencies();
484483
const subagentSource =
485484
bundle.sources.find((s) => s.id === bundle.defaultSource) ?? bundle.sources[0];
486-
agent = await createAgent(def, {
485+
agent = await createAgentWithLiveToolDispatch(def, {
487486
sources: bundle.sources,
488487
defaultSource: bundle.defaultSource,
489488
storage,

‎src/tui/runner.ts‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { isAbsolute, join, resolve as resolvePath } from "node:path";
22
import { readFile } from "node:fs/promises";
33
import { EventEmitter } from "node:events";
44
import {
5-
createAgent,
65
defineAgent,
76
defineTool,
87
createDirectorRegistry,
@@ -154,6 +153,7 @@ import { createPermissionsAdmin, type ScopedApproval } from "../permission/admin
154153
import type { GrantScope } from "../permission/types.js";
155154

156155
import { createAgentToolset, type MCPServerState, type OperatorResult } from "../agent/tools.js";
156+
import { createAgentWithLiveToolDispatch } from "../agent/live-tool-dispatch.js";
157157
import { collectWebPlugins, resolveWebProviderFromPlugins, webBrand } from "../web/plugin-provider.js";
158158
import { collectToolPlugins, resolveToolPlugins } from "../plugins/tool-plugins.js";
159159
import { scrubSecrets } from "../web/secret-scrub.js";
@@ -1417,7 +1417,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
14171417
const storage = await createOptimizedContextStore(workdir);
14181418
const sources = liveSources.length > 0 ? liveSources : [liveSource];
14191419
const defaultSource = liveDefaultSource.length > 0 ? liveDefaultSource : liveSource.id;
1420-
return createAgent(def, {
1420+
return createAgentWithLiveToolDispatch(def, {
14211421
sources,
14221422
defaultSource,
14231423
storage,
@@ -2474,10 +2474,12 @@ export async function runTUI(initialConfig: Config): Promise<number> {
24742474

24752475
// Connect MCP servers after the TUI is up so the UI is usable immediately and
24762476
// any OAuth authorization is surfaced as a copyable link rather than a browser
2477-
// pop. Newly discovered tools are advertised to the live director right away;
2478-
// once connection resolves, the agent is reloaded (when idle) so the tools are
2479-
// also dispatchable. Aborted on exit so an unfinished auth wait does not keep
2480-
// the process alive.
2477+
// pop. Each connected server's tools land on the live runner and are
2478+
// dispatchable the same turn (createAgentWithLiveToolDispatch). They stay
2479+
// unadvertised until tool_search promotes them. When every server has
2480+
// settled, reload-if-idle so construction-time maps match, then resume any
2481+
// persisted workflow. Aborted on exit so an unfinished auth wait does not
2482+
// keep the process alive.
24812483
const mcpConnectController = new AbortController();
24822484
void toolset
24832485
.connectMCP(

‎tests/integration/harness.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/**
2-
* Agent-loop integration harness for Corbits Code: wires `createAgent` to
2+
* Agent-loop integration harness for Corbits Code: wires
3+
* `createAgentWithLiveToolDispatch` (production default) to
34
* `@intx/inference-testing` so full reactor cycles run without network I/O.
45
*
56
* Production-shaped stack: `createChatDirector`, `createAgentToolset` (posix +
@@ -24,6 +25,7 @@ import { setupHarness, type Harness } from "@intx/inference-testing";
2425
import type { ContextTransform, InferenceSource } from "@intx/types/runtime";
2526
import { type } from "arktype";
2627

28+
import { createAgentWithLiveToolDispatch } from "../../src/agent/live-tool-dispatch.js";
2729
import { createChatDirector } from "../../src/agent/director.js";
2830
import { createAgentToolset } from "../../src/agent/tools.js";
2931
import { ID_PREFIX } from "../../src/branding.js";
@@ -51,6 +53,8 @@ export type OpenIntegrationSessionOpts = {
5153
systemPrompt?: string;
5254
/** Pre-inference transforms, delivered the production way: riding deps. */
5355
contextTransforms?: ContextTransform[];
56+
/** Override to pin the published createAgent snapshot (characterization). */
57+
createAgentFn?: typeof createAgent;
5458
};
5559

5660
export async function openIntegrationSession(
@@ -90,7 +94,8 @@ export async function openIntegrationSession(
9094
});
9195

9296
const storage = await createOptimizedContextStore(workdir);
93-
const agent = await createAgent(def, {
97+
const startAgent = opts.createAgentFn ?? createAgentWithLiveToolDispatch;
98+
const agent = await startAgent(def, {
9499
sources: [INTEGRATION_SOURCE],
95100
defaultSource: INTEGRATION_SOURCE.id,
96101
storage,

0 commit comments

Comments
 (0)