Plugins: fix bundle MCP path resolution
This commit is contained in:
parent
cf98a0f6e3
commit
cb45d7d737
@ -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
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user