feat: add code-mode MCP API files

* feat: add code-mode MCP API files

* fix: satisfy code-mode MCP lint
This commit is contained in:
Peter Steinberger
2026-05-31 21:29:06 +01:00
committed by GitHub
parent b0679d1f13
commit e681569536
18 changed files with 1063 additions and 41 deletions

View File

@@ -441,12 +441,13 @@ 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. They are exposed only through the generated `MCP`
namespace, which includes TypeScript-style API headers for discovery:
namespace. TypeScript-style declaration files are available through the
read-only `API` virtual file surface, so agents can inspect MCP signatures
without adding MCP schemas to the prompt:
```typescript
const servers = await MCP.$api();
const githubApi = await MCP.github.$api();
const createIssueApi = await MCP.github.$api("createIssue", { schema: true });
const files = await API.list("mcp");
const githubApi = await API.read("mcp/github.d.ts");
const issue = await MCP.github.createIssue({
owner: "openclaw",
@@ -462,7 +463,8 @@ const prompt = await MCP.docs.prompts.get({
});
```
`MCP.<server>.$api()` returns a compact header inferred from MCP tool metadata:
`API.read("mcp/<server>.d.ts")` returns compact declarations inferred from MCP
tool metadata:
```typescript
type McpToolResult = {
@@ -981,8 +983,9 @@ Code mode coverage should prove:
- all effective non-MCP 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 `$api()` returns TypeScript-style headers inferred from MCP
schemas
- `API.list("mcp")` and `API.read("mcp/<server>.d.ts")` expose TypeScript-style
MCP declarations without a bridge/tool call
- MCP namespace `$api()` remains available as an inline fallback for schemas
- MCP namespace calls work for visible MCP tools with one object input, while
direct MCP catalog entries are absent from `tools.*`
- Tool Search control tools are hidden from both the model surface and the hidden
@@ -1014,8 +1017,8 @@ Run these as integration or end-to-end tests when changing the runtime:
7. In `exec`, read `ALL_TOOLS` and assert the effective test tools are present.
8. In `exec`, call OpenClaw/plugin/client tools through `tools.search`,
`tools.describe`, and `tools.call`.
9. In `exec`, call `MCP.$api()` and `MCP.<server>.$api()` and assert the headers
describe visible MCP tools.
9. In `exec`, call `API.list("mcp")` and `API.read("mcp/<server>.d.ts")` and
assert the declaration files describe visible MCP tools.
10. In `exec`, call MCP tools through `MCP.<server>.<tool>({ ...input })` and
assert direct MCP catalog entries are absent from `ALL_TOOLS` and `tools.*`.
11. Assert denied tools are absent and cannot be called by guessed id.

View File

@@ -175,6 +175,7 @@ const QA_RELEASE_AUDIT_PROMPT_RE = /release readiness audit for the small projec
const QA_TOOL_SEARCH_PROMPT_RE = /tool search qa check/i;
const QA_TOOL_SEARCH_FAILURE_PROMPT_RE = /tool search qa failure/i;
const QA_MCP_CODE_MODE_PROMPT_RE = /mcp code mode qa check/i;
const QA_MCP_CODE_MODE_API_FILE_PROMPT_RE = /mcp code mode api file qa check/i;
type MockScenarioState = {
subagentFanoutPhase: number;
@@ -1807,23 +1808,42 @@ async function buildResponsesPayload(
return buildToolCallEventsWithArgs(targetTool, plannedArgs);
}
}
if (QA_MCP_CODE_MODE_PROMPT_RE.test(allInputText)) {
if (
QA_MCP_CODE_MODE_API_FILE_PROMPT_RE.test(allInputText) ||
QA_MCP_CODE_MODE_PROMPT_RE.test(allInputText)
) {
if (!toolOutput && hasDeclaredTool(body, "exec")) {
const useApiFiles = QA_MCP_CODE_MODE_API_FILE_PROMPT_RE.test(allInputText);
return buildToolCallEventsWithArgs("exec", {
language: "javascript",
code: [
"const rootApi = await MCP.$api();",
'const api = await MCP.fixture.$api("lookupNote", { schema: true });',
'const result = await MCP.fixture.lookupNote({ id: "alpha" });',
"return {",
' marker: "MCP_CODE_MODE_TOOL_RESULT",',
" rootServers: rootApi.servers,",
" headerHasLookup: api.header.includes('function lookupNote'),",
" schemaKeys: Object.keys(api.schemas),",
" resultText: result.content?.[0]?.text,",
" allHasMcp: ALL_TOOLS.some((tool) => tool.source === 'mcp'),",
"};",
].join("\n"),
code: useApiFiles
? [
'const files = await API.list("mcp");',
'const root = await API.read("mcp/index.d.ts");',
'const api = await API.read("mcp/fixture.d.ts");',
'const result = await MCP.fixture.lookupNote({ id: "alpha" });',
"return {",
' marker: "MCP_CODE_MODE_FILE_TOOL_RESULT",',
" files: files.files.map((file) => file.path),",
" rootHasFixture: root.content.includes('fixture'),",
" headerHasLookup: api.content.includes('function lookupNote'),",
" resultText: result.content?.[0]?.text,",
" allHasMcp: ALL_TOOLS.some((tool) => tool.source === 'mcp'),",
"};",
].join("\n")
: [
"const rootApi = await MCP.$api();",
'const api = await MCP.fixture.$api("lookupNote", { schema: true });',
'const result = await MCP.fixture.lookupNote({ id: "alpha" });',
"return {",
' marker: "MCP_CODE_MODE_TOOL_RESULT",',
" rootServers: rootApi.servers,",
" headerHasLookup: api.header.includes('function lookupNote'),",
" schemaKeys: Object.keys(api.schemas),",
" resultText: result.content?.[0]?.text,",
" allHasMcp: ALL_TOOLS.some((tool) => tool.source === 'mcp'),",
"};",
].join("\n"),
});
}
if (
@@ -1833,6 +1853,19 @@ async function buildResponsesPayload(
) {
return buildToolCallEventsWithArgs("wait", { runId: toolJson.runId });
}
if (
toolOutput.includes("MCP_CODE_MODE_FILE_TOOL_RESULT") &&
toolOutput.includes("fixture-note-alpha")
) {
return buildAssistantEvents(
"MCP_CODE_MODE_FILE_OK note=fixture-note-alpha unclear=none improvement=virtual-api-files-were-clear-and-needed-one-exec",
);
}
if (toolOutput.includes("MCP_CODE_MODE_FILE_TOOL_RESULT")) {
return buildAssistantEvents(
"MCP_CODE_MODE_FILE_FAIL unclear=code-mode-exec-did-not-return-fixture-note",
);
}
if (/MCP_CODE_MODE_TOOL_RESULT|fixture-note-alpha/.test(toolOutput)) {
return buildAssistantEvents(
"MCP_CODE_MODE_OK unclear=none improvement=virtual-header-files-would-avoid-the-first-api-call",

View File

@@ -1710,12 +1710,14 @@
"test:docker:live-gateway": "bash scripts/test-live-gateway-models-docker.sh",
"test:docker:live-gateway:claude": "OPENCLAW_LIVE_GATEWAY_PROVIDERS=claude-cli OPENCLAW_LIVE_GATEWAY_MODELS=claude-cli/claude-sonnet-4-6 bash scripts/test-live-gateway-models-docker.sh",
"test:docker:live-gateway:gemini": "OPENCLAW_LIVE_GATEWAY_PROVIDERS=google-gemini-cli OPENCLAW_LIVE_GATEWAY_MODELS=google-gemini-cli/gemini-3.1-pro-preview bash scripts/test-live-gateway-models-docker.sh",
"test:docker:live-mcp-code-mode-gateway": "bash scripts/e2e/mcp-code-mode-gateway-live-docker.sh",
"test:docker:live-models": "bash scripts/test-live-models-docker.sh",
"test:docker:live-models:claude": "OPENCLAW_LIVE_PROVIDERS=claude-cli OPENCLAW_LIVE_MODELS=claude-cli/claude-sonnet-4-6 bash scripts/test-live-models-docker.sh",
"test:docker:live-models:gemini": "OPENCLAW_LIVE_PROVIDERS=google-gemini-cli OPENCLAW_LIVE_MODELS=google-gemini-cli/gemini-3.1-pro-preview bash scripts/test-live-models-docker.sh",
"test:docker:live:all": "node scripts/run-with-env.mjs OPENCLAW_DOCKER_ALL_LIVE_MODE=only -- node scripts/test-docker-all.mjs",
"test:docker:local:all": "node scripts/run-with-env.mjs OPENCLAW_DOCKER_ALL_LIVE_MODE=skip -- node scripts/test-docker-all.mjs",
"test:docker:mcp-channels": "bash scripts/e2e/mcp-channels-docker.sh",
"test:docker:mcp-code-mode-gateway": "bash scripts/e2e/mcp-code-mode-gateway-docker.sh",
"test:docker:multi-node-update": "bash scripts/e2e/multi-node-update-docker.sh",
"test:docker:codex-on-demand": "bash scripts/e2e/codex-on-demand-docker.sh",
"test:docker:npm-onboard-channel-agent": "bash scripts/e2e/npm-onboard-channel-agent-docker.sh",

View File

@@ -0,0 +1,172 @@
import fs from "node:fs/promises";
import path from "node:path";
import { setTimeout as setNodeTimeout, clearTimeout as clearNodeTimeout } from "node:timers";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
async function fetchJson(url: string, init: RequestInit = {}): Promise<unknown> {
const timeoutMs = Number(process.env.OPENCLAW_MCP_CODE_MODE_CLIENT_TIMEOUT_MS ?? 300_000);
const controller = new AbortController();
let timeout: ReturnType<typeof setNodeTimeout> | undefined;
try {
timeout = setNodeTimeout(() => controller.abort(), timeoutMs);
const response = await fetch(url, { ...init, signal: controller.signal });
const text = await response.text();
if (!response.ok) {
throw new Error(`HTTP ${response.status} from ${url}: ${text}`);
}
return text ? JSON.parse(text) : {};
} finally {
if (timeout) {
clearNodeTimeout(timeout);
}
}
}
function outputText(response: unknown): string {
const output = (response as { output?: Array<{ type?: unknown; content?: unknown }> }).output;
if (!Array.isArray(output)) {
return "";
}
return output
.flatMap((item) => {
if (item.type !== "message" || !Array.isArray(item.content)) {
return [];
}
return item.content.flatMap((piece) => {
if (!piece || typeof piece !== "object") {
return [];
}
const record = piece as { text?: unknown };
return typeof record.text === "string" ? [record.text] : [];
});
})
.join("\n");
}
function countOccurrences(haystack: string, needle: string): number {
if (!needle) {
return 0;
}
let count = 0;
let offset = 0;
while (true) {
const next = haystack.indexOf(needle, offset);
if (next < 0) {
return count;
}
count += 1;
offset = next + needle.length;
}
}
async function readSessionLogMentions(stateDir: string): Promise<Record<string, number>> {
const sessionsDir = path.join(stateDir, "agents", "main", "sessions");
const mentions = {
apiCall: 0,
apiFileList: 0,
apiFileRead: 0,
mcpNamespace: 0,
mcpTool: 0,
toolSearchPollution: 0,
};
const files = await fs.readdir(sessionsDir).catch(() => []);
for (const file of files.filter((candidate) => candidate.endsWith(".jsonl"))) {
const raw = await fs.readFile(path.join(sessionsDir, file), "utf8").catch(() => "");
mentions.apiCall += countOccurrences(raw, "MCP.$api");
mentions.apiFileList += countOccurrences(raw, "API.list");
mentions.apiFileRead += countOccurrences(raw, "API.read");
mentions.mcpNamespace += countOccurrences(raw, "MCP.fixture");
mentions.mcpTool += countOccurrences(raw, "fixture__lookup_note");
mentions.toolSearchPollution += countOccurrences(raw, 'tools.search("lookup note"');
}
return mentions;
}
async function main() {
const gatewayUrl = process.env.GW_URL?.trim();
const gatewayToken = process.env.GW_TOKEN?.trim();
const stateDir = process.env.OPENCLAW_STATE_DIR?.trim();
const model = process.env.OPENCLAW_MCP_CODE_MODE_MODEL?.trim() || "openclaw/main";
assert(gatewayUrl, "missing GW_URL");
assert(gatewayToken, "missing GW_TOKEN");
assert(stateDir, "missing OPENCLAW_STATE_DIR");
const response = await fetchJson(`${gatewayUrl.replace(/\/$/, "")}/v1/responses`, {
method: "POST",
headers: {
authorization: `Bearer ${gatewayToken}`,
"content-type": "application/json",
"x-openclaw-agent": "main",
"x-openclaw-scopes": "operator.write",
},
body: JSON.stringify({
model,
input: [
{
type: "message",
role: "user",
content: [
{
type: "input_text",
text: [
"mcp code mode api file qa check:",
"MCP and API are code-mode globals; they are defined only inside the exec tool, not in normal chat.",
"Call exec with language javascript and this exact code:",
'const files = await API.list("mcp");',
'const root = await API.read("mcp/index.d.ts");',
'const api = await API.read("mcp/fixture.d.ts");',
'const result = await MCP.fixture.lookupNote({ id: "alpha" });',
'return { marker: "MCP_CODE_MODE_FILE_TOOL_RESULT", files: files.files.map((file) => file.path), rootHasFixture: root.content.includes("fixture"), headerHasLookup: api.content.includes("function lookupNote"), note: result.content?.[0]?.text };',
"Do not use tools.search for MCP and do not call the inline MCP API helper.",
"After exec finishes, send a normal assistant reply; do not stop after only the tool call.",
"Reply with MCP_CODE_MODE_FILE_OK note=fixture-note-alpha unclear=none only after the MCP call returns fixture-note-alpha.",
].join(" "),
},
],
},
],
max_output_tokens: 1024,
stream: false,
}),
});
const finalText = outputText(response);
const mentions = await readSessionLogMentions(stateDir);
assert(
finalText.includes("MCP_CODE_MODE_FILE_OK"),
`agent did not complete MCP API file check: ${finalText}`,
);
assert(
finalText.includes("fixture-note-alpha"),
`agent did not return fixture note from MCP call: ${finalText}`,
);
assert(
!/MCP\s+(?:was\s+)?not\s+defined|failed|error/i.test(finalText),
`agent reported MCP failure instead of a successful call: ${finalText}`,
);
assert(mentions.apiFileRead > 0, "session log lacks API.read usage");
assert(mentions.mcpNamespace > 0, "session log lacks MCP.fixture usage");
assert(mentions.mcpTool > 0, "session log lacks fixture__lookup_note call");
assert(mentions.apiCall === 0, "agent should not call MCP.$api when API files are available");
assert(mentions.toolSearchPollution === 0, "agent should not use tools.search for MCP lookup");
process.stdout.write(
`${JSON.stringify(
{
ok: true,
gatewayUrl,
finalText,
sessionLogMentions: mentions,
},
null,
2,
)}\n`,
);
}
await main();

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Runs a deterministic packaged Gateway/code-mode/MCP smoke using the Docker
# functional image and the local mock OpenAI Responses server.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-mcp-code-mode-gateway-e2e" OPENCLAW_IMAGE)"
PORT="${OPENCLAW_MCP_CODE_MODE_GATEWAY_PORT:-18789}"
MOCK_PORT="${OPENCLAW_MCP_CODE_MODE_MOCK_PORT:-44082}"
TOKEN="mcp-code-mode-e2e-$(date +%s)-$$"
CONTAINER_NAME="openclaw-mcp-code-mode-e2e-$$"
CLIENT_LOG="$(mktemp -t openclaw-mcp-code-mode-client-log.XXXXXX)"
cleanup() {
docker_e2e_docker_cmd rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
rm -f "$CLIENT_LOG"
}
trap cleanup EXIT
docker_e2e_build_or_reuse "$IMAGE_NAME" mcp-code-mode-gateway
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 mcp-code-mode-gateway empty)"
echo "Running in-container deterministic Gateway code-mode MCP API-file smoke..."
set +e
docker_e2e_run_with_harness \
--name "$CONTAINER_NAME" \
-e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \
-e "OPENCLAW_SKIP_CHANNELS=1" \
-e "OPENCLAW_SKIP_GMAIL_WATCHER=1" \
-e "OPENCLAW_SKIP_CRON=1" \
-e "OPENCLAW_SKIP_CANVAS_HOST=1" \
-e "OPENCLAW_SKIP_ACPX_RUNTIME=1" \
-e "OPENCLAW_SKIP_ACPX_RUNTIME_PROBE=1" \
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
-e "GW_URL=http://127.0.0.1:$PORT" \
-e "GW_TOKEN=$TOKEN" \
-e "OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1" \
"$IMAGE_NAME" \
bash -lc "set -euo pipefail
source scripts/lib/openclaw-e2e-instance.sh
openclaw_e2e_eval_test_state_from_b64 \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\"
entry=\"\$(openclaw_e2e_resolve_entrypoint)\"
export OPENCLAW_DOCKER_OPENAI_BASE_URL=\"http://127.0.0.1:$MOCK_PORT/v1\"
mock_pid=\"\$(openclaw_e2e_start_mock_openai \"$MOCK_PORT\" /tmp/mcp-code-mode-mock-openai.log)\"
gateway_pid=
cleanup_inner() {
openclaw_e2e_stop_process \"\${gateway_pid:-}\"
openclaw_e2e_stop_process \"\${mock_pid:-}\"
}
dump_logs_on_error() {
status=\$?
if [ \"\$status\" -ne 0 ]; then
openclaw_e2e_dump_logs \
/tmp/mcp-code-mode-gateway.log \
/tmp/mcp-code-mode-seed.log \
/tmp/mcp-code-mode-mock-openai.log
fi
cleanup_inner
exit \"\$status\"
}
trap cleanup_inner EXIT
trap dump_logs_on_error ERR
openclaw_e2e_wait_mock_openai \"$MOCK_PORT\"
tsx scripts/e2e/mcp-code-mode-gateway-seed.ts >/tmp/mcp-code-mode-seed.log
gateway_pid=\"\$(openclaw_e2e_start_gateway \"\$entry\" $PORT /tmp/mcp-code-mode-gateway.log)\"
openclaw_e2e_wait_gateway_ready \"\$gateway_pid\" /tmp/mcp-code-mode-gateway.log 480
tsx scripts/e2e/mcp-code-mode-gateway-client.ts
" >"$CLIENT_LOG" 2>&1
status=${PIPESTATUS[0]}
set -e
if [ "$status" -ne 0 ]; then
echo "Docker MCP code-mode API-file smoke failed"
cat "$CLIENT_LOG"
exit "$status"
fi
cat "$CLIENT_LOG"
echo "OK"

View File

@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# Runs the packaged Gateway/code-mode/MCP API-file smoke against a live OpenAI
# provider so the real agent has to discover and use the virtual declarations.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-mcp-code-mode-gateway-live-e2e" OPENCLAW_IMAGE)"
PORT="${OPENCLAW_MCP_CODE_MODE_LIVE_GATEWAY_PORT:-18789}"
TOKEN="mcp-code-mode-live-e2e-$(date +%s)-$$"
CONTAINER_NAME="openclaw-mcp-code-mode-live-e2e-$$"
CLIENT_LOG="$(mktemp -t openclaw-mcp-code-mode-live-log.XXXXXX)"
PROFILE_FILE="${OPENCLAW_MCP_CODE_MODE_LIVE_PROFILE_FILE:-${OPENCLAW_TESTBOX_PROFILE_FILE:-$HOME/.openclaw-testbox-live.profile}}"
cleanup() {
docker_e2e_docker_cmd rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
rm -f "$CLIENT_LOG"
}
trap cleanup EXIT
if [ ! -f "$PROFILE_FILE" ] && [ -f "$HOME/.profile" ]; then
PROFILE_FILE="$HOME/.profile"
fi
PROFILE_MOUNT=()
PROFILE_STATUS="none"
if [ -f "$PROFILE_FILE" ] && [ -r "$PROFILE_FILE" ]; then
set -a
# shellcheck disable=SC1090
source "$PROFILE_FILE"
set +a
PROFILE_MOUNT=(-v "$PROFILE_FILE":/home/appuser/.profile:ro)
PROFILE_STATUS="$PROFILE_FILE"
fi
if [ -z "${OPENAI_API_KEY:-}" ]; then
echo "ERROR: OPENAI_API_KEY was not available after sourcing $PROFILE_STATUS." >&2
exit 1
fi
# The profile is only a credential source. Keep this lane's OpenClaw runtime
# isolated from host/testbox mode flags that can change packaged behavior.
unset OPENCLAW_TESTBOX
docker_e2e_build_or_reuse "$IMAGE_NAME" mcp-code-mode-gateway-live
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 mcp-code-mode-gateway-live empty)"
echo "Running live Docker Gateway code-mode MCP API-file smoke..."
echo "Profile file: $PROFILE_STATUS"
set +e
docker_e2e_run_with_harness \
--name "$CONTAINER_NAME" \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e OPENAI_API_KEY \
-e OPENAI_BASE_URL \
-e "OPENCLAW_DOCKER_OPENAI_BASE_URL=${OPENAI_BASE_URL:-https://api.openai.com/v1}" \
-e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \
-e "OPENCLAW_SKIP_CHANNELS=1" \
-e "OPENCLAW_SKIP_GMAIL_WATCHER=1" \
-e "OPENCLAW_SKIP_CRON=1" \
-e "OPENCLAW_SKIP_CANVAS_HOST=1" \
-e "OPENCLAW_SKIP_ACPX_RUNTIME=1" \
-e "OPENCLAW_SKIP_ACPX_RUNTIME_PROBE=1" \
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
-e "GW_URL=http://127.0.0.1:$PORT" \
-e "GW_TOKEN=$TOKEN" \
-e "OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1" \
-e "OPENCLAW_MCP_CODE_MODE_MODEL=${OPENCLAW_MCP_CODE_MODE_LIVE_MODEL:-openclaw/main}" \
"${PROFILE_MOUNT[@]}" \
"$IMAGE_NAME" \
bash -lc "set -euo pipefail
source scripts/lib/openclaw-e2e-instance.sh
for profile_path in \"\$HOME/.profile\" /home/appuser/.profile; do
if [ -f \"\$profile_path\" ] && [ -r \"\$profile_path\" ]; then
set +e +u
source \"\$profile_path\"
set -euo pipefail
break
fi
done
unset OPENCLAW_TESTBOX
if [ -z \"\${OPENAI_API_KEY:-}\" ]; then
echo \"ERROR: OPENAI_API_KEY was not available inside the container.\" >&2
exit 1
fi
openclaw_e2e_eval_test_state_from_b64 \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\"
entry=\"\$(openclaw_e2e_resolve_entrypoint)\"
gateway_pid=
cleanup_inner() {
openclaw_e2e_stop_process \"\${gateway_pid:-}\"
}
dump_logs_on_error() {
status=\$?
if [ \"\$status\" -ne 0 ]; then
openclaw_e2e_dump_logs \
/tmp/mcp-code-mode-live-gateway.log \
/tmp/mcp-code-mode-live-seed.log
if [ -d \"\${OPENCLAW_STATE_DIR:-}/agents/main/sessions\" ]; then
echo \"--- session MCP/code-mode excerpts ---\" >&2
grep -R -n -E 'API\\.|MCP\\.fixture|fixture__lookup_note|Unknown API file|\"telemetry\"|\"sources\"' \
\"\$OPENCLAW_STATE_DIR/agents/main/sessions\" >&2 || true
fi
fi
cleanup_inner
exit \"\$status\"
}
trap cleanup_inner EXIT
trap dump_logs_on_error ERR
tsx scripts/e2e/mcp-code-mode-gateway-seed.ts >/tmp/mcp-code-mode-live-seed.log
gateway_pid=\"\$(openclaw_e2e_start_gateway \"\$entry\" $PORT /tmp/mcp-code-mode-live-gateway.log)\"
openclaw_e2e_wait_gateway_ready \"\$gateway_pid\" /tmp/mcp-code-mode-live-gateway.log 480
tsx scripts/e2e/mcp-code-mode-gateway-client.ts
" >"$CLIENT_LOG" 2>&1
status=${PIPESTATUS[0]}
set -e
if [ "$status" -ne 0 ]; then
echo "Live Docker MCP code-mode API-file smoke failed"
cat "$CLIENT_LOG"
exit "$status"
fi
cat "$CLIENT_LOG"
echo "OK"

View File

@@ -0,0 +1,128 @@
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { applyDockerOpenAiProviderConfig, type OpenClawConfig } from "./docker-openai-seed.ts";
const require = createRequire(import.meta.url);
async function writeProbeMcpServer(serverPath: string) {
const sdkMcpServerPath = require.resolve("@modelcontextprotocol/sdk/server/mcp.js");
const sdkStdioServerPath = require.resolve("@modelcontextprotocol/sdk/server/stdio.js");
const zodPath = require.resolve("zod");
await fs.mkdir(path.dirname(serverPath), { recursive: true });
await fs.writeFile(
serverPath,
`#!/usr/bin/env node
import { McpServer } from ${JSON.stringify(sdkMcpServerPath)};
import { StdioServerTransport } from ${JSON.stringify(sdkStdioServerPath)};
import { z } from ${JSON.stringify(zodPath)};
const notes = new Map([
["alpha", "fixture-note-alpha"],
["beta", "fixture-note-beta"],
]);
const server = new McpServer({ name: "code-mode-fixture", version: "1.0.0" });
server.tool(
"lookup_note",
"Look up one read-only fixture note by id.",
{
id: z.string().describe("Fixture note id to look up."),
},
async ({ id }) => ({
content: [{ type: "text", text: notes.get(id) ?? "missing-note" }],
}),
);
await server.connect(new StdioServerTransport());
`,
{ encoding: "utf8", mode: 0o755 },
);
}
async function main() {
const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || path.join(os.homedir(), ".openclaw");
const configPath =
process.env.OPENCLAW_CONFIG_PATH?.trim() || path.join(stateDir, "openclaw.json");
const workspaceDir = path.join(stateDir, "workspace");
const serverPath = path.join(stateDir, "mcp-code-mode-fixture", "fixture-server.mjs");
const apiKey =
process.env.OPENAI_API_KEY?.trim() ||
process.env.OPENCLAW_MCP_CODE_MODE_OPENAI_API_KEY?.trim() ||
"sk-docker-smoke-test";
const cfg = applyDockerOpenAiProviderConfig(
{
gateway: {
controlUi: {
allowInsecureAuth: true,
enabled: false,
},
http: {
endpoints: {
responses: {
enabled: true,
},
},
},
},
agents: {
defaults: {
heartbeat: {
every: "0m",
},
memorySearch: {
enabled: false,
sync: {
onSearch: false,
onSessionStart: false,
watch: false,
},
},
},
},
plugins: {
slots: {
memory: "none",
},
},
tools: {
profile: "coding",
alsoAllow: ["bundle-mcp"],
codeMode: {
enabled: true,
timeoutMs: 20_000,
maxPendingToolCalls: 16,
},
},
mcp: {
servers: {
fixture: {
command: "node",
args: [serverPath],
cwd: path.dirname(serverPath),
connectionTimeoutMs: 30_000,
},
},
},
} satisfies OpenClawConfig,
apiKey,
);
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.mkdir(workspaceDir, { recursive: true });
await writeProbeMcpServer(serverPath);
await fs.writeFile(configPath, `${JSON.stringify(cfg, null, 2)}\n`, "utf8");
process.stdout.write(
`${JSON.stringify({
ok: true,
stateDir,
configPath,
workspaceDir,
serverPath,
})}\n`,
);
}
await main();

View File

@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import fs from "node:fs";
import http from "node:http";
import { readPositiveIntEnv } from "./lib/env-limits.mjs";
@@ -72,6 +73,82 @@ function responseEvents(text) {
];
}
function buildMockFunctionCall(name, args) {
const serialized = JSON.stringify(args);
const suffix = createHash("sha256")
.update(name)
.update("\0")
.update(serialized)
.digest("hex")
.slice(0, 10);
const callId = `call_mock_${name}_${suffix}`;
const itemId = `fc_mock_${name}_${suffix}`;
const item = {
type: "function_call",
id: itemId,
call_id: callId,
name,
arguments: serialized,
};
return {
item,
itemId,
responseId: `resp_mock_${name}_${suffix}`,
serialized,
};
}
function toolCallEvents(name, args) {
const call = buildMockFunctionCall(name, args);
return [
{
type: "response.output_item.added",
item: {
type: "function_call",
id: call.itemId,
call_id: call.item.call_id,
name,
arguments: "",
},
},
{ type: "response.function_call_arguments.delta", delta: call.serialized },
{ type: "response.output_item.done", item: call.item },
{
type: "response.completed",
response: {
id: call.responseId,
status: "completed",
output: [call.item],
usage: {
input_tokens: 64,
output_tokens: 16,
total_tokens: 80,
input_tokens_details: { cached_tokens: 0 },
},
},
},
];
}
function writeResponsesEvents(res, stream, events) {
if (stream === false) {
const completed = events.find((event) => event.type === "response.completed");
writeJson(res, 200, {
id: completed?.response?.id ?? "resp_e2e",
object: "response",
status: "completed",
output: completed?.response?.output ?? [],
usage: completed?.response?.usage ?? {
input_tokens: 64,
output_tokens: 16,
total_tokens: 80,
},
});
return;
}
writeSse(res, events);
}
function writeChatCompletion(res, stream, text = successMarker) {
if (stream) {
writeSse(res, [
@@ -115,6 +192,97 @@ function resolveResponseText(bodyText) {
return matches.at(-1)?.[0] ?? successMarker;
}
function collectText(value) {
if (typeof value === "string") {
return [value];
}
if (Array.isArray(value)) {
return value.flatMap((entry) => collectText(entry));
}
if (!value || typeof value !== "object") {
return [];
}
const texts = [];
for (const key of ["text", "content", "output"]) {
if (typeof value[key] === "string") {
texts.push(value[key]);
}
}
for (const nested of Object.values(value)) {
if (nested && typeof nested === "object") {
texts.push(...collectText(nested));
}
}
return texts;
}
function stringifyFunctionCallOutput(output) {
if (typeof output === "string") {
return output;
}
try {
return JSON.stringify(output);
} catch {
return "";
}
}
function collectFunctionCallOutputText(body) {
const input = Array.isArray(body?.input) ? body.input : [];
return input
.filter((item) => item?.type === "function_call_output")
.map((item) => stringifyFunctionCallOutput(item.output))
.filter(Boolean)
.join("\n");
}
function hasDeclaredTool(bodyText, name) {
return new RegExp(`"name"\\s*:\\s*"${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`, "u").test(
bodyText,
);
}
function mcpCodeModeApiFileEvents(body, bodyText) {
const allText = collectText(body).join("\n");
if (!/mcp code mode api file qa check/i.test(allText)) {
return null;
}
const toolOutput = collectFunctionCallOutputText(body);
if (!toolOutput) {
if (!hasDeclaredTool(bodyText, "exec")) {
return null;
}
return toolCallEvents("exec", {
language: "javascript",
code: [
'const files = await API.list("mcp");',
'const root = await API.read("mcp/index.d.ts");',
'const api = await API.read("mcp/fixture.d.ts");',
'const result = await MCP.fixture.lookupNote({ id: "alpha" });',
"return {",
' marker: "MCP_CODE_MODE_FILE_TOOL_RESULT",',
" files: files.files.map((file) => file.path),",
" rootHasFixture: root.content.includes('fixture'),",
" headerHasLookup: api.content.includes('function lookupNote'),",
" resultText: result.content?.[0]?.text,",
" allHasMcp: ALL_TOOLS.some((tool) => tool.source === 'mcp'),",
"};",
].join("\n"),
});
}
if (
!/MCP_CODE_MODE_FILE_TOOL_RESULT/.test(toolOutput) ||
!/fixture-note-alpha/.test(toolOutput)
) {
return responseEvents(
"MCP_CODE_MODE_FILE_FAIL unclear=code-mode-exec-did-not-return-fixture-note",
);
}
return responseEvents(
"MCP_CODE_MODE_FILE_OK note=fixture-note-alpha unclear=none improvement=virtual-api-files-were-clear-and-needed-one-exec",
);
}
const server = http.createServer((req, res) => {
void (async () => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
@@ -145,6 +313,11 @@ const server = http.createServer((req, res) => {
}
if (req.method === "POST" && url.pathname === "/v1/responses") {
const codeModeEvents = mcpCodeModeApiFileEvents(body, bodyText);
if (codeModeEvents) {
writeResponsesEvents(res, body.stream, codeModeEvents);
return;
}
const responseText = resolveResponseText(bodyText);
if (body.stream === false) {
writeJson(res, 200, {

View File

@@ -224,6 +224,23 @@ function liveCodexNpmPluginLane() {
);
}
function liveMcpCodeModeGatewayLane() {
return liveLane(
"live-mcp-code-mode-gateway",
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-mcp-code-mode-gateway",
{
cacheKey: "mcp-code-mode-gateway",
e2eImageKind: "functional",
needsLiveImage: false,
provider: "openai",
resources: ["npm", "service"],
stateScenario: "empty",
timeoutMs: 20 * 60 * 1000,
weight: 3,
},
);
}
function kitchenSinkRpcLane() {
return serviceLane(
"kitchen-sink-rpc",
@@ -386,6 +403,15 @@ export const mainLanes = [
stateScenario: "empty",
weight: 3,
}),
serviceLane(
"mcp-code-mode-gateway",
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:mcp-code-mode-gateway",
{
resources: ["npm"],
stateScenario: "empty",
weight: 3,
},
),
lane(
"agent-bundle-mcp-tools",
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:agent-bundle-mcp-tools",
@@ -534,6 +560,7 @@ export const tailLanes = [
},
),
liveCodexNpmPluginLane(),
liveMcpCodeModeGatewayLane(),
livePluginToolLane(),
liveLane(
"live-acp-bind-claude",

View File

@@ -113,6 +113,8 @@ async function readSessionLogMentions(stateDir: string): Promise<Record<string,
const sessionsDir = path.join(stateDir, "agents", "qa", "sessions");
const mentions = {
apiCall: 0,
apiFileList: 0,
apiFileRead: 0,
mcpNamespace: 0,
mcpTool: 0,
toolSearchPollution: 0,
@@ -121,6 +123,8 @@ async function readSessionLogMentions(stateDir: string): Promise<Record<string,
for (const file of files.filter((candidate) => candidate.endsWith(".jsonl"))) {
const raw = await fs.readFile(path.join(sessionsDir, file), "utf8").catch(() => "");
mentions.apiCall += countOccurrences(raw, "MCP.$api");
mentions.apiFileList += countOccurrences(raw, "API.list");
mentions.apiFileRead += countOccurrences(raw, "API.read");
mentions.mcpNamespace += countOccurrences(raw, "MCP.fixture");
mentions.mcpTool += countOccurrences(raw, "fixture__lookup_note");
mentions.toolSearchPollution += countOccurrences(raw, 'tools.search("lookup note"');
@@ -297,7 +301,7 @@ export async function main() {
content: [
{
type: "input_text",
text: "mcp code mode qa check: inspect the MCP typed API, call the fixture lookup_note tool for alpha, and say what was unclear.",
text: "mcp code mode api file qa check: inspect the MCP TypeScript declaration files through API.read, call the fixture lookup_note tool for alpha, and return the note text plus what was unclear.",
},
],
},
@@ -319,9 +323,17 @@ export async function main() {
.map((request) => request.plannedToolName)
.filter((name): name is string => typeof name === "string");
assert(finalText.includes("MCP_CODE_MODE_OK"), "agent did not complete MCP code-mode turn");
assert(
finalText.includes("MCP_CODE_MODE_FILE_OK"),
"agent did not complete MCP code-mode API file turn",
);
assert(finalText.includes("fixture-note-alpha"), "agent did not return MCP fixture note");
assert(plannedTools.includes("exec"), "agent did not call code-mode exec");
assert(mentions.apiCall > 0 && mentions.mcpNamespace > 0, "session log lacks MCP API usage");
assert(
mentions.apiFileRead > 0 && mentions.mcpNamespace > 0,
"session log lacks MCP API file usage",
);
assert(mentions.apiCall === 0, "agent should not need MCP.$api when API files are available");
assert(mentions.mcpTool > 0, "session log lacks materialized MCP tool call");
assert(mentions.toolSearchPollution === 0, "MCP lookup leaked through tools.search");

View File

@@ -5,6 +5,7 @@ const NAMESPACE_PATH_KEY_SEPARATOR = "\u0000";
const CODE_MODE_NAMESPACE_TOOL_CALL = Symbol.for("openclaw.codeMode.namespaceToolCall");
const RESERVED_NAMESPACE_GLOBALS = new Set([
"ALL_TOOLS",
"API",
"Array",
"Boolean",
"Date",
@@ -552,6 +553,12 @@ type McpApiServerDoc = {
tools: McpApiToolDoc[];
};
export type CodeModeApiVirtualFile = {
path: string;
description?: string;
content: string;
};
function buildMcpParamDocs(schema: unknown): McpApiParamDoc[] {
const required = new Set(readRequiredKeys(schema));
return orderedSchemaKeys(schema).map((key) => {
@@ -654,6 +661,13 @@ function renderMcpRootHeader(servers: readonly McpApiServerDoc[]): string {
].join("\n");
}
function renderMcpRootFile(servers: readonly McpApiServerDoc[]): string {
const references = servers.map(
(server) => `/// <reference path="./${server.identifier}.d.ts" />`,
);
return [...references, "", renderMcpRootHeader(servers)].join("\n");
}
function buildMcpApiResponse(params: {
servers: readonly McpApiServerDoc[];
server?: McpApiServerDoc;
@@ -738,9 +752,14 @@ function toolIdentifiersForServer(
return created;
}
function createMcpNamespaceScope(
type McpNamespaceModel = {
root: CodeModeNamespaceScope;
docs: McpApiServerDoc[];
};
function createMcpNamespaceModel(
catalog: readonly CodeModeNamespaceCatalogEntry[],
): CodeModeNamespaceScope | undefined {
): McpNamespaceModel | undefined {
const mcpEntries = catalog.filter((entry) => entry.source === "mcp" && entry.id && entry.mcp);
if (mcpEntries.length === 0) {
return undefined;
@@ -819,7 +838,37 @@ function createMcpNamespaceScope(
buildMcpApiResponse({ servers: docs, server, args }),
);
}
return root;
return { root, docs };
}
function createMcpNamespaceScope(
catalog: readonly CodeModeNamespaceCatalogEntry[],
): CodeModeNamespaceScope | undefined {
return createMcpNamespaceModel(catalog)?.root;
}
export function createCodeModeApiVirtualFiles(
catalog: readonly CodeModeNamespaceCatalogEntry[] = [],
): CodeModeApiVirtualFile[] {
const model = createMcpNamespaceModel(catalog);
if (!model) {
return [];
}
const files: CodeModeApiVirtualFile[] = [
{
path: "mcp/index.d.ts",
description: "Root MCP namespace declaration and server list.",
content: renderMcpRootFile(model.docs),
},
];
for (const server of model.docs) {
files.push({
path: `mcp/${server.identifier}.d.ts`,
description: `MCP server declaration for ${server.serverName}.`,
content: renderMcpServerHeader(server, server.tools),
});
}
return files;
}
function createMcpNamespaceEntry(
@@ -866,7 +915,7 @@ function describeMcpNamespaceForPrompt(
}
return [
"- MCP: MCP server tools grouped by server.",
`Use MCP.$api() or MCP.<server>.$api() to inspect TypeScript-style API headers; visible servers: ${servers.join(", ")}.`,
`Read API files such as mcp/index.d.ts and mcp/<server>.d.ts for TypeScript-style MCP headers; visible servers: ${servers.join(", ")}.`,
"Call MCP tools as MCP.<server>.<tool>({ ...input }) with one object argument matching the header.",
];
}

View File

@@ -809,6 +809,9 @@ describe("Code Mode", () => {
code: `
const rootApi = await MCP.$api();
const api = await MCP.github.$api("createIssue", { schema: true });
const apiFiles = await API.list("mcp");
const rootFile = await API.read("mcp/index.d.ts");
const serverFile = await API.read("mcp/github.d.ts");
const created = await MCP.github.createIssue({
owner: "openclaw",
repo: "openclaw",
@@ -833,6 +836,10 @@ describe("Code Mode", () => {
}
return {
apiHeader: api.header,
apiFilePaths: apiFiles.files.map((file) => file.path),
rootFileHasReference: rootFile.content.includes('./github.d.ts'),
serverFileHasCreateIssue: serverFile.content.includes('function createIssue('),
serverFileHasTitleDoc: serverFile.content.includes('@param title Issue title Shown in tracker'),
apiSchemaTitle: api.schemas.createIssue.type,
rootServers: rootApi.servers,
createdPayload,
@@ -875,6 +882,10 @@ describe("Code Mode", () => {
hasMcp: true,
apiSchemaTitle: "object",
apiHeader: expect.stringContaining("function createIssue("),
apiFilePaths: ["mcp/index.d.ts", "mcp/github.d.ts"],
rootFileHasReference: true,
serverFileHasCreateIssue: true,
serverFileHasTitleDoc: true,
rootServers: [{ identifier: "github", serverName: "github", toolCount: 1 }],
});
const value = details.value as { apiHeader: string };
@@ -884,6 +895,69 @@ describe("Code Mode", () => {
expect(githubCreate.execute).toHaveBeenCalledTimes(1);
});
it("lets agents inspect MCP declaration files before calling MCP tools", 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", description: "Issue title" },
},
required: ["owner", "repo", "title"],
},
});
applyCodeModeCatalog({
tools: [...codeModeTools, githubCreate],
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 files = await API.list("mcp");
const api = await API.read("mcp/github.d.ts");
const created = await MCP.github.createIssue({
owner: "openclaw",
repo: "openclaw",
title: "From file docs",
});
return {
fileCount: files.files.length,
headerHasSignature: api.content.includes("function createIssue("),
usedApiCall: api.content.includes("function $api("),
created: JSON.parse(created.content[0].text),
};
`,
});
expect(details.status).toBe("completed");
expect(details.value).toEqual({
fileCount: 2,
headerHasSignature: true,
usedApiCall: true,
created: {
serverName: "github",
toolName: "create_issue",
input: {
owner: "openclaw",
repo: "openclaw",
title: "From file docs",
},
},
});
expect(githubCreate.execute).toHaveBeenCalledTimes(1);
});
it("groups MCP resources and prompts under server namespaces", async () => {
const { config, catalogRef, tools: codeModeTools } = createCodeModeHarness();
const resourceRead = mcpTool({

View File

@@ -19,6 +19,7 @@ import {
markCodeModeControlTool,
} from "./code-mode-control-tools.js";
import {
createCodeModeApiVirtualFiles,
createCodeModeNamespaceRuntime,
describeCodeModeNamespacesForPrompt,
type CodeModeNamespaceRuntime,
@@ -835,7 +836,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)`. 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.' +
'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)`. Read TypeScript-style declaration files with `API.list(prefix?)` and `API.read(path)`. 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}` : "")
);
}
@@ -858,10 +859,9 @@ async function runExec(params: {
}
const runtime = new ToolSearchRuntime(params.ctx, toToolSearchConfig(config));
const catalog = runtime.all({ includeMcp: false });
const namespaceRuntime = await createCodeModeNamespaceRuntime(
params.ctx,
runtime.namespaceEntries(),
);
const namespaceCatalog = runtime.namespaceEntries();
const namespaceRuntime = await createCodeModeNamespaceRuntime(params.ctx, namespaceCatalog);
const apiFiles = createCodeModeApiVirtualFiles(namespaceCatalog);
let source: string;
try {
source = await prepareSource({ code: params.code, language: params.language, config });
@@ -882,6 +882,7 @@ async function runExec(params: {
source,
config,
catalog,
apiFiles,
namespaces: namespaceRuntime.descriptors,
},
config.timeoutMs + 1000,
@@ -1123,7 +1124,7 @@ export function createCodeModeTools(ctx: CodeModeToolContext): AnyAgentTool[] {
code: Type.Optional(
Type.String({
description:
"JavaScript or TypeScript source to run. The `tools` object (search/describe/call), `ALL_TOOLS`, and registered namespace globals are available in scope; Node built-in modules are not.",
"JavaScript or TypeScript source to run. The `tools` object (search/describe/call), `ALL_TOOLS`, `API` virtual declaration files, and registered namespace globals are available in scope; Node built-in modules are not.",
}),
),
command: Type.Optional(

View File

@@ -43,12 +43,19 @@ type CodeModeNamespaceDescriptor = {
scope: SerializedCodeModeNamespaceValue;
};
type CodeModeApiVirtualFile = {
path: string;
description?: string;
content: string;
};
type CodeModeWorkerInput =
| {
kind: "exec";
source: string;
config: CodeModeConfig;
catalog: unknown[];
apiFiles?: CodeModeApiVirtualFile[];
namespaces: CodeModeNamespaceDescriptor[];
}
| {
@@ -185,6 +192,7 @@ const CONTROLLER_SOURCE = String.raw`
const output = [];
const pending = new Map();
const catalog = Array.isArray(globalThis.__openclawCatalog) ? globalThis.__openclawCatalog : [];
const apiFiles = Array.isArray(globalThis.__openclawApiFiles) ? globalThis.__openclawApiFiles : [];
const namespaceDescriptors = Array.isArray(globalThis.__openclawNamespaces) ? globalThis.__openclawNamespaces : [];
function safe(value) {
@@ -269,6 +277,47 @@ const CONTROLLER_SOURCE = String.raw`
call: { value: (id, input) => request("call", [id, input]), enumerable: true },
});
function normalizeApiPath(value) {
const text = String(value ?? "").trim().replace(/^\/+/, "");
if (!text || text.split("/").some((segment) => !segment || segment === "." || segment === "..")) {
throw new Error("invalid API file path");
}
return text;
}
const apiFileMap = new Map();
for (const file of apiFiles) {
if (!file || typeof file !== "object") continue;
const path = typeof file.path === "string" ? file.path : "";
const content = typeof file.content === "string" ? file.content : "";
if (!path || !content) continue;
apiFileMap.set(path, Object.freeze({
path,
content,
description: typeof file.description === "string" ? file.description : undefined,
bytes: content.length,
}));
}
const api = Object.freeze({
list: async (prefix = "") => {
const normalizedPrefix = prefix == null || String(prefix).trim() === "" ? "" : normalizeApiPath(prefix);
const files = [...apiFileMap.values()]
.filter((file) => !normalizedPrefix || file.path === normalizedPrefix || file.path.startsWith(normalizedPrefix.replace(/\/?$/, "/")))
.map((file) => Object.freeze({
path: file.path,
description: file.description,
bytes: file.bytes,
}));
return { files };
},
read: async (path) => {
const normalizedPath = normalizeApiPath(path);
const file = apiFileMap.get(normalizedPath);
if (!file) throw new Error("Unknown API file: " + normalizedPath);
return file;
},
});
const safeNameCounts = new Map();
for (const tool of catalog) {
const name = typeof tool?.name === "string" ? tool.name : "";
@@ -308,6 +357,7 @@ const CONTROLLER_SOURCE = String.raw`
Object.defineProperties(globalThis, {
ALL_TOOLS: { value: Object.freeze(catalog.slice()), enumerable: true },
API: { value: api, enumerable: true },
namespaces: { value: Object.freeze(namespaceGlobals), enumerable: true },
tools: { value: Object.freeze(baseTools), enumerable: true },
text: { value: (value) => output.push({ type: "text", text: asText(value) }), enumerable: true },
@@ -360,6 +410,7 @@ function createHostRequestHandler(params: {
async function createVm(params: {
catalog: unknown[];
apiFiles: CodeModeApiVirtualFile[];
namespaces: CodeModeNamespaceDescriptor[];
config: CodeModeConfig;
pendingRequests: PendingBridgeRequest[];
@@ -389,6 +440,12 @@ async function createVm(params: {
} finally {
namespacesHandle.dispose();
}
const apiFilesHandle = vm.hostToHandle(params.apiFiles);
try {
vm.setProp(vm.global, "__openclawApiFiles", apiFilesHandle);
} finally {
apiFilesHandle.dispose();
}
const hostRequest = vm.newFunction(
"__openclawHostRequest",
createHostRequestHandler({
@@ -548,6 +605,7 @@ async function runExec(input: Extract<CodeModeWorkerInput, { kind: "exec" }>) {
const pendingRequests: PendingBridgeRequest[] = [];
const { vm, didTimeout } = await createVm({
catalog: input.catalog,
apiFiles: input.apiFiles ?? [],
namespaces: input.namespaces,
config: input.config,
pendingRequests,
@@ -656,6 +714,7 @@ async function main(): Promise<CodeModeWorkerResult> {
source: input.source,
config: input.config as CodeModeConfig,
catalog: Array.isArray(input.catalog) ? input.catalog : [],
apiFiles: Array.isArray(input.apiFiles) ? (input.apiFiles as CodeModeApiVirtualFile[]) : [],
namespaces: Array.isArray(input.namespaces)
? (input.namespaces as CodeModeNamespaceDescriptor[])
: [],

View File

@@ -1447,13 +1447,21 @@ export async function runEmbeddedAttempt(
],
})
: undefined;
const allowedBundledTools = applyEmbeddedAttemptToolsAllow(
[...(bundleMcpRuntime?.tools ?? []), ...(bundleLspRuntime?.tools ?? [])],
const allowedBundleMcpTools = applyEmbeddedAttemptToolsAllow(
bundleMcpRuntime?.tools ?? [],
effectiveToolsAllow,
{
toolMeta: (tool) => getPluginToolMeta(tool),
},
);
const allowedBundleLspTools = applyEmbeddedAttemptToolsAllow(
bundleLspRuntime?.tools ?? [],
effectiveToolsAllow,
{
toolMeta: (tool) => getPluginToolMeta(tool),
},
);
const allowedBundledTools = [...allowedBundleMcpTools, ...allowedBundleLspTools];
const filteredBundledTools = applyFinalEffectiveToolPolicy({
bundledTools: allowedBundledTools,
config: params.config,

View File

@@ -5,6 +5,7 @@ import {
normalizedParameterFreeSchema,
} from "openclaw/plugin-sdk/agent-runtime-test-contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getPluginToolMeta, setPluginToolMeta } from "../../plugins/tools.js";
import { logAgentRuntimeToolDiagnostics, normalizeAgentRuntimeTools } from "./tools.js";
import type { AgentRuntimePlan } from "./types.js";
@@ -111,6 +112,39 @@ describe("AgentRuntimePlan tool policy helpers", () => {
});
});
it("preserves plugin metadata when provider schema normalization clones tools", () => {
const tool = createParameterFreeTool("fixture__lookup_note") as AgentTool;
setPluginToolMeta(tool, {
pluginId: "bundle-mcp",
optional: false,
mcp: {
serverName: "fixture",
safeServerName: "fixture",
toolName: "lookup_note",
operation: "tool",
},
});
const normalized = {
...tool,
parameters: normalizedParameterFreeSchema(),
};
mocks.normalizeProviderToolSchemas.mockReturnValueOnce([normalized]);
const result = normalizeAgentRuntimeTools({
tools: [tool],
provider: "openai",
});
expect(result[0]).toBe(normalized);
expect(getPluginToolMeta(result[0])).toMatchObject({
pluginId: "bundle-mcp",
mcp: {
serverName: "fixture",
toolName: "lookup_note",
},
});
});
it("can normalize without cold-loading provider runtime plugins", () => {
const tools = [createParameterFreeTool()] as AgentTool[];

View File

@@ -2,6 +2,8 @@ import type { TSchema } from "typebox";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { ProviderRuntimePluginHandle } from "../../plugins/provider-hook-runtime.js";
import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js";
import { copyPluginToolMeta } from "../../plugins/tools.js";
import { copyChannelAgentToolMeta } from "../channel-tools.js";
import {
logProviderToolSchemaDiagnostics,
normalizeProviderToolSchemas,
@@ -35,12 +37,48 @@ function runtimePlanToolContext(params: {
};
}
function copyRuntimeToolMetadata(source: AgentTool, target: AgentTool): void {
if (source === target) {
return;
}
copyPluginToolMeta(source as never, target as never);
copyChannelAgentToolMeta(source as never, target as never);
}
function preserveRuntimeToolMetadata<TSchemaType extends TSchema = TSchema, TResult = unknown>(
sourceTools: AgentTool<TSchemaType, TResult>[],
normalizedTools: AgentTool<TSchemaType, TResult>[],
): AgentTool<TSchemaType, TResult>[] {
const sourcesByUniqueName = new Map<string, AgentTool<TSchemaType, TResult>>();
const duplicateNames = new Set<string>();
for (const source of sourceTools) {
const name = source.name;
if (sourcesByUniqueName.has(name)) {
duplicateNames.add(name);
sourcesByUniqueName.delete(name);
continue;
}
if (!duplicateNames.has(name)) {
sourcesByUniqueName.set(name, source);
}
}
for (const [index, target] of normalizedTools.entries()) {
const indexedSource = sourceTools[index];
const source =
indexedSource?.name === target.name ? indexedSource : sourcesByUniqueName.get(target.name);
if (source) {
copyRuntimeToolMetadata(source, target);
}
}
return normalizedTools;
}
export function normalizeAgentRuntimeTools<
TSchemaType extends TSchema = TSchema,
TResult = unknown,
>(params: AgentRuntimeToolPolicyParams<TSchemaType, TResult>): AgentTool<TSchemaType, TResult>[] {
const planContext = runtimePlanToolContext(params);
return (
const normalized =
params.runtimePlan?.tools.normalize(params.tools, planContext) ??
normalizeProviderToolSchemas({
tools: params.tools,
@@ -53,8 +91,9 @@ export function normalizeAgentRuntimeTools<
model: params.model,
runtimeHandle: params.runtimeHandle,
allowRuntimePluginLoad: params.allowProviderRuntimePluginLoad,
})
);
});
const normalizedTools = Array.isArray(normalized) ? normalized : params.tools;
return preserveRuntimeToolMetadata(params.tools, normalizedTools);
}
export function logAgentRuntimeToolDiagnostics(params: AgentRuntimeToolPolicyParams): void {

View File

@@ -679,6 +679,7 @@ describe("scripts/lib/docker-e2e-plan", () => {
{ credentials: ["gemini"], name: "live-cli-backend-gemini" },
{ credentials: ["codex"], name: "live-codex-harness" },
{ credentials: ["openai"], name: "live-codex-media-path" },
{ credentials: ["openai"], name: "live-mcp-code-mode-gateway" },
{ credentials: ["openai"], name: "live-subagent-announce" },
{ credentials: ["codex"], name: "live-codex-bind" },
{ credentials: ["anthropic"], name: "live-acp-bind-claude" },
@@ -850,6 +851,7 @@ describe("scripts/lib/docker-e2e-plan", () => {
"openai-image-auth",
"openai-web-search-minimal",
"mcp-channels",
"mcp-code-mode-gateway",
"cron-mcp-cleanup",
"agent-bundle-mcp-tools",
"crestodian-first-run",
@@ -877,6 +879,7 @@ describe("scripts/lib/docker-e2e-plan", () => {
{ name: "openai-image-auth", stateScenario: "empty" },
{ name: "openai-web-search-minimal", stateScenario: "empty" },
{ name: "mcp-channels", stateScenario: "empty" },
{ name: "mcp-code-mode-gateway", stateScenario: "empty" },
{ name: "cron-mcp-cleanup", stateScenario: "empty" },
{ name: "agent-bundle-mcp-tools", stateScenario: "empty" },
{ name: "crestodian-first-run", stateScenario: "empty" },