From ec8cb8bcbfaed0df39d3e18119c40b3849eac58e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 31 May 2026 15:02:19 +0100 Subject: [PATCH] feat: add MCP code-mode namespace (#88636) * feat: add MCP code-mode namespace * fix: unblock mcp namespace ci gates --- docs/reference/code-mode.md | 52 +++- src/agents/agent-bundle-mcp-materialize.ts | 16 +- ...agent-bundle-mcp-tools.materialize.test.ts | 10 +- src/agents/code-mode-namespaces.ts | 271 +++++++++++++++++- src/agents/code-mode.test.ts | 211 ++++++++++++++ src/agents/code-mode.ts | 35 ++- src/agents/tool-search.ts | 28 +- src/plugins/tools.ts | 8 + 8 files changed, 603 insertions(+), 28 deletions(-) diff --git a/docs/reference/code-mode.md b/docs/reference/code-mode.md index 5d25b51196c..6a91751eeaa 100644 --- a/docs/reference/code-mode.md +++ b/docs/reference/code-mode.md @@ -44,6 +44,8 @@ When code mode is active: guest program through `ALL_TOOLS` and `tools`. - Guest code can search the hidden catalog, describe a tool, and call a tool through the same OpenClaw execution path used by normal agent turns. +- MCP tools are grouped under the `MCP` namespace. In code mode, this namespace + is the only supported way to call MCP tools. - `wait` resumes a suspended code-mode run when nested tool calls are still pending. @@ -381,6 +383,7 @@ The guest runtime exposes a small global API: ```typescript declare const ALL_TOOLS: ToolCatalogEntry[]; declare const tools: ToolCatalog; +declare const MCP: Record; declare const namespaces: Record; declare function text(value: unknown): void; @@ -432,6 +435,21 @@ const content = await tools.call(fileRead.id, { path: "README.md" }); const hits = await tools.web_search({ query: "OpenClaw code mode" }); ``` +MCP catalog entries are not callable through `tools.call(...)` or convenience +functions in code mode. Use the generated `MCP` namespace instead: + +```typescript +const issue = await MCP.github.createIssue({ + owner: "openclaw", + repo: "openclaw", + title: "Investigate gateway logs", +}); + +const snapshot = await MCP.chromeDevtools.takeSnapshot({ output: "markdown" }); +const resource = await MCP.docs.resources.read("memo://one"); +const prompt = await MCP.docs.prompts.get("brief", { topic: "release" }); +``` + The guest runtime must not expose host objects directly. Inputs and outputs cross the bridge as JSON-compatible values with explicit size caps. @@ -613,10 +631,10 @@ Namespace changes should cover the security boundary and the guest behavior: - suspended namespace calls resume through `wait` - plugin rollback clears the owning namespace registrations -Namespaces complement the generic `tools.search` / `tools.call` catalog. Use -the catalog for arbitrary enabled tools; use namespaces for plugin-owned, -documented domain APIs where concise code is more reliable than repeated schema -lookups. +Namespaces complement the generic `tools.search` / `tools.call` catalog. Use the +catalog for arbitrary enabled OpenClaw, plugin, and client tools; use `MCP` for +MCP tools; use other namespaces for plugin-owned, documented domain APIs where +concise code is more reliable than repeated schema lookups. ## Output API @@ -681,6 +699,12 @@ The catalog omits code-mode control tools: This prevents recursion and keeps the model-facing contract narrow. +MCP entries stay in the run-scoped catalog so policy, approvals, hooks, +telemetry, transcript projection, and exact tool ids remain shared with normal +tool execution. The guest-facing `tools.call(...)` bridge rejects MCP entries; +the generated `MCP..(...)` namespace resolves back to the exact +catalog id and then dispatches through the same executor path. + ## Tool Search interaction Code mode supersedes the OpenClaw Tool Search model surface for runs where it is @@ -693,6 +717,7 @@ When `tools.codeMode.enabled` is true and code mode activates: - The same cataloging idea moves inside the guest runtime. - The guest runtime receives compact `ALL_TOOLS` metadata and search, describe, and call helpers. +- MCP calls use the generated `MCP` namespace instead of `tools.call(...)`. - Nested calls dispatch through the same OpenClaw executor path that Tool Search uses. @@ -912,6 +937,8 @@ Code mode coverage should prove: - all effective tools appear in `ALL_TOOLS` - denied tools do not appear in `ALL_TOOLS` - `tools.search`, `tools.describe`, and `tools.call` work for OpenClaw tools +- MCP namespace calls work for visible MCP tools and direct MCP `tools.call` + attempts fail closed - Tool Search control tools are hidden from both the model surface and the hidden catalog - nested calls preserve approval and hook behavior @@ -939,13 +966,16 @@ Run these as integration or end-to-end tests when changing the runtime: 5. Send an agent turn with OpenClaw, plugin, MCP, and client test tools. 6. Assert the model-visible tool list is exactly `exec`, `wait`. 7. In `exec`, read `ALL_TOOLS` and assert the effective test tools are present. -8. In `exec`, call `tools.search`, `tools.describe`, and `tools.call`. -9. Assert denied tools are absent and cannot be called by guessed id. -10. Start a nested tool call that resolves after `exec` returns `waiting`. -11. Call `wait` and assert the restored VM receives the tool result. -12. Assert the final answer contains output produced after restore. -13. Assert timeout, abort, and snapshot expiry clean up runtime state. -14. Export trajectory and assert nested calls are visible under the parent +8. In `exec`, call OpenClaw/plugin/client tools through `tools.search`, + `tools.describe`, and `tools.call`. +9. In `exec`, call MCP tools through `MCP..(...)` and assert direct + MCP `tools.call(...)` attempts fail. +10. Assert denied tools are absent and cannot be called by guessed id. +11. Start a nested tool call that resolves after `exec` returns `waiting`. +12. Call `wait` and assert the restored VM receives the tool result. +13. Assert the final answer contains output produced after restore. +14. Assert timeout, abort, and snapshot expiry clean up runtime state. +15. Export trajectory and assert nested calls are visible under the parent code-mode call. Docs-only changes to this page should still run `pnpm check:docs`. diff --git a/src/agents/agent-bundle-mcp-materialize.ts b/src/agents/agent-bundle-mcp-materialize.ts index 37a21279860..083b6e022f4 100644 --- a/src/agents/agent-bundle-mcp-materialize.ts +++ b/src/agents/agent-bundle-mcp-materialize.ts @@ -3,7 +3,7 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { logWarn } from "../logger.js"; -import { setPluginToolMeta } from "../plugins/tools.js"; +import { setPluginToolMeta, type PluginToolMcpMeta } from "../plugins/tools.js"; import { buildSafeToolName, normalizeReservedToolNames, @@ -150,7 +150,7 @@ function addMcpUtilityTool(params: { serverName: string; safeServerName: string; executionMode: AnyAgentTool["executionMode"]; - operation: string; + operation: Exclude; label: string; description: string; parameters: Record; @@ -177,6 +177,12 @@ function addMcpUtilityTool(params: { setPluginToolMeta(agentTool, { pluginId: "bundle-mcp", optional: false, + mcp: { + serverName: params.serverName, + safeServerName: params.safeServerName, + toolName: params.operation, + operation: params.operation, + }, }); params.tools.push(agentTool); } @@ -242,6 +248,12 @@ export function buildBundleMcpToolsFromCatalog(params: { setPluginToolMeta(agentTool, { pluginId: "bundle-mcp", optional: false, + mcp: { + serverName: tool.serverName, + safeServerName: tool.safeServerName, + toolName: tool.toolName, + operation: "tool", + }, }); tools.push(agentTool); } diff --git a/src/agents/agent-bundle-mcp-tools.materialize.test.ts b/src/agents/agent-bundle-mcp-tools.materialize.test.ts index d359ae0d426..330848e9e5a 100644 --- a/src/agents/agent-bundle-mcp-tools.materialize.test.ts +++ b/src/agents/agent-bundle-mcp-tools.materialize.test.ts @@ -90,7 +90,15 @@ describe("createBundleMcpToolRuntime", () => { expect(runtime.tools.map((tool) => tool.name)).toEqual(["bundleProbe__bundle_probe"]); expect(runtime.tools[0].executionMode).toBe("sequential"); - expect(getPluginToolMeta(runtime.tools[0])?.pluginId).toBe("bundle-mcp"); + expect(getPluginToolMeta(runtime.tools[0])).toMatchObject({ + pluginId: "bundle-mcp", + mcp: { + serverName: "bundleProbe", + safeServerName: "bundleProbe", + toolName: "bundle_probe", + operation: "tool", + }, + }); const result = await runtime.tools[0].execute("call-bundle-probe", {}, undefined, undefined); expectTextContentBlock(result.content[0], "FROM-BUNDLE"); expect(result.details).toEqual({ diff --git a/src/agents/code-mode-namespaces.ts b/src/agents/code-mode-namespaces.ts index 9309dd88263..23d7178175a 100644 --- a/src/agents/code-mode-namespaces.ts +++ b/src/agents/code-mode-namespaces.ts @@ -14,6 +14,7 @@ const RESERVED_NAMESPACE_GLOBALS = new Set([ "JSON", "Map", "Math", + "MCP", "namespaces", "Number", "Object", @@ -45,6 +46,7 @@ export type CodeModeNamespaceToolInputMapper = (args: unknown[]) => unknown; export type CodeModeNamespaceToolCall = { readonly [CODE_MODE_NAMESPACE_TOOL_CALL]: true; readonly toolName: string; + readonly catalogId?: string; readonly input?: CodeModeNamespaceToolInputMapper; }; @@ -84,8 +86,17 @@ type CodeModeNamespaceRuntimeEntry = { }; type CodeModeNamespaceCatalogEntry = { + id?: string; + source?: string; name: string; sourceName?: string; + parameters?: unknown; + mcp?: { + serverName: string; + safeServerName: string; + toolName: string; + operation: "tool" | "resources_list" | "resources_read" | "prompts_list" | "prompts_get"; + }; }; export type CodeModeNamespaceRuntime = { @@ -97,6 +108,7 @@ export type CodeModeNamespaceRuntime = { executeTool: (params: { pluginId: string; toolName: string; + catalogId?: string; input: unknown; namespaceId: string; path: string[]; @@ -156,6 +168,27 @@ export function createCodeModeNamespaceTool( }; } +function createCodeModeNamespaceCatalogTool( + catalogId: string, + toolName: string, + input?: CodeModeNamespaceToolInputMapper, +): CodeModeNamespaceToolCall { + const normalizedCatalogId = catalogId.trim(); + const normalizedToolName = toolName.trim(); + if (!normalizedCatalogId) { + throw new Error("Code mode namespace catalogId must be non-empty."); + } + if (!normalizedToolName) { + throw new Error("Code mode namespace toolName must be non-empty."); + } + return { + [CODE_MODE_NAMESPACE_TOOL_CALL]: true, + catalogId: normalizedCatalogId, + toolName: normalizedToolName, + ...(input ? { input } : {}), + }; +} + function isCodeModeNamespaceToolCall(value: unknown): value is CodeModeNamespaceToolCall { const record = isRecord(value) ? (value as Record) : undefined; return ( @@ -265,6 +298,233 @@ function filterRegistrationsByVisibleTools( ); } +function toIdentifier(value: string, fallback: string): string { + const words = value + .trim() + .split(/[^A-Za-z0-9]+/u) + .map((word) => word.trim()) + .filter(Boolean); + const base = + words.length === 0 + ? fallback + : words + .map((word, index) => + index === 0 + ? word.charAt(0).toLowerCase() + word.slice(1) + : word.charAt(0).toUpperCase() + word.slice(1), + ) + .join(""); + const safe = base.replace(/^[^A-Za-z_$]+/u, "").replace(/[^A-Za-z0-9_$]/gu, ""); + return /^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(safe) ? safe : fallback; +} + +function uniqueIdentifier(base: string, used: Set): string { + let candidate = base; + let index = 2; + while ( + used.has(candidate) || + RESERVED_NAMESPACE_GLOBALS.has(candidate) || + FORBIDDEN_NAMESPACE_PATH_SEGMENTS.has(candidate) + ) { + candidate = `${base}${index}`; + index += 1; + } + used.add(candidate); + return candidate; +} + +function readSchemaRecord(schema: unknown): Record | undefined { + return isRecord(schema) ? schema : undefined; +} + +function readSchemaProperties(schema: unknown): Record { + const record = readSchemaRecord(schema); + return isRecord(record?.properties) ? record.properties : {}; +} + +function readRequiredKeys(schema: unknown): string[] { + const record = readSchemaRecord(schema); + return Array.isArray(record?.required) + ? record.required.filter((entry): entry is string => typeof entry === "string") + : []; +} + +function orderedSchemaKeys(schema: unknown): string[] { + const required = readRequiredKeys(schema); + const properties = Object.keys(readSchemaProperties(schema)); + return [...new Set([...required, ...properties])]; +} + +function applySchemaDefaults( + schema: unknown, + input: Record, +): Record { + const result = { ...input }; + for (const [key, descriptor] of Object.entries(readSchemaProperties(schema))) { + if (!isRecord(descriptor) || !("default" in descriptor) || result[key] !== undefined) { + continue; + } + result[key] = descriptor.default; + } + return result; +} + +function mapMcpNamespaceInput(schema: unknown, args: unknown[]): unknown { + const orderedKeys = orderedSchemaKeys(schema); + const [firstArg, ...restArgs] = args; + const recordInput = isRecord(firstArg) && restArgs.length === 0 ? { ...firstArg } : undefined; + const result: Record = recordInput ?? {}; + const positional = recordInput ? [] : args; + if (positional.length > orderedKeys.length) { + throw new Error("Too many positional arguments for MCP namespace tool."); + } + positional.forEach((value, index) => { + const key = orderedKeys[index]; + if (key) { + result[key] = value; + } + }); + const withDefaults = applySchemaDefaults(schema, result); + const missing = readRequiredKeys(schema).filter((key) => withDefaults[key] === undefined); + if (missing.length > 0) { + throw new Error( + `Missing required MCP namespace argument${missing.length === 1 ? "" : "s"}: ${missing.join(", ")}`, + ); + } + return withDefaults; +} + +function scopeAtPath( + root: CodeModeNamespaceScope, + path: readonly string[], +): CodeModeNamespaceScope { + let current: CodeModeNamespaceScope = root; + for (const segment of path) { + const next = current[segment]; + if (!isRecord(next)) { + const object = Object.create(null) as CodeModeNamespaceScope; + current[segment] = object; + current = object; + continue; + } + current = next; + } + return current; +} + +function toolIdentifiersForServer( + usedToolIdentifiers: Map>, + serverIdentifier: string, +): Set { + const existing = usedToolIdentifiers.get(serverIdentifier); + if (existing) { + return existing; + } + const created = new Set(["resources", "prompts"]); + usedToolIdentifiers.set(serverIdentifier, created); + return created; +} + +function createMcpNamespaceScope( + catalog: readonly CodeModeNamespaceCatalogEntry[], +): CodeModeNamespaceScope | undefined { + const mcpEntries = catalog.filter((entry) => entry.source === "mcp" && entry.id && entry.mcp); + if (mcpEntries.length === 0) { + return undefined; + } + const serverNames = new Map(); + const usedServerIdentifiers = new Set(); + for (const entry of mcpEntries) { + const safeServerName = entry.mcp?.safeServerName ?? entry.sourceName ?? "mcp"; + if (serverNames.has(safeServerName)) { + continue; + } + serverNames.set( + safeServerName, + uniqueIdentifier(toIdentifier(safeServerName, "server"), usedServerIdentifiers), + ); + } + const usedToolIdentifiers = new Map>(); + const root = Object.create(null) as CodeModeNamespaceScope; + for (const entry of mcpEntries.toSorted((a, b) => (a.id ?? "").localeCompare(b.id ?? ""))) { + const mcp = entry.mcp; + if (!mcp || !entry.id) { + continue; + } + const serverIdentifier = + serverNames.get(mcp.safeServerName) ?? uniqueIdentifier("server", usedServerIdentifiers); + const serverScope = scopeAtPath(root, [serverIdentifier]); + serverScope.$serverName = mcp.serverName; + const path = + mcp.operation === "resources_list" + ? ["resources", "list"] + : mcp.operation === "resources_read" + ? ["resources", "read"] + : mcp.operation === "prompts_list" + ? ["prompts", "list"] + : mcp.operation === "prompts_get" + ? ["prompts", "get"] + : [ + uniqueIdentifier( + toIdentifier(mcp.toolName, "tool"), + toolIdentifiersForServer(usedToolIdentifiers, serverIdentifier), + ), + ]; + const parent = scopeAtPath(serverScope, path.slice(0, -1)); + parent[path.at(-1) ?? "tool"] = createCodeModeNamespaceCatalogTool( + entry.id, + entry.name, + (args) => mapMcpNamespaceInput(entry.parameters, args), + ); + } + return root; +} + +function createMcpNamespaceEntry( + catalog: readonly CodeModeNamespaceCatalogEntry[], +): CodeModeNamespaceRuntimeEntry | undefined { + const scope = createMcpNamespaceScope(catalog); + if (!scope) { + return undefined; + } + const callablePaths = new Set(); + return { + registration: { + id: "mcp", + pluginId: "bundle-mcp", + globalName: "MCP", + requiredToolNames: [], + description: "MCP server tools grouped by server.", + createScope: () => scope, + }, + callablePaths, + scope, + descriptor: { + id: "mcp", + globalName: "MCP", + description: "MCP server tools grouped by server.", + scope: serializeNamespaceScopeValue(scope, [], new WeakSet(), callablePaths), + }, + }; +} + +function describeMcpNamespaceForPrompt( + catalog: readonly CodeModeNamespaceCatalogEntry[], +): string[] { + const scope = createMcpNamespaceScope(catalog); + if (!scope) { + return []; + } + const servers = Object.keys(scope).toSorted(); + if (servers.length === 0) { + return []; + } + return [ + "- MCP: MCP server tools grouped by server.", + `Use MCP..(args) for MCP tools; visible servers: ${servers.join(", ")}.`, + ]; +} + export function describeCodeModeNamespacesForPrompt( ctx: CodeModeNamespaceContext, catalog?: readonly CodeModeNamespaceCatalogEntry[], @@ -273,10 +533,12 @@ export function describeCodeModeNamespacesForPrompt( return ""; } const registrations = filterRegistrationsByVisibleTools(catalog); - if (registrations.length === 0) { + const mcpPrompt = describeMcpNamespaceForPrompt(catalog); + if (registrations.length === 0 && mcpPrompt.length === 0) { return ""; } const lines = ["Registered namespace globals are available in code mode:"]; + lines.push(...mcpPrompt); for (const registration of registrations) { const description = registration.description?.trim(); lines.push( @@ -410,6 +672,10 @@ export async function createCodeModeNamespaceRuntime( catalog: readonly CodeModeNamespaceCatalogEntry[] = [], ): Promise { const entries: CodeModeNamespaceRuntimeEntry[] = []; + const mcpEntry = createMcpNamespaceEntry(catalog); + if (mcpEntry) { + entries.push(mcpEntry); + } for (const registration of listCodeModeNamespaces()) { if (!registrationHasVisibleRequiredTools(registration, catalog)) { continue; @@ -448,7 +714,7 @@ export async function createCodeModeNamespaceRuntime( if (!isCodeModeNamespaceToolCall(target)) { throw new Error(`Code mode namespace path is not callable: ${path.join(".")}`); } - if (!entry.registration.requiredToolNames.includes(target.toolName)) { + if (!target.catalogId && !entry.registration.requiredToolNames.includes(target.toolName)) { throw new Error(`Code mode namespace path targets undeclared tool: ${target.toolName}`); } const input = target.input ? await target.input(args) : (args[0] ?? {}); @@ -456,6 +722,7 @@ export async function createCodeModeNamespaceRuntime( await executeTool({ pluginId: entry.registration.pluginId, toolName: target.toolName, + ...(target.catalogId ? { catalogId: target.catalogId } : {}), input, namespaceId, path: [...path], diff --git a/src/agents/code-mode.test.ts b/src/agents/code-mode.test.ts index a82820de4aa..bbeecf7374e 100644 --- a/src/agents/code-mode.test.ts +++ b/src/agents/code-mode.test.ts @@ -60,6 +60,47 @@ function pluginToolWithExecute( return tool; } +function mcpTool(params: { + name: string; + serverName: string; + safeServerName?: string; + toolName: string; + description?: string; + parameters?: AnyAgentTool["parameters"]; + operation?: "tool" | "resources_list" | "resources_read" | "prompts_list" | "prompts_get"; + execute?: AnyAgentTool["execute"]; +}): AnyAgentTool { + const tool: AnyAgentTool = { + name: params.name, + label: params.toolName, + description: params.description ?? `MCP ${params.toolName}`, + parameters: params.parameters ?? { + type: "object", + properties: {}, + }, + execute: + params.execute ?? + vi.fn(async (_toolCallId, input) => + jsonResult({ + serverName: params.serverName, + toolName: params.toolName, + input, + }), + ), + }; + setPluginToolMeta(tool, { + pluginId: "bundle-mcp", + optional: false, + mcp: { + serverName: params.serverName, + safeServerName: params.safeServerName ?? params.serverName, + toolName: params.toolName, + operation: params.operation ?? "tool", + }, + }); + return tool; +} + function registerTestNamespace( registration: CodeModeNamespaceRegistration & { pluginId?: string }, ): void { @@ -733,6 +774,176 @@ describe("Code Mode", () => { expect(ticket.execute).toHaveBeenCalledTimes(1); }); + it("exposes MCP tools only through the MCP namespace", async () => { + const { config, catalogRef, tools: codeModeTools } = createCodeModeHarness(); + const githubCreate = mcpTool({ + name: "github__create_issue", + serverName: "github", + toolName: "create_issue", + parameters: { + type: "object", + properties: { + owner: { type: "string" }, + repo: { type: "string" }, + title: { type: "string" }, + body: { type: "string", default: "" }, + }, + required: ["owner", "repo", "title"], + }, + }); + const compacted = applyCodeModeCatalog({ + tools: [...codeModeTools, githubCreate], + config, + sessionId: "session-code-mode", + sessionKey: "agent:main:main", + runId: "run-code-mode", + catalogRef, + }); + + expect(compacted.tools[0]?.description).toContain("MCP: MCP server tools grouped by server."); + expect(compacted.tools[0]?.description).toContain("visible servers: github"); + + const details = await runUntilCompleted({ + execTool: codeModeTools[0], + waitTool: codeModeTools[1], + code: ` + const created = await MCP.github.createIssue("openclaw", "openclaw", "Ship it"); + const createdPayload = JSON.parse(created.content[0].text); + let directCall; + try { + await tools.github__create_issue({ owner: "x", repo: "y", title: "blocked" }); + directCall = "unexpected"; + } catch (error) { + directCall = error.message; + } + return { createdPayload, createdDetails: created.details, directCall, hasMcp: "MCP" in namespaces }; + `, + }); + + expect(details.status).toBe("completed"); + expect(details.value).toEqual({ + createdPayload: { + serverName: "github", + toolName: "create_issue", + input: { + owner: "openclaw", + repo: "openclaw", + title: "Ship it", + body: "", + }, + }, + createdDetails: { + serverName: "github", + toolName: "create_issue", + input: { + owner: "openclaw", + repo: "openclaw", + title: "Ship it", + body: "", + }, + }, + directCall: "MCP tools are available in code mode only through the MCP namespace.", + hasMcp: true, + }); + expect(githubCreate.execute).toHaveBeenCalledTimes(1); + }); + + it("groups MCP resources and prompts under server namespaces", async () => { + const { config, catalogRef, tools: codeModeTools } = createCodeModeHarness(); + const resourceRead = mcpTool({ + name: "docs__resources_read", + serverName: "docs", + toolName: "resources_read", + operation: "resources_read", + parameters: { + type: "object", + properties: { uri: { type: "string" } }, + required: ["uri"], + }, + }); + const promptGet = mcpTool({ + name: "docs__prompts_get", + serverName: "docs", + toolName: "prompts_get", + operation: "prompts_get", + parameters: { + type: "object", + properties: { + name: { type: "string" }, + arguments: { type: "object" }, + }, + required: ["name"], + }, + }); + applyCodeModeCatalog({ + tools: [...codeModeTools, resourceRead, promptGet], + config, + sessionId: "session-code-mode", + sessionKey: "agent:main:main", + runId: "run-code-mode", + catalogRef, + }); + + const details = await runUntilCompleted({ + execTool: codeModeTools[0], + waitTool: codeModeTools[1], + code: ` + const resource = await MCP.docs.resources.read("memo://one"); + const prompt = await MCP.docs.prompts.get("brief", { topic: "mcp" }); + return { resource: resource.details, prompt: prompt.details }; + `, + }); + + expect(details.status).toBe("completed"); + expect(details.value).toEqual({ + resource: { + serverName: "docs", + toolName: "resources_read", + input: { uri: "memo://one" }, + }, + prompt: { + serverName: "docs", + toolName: "prompts_get", + input: { name: "brief", arguments: { topic: "mcp" } }, + }, + }); + }); + + it("renames MCP namespace identifiers that would be unsafe path segments", async () => { + const { config, catalogRef, tools: codeModeTools } = createCodeModeHarness(); + const dangerous = mcpTool({ + name: "constructor__prototype", + serverName: "constructor", + toolName: "prototype", + parameters: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + }, + }); + applyCodeModeCatalog({ + tools: [...codeModeTools, dangerous], + config, + sessionId: "session-code-mode", + sessionKey: "agent:main:main", + runId: "run-code-mode", + catalogRef, + }); + + const details = await runUntilCompleted({ + execTool: codeModeTools[0], + waitTool: codeModeTools[1], + code: 'return (await MCP.constructor2.prototype2("safe")).details;', + }); + + expect(details.status).toBe("completed"); + expect(details.value).toEqual({ + serverName: "constructor", + toolName: "prototype", + input: { value: "safe" }, + }); + }); + it("exposes registered namespace globals through the QuickJS bridge", async () => { registerTestNamespace({ id: "tickets", diff --git a/src/agents/code-mode.ts b/src/agents/code-mode.ts index 984d7f974ed..6139ef16060 100644 --- a/src/agents/code-mode.ts +++ b/src/agents/code-mode.ts @@ -517,7 +517,13 @@ async function runBridgeRequest(params: { if (typeof id !== "string") { throw new ToolInputError("call id must be a string."); } - value = await params.runtime.call(id, values[1] ?? {}, { + const described = await params.runtime.describe(id); + if (described.source === "mcp") { + throw new ToolInputError( + "MCP tools are available in code mode only through the MCP namespace.", + ); + } + value = await params.runtime.callExactId(described.id, values[1] ?? {}, { parentToolCallId: params.parentToolCallId, signal: params.signal, onUpdate: params.onUpdate, @@ -543,12 +549,17 @@ async function runBridgeRequest(params: { path, Array.isArray(callArgs) ? callArgs : [], async (request) => { - const entry = params.runtime - .all() - .find( - (candidate) => - candidate.name === request.toolName && candidate.sourceName === request.pluginId, - ); + const entry = request.catalogId + ? params.runtime + .namespaceEntries() + .find((candidate) => candidate.id === request.catalogId) + : params.runtime + .namespaceEntries() + .find( + (candidate) => + candidate.name === request.toolName && + candidate.sourceName === request.pluginId, + ); if (!entry) { throw new ToolInputError( `namespace tool is not visible in the run catalog: ${request.toolName}`, @@ -559,6 +570,9 @@ async function runBridgeRequest(params: { signal: params.signal, onUpdate: params.onUpdate, }); + if (request.catalogId) { + return called.result; + } return isRecord(called.result) && "details" in called.result ? called.result.details : called.result; @@ -769,7 +783,7 @@ function createCodeModeExecDescription( ): string { const namespacePrompt = describeCodeModeNamespacesForPrompt(ctx, catalog); return ( - 'Run JavaScript or TypeScript in OpenClaw code mode. Use `return` to pass the final value back to the agent; awaited calls without a returned value complete as `null`. Node.js modules and `require`/`import` are NOT available; for any shell, file, network, or external action, use enabled catalog tools allowed by policy from inside your code: `tools.search(query)` to find catalog entries, `tools.describe(entry.id)` for the input schema, then `tools.call(entry.id, args)`. Registered plugin namespaces are available as direct globals and through `namespaces` when their required tools are visible in the run catalog. The `language` field accepts only "javascript" or "typescript"; do not pass "bash", "shell", or other values.' + + 'Run JavaScript or TypeScript in OpenClaw code mode. Use `return` to pass the final value back to the agent; awaited calls without a returned value complete as `null`. Node.js modules and `require`/`import` are NOT available; for any shell, file, network, or external action, use enabled catalog tools allowed by policy from inside your code: `tools.search(query)` to find catalog entries, `tools.describe(entry.id)` for the input schema, then `tools.call(entry.id, args)`. MCP tools are available only through the `MCP` namespace. Registered plugin namespaces are available as direct globals and through `namespaces` when their required tools are visible in the run catalog. The `language` field accepts only "javascript" or "typescript"; do not pass "bash", "shell", or other values.' + (namespacePrompt ? `\n\n${namespacePrompt}` : "") ); } @@ -792,7 +806,10 @@ async function runExec(params: { } const runtime = new ToolSearchRuntime(params.ctx, toToolSearchConfig(config)); const catalog = runtime.all(); - const namespaceRuntime = await createCodeModeNamespaceRuntime(params.ctx, catalog); + const namespaceRuntime = await createCodeModeNamespaceRuntime( + params.ctx, + runtime.namespaceEntries(), + ); let source: string; try { source = await prepareSource({ code: params.code, language: params.language, config }); diff --git a/src/agents/tool-search.ts b/src/agents/tool-search.ts index 93d2536b709..4d6de477746 100644 --- a/src/agents/tool-search.ts +++ b/src/agents/tool-search.ts @@ -8,7 +8,7 @@ import { } from "@openclaw/normalization-core/string-normalization"; import { Type } from "typebox"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { getPluginToolMeta } from "../plugins/tools.js"; +import { getPluginToolMeta, type PluginToolMcpMeta } from "../plugins/tools.js"; import { isToolWrappedWithBeforeToolCallHook, type HookContext, @@ -89,6 +89,7 @@ export type ToolSearchCatalogEntry = { id: string; source: CatalogSource; sourceName?: string; + mcp?: PluginToolMcpMeta; name: string; label?: string; description: string; @@ -523,6 +524,7 @@ function catalogEntriesFingerprint(entries: readonly ToolSearchCatalogEntry[]): entry.id, entry.source, entry.sourceName ?? "", + stableJsonFingerprint(entry.mcp), entry.name, entry.label ?? "", entry.description, @@ -602,11 +604,20 @@ function rememberReusableCatalog(key: string | undefined, catalog: ToolSearchCat } } -function classifyTool(tool: CatalogTool): { source: CatalogSource; sourceName?: string } { +function classifyTool(tool: CatalogTool): { + source: CatalogSource; + sourceName?: string; + mcp?: PluginToolMcpMeta; +} { const meta = getPluginToolMeta(tool as AnyAgentTool); const pluginId = meta?.pluginId?.trim(); if (pluginId === "bundle-mcp") { - return { source: "mcp", sourceName: pluginId }; + const mcp = meta?.mcp; + return { + source: "mcp", + sourceName: pluginId, + ...(mcp ? { mcp } : {}), + }; } if (pluginId) { return { source: "openclaw", sourceName: pluginId }; @@ -640,6 +651,7 @@ function toCatalogEntry( id: makeCatalogId(tool, source, sourceName), source, sourceName, + ...(source === "mcp" && classified.mcp ? { mcp: classified.mcp } : {}), name: tool.name, label: tool.label, description: tool.description ?? "", @@ -953,6 +965,7 @@ function compactEntry(entry: ToolSearchCatalogEntry) { id: entry.id, source: entry.source, sourceName: entry.sourceName, + ...(entry.mcp ? { mcp: entry.mcp } : {}), name: entry.name, label: entry.label, description: entry.description, @@ -1110,6 +1123,15 @@ export class ToolSearchRuntime { return catalog.entries.map((entry) => compactEntry(entry)); }; + namespaceEntries = () => { + const catalog = resolveCatalog(this.ctx); + return catalog.entries.map((entry) => + Object.assign(compactEntry(entry), { + parameters: entry.parameters ?? {}, + }), + ); + }; + describe = async (id: string) => { const catalog = resolveCatalog(this.ctx); catalog.describeCount += 1; diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index 57c18330419..b34112dead3 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -40,10 +40,18 @@ export { resetPluginToolDescriptorCache as resetPluginToolFactoryCache, } from "./tool-descriptor-cache.js"; +export type PluginToolMcpMeta = { + serverName: string; + safeServerName: string; + toolName: string; + operation: "tool" | "resources_list" | "resources_read" | "prompts_list" | "prompts_get"; +}; + export type PluginToolMeta = { pluginId: string; optional: boolean; trustedLocalMedia?: boolean; + mcp?: PluginToolMcpMeta; }; type PluginToolFactoryTimingResult = "array" | "error" | "null" | "single";