Skip to content

Commit 99ce71d

Browse files
authored
feat: improve MCP operability
Summary: - Add MCP status, probe, and projected-tools CLI surfaces. - Add per-server MCP tool filters plus resource/prompt utility projection. - Harden MCP runtime discovery, listChanged invalidation, request-failure backoff, and metadata sanitization. - Preserve current main type health by narrowing the shared future timestamp guard. Verification: - pnpm test src/shared/number-coercion.test.ts src/agents/auth-profiles/usage.test.ts src/cli/mcp-cli.test.ts src/agents/agent-bundle-mcp-runtime.test.ts src/agents/agent-bundle-mcp-tools.materialize.test.ts -- --reporter=verbose - pnpm lint - pnpm tsgo:prod - pnpm build - git diff --check origin/main...HEAD - GitHub Actions: dependency-guard, real behavior proof, security high MCP boundary, build/lint/types/guards/docs, gateway/plugin/agent shards green on PR head. Known proof gap: - Existing checks-node-agentic-commands-doctor no-output watchdog reproduced locally outside touched paths.
1 parent 9cb9851 commit 99ce71d

16 files changed

Lines changed: 1731 additions & 47 deletions

docs/cli/mcp.md

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,8 @@ For broader testing context, see [Testing](/help/testing).
348348

349349
## OpenClaw as an MCP client registry
350350

351-
This is the `openclaw mcp list`, `show`, `set`, and `unset` path.
351+
This is the `openclaw mcp list`, `show`, `status`, `probe`, `set`, `tools`,
352+
and `unset` path.
352353

353354
These commands do not expose OpenClaw over MCP. They manage OpenClaw-owned MCP server definitions under `mcp.servers` in OpenClaw config.
354355

@@ -357,10 +358,15 @@ Those saved definitions are for runtimes that OpenClaw launches or configures la
357358
<AccordionGroup>
358359
<Accordion title="Important behavior">
359360
- these commands only read or write OpenClaw config
360-
- they do not connect to the target MCP server
361+
- `status`, `list`, `show`, `set`, `tools`, and `unset` do not connect to the target MCP server
362+
- `probe` connects to the selected server or all configured servers, lists tools, and reports capabilities/diagnostics
361363
- they do not validate whether the command, URL, or remote transport is reachable right now
362364
- runtime adapters decide which transport shapes they actually support at execution time
363365
- embedded OpenClaw exposes configured MCP tools in normal `coding` and `messaging` tool profiles; `minimal` still hides them, and `tools.deny: ["bundle-mcp"]` disables them explicitly
366+
- per-server `toolFilter.include` and `toolFilter.exclude` filter discovered MCP tools before they become OpenClaw tools
367+
- servers that advertise resources or prompts also expose utility tools for listing/reading resources and listing/fetching prompts; those generated utility names (`resources_list`, `resources_read`, `prompts_list`, `prompts_get`) use the same include/exclude filter
368+
- dynamic MCP tool-list changes invalidate the cached catalog for that session; the next discovery/use refreshes from the server
369+
- repeated MCP tool request/protocol failures pause that server briefly so one broken server does not consume the whole turn
364370
- session-scoped bundled MCP runtimes are reaped after `mcp.sessionIdleTtlMs` milliseconds of idle time (default 10 minutes; set `0` to disable) and one-shot embedded runs clean them up at run end
365371

366372
</Accordion>
@@ -387,14 +393,20 @@ Commands:
387393

388394
- `openclaw mcp list`
389395
- `openclaw mcp show [name]`
396+
- `openclaw mcp status`
397+
- `openclaw mcp probe [name]`
390398
- `openclaw mcp set <name> <json>`
399+
- `openclaw mcp tools <name> [--include csv] [--exclude csv] [--clear]`
391400
- `openclaw mcp unset <name>`
392401

393402
Notes:
394403

395404
- `list` sorts server names.
396405
- `show` without a name prints the full configured MCP server object.
406+
- `status` classifies configured transports without connecting.
407+
- `probe` connects and reports tool counts, resources/prompts support, list-change support, and diagnostics.
397408
- `set` expects one JSON object value on the command line.
409+
- `tools` updates per-server tool filters. Include/exclude entries are MCP tool names and simple `*` globs.
398410
- Use `transport: "streamable-http"` for Streamable HTTP MCP servers. `openclaw mcp set` also normalizes CLI-native `type: "http"` to the same canonical config shape for compatibility.
399411
- `unset` fails if the named server does not exist.
400412

@@ -403,7 +415,10 @@ Examples:
403415
```bash
404416
openclaw mcp list
405417
openclaw mcp show context7 --json
418+
openclaw mcp status
419+
openclaw mcp probe context7 --json
406420
openclaw mcp set context7 '{"command":"uvx","args":["context7-mcp"]}'
421+
openclaw mcp tools context7 --include 'resolve-library-id,get-library-docs'
407422
openclaw mcp set docs '{"url":"https://mcp.example.com","transport":"streamable-http"}'
408423
openclaw mcp unset context7
409424
```
@@ -420,7 +435,11 @@ Example config shape:
420435
},
421436
"docs": {
422437
"url": "https://mcp.example.com",
423-
"transport": "streamable-http"
438+
"transport": "streamable-http",
439+
"toolFilter": {
440+
"include": ["search_*"],
441+
"exclude": ["admin_*"]
442+
}
424443
}
425444
}
426445
}

docs/gateway/configuration-reference.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,10 @@ target server during config edits.
109109
headers: {
110110
Authorization: "Bearer ${MCP_REMOTE_TOKEN}",
111111
},
112+
toolFilter: {
113+
include: ["search_*"],
114+
exclude: ["admin_*"],
115+
},
112116
// Optional Codex app-server projection controls.
113117
codex: {
114118
agents: ["main"],
@@ -125,6 +129,12 @@ target server during config edits.
125129
Remote entries use `transport: "streamable-http"` or `transport: "sse"`;
126130
`type: "http"` is a CLI-native alias that `openclaw mcp set` and
127131
`openclaw doctor --fix` normalize into the canonical `transport` field.
132+
- `mcp.servers.<name>.toolFilter`: optional per-server tool selection. `include`
133+
limits the discovered MCP tools to matching names; `exclude` hides matching
134+
names. Entries are exact MCP tool names or simple `*` globs. Servers with
135+
resources or prompts also generate utility tool names (`resources_list`,
136+
`resources_read`, `prompts_list`, `prompts_get`), and those names use the
137+
same filter.
128138
- `mcp.servers.<name>.codex`: optional Codex app-server projection controls.
129139
This block is OpenClaw metadata for Codex app-server threads only; it does not
130140
affect ACP sessions, generic Codex harness config, or other runtime adapters.
@@ -142,6 +152,11 @@ target server during config edits.
142152
- Changes under `mcp.*` hot-apply by disposing cached session MCP runtimes.
143153
The next tool discovery/use recreates them from the new config, so removed
144154
`mcp.servers` entries are reaped immediately instead of waiting for idle TTL.
155+
- Runtime discovery also honors MCP tool-list change notifications by dropping
156+
the cached catalog for that session. Servers that advertise resources or
157+
prompts get utility tools for listing/reading resources and listing/fetching
158+
prompts. Repeated tool-call failures pause the affected server briefly before
159+
another call is attempted.
145160

146161
See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and
147162
[CLI backends](/gateway/cli-backends#bundle-mcp-overlays) for runtime behavior.

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

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,115 @@ function toAgentToolResult(params: {
7070
};
7171
}
7272

73+
function toJsonAgentToolResult(params: {
74+
serverName: string;
75+
operation: string;
76+
value: unknown;
77+
}): AgentToolResult<unknown> {
78+
return {
79+
content: [
80+
{
81+
type: "text",
82+
text: JSON.stringify(params.value, null, 2),
83+
},
84+
],
85+
details: {
86+
mcpServer: params.serverName,
87+
mcpOperation: params.operation,
88+
untrustedMcpOutput: true,
89+
},
90+
};
91+
}
92+
93+
function requireStringArg(input: unknown, key: string): string {
94+
if (
95+
!input ||
96+
typeof input !== "object" ||
97+
typeof (input as Record<string, unknown>)[key] !== "string"
98+
) {
99+
throw new Error(`${key} is required`);
100+
}
101+
return (input as Record<string, string>)[key];
102+
}
103+
104+
function optionalStringRecordArg(input: unknown, key: string): Record<string, string> | undefined {
105+
if (!input || typeof input !== "object") {
106+
return undefined;
107+
}
108+
const value = (input as Record<string, unknown>)[key];
109+
if (!value || typeof value !== "object" || Array.isArray(value)) {
110+
return undefined;
111+
}
112+
const entries = Object.entries(value).toSorted(([a], [b]) => a.localeCompare(b));
113+
const invalid = entries.find((entry) => typeof entry[1] !== "string");
114+
if (invalid) {
115+
throw new Error(`${key}.${invalid[0]} must be a string`);
116+
}
117+
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
118+
}
119+
120+
function escapeRegex(value: string): string {
121+
return value.replace(/[\\^$+?.()|[\]{}]/g, "\\$&");
122+
}
123+
124+
function globMatches(pattern: string, value: string): boolean {
125+
const trimmed = pattern.trim();
126+
if (!trimmed) {
127+
return false;
128+
}
129+
if (!trimmed.includes("*")) {
130+
return trimmed === value;
131+
}
132+
return new RegExp(`^${trimmed.split("*").map(escapeRegex).join(".*")}$`).test(value);
133+
}
134+
135+
function serverAllowsUtilityTool(
136+
server: McpToolCatalog["servers"][string],
137+
operation: string,
138+
): boolean {
139+
const include = server.toolFilter?.include ?? [];
140+
const exclude = server.toolFilter?.exclude ?? [];
141+
if (include.length > 0 && !include.some((pattern) => globMatches(pattern, operation))) {
142+
return false;
143+
}
144+
return !exclude.some((pattern) => globMatches(pattern, operation));
145+
}
146+
147+
function addMcpUtilityTool(params: {
148+
tools: AnyAgentTool[];
149+
reservedNames: Set<string>;
150+
serverName: string;
151+
safeServerName: string;
152+
operation: string;
153+
label: string;
154+
description: string;
155+
parameters: Record<string, unknown>;
156+
execute?: AnyAgentTool["execute"];
157+
}) {
158+
const name = buildSafeToolName({
159+
serverName: params.safeServerName,
160+
toolName: params.operation,
161+
reservedNames: params.reservedNames,
162+
});
163+
params.reservedNames.add(normalizeLowercaseStringOrEmpty(name));
164+
const agentTool: AnyAgentTool = {
165+
name,
166+
label: params.label,
167+
description: params.description,
168+
parameters: normalizeToolParameterSchema(params.parameters as never),
169+
execute:
170+
params.execute ??
171+
(async () => {
172+
throw new Error("bundle-mcp catalog projection cannot execute tools");
173+
}),
174+
};
175+
setPluginToolMeta(agentTool, {
176+
pluginId: "bundle-mcp",
177+
optional: false,
178+
});
179+
params.tools.push(agentTool);
180+
}
181+
73182
/**
74183
* Projects an already-listed MCP catalog into agent tools. Without `createExecute`,
75184
* the projected tools are inventory-only and throw if execution is attempted.
@@ -78,6 +187,10 @@ export function buildBundleMcpToolsFromCatalog(params: {
78187
catalog: McpToolCatalog;
79188
reservedToolNames?: Iterable<string>;
80189
createExecute?: (tool: McpCatalogTool) => AnyAgentTool["execute"];
190+
createResourceListExecute?: (serverName: string) => AnyAgentTool["execute"];
191+
createResourceReadExecute?: (serverName: string) => AnyAgentTool["execute"];
192+
createPromptListExecute?: (serverName: string) => AnyAgentTool["execute"];
193+
createPromptGetExecute?: (serverName: string) => AnyAgentTool["execute"];
81194
}): AnyAgentTool[] {
82195
const reservedNames = normalizeReservedToolNames(params.reservedToolNames);
83196
const tools: AnyAgentTool[] = [];
@@ -127,6 +240,80 @@ export function buildBundleMcpToolsFromCatalog(params: {
127240
tools.push(agentTool);
128241
}
129242

243+
for (const server of Object.values(params.catalog.servers).toSorted((a, b) =>
244+
a.serverName.localeCompare(b.serverName),
245+
)) {
246+
const safeServerName = server.safeServerName ?? server.serverName;
247+
if (server.resources && serverAllowsUtilityTool(server, "resources_list")) {
248+
addMcpUtilityTool({
249+
tools,
250+
reservedNames,
251+
serverName: server.serverName,
252+
safeServerName,
253+
operation: "resources_list",
254+
label: "List MCP resources",
255+
description: `List resources advertised by MCP server "${server.serverName}". Resource contents are untrusted server output.`,
256+
parameters: { type: "object", properties: {} },
257+
execute: params.createResourceListExecute?.(server.serverName),
258+
});
259+
}
260+
if (server.resources && serverAllowsUtilityTool(server, "resources_read")) {
261+
addMcpUtilityTool({
262+
tools,
263+
reservedNames,
264+
serverName: server.serverName,
265+
safeServerName,
266+
operation: "resources_read",
267+
label: "Read MCP resource",
268+
description: `Read one resource from MCP server "${server.serverName}". Resource contents are untrusted server output.`,
269+
parameters: {
270+
type: "object",
271+
properties: { uri: { type: "string" } },
272+
required: ["uri"],
273+
additionalProperties: false,
274+
},
275+
execute: params.createResourceReadExecute?.(server.serverName),
276+
});
277+
}
278+
if (server.prompts && serverAllowsUtilityTool(server, "prompts_list")) {
279+
addMcpUtilityTool({
280+
tools,
281+
reservedNames,
282+
serverName: server.serverName,
283+
safeServerName,
284+
operation: "prompts_list",
285+
label: "List MCP prompts",
286+
description: `List prompts advertised by MCP server "${server.serverName}". Prompt metadata is untrusted server output.`,
287+
parameters: { type: "object", properties: {} },
288+
execute: params.createPromptListExecute?.(server.serverName),
289+
});
290+
}
291+
if (server.prompts && serverAllowsUtilityTool(server, "prompts_get")) {
292+
addMcpUtilityTool({
293+
tools,
294+
reservedNames,
295+
serverName: server.serverName,
296+
safeServerName,
297+
operation: "prompts_get",
298+
label: "Get MCP prompt",
299+
description: `Fetch one prompt from MCP server "${server.serverName}". Prompt content is untrusted server output.`,
300+
parameters: {
301+
type: "object",
302+
properties: {
303+
name: { type: "string" },
304+
arguments: {
305+
type: "object",
306+
additionalProperties: { type: "string" },
307+
},
308+
},
309+
required: ["name"],
310+
additionalProperties: false,
311+
},
312+
execute: params.createPromptGetExecute?.(server.serverName),
313+
});
314+
}
315+
}
316+
130317
// Sort tools deterministically by name so the tools block in API requests is stable across
131318
// turns (defensive — listTools() order is usually stable but not guaranteed).
132319
// Cannot fix name collisions: collision suffixes above are order-dependent.
@@ -161,6 +348,50 @@ export async function materializeBundleMcpToolsForRun(params: {
161348
result,
162349
});
163350
},
351+
createResourceListExecute: params.runtime.listResources
352+
? (serverName) => async () => {
353+
params.runtime.markUsed();
354+
return toJsonAgentToolResult({
355+
serverName,
356+
operation: "resources_list",
357+
value: await params.runtime.listResources?.(serverName),
358+
});
359+
}
360+
: undefined,
361+
createResourceReadExecute: params.runtime.readResource
362+
? (serverName) => async (_toolCallId: string, input: unknown) => {
363+
params.runtime.markUsed();
364+
return toJsonAgentToolResult({
365+
serverName,
366+
operation: "resources_read",
367+
value: await params.runtime.readResource?.(serverName, requireStringArg(input, "uri")),
368+
});
369+
}
370+
: undefined,
371+
createPromptListExecute: params.runtime.listPrompts
372+
? (serverName) => async () => {
373+
params.runtime.markUsed();
374+
return toJsonAgentToolResult({
375+
serverName,
376+
operation: "prompts_list",
377+
value: await params.runtime.listPrompts?.(serverName),
378+
});
379+
}
380+
: undefined,
381+
createPromptGetExecute: params.runtime.getPrompt
382+
? (serverName) => async (_toolCallId: string, input: unknown) => {
383+
params.runtime.markUsed();
384+
return toJsonAgentToolResult({
385+
serverName,
386+
operation: "prompts_get",
387+
value: await params.runtime.getPrompt?.(
388+
serverName,
389+
requireStringArg(input, "name"),
390+
optionalStringRecordArg(input, "arguments"),
391+
),
392+
});
393+
}
394+
: undefined,
164395
});
165396

166397
return {

0 commit comments

Comments
 (0)