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

@@ -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();
}
@@ -164,4 +166,67 @@ describe("loadEnabledBundleMcpConfig", () => {
env.restore();
}
});
it("resolves inline Claude MCP paths from the plugin root and expands CLAUDE_PLUGIN_ROOT", async () => {
const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]);
try {
const homeDir = await createTempDir("openclaw-bundle-inline-placeholder-home-");
const workspaceDir = await createTempDir("openclaw-bundle-inline-placeholder-workspace-");
process.env.HOME = homeDir;
process.env.USERPROFILE = homeDir;
delete process.env.OPENCLAW_HOME;
delete process.env.OPENCLAW_STATE_DIR;
const pluginRoot = path.join(homeDir, ".openclaw", "extensions", "inline-claude");
await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true });
await fs.writeFile(
path.join(pluginRoot, ".claude-plugin", "plugin.json"),
`${JSON.stringify(
{
name: "inline-claude",
mcpServers: {
inlineProbe: {
command: "${CLAUDE_PLUGIN_ROOT}/bin/server.sh",
args: ["${CLAUDE_PLUGIN_ROOT}/servers/probe.mjs", "./local-probe.mjs"],
cwd: "${CLAUDE_PLUGIN_ROOT}",
env: {
PLUGIN_ROOT: "${CLAUDE_PLUGIN_ROOT}",
},
},
},
},
null,
2,
)}\n`,
"utf-8",
);
const loaded = loadEnabledBundleMcpConfig({
workspaceDir,
cfg: {
plugins: {
entries: {
"inline-claude": { enabled: true },
},
},
},
});
const resolvedPluginRoot = await fs.realpath(pluginRoot);
expect(loaded.diagnostics).toEqual([]);
expect(loaded.config.mcpServers.inlineProbe).toEqual({
command: path.join(resolvedPluginRoot, "bin", "server.sh"),
args: [
path.join(resolvedPluginRoot, "servers", "probe.mjs"),
path.join(resolvedPluginRoot, "local-probe.mjs"),
],
cwd: resolvedPluginRoot,
env: {
PLUGIN_ROOT: resolvedPluginRoot,
},
});
} finally {
env.restore();
}
});
});

View File

@@ -28,12 +28,18 @@ export type EnabledBundleMcpConfigResult = {
config: BundleMcpConfig;
diagnostics: BundleMcpDiagnostic[];
};
export type BundleMcpRuntimeSupport = {
hasSupportedStdioServer: boolean;
unsupportedServerNames: string[];
diagnostics: string[];
};
const MANIFEST_PATH_BY_FORMAT: Record<PluginBundleFormat, string> = {
claude: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH,
codex: CODEX_BUNDLE_MANIFEST_RELATIVE_PATH,
cursor: CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH,
};
const CLAUDE_PLUGIN_ROOT_PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}";
function normalizePathList(value: unknown): string[] {
if (typeof value === "string") {
@@ -131,36 +137,68 @@ function isExplicitRelativePath(value: string): boolean {
return value === "." || value === ".." || value.startsWith("./") || value.startsWith("../");
}
function expandBundleRootPlaceholders(value: string, rootDir: string): string {
if (!value.includes(CLAUDE_PLUGIN_ROOT_PLACEHOLDER)) {
return value;
}
return value.split(CLAUDE_PLUGIN_ROOT_PLACEHOLDER).join(rootDir);
}
function absolutizeBundleMcpServer(params: {
rootDir: string;
baseDir: string;
server: BundleMcpServerConfig;
}): 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);
if (typeof command === "string") {
const expanded = expandBundleRootPlaceholders(command, params.rootDir);
next.command = isExplicitRelativePath(expanded)
? path.resolve(params.baseDir, expanded)
: expanded;
}
const cwd = next.cwd;
if (typeof cwd === "string" && !path.isAbsolute(cwd)) {
next.cwd = path.resolve(params.baseDir, cwd);
if (typeof cwd === "string") {
const expanded = expandBundleRootPlaceholders(cwd, params.rootDir);
next.cwd = path.isAbsolute(expanded) ? expanded : path.resolve(params.baseDir, expanded);
}
const workingDirectory = next.workingDirectory;
if (typeof workingDirectory === "string" && !path.isAbsolute(workingDirectory)) {
next.workingDirectory = path.resolve(params.baseDir, workingDirectory);
if (typeof workingDirectory === "string") {
const expanded = expandBundleRootPlaceholders(workingDirectory, params.rootDir);
next.workingDirectory = path.isAbsolute(expanded)
? expanded
: path.resolve(params.baseDir, expanded);
}
if (Array.isArray(next.args)) {
next.args = next.args.map((entry) => {
if (typeof entry !== "string" || !isExplicitRelativePath(entry)) {
if (typeof entry !== "string") {
return entry;
}
return path.resolve(params.baseDir, entry);
const expanded = expandBundleRootPlaceholders(entry, params.rootDir);
if (!isExplicitRelativePath(expanded)) {
return expanded;
}
return path.resolve(params.baseDir, expanded);
});
}
if (isRecord(next.env)) {
next.env = Object.fromEntries(
Object.entries(next.env).map(([key, value]) => [
key,
typeof value === "string" ? expandBundleRootPlaceholders(value, params.rootDir) : value,
]),
);
}
return next;
}
@@ -190,7 +228,7 @@ function loadBundleFileBackedMcpConfig(params: {
mcpServers: Object.fromEntries(
Object.entries(servers).map(([serverName, server]) => [
serverName,
absolutizeBundleMcpServer({ baseDir, server }),
absolutizeBundleMcpServer({ rootDir: params.rootDir, baseDir, server }),
]),
),
};
@@ -211,7 +249,7 @@ function loadBundleInlineMcpConfig(params: {
mcpServers: Object.fromEntries(
Object.entries(servers).map(([serverName, server]) => [
serverName,
absolutizeBundleMcpServer({ baseDir: params.baseDir, server }),
absolutizeBundleMcpServer({ rootDir: params.baseDir, baseDir: params.baseDir, server }),
]),
),
};
@@ -252,13 +290,35 @@ function loadBundleMcpConfig(params: {
merged,
loadBundleInlineMcpConfig({
raw: manifestLoaded.raw,
baseDir: path.dirname(path.join(params.rootDir, manifestRelativePath)),
baseDir: params.rootDir,
}),
) as BundleMcpConfig;
return { config: merged, diagnostics: [] };
}
export function inspectBundleMcpRuntimeSupport(params: {
pluginId: string;
rootDir: string;
bundleFormat: PluginBundleFormat;
}): BundleMcpRuntimeSupport {
const loaded = loadBundleMcpConfig(params);
const unsupportedServerNames: string[] = [];
let hasSupportedStdioServer = false;
for (const [serverName, server] of Object.entries(loaded.config.mcpServers)) {
if (typeof server.command === "string" && server.command.trim().length > 0) {
hasSupportedStdioServer = true;
continue;
}
unsupportedServerNames.push(serverName);
}
return {
hasSupportedStdioServer,
unsupportedServerNames,
diagnostics: loaded.diagnostics,
};
}
export function loadEnabledBundleMcpConfig(params: {
workspaceDir: string;
cfg?: OpenClawConfig;

View File

@@ -420,6 +420,116 @@ 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("warns when bundle MCP only declares unsupported non-stdio transports", () => {
useNoBundledPlugins();
const workspaceDir = makeTempDir();
const stateDir = makeTempDir();
const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "claude-mcp-url");
fs.mkdirSync(path.join(bundleRoot, ".claude-plugin"), { recursive: true });
fs.writeFileSync(
path.join(bundleRoot, ".claude-plugin", "plugin.json"),
JSON.stringify({
name: "Claude MCP URL",
}),
"utf-8",
);
fs.writeFileSync(
path.join(bundleRoot, ".mcp.json"),
JSON.stringify({
mcpServers: {
remoteProbe: {
url: "http://127.0.0.1:8787/mcp",
},
},
}),
"utf-8",
);
const registry = withEnv(
{
OPENCLAW_HOME: stateDir,
OPENCLAW_STATE_DIR: stateDir,
},
() =>
loadOpenClawPlugins({
workspaceDir,
config: {
plugins: {
entries: {
"claude-mcp-url": {
enabled: true,
},
},
},
},
cache: false,
}),
);
const plugin = registry.plugins.find((entry) => entry.id === "claude-mcp-url");
expect(plugin?.status).toBe("loaded");
expect(plugin?.bundleCapabilities).toEqual(expect.arrayContaining(["mcpServers"]));
expect(
registry.diagnostics.some(
(diag) =>
diag.pluginId === "claude-mcp-url" &&
diag.message.includes("stdio only today") &&
diag.message.includes("remoteProbe"),
),
).toBe(true);
});
it("treats Cursor command roots as supported bundle skill surfaces", () => {
useNoBundledPlugins();
const workspaceDir = makeTempDir();

View File

@@ -11,6 +11,7 @@ import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveUserPath } from "../utils.js";
import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js";
import { clearPluginCommands } from "./commands.js";
import {
applyTestPluginDefaults,
@@ -1099,6 +1100,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
const unsupportedCapabilities = (record.bundleCapabilities ?? []).filter(
(capability) =>
capability !== "skills" &&
capability !== "mcpServers" &&
capability !== "settings" &&
!(
capability === "commands" &&
@@ -1114,6 +1116,36 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
message: `bundle capability detected but not wired into OpenClaw yet: ${capability}`,
});
}
if (
enableState.enabled &&
record.rootDir &&
record.bundleFormat &&
(record.bundleCapabilities ?? []).includes("mcpServers")
) {
const runtimeSupport = inspectBundleMcpRuntimeSupport({
pluginId: record.id,
rootDir: record.rootDir,
bundleFormat: record.bundleFormat,
});
for (const message of runtimeSupport.diagnostics) {
registry.diagnostics.push({
level: "warn",
pluginId: record.id,
source: record.source,
message,
});
}
if (runtimeSupport.unsupportedServerNames.length > 0) {
registry.diagnostics.push({
level: "warn",
pluginId: record.id,
source: record.source,
message:
"bundle MCP servers use unsupported transports or incomplete configs " +
`(stdio only today): ${runtimeSupport.unsupportedServerNames.join(", ")}`,
});
}
}
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
continue;