Agents: run bundle MCP tools in embedded Pi (#48611)

* Agents: run bundle MCP tools in embedded Pi

* Plugins: fix bundle MCP path resolution

* Plugins: warn on unsupported bundle MCP transports

* Commands: add embedded Pi MCP management

* Config: move MCP management to top-level config
This commit is contained in:
Vincent Koc
2026-03-16 21:46:05 -07:00
committed by GitHub
parent 38bc364aed
commit 06459ca0df
37 changed files with 2051 additions and 30 deletions

View File

@@ -0,0 +1,29 @@
import type { OpenClawConfig } from "../config/config.js";
import { normalizeConfiguredMcpServers } from "../config/mcp-config.js";
import type { BundleMcpDiagnostic, BundleMcpServerConfig } from "../plugins/bundle-mcp.js";
import { loadEnabledBundleMcpConfig } from "../plugins/bundle-mcp.js";
export type EmbeddedPiMcpConfig = {
mcpServers: Record<string, BundleMcpServerConfig>;
diagnostics: BundleMcpDiagnostic[];
};
export function loadEmbeddedPiMcpConfig(params: {
workspaceDir: string;
cfg?: OpenClawConfig;
}): EmbeddedPiMcpConfig {
const bundleMcp = loadEnabledBundleMcpConfig({
workspaceDir: params.workspaceDir,
cfg: params.cfg,
});
const configuredMcp = normalizeConfiguredMcpServers(params.cfg?.mcp?.servers);
return {
// OpenClaw config is the owner-managed layer, so it overrides bundle defaults.
mcpServers: {
...bundleMcp.config.mcpServers,
...configuredMcp,
},
diagnostics: bundleMcp.diagnostics,
};
}

79
src/agents/mcp-stdio.ts Normal file
View File

@@ -0,0 +1,79 @@
type StdioMcpServerLaunchConfig = {
command: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
};
type StdioMcpServerLaunchResult =
| { ok: true; config: StdioMcpServerLaunchConfig }
| { ok: false; reason: string };
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 : [];
}
export function resolveStdioMcpServerLaunchConfig(raw: unknown): StdioMcpServerLaunchResult {
if (!isRecord(raw)) {
return { ok: false, reason: "server config must be an object" };
}
if (typeof raw.command !== "string" || raw.command.trim().length === 0) {
if (typeof raw.url === "string" && raw.url.trim().length > 0) {
return {
ok: false,
reason: "only stdio MCP servers are supported right now",
};
}
return { ok: false, reason: "its command is missing" };
}
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 {
ok: true,
config: {
command: raw.command,
args: toStringArray(raw.args),
env: toStringRecord(raw.env),
cwd,
},
};
}
export function describeStdioMcpServerLaunchConfig(config: StdioMcpServerLaunchConfig): 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}`;
}
export type { StdioMcpServerLaunchConfig, StdioMcpServerLaunchResult };

View File

@@ -0,0 +1,184 @@
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();
}
});
it("loads configured stdio MCP tools without a bundle", async () => {
const workspaceDir = await makeTempDir("openclaw-bundle-mcp-tools-");
const serverScriptPath = path.join(workspaceDir, "servers", "configured-probe.mjs");
await writeBundleProbeMcpServer(serverScriptPath);
const runtime = await createBundleMcpToolRuntime({
workspaceDir,
cfg: {
mcp: {
servers: {
configuredProbe: {
command: "node",
args: [serverScriptPath],
env: {
BUNDLE_PROBE_TEXT: "FROM-CONFIG",
},
},
},
},
},
});
try {
expect(runtime.tools.map((tool) => tool.name)).toEqual(["bundle_probe"]);
const result = await runtime.tools[0].execute(
"call-configured-probe",
{},
undefined,
undefined,
);
expect(result.content[0]).toMatchObject({
type: "text",
text: "FROM-CONFIG",
});
expect(result.details).toEqual({
mcpServer: "configuredProbe",
mcpTool: "bundle_probe",
});
} finally {
await runtime.dispose();
}
});
});

View File

@@ -0,0 +1,225 @@
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 { loadEmbeddedPiMcpConfig } from "./embedded-pi-mcp.js";
import {
describeStdioMcpServerLaunchConfig,
resolveStdioMcpServerLaunchConfig,
} from "./mcp-stdio.js";
import type { AnyAgentTool } from "./tools/common.js";
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);
}
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 = loadEmbeddedPiMcpConfig({
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.mcpServers)) {
const launch = resolveStdioMcpServerLaunchConfig(rawServer);
if (!launch.ok) {
logWarn(`bundle-mcp: skipped server "${serverName}" because ${launch.reason}.`);
continue;
}
const launchConfig = launch.config;
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}" (${describeStdioMcpServerLaunchConfig(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}" (${describeStdioMcpServerLaunchConfig(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,106 @@ 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("lets top-level MCP config override bundle MCP defaults", 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.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: {
sharedServer: {
command: "node",
args: ["./servers/bundle.mjs"],
},
},
}),
"utf-8",
);
hoisted.loadPluginManifestRegistry.mockReturnValue(
buildRegistry({ pluginRoot, settingsFiles: [] }),
);
const snapshot = loadEnabledBundlePiSettingsSnapshot({
cwd: workspaceDir,
cfg: {
mcp: {
servers: {
sharedServer: {
url: "https://example.com/mcp",
},
},
},
plugins: {
entries: {
"claude-bundle": { enabled: true },
},
},
},
});
expect(snapshot.mcpServers).toEqual({
sharedServer: {
url: "https://example.com/mcp",
},
});
});
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

@@ -8,6 +8,7 @@ import { createSubsystemLogger } from "../logging/subsystem.js";
import { normalizePluginsConfig, resolveEffectiveEnableState } from "../plugins/config-state.js";
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
import { isRecord } from "../utils.js";
import { loadEmbeddedPiMcpConfig } from "./embedded-pi-mcp.js";
import { applyPiCompactionSettingsFromConfig } from "./pi-settings.js";
const log = createSubsystemLogger("embedded-pi-settings");
@@ -107,6 +108,19 @@ export function loadEnabledBundlePiSettingsSnapshot(params: {
}
}
const embeddedPiMcp = loadEmbeddedPiMcpConfig({
workspaceDir,
cfg: params.cfg,
});
for (const diagnostic of embeddedPiMcp.diagnostics) {
log.warn(`bundle MCP skipped for ${diagnostic.pluginId}: ${diagnostic.message}`);
}
if (Object.keys(embeddedPiMcp.mcpServers).length > 0) {
snapshot = applyMergePatch(snapshot, {
mcpServers: embeddedPiMcp.mcpServers,
}) as PiSettingsSnapshot;
}
return snapshot;
}