Agents: run bundle MCP tools in embedded Pi

This commit is contained in:
Vincent Koc 2026-03-16 18:11:45 -07:00
parent 38bc364aed
commit cf98a0f6e3
15 changed files with 932 additions and 19 deletions

View File

@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
- secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant.
- Browser/existing-session: support `browser.profiles.<name>.userDataDir` so Chrome DevTools MCP can attach to Brave, Edge, and other Chromium-based browsers through their own user data directories. (#48170) Thanks @velvet-shark.
- Skills/prompt budget: preserve all registered skills via a compact catalog fallback before dropping entries when the full prompt format exceeds `maxSkillsPromptChars`. (#47553) Thanks @snese.
- Plugins/bundles: make enabled bundle MCP servers expose runnable tools in embedded Pi, and default relative bundle MCP launches to the bundle root so marketplace bundles like Context7 work through Pi instead of stopping at config import.
### Breaking

View File

@ -104,11 +104,15 @@ loader. Cursor command markdown works through the same path.
- `HOOK.md`
- `handler.ts` or `handler.js`
#### MCP for CLI backends
#### MCP for Pi
- enabled bundles can contribute MCP server config
- current runtime wiring is used by the `claude-cli` backend
- OpenClaw merges bundle MCP config into the backend `--mcp-config` file
- OpenClaw merges bundle MCP config into the effective embedded Pi settings as
`mcpServers`
- OpenClaw also exposes supported bundle MCP tools during embedded Pi agent
turns by launching the declared MCP servers as subprocesses
- project-local Pi settings still apply after bundle defaults, so workspace
settings can override bundle MCP entries when needed
#### Embedded Pi settings
@ -133,7 +137,6 @@ diagnostics/info output, but OpenClaw does not run them yet:
- Cursor `.cursor/agents`
- Cursor `.cursor/hooks.json`
- Cursor `.cursor/rules`
- Cursor `mcpServers` outside the current mapped runtime paths
- Codex inline/app metadata beyond capability reporting
## Capability reporting
@ -153,7 +156,8 @@ Current exceptions:
- Claude `commands` is considered supported because it maps to skills
- Claude `settings` is considered supported because it maps to embedded Pi settings
- Cursor `commands` is considered supported because it maps to skills
- bundle MCP is considered supported where OpenClaw actually imports it
- bundle MCP is considered supported because it maps into embedded Pi settings
and exposes tools to embedded Pi
- Codex `hooks` is considered supported only for OpenClaw hook-pack layouts
## Format differences
@ -195,6 +199,7 @@ Claude-specific notes:
- `commands/` is treated like skill content
- `settings.json` is imported into embedded Pi settings
- `.mcp.json` and manifest `mcpServers` can expose tools to embedded Pi
- `hooks/hooks.json` is detected, but not executed as Claude automation
### Cursor
@ -246,7 +251,9 @@ Current behavior:
- bundle discovery reads files inside the plugin root with boundary checks
- skills and hook-pack paths must stay inside the plugin root
- bundle settings files are read with the same boundary checks
- OpenClaw does not execute arbitrary bundle runtime code in-process
- supported bundle MCP servers may be launched as subprocesses for embedded Pi
tool calls
- OpenClaw does not load arbitrary bundle runtime modules in-process
This makes bundle support safer by default than native plugin modules, but you
should still treat third-party bundles as trusted content for the features they

View File

@ -214,18 +214,22 @@ plugins:
OpenClaw skill loader
- supported now: Claude bundle `settings.json` defaults for embedded Pi agent
settings (with shell override keys sanitized)
- supported now: bundle MCP config, merged into embedded Pi agent settings as
`mcpServers`, with supported bundle MCP tools exposed during embedded Pi
agent turns
- supported now: Cursor `.cursor/commands/*.md` roots, mapped into the normal
OpenClaw skill loader
- supported now: Codex bundle hook directories that use the OpenClaw hook-pack
layout (`HOOK.md` + `handler.ts`/`handler.js`)
- detected but not wired yet: other declared bundle capabilities such as
agents, Claude hook automation, Cursor rules/hooks/MCP metadata, MCP/app/LSP
agents, Claude hook automation, Cursor rules/hooks metadata, app/LSP
metadata, output styles
That means bundle install/discovery/list/info/enablement all work, and bundle
skills, Claude command-skills, Claude bundle settings defaults, and compatible
Codex hook directories load when the bundle is enabled, but bundle runtime code
is not executed in-process.
Codex hook directories load when the bundle is enabled. Supported bundle MCP
servers may also run as subprocesses for embedded Pi tool calls, but bundle
runtime modules are not loaded in-process.
Bundle hook support is limited to the normal OpenClaw hook directory format
(`HOOK.md` plus `handler.ts`/`handler.js` under the declared hook roots).

View File

@ -0,0 +1,141 @@
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createBundleMcpToolRuntime } from "./pi-bundle-mcp-tools.js";
const require = createRequire(import.meta.url);
const SDK_SERVER_MCP_PATH = require.resolve("@modelcontextprotocol/sdk/server/mcp.js");
const SDK_SERVER_STDIO_PATH = require.resolve("@modelcontextprotocol/sdk/server/stdio.js");
const tempDirs: string[] = [];
async function makeTempDir(prefix: string): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
async function writeExecutable(filePath: string, content: string): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, { encoding: "utf-8", mode: 0o755 });
}
async function writeBundleProbeMcpServer(filePath: string): Promise<void> {
await writeExecutable(
filePath,
`#!/usr/bin/env node
import { McpServer } from ${JSON.stringify(SDK_SERVER_MCP_PATH)};
import { StdioServerTransport } from ${JSON.stringify(SDK_SERVER_STDIO_PATH)};
const server = new McpServer({ name: "bundle-probe", version: "1.0.0" });
server.tool("bundle_probe", "Bundle MCP probe", async () => {
return {
content: [{ type: "text", text: process.env.BUNDLE_PROBE_TEXT ?? "missing-probe-text" }],
};
});
await server.connect(new StdioServerTransport());
`,
);
}
async function writeClaudeBundle(params: {
pluginRoot: string;
serverScriptPath: string;
}): Promise<void> {
await fs.mkdir(path.join(params.pluginRoot, ".claude-plugin"), { recursive: true });
await fs.writeFile(
path.join(params.pluginRoot, ".claude-plugin", "plugin.json"),
`${JSON.stringify({ name: "bundle-probe" }, null, 2)}\n`,
"utf-8",
);
await fs.writeFile(
path.join(params.pluginRoot, ".mcp.json"),
`${JSON.stringify(
{
mcpServers: {
bundleProbe: {
command: "node",
args: [path.relative(params.pluginRoot, params.serverScriptPath)],
env: {
BUNDLE_PROBE_TEXT: "FROM-BUNDLE",
},
},
},
},
null,
2,
)}\n`,
"utf-8",
);
}
afterEach(async () => {
await Promise.all(
tempDirs.splice(0, tempDirs.length).map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
describe("createBundleMcpToolRuntime", () => {
it("loads bundle MCP tools and executes them", async () => {
const workspaceDir = await makeTempDir("openclaw-bundle-mcp-tools-");
const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "bundle-probe");
const serverScriptPath = path.join(pluginRoot, "servers", "bundle-probe.mjs");
await writeBundleProbeMcpServer(serverScriptPath);
await writeClaudeBundle({ pluginRoot, serverScriptPath });
const runtime = await createBundleMcpToolRuntime({
workspaceDir,
cfg: {
plugins: {
entries: {
"bundle-probe": { enabled: true },
},
},
},
});
try {
expect(runtime.tools.map((tool) => tool.name)).toEqual(["bundle_probe"]);
const result = await runtime.tools[0].execute("call-bundle-probe", {}, undefined, undefined);
expect(result.content[0]).toMatchObject({
type: "text",
text: "FROM-BUNDLE",
});
expect(result.details).toEqual({
mcpServer: "bundleProbe",
mcpTool: "bundle_probe",
});
} finally {
await runtime.dispose();
}
});
it("skips bundle MCP tools that collide with existing tool names", async () => {
const workspaceDir = await makeTempDir("openclaw-bundle-mcp-tools-");
const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "bundle-probe");
const serverScriptPath = path.join(pluginRoot, "servers", "bundle-probe.mjs");
await writeBundleProbeMcpServer(serverScriptPath);
await writeClaudeBundle({ pluginRoot, serverScriptPath });
const runtime = await createBundleMcpToolRuntime({
workspaceDir,
cfg: {
plugins: {
entries: {
"bundle-probe": { enabled: true },
},
},
},
reservedToolNames: ["bundle_probe"],
});
try {
expect(runtime.tools).toEqual([]);
} finally {
await runtime.dispose();
}
});
});

View File

@ -0,0 +1,278 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { logDebug, logWarn } from "../logger.js";
import { loadEnabledBundleMcpConfig } from "../plugins/bundle-mcp.js";
import type { AnyAgentTool } from "./tools/common.js";
type BundleMcpServerLaunchConfig = {
command: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
};
type BundleMcpToolRuntime = {
tools: AnyAgentTool[];
dispose: () => Promise<void>;
};
type BundleMcpSession = {
serverName: string;
client: Client;
transport: StdioClientTransport;
detachStderr?: () => void;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function toStringRecord(value: unknown): Record<string, string> | undefined {
if (!isRecord(value)) {
return undefined;
}
const entries = Object.entries(value)
.map(([key, entry]) => {
if (typeof entry === "string") {
return [key, entry] as const;
}
if (typeof entry === "number" || typeof entry === "boolean") {
return [key, String(entry)] as const;
}
return null;
})
.filter((entry): entry is readonly [string, string] => entry !== null);
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
function toStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const entries = value.filter((entry): entry is string => typeof entry === "string");
return entries.length > 0 ? entries : [];
}
function resolveLaunchConfig(raw: unknown): BundleMcpServerLaunchConfig | null {
if (!isRecord(raw) || typeof raw.command !== "string" || raw.command.trim().length === 0) {
return null;
}
const cwd =
typeof raw.cwd === "string" && raw.cwd.trim().length > 0
? raw.cwd
: typeof raw.workingDirectory === "string" && raw.workingDirectory.trim().length > 0
? raw.workingDirectory
: undefined;
return {
command: raw.command,
args: toStringArray(raw.args),
env: toStringRecord(raw.env),
cwd,
};
}
function describeServerLaunchConfig(config: BundleMcpServerLaunchConfig): string {
const args =
Array.isArray(config.args) && config.args.length > 0 ? ` ${config.args.join(" ")}` : "";
const cwd = config.cwd ? ` (cwd=${config.cwd})` : "";
return `${config.command}${args}${cwd}`;
}
async function listAllTools(client: Client) {
const tools: Awaited<ReturnType<Client["listTools"]>>["tools"] = [];
let cursor: string | undefined;
do {
const page = await client.listTools(cursor ? { cursor } : undefined);
tools.push(...page.tools);
cursor = page.nextCursor;
} while (cursor);
return tools;
}
function toAgentToolResult(params: {
serverName: string;
toolName: string;
result: CallToolResult;
}): AgentToolResult<unknown> {
const content = Array.isArray(params.result.content)
? (params.result.content as AgentToolResult<unknown>["content"])
: [];
const normalizedContent: AgentToolResult<unknown>["content"] =
content.length > 0
? content
: params.result.structuredContent !== undefined
? [
{
type: "text",
text: JSON.stringify(params.result.structuredContent, null, 2),
},
]
: ([
{
type: "text",
text: JSON.stringify(
{
status: params.result.isError === true ? "error" : "ok",
server: params.serverName,
tool: params.toolName,
},
null,
2,
),
},
] as AgentToolResult<unknown>["content"]);
const details: Record<string, unknown> = {
mcpServer: params.serverName,
mcpTool: params.toolName,
};
if (params.result.structuredContent !== undefined) {
details.structuredContent = params.result.structuredContent;
}
if (params.result.isError === true) {
details.status = "error";
}
return {
content: normalizedContent,
details,
};
}
function attachStderrLogging(serverName: string, transport: StdioClientTransport) {
const stderr = transport.stderr;
if (!stderr || typeof stderr.on !== "function") {
return undefined;
}
const onData = (chunk: Buffer | string) => {
const message = String(chunk).trim();
if (!message) {
return;
}
for (const line of message.split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed) {
logDebug(`bundle-mcp:${serverName}: ${trimmed}`);
}
}
};
stderr.on("data", onData);
return () => {
if (typeof stderr.off === "function") {
stderr.off("data", onData);
} else if (typeof stderr.removeListener === "function") {
stderr.removeListener("data", onData);
}
};
}
async function disposeSession(session: BundleMcpSession) {
session.detachStderr?.();
await session.client.close().catch(() => {});
await session.transport.close().catch(() => {});
}
export async function createBundleMcpToolRuntime(params: {
workspaceDir: string;
cfg?: OpenClawConfig;
reservedToolNames?: Iterable<string>;
}): Promise<BundleMcpToolRuntime> {
const loaded = loadEnabledBundleMcpConfig({
workspaceDir: params.workspaceDir,
cfg: params.cfg,
});
for (const diagnostic of loaded.diagnostics) {
logWarn(`bundle-mcp: ${diagnostic.pluginId}: ${diagnostic.message}`);
}
const reservedNames = new Set(
Array.from(params.reservedToolNames ?? [], (name) => name.trim().toLowerCase()).filter(Boolean),
);
const sessions: BundleMcpSession[] = [];
const tools: AnyAgentTool[] = [];
try {
for (const [serverName, rawServer] of Object.entries(loaded.config.mcpServers)) {
const launchConfig = resolveLaunchConfig(rawServer);
if (!launchConfig) {
logWarn(`bundle-mcp: skipped server "${serverName}" because its command is missing.`);
continue;
}
const transport = new StdioClientTransport({
command: launchConfig.command,
args: launchConfig.args,
env: launchConfig.env,
cwd: launchConfig.cwd,
stderr: "pipe",
});
const client = new Client(
{
name: "openclaw-bundle-mcp",
version: "0.0.0",
},
{},
);
const session: BundleMcpSession = {
serverName,
client,
transport,
detachStderr: attachStderrLogging(serverName, transport),
};
try {
await client.connect(transport);
const listedTools = await listAllTools(client);
sessions.push(session);
for (const tool of listedTools) {
const normalizedName = tool.name.trim().toLowerCase();
if (!normalizedName) {
continue;
}
if (reservedNames.has(normalizedName)) {
logWarn(
`bundle-mcp: skipped tool "${tool.name}" from server "${serverName}" because the name already exists.`,
);
continue;
}
reservedNames.add(normalizedName);
tools.push({
name: tool.name,
label: tool.title ?? tool.name,
description:
tool.description?.trim() ||
`Provided by bundle MCP server "${serverName}" (${describeServerLaunchConfig(launchConfig)}).`,
parameters: tool.inputSchema,
execute: async (_toolCallId, input) => {
const result = (await client.callTool({
name: tool.name,
arguments: isRecord(input) ? input : {},
})) as CallToolResult;
return toAgentToolResult({
serverName,
toolName: tool.name,
result,
});
},
});
}
} catch (error) {
logWarn(
`bundle-mcp: failed to start server "${serverName}" (${describeServerLaunchConfig(launchConfig)}): ${String(error)}`,
);
await disposeSession(session);
}
}
return {
tools,
dispose: async () => {
await Promise.allSettled(sessions.map((session) => disposeSession(session)));
},
};
} catch (error) {
await Promise.allSettled(sessions.map((session) => disposeSession(session)));
throw error;
}
}

View File

@ -0,0 +1,302 @@
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
import "./test-helpers/fast-coding-tools.js";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
cleanupEmbeddedPiRunnerTestWorkspace,
createEmbeddedPiRunnerOpenAiConfig,
createEmbeddedPiRunnerTestWorkspace,
type EmbeddedPiRunnerTestWorkspace,
immediateEnqueue,
} from "./test-helpers/pi-embedded-runner-e2e-fixtures.js";
const E2E_TIMEOUT_MS = 20_000;
const require = createRequire(import.meta.url);
const SDK_SERVER_MCP_PATH = require.resolve("@modelcontextprotocol/sdk/server/mcp.js");
const SDK_SERVER_STDIO_PATH = require.resolve("@modelcontextprotocol/sdk/server/stdio.js");
function createMockUsage(input: number, output: number) {
return {
input,
output,
cacheRead: 0,
cacheWrite: 0,
totalTokens: input + output,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
}
let streamCallCount = 0;
let observedContexts: Array<Array<{ role?: string; content?: unknown }>> = [];
async function writeExecutable(filePath: string, content: string): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, { encoding: "utf-8", mode: 0o755 });
}
async function writeBundleProbeMcpServer(filePath: string): Promise<void> {
await writeExecutable(
filePath,
`#!/usr/bin/env node
import { McpServer } from ${JSON.stringify(SDK_SERVER_MCP_PATH)};
import { StdioServerTransport } from ${JSON.stringify(SDK_SERVER_STDIO_PATH)};
const server = new McpServer({ name: "bundle-probe", version: "1.0.0" });
server.tool("bundle_probe", "Bundle MCP probe", async () => {
return {
content: [{ type: "text", text: process.env.BUNDLE_PROBE_TEXT ?? "missing-probe-text" }],
};
});
await server.connect(new StdioServerTransport());
`,
);
}
async function writeClaudeBundle(params: {
pluginRoot: string;
serverScriptPath: string;
}): Promise<void> {
await fs.mkdir(path.join(params.pluginRoot, ".claude-plugin"), { recursive: true });
await fs.writeFile(
path.join(params.pluginRoot, ".claude-plugin", "plugin.json"),
`${JSON.stringify({ name: "bundle-probe" }, null, 2)}\n`,
"utf-8",
);
await fs.writeFile(
path.join(params.pluginRoot, ".mcp.json"),
`${JSON.stringify(
{
mcpServers: {
bundleProbe: {
command: "node",
args: [path.relative(params.pluginRoot, params.serverScriptPath)],
env: {
BUNDLE_PROBE_TEXT: "FROM-BUNDLE",
},
},
},
},
null,
2,
)}\n`,
"utf-8",
);
}
vi.mock("@mariozechner/pi-coding-agent", async () => {
return await vi.importActual<typeof import("@mariozechner/pi-coding-agent")>(
"@mariozechner/pi-coding-agent",
);
});
vi.mock("@mariozechner/pi-ai", async () => {
const actual = await vi.importActual<typeof import("@mariozechner/pi-ai")>("@mariozechner/pi-ai");
const buildToolUseMessage = (model: { api: string; provider: string; id: string }) => ({
role: "assistant" as const,
content: [
{
type: "toolCall" as const,
id: "tc-bundle-mcp-1",
name: "bundle_probe",
arguments: {},
},
],
stopReason: "toolUse" as const,
api: model.api,
provider: model.provider,
model: model.id,
usage: createMockUsage(1, 1),
timestamp: Date.now(),
});
const buildStopMessage = (
model: { api: string; provider: string; id: string },
text: string,
) => ({
role: "assistant" as const,
content: [{ type: "text" as const, text }],
stopReason: "stop" as const,
api: model.api,
provider: model.provider,
model: model.id,
usage: createMockUsage(1, 1),
timestamp: Date.now(),
});
return {
...actual,
complete: async (model: { api: string; provider: string; id: string }) => {
streamCallCount += 1;
return streamCallCount === 1
? buildToolUseMessage(model)
: buildStopMessage(model, "BUNDLE MCP OK FROM-BUNDLE");
},
completeSimple: async (model: { api: string; provider: string; id: string }) => {
streamCallCount += 1;
return streamCallCount === 1
? buildToolUseMessage(model)
: buildStopMessage(model, "BUNDLE MCP OK FROM-BUNDLE");
},
streamSimple: (
model: { api: string; provider: string; id: string },
context: { messages?: Array<{ role?: string; content?: unknown }> },
) => {
streamCallCount += 1;
const messages = (context.messages ?? []).map((message) => ({ ...message }));
observedContexts.push(messages);
const stream = actual.createAssistantMessageEventStream();
queueMicrotask(() => {
if (streamCallCount === 1) {
stream.push({
type: "done",
reason: "toolUse",
message: buildToolUseMessage(model),
});
stream.end();
return;
}
const toolResultText = messages.flatMap((message) =>
Array.isArray(message.content)
? (message.content as Array<{ type?: string; text?: string }>)
.filter((entry) => entry.type === "text" && typeof entry.text === "string")
.map((entry) => entry.text ?? "")
: [],
);
const sawBundleResult = toolResultText.some((text) => text.includes("FROM-BUNDLE"));
if (!sawBundleResult) {
stream.push({
type: "done",
reason: "error",
message: {
role: "assistant" as const,
content: [],
stopReason: "error" as const,
errorMessage: "bundle MCP tool result missing from context",
api: model.api,
provider: model.provider,
model: model.id,
usage: createMockUsage(1, 0),
timestamp: Date.now(),
},
});
stream.end();
return;
}
stream.push({
type: "done",
reason: "stop",
message: buildStopMessage(model, "BUNDLE MCP OK FROM-BUNDLE"),
});
stream.end();
});
return stream;
},
};
});
let runEmbeddedPiAgent: typeof import("./pi-embedded-runner/run.js").runEmbeddedPiAgent;
let e2eWorkspace: EmbeddedPiRunnerTestWorkspace | undefined;
let agentDir: string;
let workspaceDir: string;
beforeAll(async () => {
vi.useRealTimers();
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner/run.js"));
e2eWorkspace = await createEmbeddedPiRunnerTestWorkspace("openclaw-bundle-mcp-pi-");
({ agentDir, workspaceDir } = e2eWorkspace);
}, 180_000);
afterAll(async () => {
await cleanupEmbeddedPiRunnerTestWorkspace(e2eWorkspace);
e2eWorkspace = undefined;
});
const readSessionMessages = async (sessionFile: string) => {
const raw = await fs.readFile(sessionFile, "utf-8");
return raw
.split(/\r?\n/)
.filter(Boolean)
.map(
(line) =>
JSON.parse(line) as { type?: string; message?: { role?: string; content?: unknown } },
)
.filter((entry) => entry.type === "message")
.map((entry) => entry.message) as Array<{ role?: string; content?: unknown }>;
};
describe("runEmbeddedPiAgent bundle MCP e2e", () => {
it(
"loads bundle MCP into Pi, executes the MCP tool, and includes the result in the follow-up turn",
{ timeout: E2E_TIMEOUT_MS },
async () => {
streamCallCount = 0;
observedContexts = [];
const sessionFile = path.join(workspaceDir, "session-bundle-mcp-e2e.jsonl");
const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "bundle-probe");
const serverScriptPath = path.join(pluginRoot, "servers", "bundle-probe.mjs");
await writeBundleProbeMcpServer(serverScriptPath);
await writeClaudeBundle({ pluginRoot, serverScriptPath });
const cfg = {
...createEmbeddedPiRunnerOpenAiConfig(["mock-bundle-mcp"]),
plugins: {
entries: {
"bundle-probe": { enabled: true },
},
},
};
const result = await runEmbeddedPiAgent({
sessionId: "bundle-mcp-e2e",
sessionKey: "agent:test:bundle-mcp-e2e",
sessionFile,
workspaceDir,
config: cfg,
prompt: "Use the bundle MCP tool and report its result.",
provider: "openai",
model: "mock-bundle-mcp",
timeoutMs: 10_000,
agentDir,
runId: "run-bundle-mcp-e2e",
enqueue: immediateEnqueue,
});
expect(result.meta.stopReason).toBe("stop");
expect(result.payloads?.[0]?.text).toContain("BUNDLE MCP OK FROM-BUNDLE");
expect(streamCallCount).toBe(2);
const followUpContext = observedContexts[1] ?? [];
const followUpTexts = followUpContext.flatMap((message) =>
Array.isArray(message.content)
? (message.content as Array<{ type?: string; text?: string }>)
.filter((entry) => entry.type === "text" && typeof entry.text === "string")
.map((entry) => entry.text ?? "")
: [],
);
expect(followUpTexts.some((text) => text.includes("FROM-BUNDLE"))).toBe(true);
const messages = await readSessionMessages(sessionFile);
const toolResults = messages.filter((message) => message?.role === "toolResult");
const toolResultText = toolResults.flatMap((message) =>
Array.isArray(message.content)
? (message.content as Array<{ type?: string; text?: string }>)
.filter((entry) => entry.type === "text" && typeof entry.text === "string")
.map((entry) => entry.text ?? "")
: [],
);
expect(toolResultText.some((text) => text.includes("FROM-BUNDLE"))).toBe(true);
},
);
});

View File

@ -53,6 +53,7 @@ import { supportsModelTools } from "../model-tool-support.js";
import { ensureOpenClawModelsJson } from "../models-config.js";
import { createConfiguredOllamaStreamFn } from "../ollama-stream.js";
import { resolveOwnerDisplaySetting } from "../owner-display.js";
import { createBundleMcpToolRuntime } from "../pi-bundle-mcp-tools.js";
import {
ensureSessionHeader,
validateAnthropicTurns,
@ -583,12 +584,24 @@ export async function compactEmbeddedPiSessionDirect(
modelContextWindowTokens: ctxInfo.tokens,
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
});
const toolsEnabled = supportsModelTools(runtimeModel);
const tools = sanitizeToolsForGoogle({
tools: supportsModelTools(runtimeModel) ? toolsRaw : [],
tools: toolsEnabled ? toolsRaw : [],
provider,
});
const allowedToolNames = collectAllowedToolNames({ tools });
logToolSchemasForGoogle({ tools, provider });
const bundleMcpRuntime = toolsEnabled
? await createBundleMcpToolRuntime({
workspaceDir: effectiveWorkspace,
cfg: params.config,
reservedToolNames: tools.map((tool) => tool.name),
})
: undefined;
const effectiveTools =
bundleMcpRuntime && bundleMcpRuntime.tools.length > 0
? [...tools, ...bundleMcpRuntime.tools]
: tools;
const allowedToolNames = collectAllowedToolNames({ tools: effectiveTools });
logToolSchemasForGoogle({ tools: effectiveTools, provider });
const machineName = await getMachineDisplayName();
const runtimeChannel = normalizeMessageChannel(params.messageChannel ?? params.messageProvider);
let runtimeCapabilities = runtimeChannel
@ -705,7 +718,7 @@ export async function compactEmbeddedPiSessionDirect(
reactionGuidance,
messageToolHints,
sandboxInfo,
tools,
tools: effectiveTools,
modelAliasLines: buildModelAliasLines(params.config),
userTimezone,
userTime,
@ -768,7 +781,7 @@ export async function compactEmbeddedPiSessionDirect(
}
const { builtInTools, customTools } = splitSdkTools({
tools,
tools: effectiveTools,
sandboxEnabled: !!sandbox?.enabled,
});
@ -1060,6 +1073,7 @@ export async function compactEmbeddedPiSessionDirect(
clearPendingOnTimeout: true,
});
session.dispose();
await bundleMcpRuntime?.dispose();
}
} finally {
await sessionLock.release();

View File

@ -59,6 +59,7 @@ import { supportsModelTools } from "../../model-tool-support.js";
import { createConfiguredOllamaStreamFn } from "../../ollama-stream.js";
import { createOpenAIWebSocketStreamFn, releaseWsSession } from "../../openai-ws-stream.js";
import { resolveOwnerDisplaySetting } from "../../owner-display.js";
import { createBundleMcpToolRuntime } from "../../pi-bundle-mcp-tools.js";
import {
downgradeOpenAIFunctionCallReasoningPairs,
isCloudCodeAssistFormatError,
@ -1547,11 +1548,25 @@ export async function runEmbeddedAttempt(
provider: params.provider,
});
const clientTools = toolsEnabled ? params.clientTools : undefined;
const bundleMcpRuntime = toolsEnabled
? await createBundleMcpToolRuntime({
workspaceDir: effectiveWorkspace,
cfg: params.config,
reservedToolNames: [
...tools.map((tool) => tool.name),
...(clientTools?.map((tool) => tool.function.name) ?? []),
],
})
: undefined;
const effectiveTools =
bundleMcpRuntime && bundleMcpRuntime.tools.length > 0
? [...tools, ...bundleMcpRuntime.tools]
: tools;
const allowedToolNames = collectAllowedToolNames({
tools,
tools: effectiveTools,
clientTools,
});
logToolSchemasForGoogle({ tools, provider: params.provider });
logToolSchemasForGoogle({ tools: effectiveTools, provider: params.provider });
const machineName = await getMachineDisplayName();
const runtimeChannel = normalizeMessageChannel(params.messageChannel ?? params.messageProvider);
@ -1673,7 +1688,7 @@ export async function runEmbeddedAttempt(
runtimeInfo,
messageToolHints,
sandboxInfo,
tools,
tools: effectiveTools,
modelAliasLines: buildModelAliasLines(params.config),
userTimezone,
userTime,
@ -1708,7 +1723,7 @@ export async function runEmbeddedAttempt(
bootstrapFiles: hookAdjustedBootstrapFiles,
injectedFiles: contextFiles,
skillsPrompt,
tools,
tools: effectiveTools,
});
const systemPromptOverride = createSystemPromptOverride(appendPrompt);
let systemPromptText = systemPromptOverride();
@ -1808,7 +1823,7 @@ export async function runEmbeddedAttempt(
const hookRunner = getGlobalHookRunner();
const { builtInTools, customTools } = splitSdkTools({
tools,
tools: effectiveTools,
sandboxEnabled: !!sandbox?.enabled,
});
@ -2868,6 +2883,7 @@ export async function runEmbeddedAttempt(
});
session?.dispose();
releaseWsSession(params.sessionId);
await bundleMcpRuntime?.dispose();
await sessionLock.release();
}
} finally {

View File

@ -79,6 +79,54 @@ describe("loadEnabledBundlePiSettingsSnapshot", () => {
expect(snapshot.compaction?.keepRecentTokens).toBe(64_000);
});
it("loads enabled bundle MCP servers into the Pi settings snapshot", async () => {
const workspaceDir = await tempDirs.make("openclaw-workspace-");
const pluginRoot = await tempDirs.make("openclaw-bundle-");
await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true });
await fs.mkdir(path.join(pluginRoot, "servers"), { recursive: true });
await fs.writeFile(
path.join(pluginRoot, ".claude-plugin", "plugin.json"),
JSON.stringify({
name: "claude-bundle",
}),
"utf-8",
);
await fs.writeFile(
path.join(pluginRoot, ".mcp.json"),
JSON.stringify({
mcpServers: {
bundleProbe: {
command: "node",
args: ["./servers/probe.mjs"],
},
},
}),
"utf-8",
);
hoisted.loadPluginManifestRegistry.mockReturnValue(
buildRegistry({ pluginRoot, settingsFiles: [] }),
);
const snapshot = loadEnabledBundlePiSettingsSnapshot({
cwd: workspaceDir,
cfg: {
plugins: {
entries: {
"claude-bundle": { enabled: true },
},
},
},
});
expect(snapshot.mcpServers).toEqual({
bundleProbe: {
command: "node",
args: [path.join(pluginRoot, "servers", "probe.mjs")],
cwd: pluginRoot,
},
});
});
it("ignores disabled bundle plugins", async () => {
const workspaceDir = await tempDirs.make("openclaw-workspace-");
const pluginRoot = await tempDirs.make("openclaw-bundle-");

View File

@ -93,4 +93,34 @@ describe("buildEmbeddedPiSettingsSnapshot", () => {
expect(snapshot.compaction?.reserveTokens).toBe(32_000);
expect(snapshot.hideThinkingBlock).toBe(true);
});
it("lets project Pi settings override bundle MCP defaults", () => {
const snapshot = buildEmbeddedPiSettingsSnapshot({
globalSettings,
pluginSettings: {
mcpServers: {
bundleProbe: {
command: "node",
args: ["/plugins/probe.mjs"],
},
},
},
projectSettings: {
mcpServers: {
bundleProbe: {
command: "deno",
args: ["/workspace/probe.ts"],
},
},
},
policy: "sanitize",
});
expect(snapshot.mcpServers).toEqual({
bundleProbe: {
command: "deno",
args: ["/workspace/probe.ts"],
},
});
});
});

View File

@ -5,6 +5,7 @@ import type { OpenClawConfig } from "../config/config.js";
import { applyMergePatch } from "../config/merge-patch.js";
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { loadEnabledBundleMcpConfig } from "../plugins/bundle-mcp.js";
import { normalizePluginsConfig, resolveEffectiveEnableState } from "../plugins/config-state.js";
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
import { isRecord } from "../utils.js";
@ -107,6 +108,19 @@ export function loadEnabledBundlePiSettingsSnapshot(params: {
}
}
const bundleMcp = loadEnabledBundleMcpConfig({
workspaceDir,
cfg: params.cfg,
});
for (const diagnostic of bundleMcp.diagnostics) {
log.warn(`bundle MCP skipped for ${diagnostic.pluginId}: ${diagnostic.message}`);
}
if (Object.keys(bundleMcp.config.mcpServers).length > 0) {
snapshot = applyMergePatch(snapshot, {
mcpServers: bundleMcp.config.mcpServers,
}) as PiSettingsSnapshot;
}
return snapshot;
}

View File

@ -81,6 +81,7 @@ describe("loadEnabledBundleMcpConfig", () => {
const loadedServer = loaded.config.mcpServers.bundleProbe;
const loadedArgs = getServerArgs(loadedServer);
const loadedServerPath = typeof loadedArgs?.[0] === "string" ? loadedArgs[0] : undefined;
const resolvedPluginRoot = await fs.realpath(pluginRoot);
expect(loaded.diagnostics).toEqual([]);
expect(isRecord(loadedServer) ? loadedServer.command : undefined).toBe("node");
@ -90,6 +91,7 @@ describe("loadEnabledBundleMcpConfig", () => {
throw new Error("expected bundled MCP args to include the server path");
}
expect(await fs.realpath(loadedServerPath)).toBe(resolvedServerPath);
expect(loadedServer.cwd).toBe(resolvedPluginRoot);
} finally {
env.restore();
}

View File

@ -137,6 +137,10 @@ function absolutizeBundleMcpServer(params: {
}): BundleMcpServerConfig {
const next: BundleMcpServerConfig = { ...params.server };
if (typeof next.cwd !== "string" && typeof next.workingDirectory !== "string") {
next.cwd = params.baseDir;
}
const command = next.command;
if (typeof command === "string" && isExplicitRelativePath(command)) {
next.command = path.resolve(params.baseDir, command);

View File

@ -420,6 +420,57 @@ describe("bundle plugins", () => {
).toBe(false);
});
it("treats bundle MCP as a supported bundle surface", () => {
const workspaceDir = makeTempDir();
const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "claude-mcp");
mkdirSafe(path.join(bundleRoot, ".claude-plugin"));
fs.writeFileSync(
path.join(bundleRoot, ".claude-plugin", "plugin.json"),
JSON.stringify({
name: "Claude MCP",
}),
"utf-8",
);
fs.writeFileSync(
path.join(bundleRoot, ".mcp.json"),
JSON.stringify({
mcpServers: {
probe: {
command: "node",
args: ["./probe.mjs"],
},
},
}),
"utf-8",
);
const registry = loadOpenClawPlugins({
workspaceDir,
config: {
plugins: {
entries: {
"claude-mcp": {
enabled: true,
},
},
},
},
cache: false,
});
const plugin = registry.plugins.find((entry) => entry.id === "claude-mcp");
expect(plugin?.status).toBe("loaded");
expect(plugin?.bundleFormat).toBe("claude");
expect(plugin?.bundleCapabilities).toEqual(expect.arrayContaining(["mcpServers"]));
expect(
registry.diagnostics.some(
(diag) =>
diag.pluginId === "claude-mcp" &&
diag.message.includes("bundle capability detected but not wired"),
),
).toBe(false);
});
it("treats Cursor command roots as supported bundle skill surfaces", () => {
useNoBundledPlugins();
const workspaceDir = makeTempDir();

View File

@ -1099,6 +1099,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
const unsupportedCapabilities = (record.bundleCapabilities ?? []).filter(
(capability) =>
capability !== "skills" &&
capability !== "mcpServers" &&
capability !== "settings" &&
!(
capability === "commands" &&