feat: add MCP code-mode namespace (#88636)

* feat: add MCP code-mode namespace

* fix: unblock mcp namespace ci gates
This commit is contained in:
Peter Steinberger
2026-05-31 15:02:19 +01:00
committed by GitHub
parent 44c65de17a
commit ec8cb8bcbf
8 changed files with 603 additions and 28 deletions

View File

@@ -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<string, unknown>;
declare const namespaces: Record<string, unknown>;
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.<server>.<tool>(...)` 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.<server>.<tool>(...)` 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`.

View File

@@ -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<PluginToolMcpMeta["operation"], "tool">;
label: string;
description: string;
parameters: Record<string, unknown>;
@@ -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);
}

View File

@@ -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({

View File

@@ -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<PropertyKey, unknown>) : 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>): 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<string, unknown> | undefined {
return isRecord(schema) ? schema : undefined;
}
function readSchemaProperties(schema: unknown): Record<string, unknown> {
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<string, unknown>,
): Record<string, unknown> {
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<string, unknown> = 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<string, Set<string>>,
serverIdentifier: string,
): Set<string> {
const existing = usedToolIdentifiers.get(serverIdentifier);
if (existing) {
return existing;
}
const created = new Set<string>(["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<string, string>();
const usedServerIdentifiers = new Set<string>();
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<string, Set<string>>();
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<string>();
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<object>(), 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.<server>.<tool>(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<CodeModeNamespaceRuntime> {
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],

View File

@@ -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",

View File

@@ -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 });

View File

@@ -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;

View File

@@ -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";