From e681569536ea89787031b8cb512af6491c460f49 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 31 May 2026 21:29:06 +0100 Subject: [PATCH] feat: add code-mode MCP API files * feat: add code-mode MCP API files * fix: satisfy code-mode MCP lint --- docs/reference/code-mode.md | 21 ++- .../src/providers/mock-openai/server.ts | 61 ++++-- package.json | 2 + scripts/e2e/mcp-code-mode-gateway-client.ts | 172 +++++++++++++++++ scripts/e2e/mcp-code-mode-gateway-docker.sh | 81 ++++++++ .../e2e/mcp-code-mode-gateway-live-docker.sh | 124 +++++++++++++ scripts/e2e/mcp-code-mode-gateway-seed.ts | 128 +++++++++++++ scripts/e2e/mock-openai-server.mjs | 173 ++++++++++++++++++ scripts/lib/docker-e2e-scenarios.mjs | 27 +++ scripts/mcp-code-mode-gateway-e2e.ts | 18 +- src/agents/code-mode-namespaces.ts | 57 +++++- src/agents/code-mode.test.ts | 74 ++++++++ src/agents/code-mode.ts | 13 +- src/agents/code-mode.worker.ts | 59 ++++++ .../embedded-agent-runner/run/attempt.ts | 12 +- src/agents/runtime-plan/tools.test.ts | 34 ++++ src/agents/runtime-plan/tools.ts | 45 ++++- test/scripts/docker-e2e-plan.test.ts | 3 + 18 files changed, 1063 insertions(+), 41 deletions(-) create mode 100644 scripts/e2e/mcp-code-mode-gateway-client.ts create mode 100755 scripts/e2e/mcp-code-mode-gateway-docker.sh create mode 100755 scripts/e2e/mcp-code-mode-gateway-live-docker.sh create mode 100644 scripts/e2e/mcp-code-mode-gateway-seed.ts diff --git a/docs/reference/code-mode.md b/docs/reference/code-mode.md index af9c0f79b0ee..3809538f5c1d 100644 --- a/docs/reference/code-mode.md +++ b/docs/reference/code-mode.md @@ -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..$api()` returns a compact header inferred from MCP tool metadata: +`API.read("mcp/.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/.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..$api()` and assert the headers - describe visible MCP tools. +9. In `exec`, call `API.list("mcp")` and `API.read("mcp/.d.ts")` and + assert the declaration files describe visible MCP tools. 10. In `exec`, call MCP tools through `MCP..({ ...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. diff --git a/extensions/qa-lab/src/providers/mock-openai/server.ts b/extensions/qa-lab/src/providers/mock-openai/server.ts index f9afa486712d..8019b6c63155 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.ts @@ -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", diff --git a/package.json b/package.json index 6e025d3126d2..fb70792e62e8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/e2e/mcp-code-mode-gateway-client.ts b/scripts/e2e/mcp-code-mode-gateway-client.ts new file mode 100644 index 000000000000..7e3e6a7585c7 --- /dev/null +++ b/scripts/e2e/mcp-code-mode-gateway-client.ts @@ -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 { + const timeoutMs = Number(process.env.OPENCLAW_MCP_CODE_MODE_CLIENT_TIMEOUT_MS ?? 300_000); + const controller = new AbortController(); + let timeout: ReturnType | 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> { + 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(); diff --git a/scripts/e2e/mcp-code-mode-gateway-docker.sh b/scripts/e2e/mcp-code-mode-gateway-docker.sh new file mode 100755 index 000000000000..3983e4cd95d0 --- /dev/null +++ b/scripts/e2e/mcp-code-mode-gateway-docker.sh @@ -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" diff --git a/scripts/e2e/mcp-code-mode-gateway-live-docker.sh b/scripts/e2e/mcp-code-mode-gateway-live-docker.sh new file mode 100755 index 000000000000..1d8e97c8a154 --- /dev/null +++ b/scripts/e2e/mcp-code-mode-gateway-live-docker.sh @@ -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" diff --git a/scripts/e2e/mcp-code-mode-gateway-seed.ts b/scripts/e2e/mcp-code-mode-gateway-seed.ts new file mode 100644 index 000000000000..b58f9edd5e0b --- /dev/null +++ b/scripts/e2e/mcp-code-mode-gateway-seed.ts @@ -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(); diff --git a/scripts/e2e/mock-openai-server.mjs b/scripts/e2e/mock-openai-server.mjs index 8fc279b55485..8c8787fe12a6 100644 --- a/scripts/e2e/mock-openai-server.mjs +++ b/scripts/e2e/mock-openai-server.mjs @@ -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, { diff --git a/scripts/lib/docker-e2e-scenarios.mjs b/scripts/lib/docker-e2e-scenarios.mjs index 671aff055f70..db57e4c5a877 100644 --- a/scripts/lib/docker-e2e-scenarios.mjs +++ b/scripts/lib/docker-e2e-scenarios.mjs @@ -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", diff --git a/scripts/mcp-code-mode-gateway-e2e.ts b/scripts/mcp-code-mode-gateway-e2e.ts index dbbf60a108a9..17581847284c 100644 --- a/scripts/mcp-code-mode-gateway-e2e.ts +++ b/scripts/mcp-code-mode-gateway-e2e.ts @@ -113,6 +113,8 @@ async function readSessionLogMentions(stateDir: string): Promise 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"); diff --git a/src/agents/code-mode-namespaces.ts b/src/agents/code-mode-namespaces.ts index a6f6cd5648a8..e2bb4576a6a6 100644 --- a/src/agents/code-mode-namespaces.ts +++ b/src/agents/code-mode-namespaces.ts @@ -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) => `/// `, + ); + 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..$api() to inspect TypeScript-style API headers; visible servers: ${servers.join(", ")}.`, + `Read API files such as mcp/index.d.ts and mcp/.d.ts for TypeScript-style MCP headers; visible servers: ${servers.join(", ")}.`, "Call MCP tools as MCP..({ ...input }) with one object argument matching the header.", ]; } diff --git a/src/agents/code-mode.test.ts b/src/agents/code-mode.test.ts index b8aa2679986e..a44c628376c5 100644 --- a/src/agents/code-mode.test.ts +++ b/src/agents/code-mode.test.ts @@ -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({ diff --git a/src/agents/code-mode.ts b/src/agents/code-mode.ts index f565f5ba52b2..a9c930fbb092 100644 --- a/src/agents/code-mode.ts +++ b/src/agents/code-mode.ts @@ -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( diff --git a/src/agents/code-mode.worker.ts b/src/agents/code-mode.worker.ts index 3879622d4b3d..77f05bc94f71 100644 --- a/src/agents/code-mode.worker.ts +++ b/src/agents/code-mode.worker.ts @@ -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) { 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 { 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[]) : [], diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts index 01c22d8e8036..1ea9215f6931 100644 --- a/src/agents/embedded-agent-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -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, diff --git a/src/agents/runtime-plan/tools.test.ts b/src/agents/runtime-plan/tools.test.ts index 595c0ec5af37..f585dfa7932e 100644 --- a/src/agents/runtime-plan/tools.test.ts +++ b/src/agents/runtime-plan/tools.test.ts @@ -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[]; diff --git a/src/agents/runtime-plan/tools.ts b/src/agents/runtime-plan/tools.ts index 168fd7c34dcc..eb8229331814 100644 --- a/src/agents/runtime-plan/tools.ts +++ b/src/agents/runtime-plan/tools.ts @@ -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( + sourceTools: AgentTool[], + normalizedTools: AgentTool[], +): AgentTool[] { + const sourcesByUniqueName = new Map>(); + const duplicateNames = new Set(); + 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): AgentTool[] { 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 { diff --git a/test/scripts/docker-e2e-plan.test.ts b/test/scripts/docker-e2e-plan.test.ts index 07893e48907e..079bb80f558c 100644 --- a/test/scripts/docker-e2e-plan.test.ts +++ b/test/scripts/docker-e2e-plan.test.ts @@ -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" },