Skip to content

Commit 7a36bb3

Browse files
authored
feat(gateway): show warm MCP tools in effective inventory
Add read-only MCP visibility to `tools.effective` by projecting MCP tools only after a session catalog has already been warmed by an agent turn. Keep the gateway additive: no `tools.effective.refresh`, no forced MCP startup, and no behavior change for MCP loading. Verification: - `git diff --check origin/main..HEAD` - `node scripts/run-vitest.mjs run --config test/vitest/vitest.agents.config.ts --reporter=verbose src/agents/tools-effective-inventory.test.ts` - GitHub checks green on `a8a7f8442adb216f60da24d50118374a15c62e06`, including `Real behavior proof`, `check-guards`, `check-prod-types`, `check-test-types`, `build-artifacts`, `Critical Quality (gateway-runtime-boundary)`, and `Critical Quality (network-runtime-boundary)`. Co-authored-by: David Huang <nxmxbbd@gmail.com>
1 parent b261e9e commit 7a36bb3

20 files changed

Lines changed: 1046 additions & 221 deletions

docs/gateway/protocol.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -562,8 +562,14 @@ terminal summary, and sanitized error text.
562562
- `sessionKey` is required.
563563
- The gateway derives trusted runtime context from the session server-side instead of accepting
564564
caller-supplied auth or delivery context.
565-
- The response is session-scoped and reflects what the active conversation can use right now,
566-
including core, plugin, and channel tools.
565+
- The response is a session-scoped server-derived projection of the active inventory,
566+
including core, plugin, channel, and already-discovered MCP server tools.
567+
- `tools.effective` is read-only for MCP: it may project a warm session MCP catalog through the
568+
final tool policy, but it does not create MCP runtimes, connect transports, or issue
569+
`tools/list`. If no matching warm catalog exists, the response may include a notice such as
570+
`mcp-not-yet-connected`, `mcp-not-yet-listed`, or `mcp-stale-catalog`.
571+
- Effective tool entries use `source="core"`, `source="plugin"`, `source="channel"`, or
572+
`source="mcp"`.
567573
- Operators may call `tools.invoke` (`operator.write`) to invoke one available tool through the
568574
same gateway policy path as `/tools/invoke`.
569575
- `name` is required. `args`, `sessionKey`, `agentId`, `confirm`, and

docs/web/webchat.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,15 @@ Normal agent-run final answers should be durable because the embedded runtime wr
6060
## Control UI agents tools panel
6161

6262
- The Control UI `/agents` Tools panel has two separate views:
63-
- **Available Right Now** uses `tools.effective(sessionKey=...)` and shows what the current
64-
session can actually use at runtime, including core, plugin, and channel-owned tools.
63+
- **Available Right Now** uses `tools.effective(sessionKey=...)` and shows a server-derived
64+
read-only projection of the current session inventory, including core, plugin, channel-owned,
65+
and already-discovered MCP server tools.
6566
- **Tool Configuration** uses `tools.catalog` and stays focused on profiles, overrides, and
6667
catalog semantics.
6768
- Runtime availability is session-scoped. Switching sessions on the same agent can change the
68-
**Available Right Now** list.
69+
**Available Right Now** list. If configured MCP servers have not been connected or were changed
70+
since the last discovery, the panel shows a notice instead of silently starting MCP transports
71+
from the read path.
6972
- The config editor does not imply runtime availability; effective access still follows policy
7073
precedence (`allow`/`deny`, per-agent and provider/channel overrides).
7174

src/agents/agent-bundle-mcp-materialize.ts

Lines changed: 52 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@ import {
99
normalizeReservedToolNames,
1010
TOOL_NAME_SEPARATOR,
1111
} from "./agent-bundle-mcp-names.js";
12-
import type { BundleMcpToolRuntime, SessionMcpRuntime } from "./agent-bundle-mcp-types.js";
12+
import type {
13+
BundleMcpToolRuntime,
14+
McpCatalogTool,
15+
McpToolCatalog,
16+
SessionMcpRuntime,
17+
} from "./agent-bundle-mcp-types.js";
1318
import { normalizeToolParameterSchema } from "./agent-tools-parameter-schema.js";
1419
import type { AgentToolResult } from "./runtime/index.js";
1520
import type { AnyAgentTool } from "./tools/common.js";
@@ -62,24 +67,18 @@ function toAgentToolResult(params: {
6267
};
6368
}
6469

65-
export async function materializeBundleMcpToolsForRun(params: {
66-
runtime: SessionMcpRuntime;
70+
/**
71+
* Projects an already-listed MCP catalog into agent tools. Without `createExecute`,
72+
* the projected tools are inventory-only and throw if execution is attempted.
73+
*/
74+
export function buildBundleMcpToolsFromCatalog(params: {
75+
catalog: McpToolCatalog;
6776
reservedToolNames?: Iterable<string>;
68-
disposeRuntime?: () => Promise<void>;
69-
}): Promise<BundleMcpToolRuntime> {
70-
let disposed = false;
71-
const releaseLease = params.runtime.acquireLease?.();
72-
params.runtime.markUsed();
73-
let catalog;
74-
try {
75-
catalog = await params.runtime.getCatalog();
76-
} catch (error) {
77-
releaseLease?.();
78-
throw error;
79-
}
77+
createExecute?: (tool: McpCatalogTool) => AnyAgentTool["execute"];
78+
}): AnyAgentTool[] {
8079
const reservedNames = normalizeReservedToolNames(params.reservedToolNames);
81-
const tools: BundleMcpToolRuntime["tools"] = [];
82-
const sortedCatalogTools = [...catalog.tools].toSorted((a, b) => {
80+
const tools: AnyAgentTool[] = [];
81+
const sortedCatalogTools = [...params.catalog.tools].toSorted((a, b) => {
8382
const serverOrder = a.safeServerName.localeCompare(b.safeServerName);
8483
if (serverOrder !== 0) {
8584
return serverOrder;
@@ -112,15 +111,11 @@ export async function materializeBundleMcpToolsForRun(params: {
112111
label: tool.title ?? tool.toolName,
113112
description: tool.description || tool.fallbackDescription,
114113
parameters: normalizeToolParameterSchema(tool.inputSchema),
115-
execute: async (_toolCallId: string, input: unknown) => {
116-
params.runtime.markUsed();
117-
const result = await params.runtime.callTool(tool.serverName, tool.toolName, input);
118-
return toAgentToolResult({
119-
serverName: tool.serverName,
120-
toolName: tool.toolName,
121-
result,
122-
});
123-
},
114+
execute:
115+
params.createExecute?.(tool) ??
116+
(async () => {
117+
throw new Error("bundle-mcp catalog projection cannot execute tools");
118+
}),
124119
};
125120
setPluginToolMeta(agentTool, {
126121
pluginId: "bundle-mcp",
@@ -133,6 +128,37 @@ export async function materializeBundleMcpToolsForRun(params: {
133128
// turns (defensive — listTools() order is usually stable but not guaranteed).
134129
// Cannot fix name collisions: collision suffixes above are order-dependent.
135130
tools.sort((a, b) => a.name.localeCompare(b.name));
131+
return tools;
132+
}
133+
134+
export async function materializeBundleMcpToolsForRun(params: {
135+
runtime: SessionMcpRuntime;
136+
reservedToolNames?: Iterable<string>;
137+
disposeRuntime?: () => Promise<void>;
138+
}): Promise<BundleMcpToolRuntime> {
139+
let disposed = false;
140+
const releaseLease = params.runtime.acquireLease?.();
141+
params.runtime.markUsed();
142+
let catalog;
143+
try {
144+
catalog = await params.runtime.getCatalog();
145+
} catch (error) {
146+
releaseLease?.();
147+
throw error;
148+
}
149+
const tools = buildBundleMcpToolsFromCatalog({
150+
catalog,
151+
reservedToolNames: params.reservedToolNames,
152+
createExecute: (tool) => async (_toolCallId: string, input: unknown) => {
153+
params.runtime.markUsed();
154+
const result = await params.runtime.callTool(tool.serverName, tool.toolName, input);
155+
return toAgentToolResult({
156+
serverName: tool.serverName,
157+
toolName: tool.toolName,
158+
result,
159+
});
160+
},
161+
});
136162

137163
return {
138164
tools,

src/agents/agent-bundle-mcp-runtime.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import fs from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
44
import { afterEach, describe, expect, it, vi } from "vitest";
5-
import { writeExecutable } from "./bundle-mcp-shared.test-harness.js";
65
import { createBundleMcpJsonSchemaValidator } from "./agent-bundle-mcp-runtime.js";
76
import { cleanupBundleMcpHarness } from "./agent-bundle-mcp-test-harness.js";
87
import {
@@ -13,6 +12,7 @@ import {
1312
retireSessionMcpRuntimeForSessionKey,
1413
} from "./agent-bundle-mcp-tools.js";
1514
import type { SessionMcpRuntime } from "./agent-bundle-mcp-types.js";
15+
import { writeExecutable } from "./bundle-mcp-shared.test-harness.js";
1616

1717
vi.mock("./embedded-agent-mcp.js", () => ({
1818
loadEmbeddedAgentMcpConfig: (params: {
@@ -168,6 +168,7 @@ function makeRuntime(
168168
markUsed: () => {
169169
lastUsedAt = Date.now();
170170
},
171+
peekCatalog: () => null,
171172
getCatalog: async () => ({
172173
version: 1,
173174
generatedAt: 0,
@@ -724,6 +725,46 @@ describe("session MCP runtime", () => {
724725
expect(manager.listSessionIds()).not.toContain("session-a");
725726
});
726727

728+
it("peeks existing runtimes and populated catalogs without creating new runtimes", async () => {
729+
let catalogReady = false;
730+
const createRuntime: RuntimeFactory = (params) => {
731+
const base = makeRuntime([{ toolName: "bundle_probe", description: "Bundle MCP probe" }]);
732+
let cachedCatalog: ReturnType<SessionMcpRuntime["peekCatalog"]> = null;
733+
return {
734+
...base,
735+
sessionId: params.sessionId,
736+
sessionKey: params.sessionKey,
737+
workspaceDir: params.workspaceDir,
738+
configFingerprint: params.configFingerprint ?? "fingerprint",
739+
peekCatalog: () => cachedCatalog,
740+
getCatalog: async () => {
741+
const catalog = await base.getCatalog();
742+
cachedCatalog = catalog;
743+
catalogReady = true;
744+
return catalog;
745+
},
746+
};
747+
};
748+
const manager = testing.createSessionMcpRuntimeManager({ createRuntime });
749+
750+
expect(manager.peekSession({ sessionId: "session-peek" })).toBeUndefined();
751+
752+
const runtime = await manager.getOrCreate({
753+
sessionId: "session-peek",
754+
sessionKey: "agent:test:session-peek",
755+
workspaceDir: "/workspace",
756+
});
757+
expect(manager.peekSession({ sessionId: "session-peek" })).toBe(runtime);
758+
expect(manager.peekSession({ sessionKey: "agent:test:session-peek" })).toBe(runtime);
759+
expect(runtime.peekCatalog()).toBeNull();
760+
expect(catalogReady).toBe(false);
761+
762+
await runtime.getCatalog();
763+
764+
expect(catalogReady).toBe(true);
765+
expect(runtime.peekCatalog()?.tools.map((tool) => tool.toolName)).toEqual(["bundle_probe"]);
766+
});
767+
727768
it("recreates the session runtime when MCP config changes", async () => {
728769
const createRuntime: RuntimeFactory = (params) => {
729770
const probeText = String(

src/agents/agent-bundle-mcp-runtime.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
33
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
44
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
55
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
6+
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv-provider.js";
67
import type {
78
JsonSchemaType,
89
JsonSchemaValidator,
910
jsonSchemaValidator,
1011
} from "@modelcontextprotocol/sdk/validation/types.js";
11-
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv-provider.js";
1212
import { Compile } from "typebox/compile";
1313
import type { OpenClawConfig } from "../config/types.openclaw.js";
1414
import { logWarn } from "../logger.js";
@@ -242,6 +242,33 @@ function loadSessionMcpConfig(params: {
242242
};
243243
}
244244

245+
/**
246+
* Loads enabled MCP config metadata for a session without creating runtimes,
247+
* connecting transports, or issuing MCP tools/list requests.
248+
*/
249+
export function resolveSessionMcpConfigSummary(params: {
250+
workspaceDir: string;
251+
cfg?: OpenClawConfig;
252+
}): { fingerprint: string; serverNames: string[] } {
253+
const { loaded, fingerprint } = loadSessionMcpConfig({
254+
workspaceDir: params.workspaceDir,
255+
cfg: params.cfg,
256+
logDiagnostics: false,
257+
});
258+
return {
259+
fingerprint,
260+
serverNames: Object.keys(loaded.mcpServers).toSorted((a, b) => a.localeCompare(b)),
261+
};
262+
}
263+
264+
/** Returns the session MCP config fingerprint with the same no-runtime/no-connect contract as the summary helper. */
265+
export function resolveSessionMcpConfigFingerprint(params: {
266+
workspaceDir: string;
267+
cfg?: OpenClawConfig;
268+
}): string {
269+
return resolveSessionMcpConfigSummary(params).fingerprint;
270+
}
271+
245272
function createDisposedError(sessionId: string): Error {
246273
return new Error(`bundle-mcp runtime disposed for session ${sessionId}`);
247274
}
@@ -421,6 +448,10 @@ export function createSessionMcpRuntime(params: {
421448
};
422449
},
423450
getCatalog,
451+
/** Synchronous catalog snapshot only; must not connect transports or issue tools/list. */
452+
peekCatalog() {
453+
return catalog;
454+
},
424455
markUsed() {
425456
lastUsedAt = Date.now();
426457
},
@@ -611,6 +642,13 @@ function createSessionMcpRuntimeManager(
611642
resolveSessionId(sessionKey) {
612643
return sessionIdBySessionKey.get(sessionKey);
613644
},
645+
/** Synchronous lookup only; must not create runtimes or connect transports. */
646+
peekSession(params) {
647+
const sessionId =
648+
params.sessionId ??
649+
(params.sessionKey ? sessionIdBySessionKey.get(params.sessionKey) : undefined);
650+
return sessionId ? runtimesBySessionId.get(sessionId) : undefined;
651+
},
614652
async disposeSession(sessionId) {
615653
const inFlight = createInFlight.get(sessionId);
616654
createInFlight.delete(sessionId);
@@ -666,6 +704,19 @@ export async function getOrCreateSessionMcpRuntime(params: {
666704
return await getSessionMcpRuntimeManager().getOrCreate(params);
667705
}
668706

707+
/** Looks up an existing session MCP runtime without creating it or connecting transports. */
708+
export function peekSessionMcpRuntime(params: {
709+
sessionId?: string | null;
710+
sessionKey?: string | null;
711+
}): SessionMcpRuntime | undefined {
712+
const sessionId = normalizeOptionalString(params.sessionId);
713+
const sessionKey = normalizeOptionalString(params.sessionKey);
714+
return getSessionMcpRuntimeManager().peekSession({
715+
...(sessionId ? { sessionId } : {}),
716+
...(sessionKey ? { sessionKey } : {}),
717+
});
718+
}
719+
669720
export async function disposeSessionMcpRuntime(sessionId: string): Promise<void> {
670721
await getSessionMcpRuntimeManager().disposeSession(sessionId);
671722
}

src/agents/agent-bundle-mcp-tools.materialize.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,18 @@ function makeToolRuntime(
5151
},
5252
tools,
5353
}),
54+
peekCatalog: () => ({
55+
version: 1,
56+
generatedAt: 0,
57+
servers: {
58+
[serverName]: {
59+
serverName,
60+
launchSummary: serverName,
61+
toolCount: tools.length,
62+
},
63+
},
64+
tools,
65+
}),
5466
callTool: async () => ({
5567
content: [{ type: "text", text: params.resultText ?? "FROM-BUNDLE" }],
5668
isError: false,

src/agents/agent-bundle-mcp-tools.request-boundary.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,18 @@ function makeConfiguredRuntime(
5555
},
5656
tools,
5757
}),
58+
peekCatalog: () => ({
59+
version: 1,
60+
generatedAt: 0,
61+
servers: {
62+
[serverName]: {
63+
serverName,
64+
launchSummary: serverName,
65+
toolCount: tools.length,
66+
},
67+
},
68+
tools,
69+
}),
5870
callTool: async () => ({
5971
content: [{ type: "text", text: "FROM-CONFIG" }],
6072
isError: false,

src/agents/agent-bundle-mcp-tools.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,14 @@ export {
1414
disposeSessionMcpRuntime,
1515
getOrCreateSessionMcpRuntime,
1616
getSessionMcpRuntimeManager,
17+
peekSessionMcpRuntime,
18+
resolveSessionMcpConfigFingerprint,
19+
resolveSessionMcpConfigSummary,
1720
retireSessionMcpRuntime,
1821
retireSessionMcpRuntimeForSessionKey,
1922
} from "./agent-bundle-mcp-runtime.js";
2023
export {
24+
buildBundleMcpToolsFromCatalog,
2125
createBundleMcpToolRuntime,
2226
materializeBundleMcpToolsForRun,
2327
} from "./agent-bundle-mcp-materialize.js";

src/agents/agent-bundle-mcp-types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@ export type SessionMcpRuntime = {
4040
lastUsedAt: number;
4141
activeLeases?: number;
4242
acquireLease?: () => () => void;
43+
/** Lists tools if needed and may connect MCP transports. */
4344
getCatalog: () => Promise<McpToolCatalog>;
45+
/** Returns the cached catalog only; must not start runtimes, connect transports, or issue tools/list. */
46+
peekCatalog: () => McpToolCatalog | null;
4447
markUsed: () => void;
4548
callTool: (serverName: string, toolName: string, input: unknown) => Promise<CallToolResult>;
4649
dispose: () => Promise<void>;
@@ -55,6 +58,11 @@ export type SessionMcpRuntimeManager = {
5558
}) => Promise<SessionMcpRuntime>;
5659
bindSessionKey: (sessionKey: string, sessionId: string) => void;
5760
resolveSessionId: (sessionKey: string) => string | undefined;
61+
/** Looks up an existing runtime only; must not create runtimes or connect transports. */
62+
peekSession: (params: {
63+
sessionId?: string;
64+
sessionKey?: string;
65+
}) => SessionMcpRuntime | undefined;
5866
disposeSession: (sessionId: string) => Promise<void>;
5967
disposeAll: () => Promise<void>;
6068
sweepIdleRuntimes: () => Promise<number>;

0 commit comments

Comments
 (0)