fix(agents): expose configured MCP tools in Pi profiles

This commit is contained in:
Peter Steinberger
2026-04-23 00:46:27 +01:00
parent bba63d4e78
commit c4e5ca8625
17 changed files with 301 additions and 25 deletions

View File

@@ -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. - 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/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. - 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. - 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. - 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. - 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.

View File

@@ -369,6 +369,9 @@ Important behavior:
reachable right now reachable right now
- runtime adapters decide which transport shapes they actually support at - runtime adapters decide which transport shapes they actually support at
execution time 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 ## Saved MCP server definitions

View File

@@ -882,7 +882,7 @@ These Docker runners split into two buckets:
`OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=90000`. Override those env vars when you `OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=90000`. Override those env vars when you
explicitly want the larger exhaustive scan. 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. - `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: 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`) - 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`) - 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`) - 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`) - 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`. - 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: - 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 permission notifications over the real stdio MCP bridge. The notification check
inspects the raw stdio MCP frames directly so the smoke validates what the 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. 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): Manual ACP plain-language thread smoke (not CI):

View File

@@ -104,6 +104,8 @@ loader. Cursor command markdown works through the same path.
`mcpServers` `mcpServers`
- OpenClaw exposes supported bundle MCP tools during embedded Pi agent turns by - OpenClaw exposes supported bundle MCP tools during embedded Pi agent turns by
launching stdio servers or connecting to HTTP servers 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 - project-local Pi settings still apply after bundle defaults, so workspace
settings can override bundle MCP entries when needed settings can override bundle MCP entries when needed
- bundle MCP tool catalogs are sorted deterministically before registration, so - 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 - colliding sanitized names are disambiguated with numeric suffixes
- final exposed tool order is deterministic by safe name to keep repeated Pi - final exposed tool order is deterministic by safe name to keep repeated Pi
turns cache-stable 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 #### Embedded Pi settings

View File

@@ -139,6 +139,11 @@ Per-agent override: `agents.list[].tools.profile`.
| `messaging` | `group:messaging`, `sessions_list`, `sessions_history`, `sessions_send`, `session_status` | | `messaging` | `group:messaging`, `sessions_list`, `sessions_history`, `sessions_send`, `session_status` |
| `minimal` | `session_status` only | | `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 ### Tool groups
Use `group:*` shorthands in allow/deny lists: Use `group:*` shorthands in allow/deny lists:

View File

@@ -1412,7 +1412,7 @@
"test:contracts:plugins": "node scripts/run-vitest.mjs run --config test/vitest/vitest.contracts-plugin.config.ts --maxWorkers=1", "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": "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: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:bundled-channel-deps": "bash scripts/e2e/bundled-channel-runtime-deps-docker.sh",
"test:docker:cleanup": "bash scripts/test-cleanup-docker.sh", "test:docker:cleanup": "bash scripts/test-cleanup-docker.sh",
"test:docker:cron-mcp-cleanup": "bash scripts/e2e/cron-mcp-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:mcp-channels": "bash scripts/e2e/mcp-channels-docker.sh",
"test:docker:onboard": "bash scripts/e2e/onboard-docker.sh", "test:docker:onboard": "bash scripts/e2e/onboard-docker.sh",
"test:docker:openwebui": "bash scripts/e2e/openwebui-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:plugins": "bash scripts/e2e/plugins-docker.sh",
"test:docker:qr": "bash scripts/e2e/qr-import-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", "test:e2e": "node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts",

View File

@@ -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<ReturnType<typeof materializeBundleMcpToolsForRun>>["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();

View File

@@ -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"

View File

@@ -2,6 +2,7 @@ import { spawn, type ChildProcess } from "node:child_process";
import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { OpenClawConfig } from "../config/types.openclaw.js";
import { logDebug, logWarn } from "../logger.js"; import { logDebug, logWarn } from "../logger.js";
import { setPluginToolMeta } from "../plugins/tools.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import { loadEmbeddedPiLspConfig } from "./embedded-pi-lsp.js"; import { loadEmbeddedPiLspConfig } from "./embedded-pi-lsp.js";
import { import {
@@ -368,6 +369,10 @@ export async function createBundleLspToolRuntime(params: {
continue; continue;
} }
reservedNames.add(normalizedName); reservedNames.add(normalizedName);
setPluginToolMeta(tool, {
pluginId: "bundle-lsp",
optional: false,
});
tools.push(tool); tools.push(tool);
} }

View File

@@ -3,6 +3,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { OpenClawConfig } from "../config/types.openclaw.js";
import { logWarn } from "../logger.js"; import { logWarn } from "../logger.js";
import { setPluginToolMeta } from "../plugins/tools.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { import {
buildSafeToolName, buildSafeToolName,
@@ -10,6 +11,7 @@ import {
TOOL_NAME_SEPARATOR, TOOL_NAME_SEPARATOR,
} from "./pi-bundle-mcp-names.js"; } from "./pi-bundle-mcp-names.js";
import type { BundleMcpToolRuntime, SessionMcpRuntime } from "./pi-bundle-mcp-types.js"; import type { BundleMcpToolRuntime, SessionMcpRuntime } from "./pi-bundle-mcp-types.js";
import type { AnyAgentTool } from "./tools/common.js";
function toAgentToolResult(params: { function toAgentToolResult(params: {
serverName: string; serverName: string;
@@ -96,7 +98,7 @@ export async function materializeBundleMcpToolsForRun(params: {
); );
} }
reservedNames.add(normalizeLowercaseStringOrEmpty(safeToolName)); reservedNames.add(normalizeLowercaseStringOrEmpty(safeToolName));
tools.push({ const agentTool: AnyAgentTool = {
name: safeToolName, name: safeToolName,
label: tool.title ?? tool.toolName, label: tool.title ?? tool.toolName,
description: tool.description || tool.fallbackDescription, description: tool.description || tool.fallbackDescription,
@@ -109,7 +111,12 @@ export async function materializeBundleMcpToolsForRun(params: {
result, 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 // Sort tools deterministically by name so the tools block in API requests is stable across

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getPluginToolMeta } from "../plugins/tools.js";
import { import {
createBundleMcpToolRuntime, createBundleMcpToolRuntime,
materializeBundleMcpToolsForRun, materializeBundleMcpToolsForRun,
@@ -58,6 +59,7 @@ describe("createBundleMcpToolRuntime", () => {
}); });
expect(runtime.tools.map((tool) => tool.name)).toEqual(["bundleProbe__bundle_probe"]); 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); const result = await runtime.tools[0].execute("call-bundle-probe", {}, undefined, undefined);
expect(result.content[0]).toMatchObject({ expect(result.content[0]).toMatchObject({
type: "text", type: "text",

View File

@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import "./test-helpers/fast-coding-tools.js"; import "./test-helpers/fast-coding-tools.js";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { setPluginToolMeta } from "../plugins/tools.js";
import { import {
cleanupEmbeddedPiRunnerTestWorkspace, cleanupEmbeddedPiRunnerTestWorkspace,
createEmbeddedPiRunnerOpenAiConfig, createEmbeddedPiRunnerOpenAiConfig,
@@ -53,24 +54,26 @@ vi.mock("./pi-bundle-mcp-tools.js", () => ({
}), }),
dispose: async () => {}, dispose: async () => {},
}), }),
materializeBundleMcpToolsForRun: async () => ({ materializeBundleMcpToolsForRun: async () => {
tools: [ const tool = {
{ name: "bundleProbe__bundle_probe",
name: "bundleProbe__bundle_probe", label: "bundle_probe",
label: "bundle_probe", description: "Bundle MCP probe",
description: "Bundle MCP probe", parameters: { type: "object", properties: {} },
parameters: { type: "object", properties: {} }, execute: async () => ({
execute: async () => ({ content: [{ type: "text", text: "FROM-BUNDLE" }],
content: [{ type: "text", text: "FROM-BUNDLE" }], details: {
details: { mcpServer: "bundleProbe",
mcpServer: "bundleProbe", mcpTool: "bundle_probe",
mcpTool: "bundle_probe", },
}, }),
}), };
}, setPluginToolMeta(tool as any, { pluginId: "bundle-mcp", optional: false });
], return {
dispose: async () => {}, tools: [tool],
}), dispose: async () => {},
};
},
})); }));
vi.mock("@mariozechner/pi-ai", async () => { vi.mock("@mariozechner/pi-ai", async () => {

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { setPluginToolMeta } from "../../plugins/tools.js";
import type { AnyAgentTool } from "../tools/common.js"; import type { AnyAgentTool } from "../tools/common.js";
import { applyFinalEffectiveToolPolicy } from "./effective-tool-policy.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); 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([]);
});
}); });

View File

@@ -14,4 +14,10 @@ describe("tool-catalog", () => {
expect(policy!.allow).toContain("video_generate"); expect(policy!.allow).toContain("video_generate");
expect(policy!.allow).toContain("update_plan"); 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");
});
}); });

View File

@@ -318,10 +318,10 @@ const CORE_TOOL_PROFILES: Record<ToolProfileId, ToolProfilePolicy> = {
allow: listCoreToolIdsForProfile("minimal"), allow: listCoreToolIdsForProfile("minimal"),
}, },
coding: { coding: {
allow: listCoreToolIdsForProfile("coding"), allow: [...listCoreToolIdsForProfile("coding"), "bundle-mcp"],
}, },
messaging: { messaging: {
allow: listCoreToolIdsForProfile("messaging"), allow: [...listCoreToolIdsForProfile("messaging"), "bundle-mcp"],
}, },
full: {}, full: {},
}; };

View File

@@ -128,7 +128,9 @@ export function applyToolPolicyPipeline(params: {
const warnableGatedCoreEntries = step.suppressUnavailableCoreToolWarning const warnableGatedCoreEntries = step.suppressUnavailableCoreToolWarning
? [] ? []
: gatedCoreEntries.filter((entry) => !unavailableCoreWarningAllowlist.has(entry)); : 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]; const warningEntries = [...warnableGatedCoreEntries, ...otherEntries];
if ( if (
shouldWarnAboutUnknownAllowlist({ shouldWarnAboutUnknownAllowlist({

View File

@@ -13,13 +13,17 @@ import {
} from "./runtime/load-context.js"; } from "./runtime/load-context.js";
import type { OpenClawPluginToolContext } from "./types.js"; import type { OpenClawPluginToolContext } from "./types.js";
type PluginToolMeta = { export type PluginToolMeta = {
pluginId: string; pluginId: string;
optional: boolean; optional: boolean;
}; };
const pluginToolMeta = new WeakMap<AnyAgentTool, PluginToolMeta>(); const pluginToolMeta = new WeakMap<AnyAgentTool, PluginToolMeta>();
export function setPluginToolMeta(tool: AnyAgentTool, meta: PluginToolMeta): void {
pluginToolMeta.set(tool, meta);
}
export function getPluginToolMeta(tool: AnyAgentTool): PluginToolMeta | undefined { export function getPluginToolMeta(tool: AnyAgentTool): PluginToolMeta | undefined {
return pluginToolMeta.get(tool); return pluginToolMeta.get(tool);
} }