mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-03 12:04:05 +00:00
feat: add MCP code-mode namespace (#88636)
* feat: add MCP code-mode namespace * fix: unblock mcp namespace ci gates
This commit is contained in:
committed by
GitHub
parent
44c65de17a
commit
ec8cb8bcbf
@@ -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`.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user