Plugins: fix bundle MCP path resolution

This commit is contained in:
Vincent Koc 2026-03-16 18:33:39 -07:00
parent cf98a0f6e3
commit cb45d7d737
5 changed files with 140 additions and 30 deletions

View File

@ -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

View File

@ -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).

View File

@ -13,6 +13,9 @@ type BundleMcpServerLaunchConfig = {
env?: Record<string, string>;
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,

View File

@ -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();
}
});
});

View File

@ -34,6 +34,7 @@ const MANIFEST_PATH_BY_FORMAT: Record<PluginBundleFormat, string> = {
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;