mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
committed by
GitHub
parent
b0679d1f13
commit
e681569536
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
172
scripts/e2e/mcp-code-mode-gateway-client.ts
Normal file
172
scripts/e2e/mcp-code-mode-gateway-client.ts
Normal 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();
|
||||
81
scripts/e2e/mcp-code-mode-gateway-docker.sh
Executable file
81
scripts/e2e/mcp-code-mode-gateway-docker.sh
Executable 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"
|
||||
124
scripts/e2e/mcp-code-mode-gateway-live-docker.sh
Executable file
124
scripts/e2e/mcp-code-mode-gateway-live-docker.sh
Executable 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"
|
||||
128
scripts/e2e/mcp-code-mode-gateway-seed.ts
Normal file
128
scripts/e2e/mcp-code-mode-gateway-seed.ts
Normal 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();
|
||||
@@ -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, {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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.",
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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[])
|
||||
: [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user