From cb45d7d7376d2ad3ee634e8f3474832f71916859 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 18:33:39 -0700 Subject: [PATCH] Plugins: fix bundle MCP path resolution --- docs/plugins/bundles.md | 11 +++--- docs/tools/plugin.md | 9 +++-- src/agents/pi-bundle-mcp-tools.ts | 36 +++++++++++++----- src/plugins/bundle-mcp.test.ts | 63 +++++++++++++++++++++++++++++++ src/plugins/bundle-mcp.ts | 51 +++++++++++++++++++------ 5 files changed, 140 insertions(+), 30 deletions(-) diff --git a/docs/plugins/bundles.md b/docs/plugins/bundles.md index d7df9570cdf..bc6bc49e5a0 100644 --- a/docs/plugins/bundles.md +++ b/docs/plugins/bundles.md @@ -110,7 +110,7 @@ loader. Cursor command markdown works through the same path. - 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 + turns by launching supported stdio MCP servers as subprocesses - project-local Pi settings still apply after bundle defaults, so workspace settings can override bundle MCP entries when needed @@ -157,7 +157,7 @@ Current exceptions: - 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 because it maps into embedded Pi settings - and exposes tools to embedded Pi + and exposes supported stdio tools to embedded Pi - Codex `hooks` is considered supported only for OpenClaw hook-pack layouts ## Format differences @@ -199,7 +199,8 @@ 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 +- `.mcp.json` and manifest `mcpServers` can expose supported stdio tools to + embedded Pi - `hooks/hooks.json` is detected, but not executed as Claude automation ### Cursor @@ -251,8 +252,8 @@ 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 -- supported bundle MCP servers may be launched as subprocesses for embedded Pi - tool calls +- supported stdio 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 diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 7cba12d6b2a..48acd41e202 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -215,8 +215,8 @@ plugins: - 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 + `mcpServers`, with supported stdio 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 @@ -228,8 +228,9 @@ plugins: 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. Supported bundle MCP -servers may also run as subprocesses for embedded Pi tool calls, but bundle -runtime modules are not loaded in-process. +servers may also run as subprocesses for embedded Pi tool calls when they use +supported stdio transport, 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). diff --git a/src/agents/pi-bundle-mcp-tools.ts b/src/agents/pi-bundle-mcp-tools.ts index fce594bce6d..87977f6c650 100644 --- a/src/agents/pi-bundle-mcp-tools.ts +++ b/src/agents/pi-bundle-mcp-tools.ts @@ -13,6 +13,9 @@ type BundleMcpServerLaunchConfig = { env?: Record; cwd?: string; }; +type BundleMcpServerLaunchResult = + | { ok: true; config: BundleMcpServerLaunchConfig } + | { ok: false; reason: string }; type BundleMcpToolRuntime = { tools: AnyAgentTool[]; @@ -56,9 +59,18 @@ function toStringArray(value: unknown): string[] | undefined { 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; +function resolveLaunchConfig(raw: unknown): BundleMcpServerLaunchResult { + 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 bundle 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 @@ -67,10 +79,13 @@ function resolveLaunchConfig(raw: unknown): BundleMcpServerLaunchConfig | null { ? raw.workingDirectory : undefined; return { - command: raw.command, - args: toStringArray(raw.args), - env: toStringRecord(raw.env), - cwd, + ok: true, + config: { + command: raw.command, + args: toStringArray(raw.args), + env: toStringRecord(raw.env), + cwd, + }, }; } @@ -194,11 +209,12 @@ export async function createBundleMcpToolRuntime(params: { 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.`); + const launch = resolveLaunchConfig(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, diff --git a/src/plugins/bundle-mcp.test.ts b/src/plugins/bundle-mcp.test.ts index d68083d4ecb..ce4c460baf0 100644 --- a/src/plugins/bundle-mcp.test.ts +++ b/src/plugins/bundle-mcp.test.ts @@ -166,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(); + } + }); }); diff --git a/src/plugins/bundle-mcp.ts b/src/plugins/bundle-mcp.ts index 62288ed7a7a..179254f4dbc 100644 --- a/src/plugins/bundle-mcp.ts +++ b/src/plugins/bundle-mcp.ts @@ -34,6 +34,7 @@ const MANIFEST_PATH_BY_FORMAT: Record = { 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,7 +132,15 @@ 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 { @@ -142,29 +151,49 @@ function absolutizeBundleMcpServer(params: { } 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; } @@ -194,7 +223,7 @@ function loadBundleFileBackedMcpConfig(params: { mcpServers: Object.fromEntries( Object.entries(servers).map(([serverName, server]) => [ serverName, - absolutizeBundleMcpServer({ baseDir, server }), + absolutizeBundleMcpServer({ rootDir: params.rootDir, baseDir, server }), ]), ), }; @@ -215,7 +244,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 }), ]), ), }; @@ -256,7 +285,7 @@ function loadBundleMcpConfig(params: { merged, loadBundleInlineMcpConfig({ raw: manifestLoaded.raw, - baseDir: path.dirname(path.join(params.rootDir, manifestRelativePath)), + baseDir: params.rootDir, }), ) as BundleMcpConfig;