mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-24 07:01:49 +00:00
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:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user