From c4e5ca8625177f17e9e36330c9c256fb7bb2a900 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 23 Apr 2026 00:46:27 +0100 Subject: [PATCH] fix(agents): expose configured MCP tools in Pi profiles --- CHANGELOG.md | 3 + docs/cli/mcp.md | 3 + docs/help/testing.md | 8 +- docs/plugins/bundles.md | 5 + docs/tools/index.md | 5 + package.json | 3 +- .../e2e/pi-bundle-mcp-tools-docker-client.ts | 157 ++++++++++++++++++ scripts/e2e/pi-bundle-mcp-tools-docker.sh | 40 +++++ src/agents/pi-bundle-lsp-runtime.ts | 5 + src/agents/pi-bundle-mcp-materialize.ts | 9 +- .../pi-bundle-mcp-tools.materialize.test.ts | 2 + .../pi-embedded-runner.bundle-mcp.e2e.test.ts | 39 +++-- .../effective-tool-policy.test.ts | 27 +++ src/agents/tool-catalog.test.ts | 6 + src/agents/tool-catalog.ts | 4 +- src/agents/tool-policy-pipeline.ts | 4 +- src/plugins/tools.ts | 6 +- 17 files changed, 301 insertions(+), 25 deletions(-) create mode 100644 scripts/e2e/pi-bundle-mcp-tools-docker-client.ts create mode 100755 scripts/e2e/pi-bundle-mcp-tools-docker.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 59cae311e01..ce2120b3959 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,9 @@ Docs: https://docs.openclaw.ai - Providers/Amazon Bedrock Mantle: refresh IAM-backed bearer tokens at runtime instead of baking discovery-time tokens into provider config, so long-lived Mantle sessions keep working after the initial token ages out. Thanks @wirjo. - Config/includes: write through single-file top-level includes for isolated OpenClaw-owned mutations, so `plugins install` and `plugins update` update an included `plugins.json5` file instead of flattening modular `$include` configs. Fixes #41050 and #66048. - Config/reload: plan gateway reloads from source-authored config instead of runtime-materialized snapshots, so plugin update writes no longer trigger false restarts from derived provider/plugin config paths. Fixes #68732. +- Agents/MCP: keep `mcp.servers` and bundle MCP tools available in Pi embedded + `coding` and `messaging` sessions while preserving `minimal` profile and + `tools.deny: ["bundle-mcp"]` opt-out behavior. Fixes #68875 and #68818. - Codex harness: rotate the shared app-server websocket client when the configured bearer token changes, so auth-token refreshes reconnect with the new `Authorization` header instead of reusing a stale socket. (#70328) Thanks @Lucenx9. - Telegram/sandbox: keep Telegram bot DMs on per-account sender session keys even when `session.dmScope=main`, so sandbox/tool policy can distinguish Telegram-originated direct chats from the agent main session. - Config/models: merge provider-scoped model allowlist updates and protect model/provider map writes from accidental full replacement, adding `config set --merge` for additive updates and `--replace` for intentional clobbers. Fixes #65920, #68392, and #68653. diff --git a/docs/cli/mcp.md b/docs/cli/mcp.md index 02e42e6fae2..3c7459ab4b1 100644 --- a/docs/cli/mcp.md +++ b/docs/cli/mcp.md @@ -369,6 +369,9 @@ Important behavior: reachable right now - runtime adapters decide which transport shapes they actually support at execution time +- embedded Pi exposes configured MCP tools in normal `coding` and `messaging` + tool profiles; `minimal` still hides them, and `tools.deny: ["bundle-mcp"]` + disables them explicitly ## Saved MCP server definitions diff --git a/docs/help/testing.md b/docs/help/testing.md index 0baf185e909..1d9076f9fe3 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -882,7 +882,7 @@ These Docker runners split into two buckets: `OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=90000`. Override those env vars when you explicitly want the larger exhaustive scan. - `test:docker:all` builds the live Docker image once via `test:docker:live-build`, then reuses it for the two live Docker lanes. -- Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:gateway-network`, `test:docker:mcp-channels`, and `test:docker:plugins` boot one or more real containers and verify higher-level integration paths. +- Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:gateway-network`, `test:docker:mcp-channels`, `test:docker:pi-bundle-mcp-tools`, and `test:docker:plugins` boot one or more real containers and verify higher-level integration paths. The live-model Docker runners also bind-mount only the needed CLI auth homes (or all supported ones when the run is not narrowed), then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store: @@ -895,6 +895,7 @@ The live-model Docker runners also bind-mount only the needed CLI auth homes (or - Onboarding wizard (TTY, full scaffolding): `pnpm test:docker:onboard` (script: `scripts/e2e/onboard-docker.sh`) - Gateway networking (two containers, WS auth + health): `pnpm test:docker:gateway-network` (script: `scripts/e2e/gateway-network-docker.sh`) - MCP channel bridge (seeded Gateway + stdio bridge + raw Claude notification-frame smoke): `pnpm test:docker:mcp-channels` (script: `scripts/e2e/mcp-channels-docker.sh`) +- Pi bundle MCP tools (real stdio MCP server + embedded Pi profile allow/deny smoke): `pnpm test:docker:pi-bundle-mcp-tools` (script: `scripts/e2e/pi-bundle-mcp-tools-docker.sh`) - Plugins (install smoke + `/plugin` alias + Claude-bundle restart semantics): `pnpm test:docker:plugins` (script: `scripts/e2e/plugins-docker.sh`) - Bundled plugin runtime deps: `pnpm test:docker:bundled-channel-deps` builds a small Docker runner image by default, builds and packs OpenClaw once on the host, then mounts that tarball into each Linux install scenario. Reuse the image with `OPENCLAW_SKIP_DOCKER_BUILD=1`, skip the host rebuild after a fresh local build with `OPENCLAW_BUNDLED_CHANNEL_HOST_BUILD=0`, or point at an existing tarball with `OPENCLAW_BUNDLED_CHANNEL_PACKAGE_TGZ=/path/to/openclaw-*.tgz`. - Narrow bundled plugin runtime deps while iterating by disabling unrelated scenarios, for example: @@ -931,6 +932,11 @@ live event queue behavior, outbound send routing, and Claude-style channel + permission notifications over the real stdio MCP bridge. The notification check inspects the raw stdio MCP frames directly so the smoke validates what the bridge actually emits, not just what a specific client SDK happens to surface. +`test:docker:pi-bundle-mcp-tools` is deterministic and does not need a live +model key. It builds the repo Docker image, starts a real stdio MCP probe server +inside the container, materializes that server through the embedded Pi bundle +MCP runtime, executes the tool, then verifies `coding` and `messaging` keep +`bundle-mcp` tools while `minimal` and `tools.deny: ["bundle-mcp"]` filter them. Manual ACP plain-language thread smoke (not CI): diff --git a/docs/plugins/bundles.md b/docs/plugins/bundles.md index ba2cc815bb6..166ac7a2c91 100644 --- a/docs/plugins/bundles.md +++ b/docs/plugins/bundles.md @@ -104,6 +104,8 @@ loader. Cursor command markdown works through the same path. `mcpServers` - OpenClaw exposes supported bundle MCP tools during embedded Pi agent turns by launching stdio servers or connecting to HTTP servers +- the `coding` and `messaging` tool profiles include bundle MCP tools by + default; use `tools.deny: ["bundle-mcp"]` to opt out for an agent or gateway - project-local Pi settings still apply after bundle defaults, so workspace settings can override bundle MCP entries when needed - bundle MCP tool catalogs are sorted deterministically before registration, so @@ -170,6 +172,9 @@ OpenClaw registers bundle MCP tools with provider-safe names in the form - colliding sanitized names are disambiguated with numeric suffixes - final exposed tool order is deterministic by safe name to keep repeated Pi turns cache-stable +- profile filtering treats all tools from one bundle MCP server as plugin-owned + by `bundle-mcp`, so profile allowlists and deny lists can include either + individual exposed tool names or the `bundle-mcp` plugin key #### Embedded Pi settings diff --git a/docs/tools/index.md b/docs/tools/index.md index 26134f1a4d4..d43f005ef46 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -139,6 +139,11 @@ Per-agent override: `agents.list[].tools.profile`. | `messaging` | `group:messaging`, `sessions_list`, `sessions_history`, `sessions_send`, `session_status` | | `minimal` | `session_status` only | +The `coding` and `messaging` profiles also allow configured bundle MCP tools +under the plugin key `bundle-mcp`. Add `tools.deny: ["bundle-mcp"]` when you +want a profile to keep its normal built-ins but hide all configured MCP tools. +The `minimal` profile does not include bundle MCP tools. + ### Tool groups Use `group:*` shorthands in allow/deny lists: diff --git a/package.json b/package.json index b3b6d004855..4463434131a 100644 --- a/package.json +++ b/package.json @@ -1412,7 +1412,7 @@ "test:contracts:plugins": "node scripts/run-vitest.mjs run --config test/vitest/vitest.contracts-plugin.config.ts --maxWorkers=1", "test:coverage": "node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts --coverage", "test:coverage:changed": "node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts --coverage --changed origin/main", - "test:docker:all": "pnpm test:docker:live-build && OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-models && OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-gateway && pnpm test:docker:openwebui && pnpm test:docker:onboard && pnpm test:docker:gateway-network && pnpm test:docker:mcp-channels && pnpm test:docker:cron-mcp-cleanup && pnpm test:docker:qr && pnpm test:docker:doctor-switch && pnpm test:docker:plugins && pnpm test:docker:bundled-channel-deps && pnpm test:docker:cleanup", + "test:docker:all": "pnpm test:docker:live-build && OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-models && OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-gateway && pnpm test:docker:openwebui && pnpm test:docker:onboard && pnpm test:docker:gateway-network && pnpm test:docker:mcp-channels && pnpm test:docker:pi-bundle-mcp-tools && pnpm test:docker:cron-mcp-cleanup && pnpm test:docker:qr && pnpm test:docker:doctor-switch && pnpm test:docker:plugins && pnpm test:docker:bundled-channel-deps && pnpm test:docker:cleanup", "test:docker:bundled-channel-deps": "bash scripts/e2e/bundled-channel-runtime-deps-docker.sh", "test:docker:cleanup": "bash scripts/test-cleanup-docker.sh", "test:docker:cron-mcp-cleanup": "bash scripts/e2e/cron-mcp-cleanup-docker.sh", @@ -1440,6 +1440,7 @@ "test:docker:mcp-channels": "bash scripts/e2e/mcp-channels-docker.sh", "test:docker:onboard": "bash scripts/e2e/onboard-docker.sh", "test:docker:openwebui": "bash scripts/e2e/openwebui-docker.sh", + "test:docker:pi-bundle-mcp-tools": "bash scripts/e2e/pi-bundle-mcp-tools-docker.sh", "test:docker:plugins": "bash scripts/e2e/plugins-docker.sh", "test:docker:qr": "bash scripts/e2e/qr-import-docker.sh", "test:e2e": "node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts", diff --git a/scripts/e2e/pi-bundle-mcp-tools-docker-client.ts b/scripts/e2e/pi-bundle-mcp-tools-docker-client.ts new file mode 100644 index 00000000000..dd908764406 --- /dev/null +++ b/scripts/e2e/pi-bundle-mcp-tools-docker-client.ts @@ -0,0 +1,157 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import { materializeBundleMcpToolsForRun } from "../../src/agents/pi-bundle-mcp-materialize.ts"; +import { + disposeAllSessionMcpRuntimes, + getOrCreateSessionMcpRuntime, +} from "../../src/agents/pi-bundle-mcp-runtime.ts"; +import { applyFinalEffectiveToolPolicy } from "../../src/agents/pi-embedded-runner/effective-tool-policy.ts"; +import type { OpenClawConfig } from "../../src/config/types.openclaw.ts"; +import { getPluginToolMeta } from "../../src/plugins/tools.ts"; + +const require = createRequire(import.meta.url); + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +async function writeProbeServer(serverPath: string) { + const sdkMcpServerPath = require.resolve("@modelcontextprotocol/sdk/server/mcp.js"); + const sdkStdioServerPath = require.resolve("@modelcontextprotocol/sdk/server/stdio.js"); + await fs.writeFile( + serverPath, + `#!/usr/bin/env node +import { McpServer } from ${JSON.stringify(sdkMcpServerPath)}; +import { StdioServerTransport } from ${JSON.stringify(sdkStdioServerPath)}; + +const server = new McpServer({ name: "pi-bundle-mcp-tools-probe", version: "1.0.0" }); +server.tool("docker_probe", "Docker Pi MCP tool availability probe", async () => ({ + content: [{ type: "text", text: "pi-bundle-mcp-tools-ok" }], +})); + +await server.connect(new StdioServerTransport()); +`, + { encoding: "utf-8", mode: 0o755 }, + ); +} + +function applyPolicy(params: { + tools: Awaited>["tools"]; + config: OpenClawConfig; +}) { + const warnings: string[] = []; + return { + tools: applyFinalEffectiveToolPolicy({ + bundledTools: params.tools, + config: params.config, + sessionKey: "agent:main:docker-pi-bundle-mcp", + agentId: "main", + senderIsOwner: true, + warn: (message) => { + warnings.push(message); + }, + }), + warnings, + }; +} + +async function main() { + const stateDir = + process.env.OPENCLAW_STATE_DIR?.trim() || + path.join(os.tmpdir(), `openclaw-pi-bundle-mcp-${process.pid}`); + const probeDir = path.join(stateDir, "pi-bundle-mcp-tools"); + const serverPath = path.join(probeDir, "probe-server.mjs"); + await fs.mkdir(probeDir, { recursive: true }); + await writeProbeServer(serverPath); + + const cfg: OpenClawConfig = { + tools: { + profile: "coding", + }, + mcp: { + servers: { + dockerProbe: { + command: "node", + args: [serverPath], + cwd: probeDir, + connectionTimeoutMs: 5000, + }, + }, + }, + }; + + try { + const runtime = await getOrCreateSessionMcpRuntime({ + sessionId: `docker-pi-bundle-mcp-${randomUUID()}`, + sessionKey: "agent:main:docker-pi-bundle-mcp", + workspaceDir: probeDir, + cfg, + }); + const materialized = await materializeBundleMcpToolsForRun({ runtime }); + const probeTool = materialized.tools.find((tool) => tool.name === "dockerProbe__docker_probe"); + assert(probeTool, "expected dockerProbe__docker_probe to materialize"); + assert( + getPluginToolMeta(probeTool)?.pluginId === "bundle-mcp", + "expected materialized MCP tool to be tagged as bundle-mcp", + ); + + const result = await probeTool.execute("docker-mcp-probe", {}, undefined, undefined); + assert( + result.content.some((item) => item.type === "text" && item.text === "pi-bundle-mcp-tools-ok"), + "expected materialized MCP tool execution result", + ); + + const coding = applyPolicy({ tools: materialized.tools, config: cfg }); + assert( + coding.tools.some((tool) => tool.name === probeTool.name), + "expected coding profile to keep bundle MCP tools", + ); + + const messaging = applyPolicy({ + tools: materialized.tools, + config: { ...cfg, tools: { profile: "messaging" } }, + }); + assert( + messaging.tools.some((tool) => tool.name === probeTool.name), + "expected messaging profile to keep bundle MCP tools", + ); + + const minimal = applyPolicy({ + tools: materialized.tools, + config: { ...cfg, tools: { profile: "minimal" } }, + }); + assert(minimal.tools.length === 0, "expected minimal profile to filter bundle MCP tools"); + + const denied = applyPolicy({ + tools: materialized.tools, + config: { ...cfg, tools: { profile: "coding", deny: ["bundle-mcp"] } }, + }); + assert(denied.tools.length === 0, "expected tools.deny bundle-mcp to filter MCP tools"); + + process.stdout.write( + JSON.stringify( + { + ok: true, + tool: probeTool.name, + profileCounts: { + coding: coding.tools.length, + messaging: messaging.tools.length, + minimal: minimal.tools.length, + denied: denied.tools.length, + }, + }, + null, + 2, + ) + "\n", + ); + } finally { + await disposeAllSessionMcpRuntimes(); + } +} + +await main(); diff --git a/scripts/e2e/pi-bundle-mcp-tools-docker.sh b/scripts/e2e/pi-bundle-mcp-tools-docker.sh new file mode 100755 index 00000000000..0440408b48a --- /dev/null +++ b/scripts/e2e/pi-bundle-mcp-tools-docker.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh" +IMAGE_NAME="${OPENCLAW_IMAGE:-openclaw-pi-bundle-mcp-tools-e2e}" +CONTAINER_NAME="openclaw-pi-bundle-mcp-tools-e2e-$$" +RUN_LOG="$(mktemp -t openclaw-pi-bundle-mcp-tools-log.XXXXXX)" + +cleanup() { + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + rm -f "$RUN_LOG" +} +trap cleanup EXIT + +if [ "${OPENCLAW_SKIP_DOCKER_BUILD:-0}" != "1" ]; then + echo "Building Docker image..." + run_logged pi-bundle-mcp-tools-build docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" +fi + +echo "Running in-container Pi bundle MCP tool availability smoke..." +set +e +docker run --rm \ + --name "$CONTAINER_NAME" \ + -e "OPENCLAW_STATE_DIR=/tmp/openclaw-state" \ + "$IMAGE_NAME" \ + bash -lc "set -euo pipefail + node --import tsx scripts/e2e/pi-bundle-mcp-tools-docker-client.ts + " >"$RUN_LOG" 2>&1 +status=${PIPESTATUS[0]} +set -e + +if [ "$status" -ne 0 ]; then + echo "Docker Pi bundle MCP tool availability smoke failed" + cat "$RUN_LOG" + exit "$status" +fi + +cat "$RUN_LOG" +echo "OK" diff --git a/src/agents/pi-bundle-lsp-runtime.ts b/src/agents/pi-bundle-lsp-runtime.ts index cd8b7cb0069..5203884efe5 100644 --- a/src/agents/pi-bundle-lsp-runtime.ts +++ b/src/agents/pi-bundle-lsp-runtime.ts @@ -2,6 +2,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { logDebug, logWarn } from "../logger.js"; +import { setPluginToolMeta } from "../plugins/tools.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { loadEmbeddedPiLspConfig } from "./embedded-pi-lsp.js"; import { @@ -368,6 +369,10 @@ export async function createBundleLspToolRuntime(params: { continue; } reservedNames.add(normalizedName); + setPluginToolMeta(tool, { + pluginId: "bundle-lsp", + optional: false, + }); tools.push(tool); } diff --git a/src/agents/pi-bundle-mcp-materialize.ts b/src/agents/pi-bundle-mcp-materialize.ts index 4cc408a2c8d..a222ea27663 100644 --- a/src/agents/pi-bundle-mcp-materialize.ts +++ b/src/agents/pi-bundle-mcp-materialize.ts @@ -3,6 +3,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { logWarn } from "../logger.js"; +import { setPluginToolMeta } from "../plugins/tools.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { buildSafeToolName, @@ -10,6 +11,7 @@ import { TOOL_NAME_SEPARATOR, } from "./pi-bundle-mcp-names.js"; import type { BundleMcpToolRuntime, SessionMcpRuntime } from "./pi-bundle-mcp-types.js"; +import type { AnyAgentTool } from "./tools/common.js"; function toAgentToolResult(params: { serverName: string; @@ -96,7 +98,7 @@ export async function materializeBundleMcpToolsForRun(params: { ); } reservedNames.add(normalizeLowercaseStringOrEmpty(safeToolName)); - tools.push({ + const agentTool: AnyAgentTool = { name: safeToolName, label: tool.title ?? tool.toolName, description: tool.description || tool.fallbackDescription, @@ -109,7 +111,12 @@ export async function materializeBundleMcpToolsForRun(params: { result, }); }, + }; + setPluginToolMeta(agentTool, { + pluginId: "bundle-mcp", + optional: false, }); + tools.push(agentTool); } // Sort tools deterministically by name so the tools block in API requests is stable across diff --git a/src/agents/pi-bundle-mcp-tools.materialize.test.ts b/src/agents/pi-bundle-mcp-tools.materialize.test.ts index e9bad2fd500..038a0fcebd6 100644 --- a/src/agents/pi-bundle-mcp-tools.materialize.test.ts +++ b/src/agents/pi-bundle-mcp-tools.materialize.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { getPluginToolMeta } from "../plugins/tools.js"; import { createBundleMcpToolRuntime, materializeBundleMcpToolsForRun, @@ -58,6 +59,7 @@ describe("createBundleMcpToolRuntime", () => { }); expect(runtime.tools.map((tool) => tool.name)).toEqual(["bundleProbe__bundle_probe"]); + expect(getPluginToolMeta(runtime.tools[0])?.pluginId).toBe("bundle-mcp"); const result = await runtime.tools[0].execute("call-bundle-probe", {}, undefined, undefined); expect(result.content[0]).toMatchObject({ type: "text", diff --git a/src/agents/pi-embedded-runner.bundle-mcp.e2e.test.ts b/src/agents/pi-embedded-runner.bundle-mcp.e2e.test.ts index a6d7d4e921f..3602309c332 100644 --- a/src/agents/pi-embedded-runner.bundle-mcp.e2e.test.ts +++ b/src/agents/pi-embedded-runner.bundle-mcp.e2e.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import "./test-helpers/fast-coding-tools.js"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { setPluginToolMeta } from "../plugins/tools.js"; import { cleanupEmbeddedPiRunnerTestWorkspace, createEmbeddedPiRunnerOpenAiConfig, @@ -53,24 +54,26 @@ vi.mock("./pi-bundle-mcp-tools.js", () => ({ }), dispose: async () => {}, }), - materializeBundleMcpToolsForRun: async () => ({ - tools: [ - { - name: "bundleProbe__bundle_probe", - label: "bundle_probe", - description: "Bundle MCP probe", - parameters: { type: "object", properties: {} }, - execute: async () => ({ - content: [{ type: "text", text: "FROM-BUNDLE" }], - details: { - mcpServer: "bundleProbe", - mcpTool: "bundle_probe", - }, - }), - }, - ], - dispose: async () => {}, - }), + materializeBundleMcpToolsForRun: async () => { + const tool = { + name: "bundleProbe__bundle_probe", + label: "bundle_probe", + description: "Bundle MCP probe", + parameters: { type: "object", properties: {} }, + execute: async () => ({ + content: [{ type: "text", text: "FROM-BUNDLE" }], + details: { + mcpServer: "bundleProbe", + mcpTool: "bundle_probe", + }, + }), + }; + setPluginToolMeta(tool as any, { pluginId: "bundle-mcp", optional: false }); + return { + tools: [tool], + dispose: async () => {}, + }; + }, })); vi.mock("@mariozechner/pi-ai", async () => { diff --git a/src/agents/pi-embedded-runner/effective-tool-policy.test.ts b/src/agents/pi-embedded-runner/effective-tool-policy.test.ts index 2e210d067f2..941614bcd08 100644 --- a/src/agents/pi-embedded-runner/effective-tool-policy.test.ts +++ b/src/agents/pi-embedded-runner/effective-tool-policy.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { setPluginToolMeta } from "../../plugins/tools.js"; import type { AnyAgentTool } from "../tools/common.js"; import { applyFinalEffectiveToolPolicy } from "./effective-tool-policy.js"; @@ -117,4 +118,30 @@ describe("applyFinalEffectiveToolPolicy", () => { expect(warnings.some((w) => w.includes("totally-made-up-tool"))).toBe(true); }); + + it("keeps bundle MCP tools in the coding profile via plugin metadata", () => { + const mcpTool = makeTool("bundleProbe__bundle_probe"); + setPluginToolMeta(mcpTool, { pluginId: "bundle-mcp", optional: false }); + + const filtered = applyFinalEffectiveToolPolicy({ + bundledTools: [mcpTool], + config: { tools: { profile: "coding" } }, + warn: () => {}, + }); + + expect(filtered.map((tool) => tool.name)).toEqual(["bundleProbe__bundle_probe"]); + }); + + it("lets explicit deny entries override the profile bundle MCP allowlist", () => { + const mcpTool = makeTool("bundleProbe__bundle_probe"); + setPluginToolMeta(mcpTool, { pluginId: "bundle-mcp", optional: false }); + + const filtered = applyFinalEffectiveToolPolicy({ + bundledTools: [mcpTool], + config: { tools: { profile: "coding", deny: ["bundle-mcp"] } }, + warn: () => {}, + }); + + expect(filtered).toEqual([]); + }); }); diff --git a/src/agents/tool-catalog.test.ts b/src/agents/tool-catalog.test.ts index b959c1abb19..0a47f1663fd 100644 --- a/src/agents/tool-catalog.test.ts +++ b/src/agents/tool-catalog.test.ts @@ -14,4 +14,10 @@ describe("tool-catalog", () => { expect(policy!.allow).toContain("video_generate"); expect(policy!.allow).toContain("update_plan"); }); + + it("includes bundle MCP tools in coding and messaging profile policies", () => { + expect(resolveCoreToolProfilePolicy("coding")?.allow).toContain("bundle-mcp"); + expect(resolveCoreToolProfilePolicy("messaging")?.allow).toContain("bundle-mcp"); + expect(resolveCoreToolProfilePolicy("minimal")?.allow).not.toContain("bundle-mcp"); + }); }); diff --git a/src/agents/tool-catalog.ts b/src/agents/tool-catalog.ts index 6b634c56888..483eb19fea8 100644 --- a/src/agents/tool-catalog.ts +++ b/src/agents/tool-catalog.ts @@ -318,10 +318,10 @@ const CORE_TOOL_PROFILES: Record = { allow: listCoreToolIdsForProfile("minimal"), }, coding: { - allow: listCoreToolIdsForProfile("coding"), + allow: [...listCoreToolIdsForProfile("coding"), "bundle-mcp"], }, messaging: { - allow: listCoreToolIdsForProfile("messaging"), + allow: [...listCoreToolIdsForProfile("messaging"), "bundle-mcp"], }, full: {}, }; diff --git a/src/agents/tool-policy-pipeline.ts b/src/agents/tool-policy-pipeline.ts index 5fe5e5002fd..e91edb36e71 100644 --- a/src/agents/tool-policy-pipeline.ts +++ b/src/agents/tool-policy-pipeline.ts @@ -128,7 +128,9 @@ export function applyToolPolicyPipeline(params: { const warnableGatedCoreEntries = step.suppressUnavailableCoreToolWarning ? [] : gatedCoreEntries.filter((entry) => !unavailableCoreWarningAllowlist.has(entry)); - const otherEntries = resolved.unknownAllowlist.filter((entry) => !isKnownCoreToolId(entry)); + const otherEntries = resolved.unknownAllowlist.filter( + (entry) => !isKnownCoreToolId(entry) && !unavailableCoreWarningAllowlist.has(entry), + ); const warningEntries = [...warnableGatedCoreEntries, ...otherEntries]; if ( shouldWarnAboutUnknownAllowlist({ diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index ea8ef727e48..bd17225cf70 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -13,13 +13,17 @@ import { } from "./runtime/load-context.js"; import type { OpenClawPluginToolContext } from "./types.js"; -type PluginToolMeta = { +export type PluginToolMeta = { pluginId: string; optional: boolean; }; const pluginToolMeta = new WeakMap(); +export function setPluginToolMeta(tool: AnyAgentTool, meta: PluginToolMeta): void { + pluginToolMeta.set(tool, meta); +} + export function getPluginToolMeta(tool: AnyAgentTool): PluginToolMeta | undefined { return pluginToolMeta.get(tool); }