// Bundles MCP metadata exposed by plugins for package output. import path from "node:path"; import { isStringRecord } from "@openclaw/normalization-core/record-coerce"; import { resolveMcpTransportConfig } from "../agents/mcp-transport-config.js"; import { applyMergePatch } from "../config/merge-patch.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { isPathInside } from "../infra/path-guards.js"; import { isRecord } from "../utils.js"; import { loadEnabledBundleConfig, readBundleJsonObject, resolveBundleJsonOpenFailure, } from "./bundle-config-shared.js"; import { AGENT_BUNDLE_MANIFEST_RELATIVE_PATH, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, CODEX_BUNDLE_MANIFEST_RELATIVE_PATH, CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH, mergeBundlePathLists, normalizeBundlePathList, } from "./bundle-manifest.js"; import { encodePluginInstallDirName } from "./install-paths.js"; import { resolveActivePluginInstallRoots } from "./install-root-context.js"; import type { PluginManifestRegistry } from "./manifest-registry.js"; import type { PluginBundleFormat } from "./manifest-types.js"; import { pluginCacheExistsSync, pluginCacheRealpathSync } from "./plugin-cache-files.js"; export type BundleMcpServerConfig = Record; export type BundleMcpConfig = { mcpServers: Record; }; export type BundleMcpDataDirOwnership = { pluginId: string; dataDir: string; }; type BundleMcpRuntimeConfig = BundleMcpConfig & { prepareDataDirsByServer: Record; }; export type BundleMcpDiagnostic = { pluginId: string; message: string; }; type EnabledBundleMcpConfigResult = { config: BundleMcpConfig; diagnostics: BundleMcpDiagnostic[]; prepareDataDirsByServer: Record; }; type BundleMcpRuntimeSupport = { hasSupportedStdioServer: boolean; supportedServerNames: string[]; stdioServerNames: string[]; unsupportedServerNames: string[]; diagnostics: string[]; }; const MANIFEST_PATH_BY_FORMAT: Record = { agent: AGENT_BUNDLE_MANIFEST_RELATIVE_PATH, 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}"; const AGENT_PLUGIN_ROOT_PLACEHOLDER = "${PLUGIN_ROOT}"; const AGENT_PLUGIN_DATA_PLACEHOLDER = "${PLUGIN_DATA}"; const AGENT_MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json"; const BUNDLE_PLACEHOLDER_PATTERN = /\$\{(?:CLAUDE_PLUGIN_ROOT|PLUGIN_ROOT|PLUGIN_DATA)\}/g; const AGENT_MCP_TOP_LEVEL_KEYS = new Set(["$schema", "mcpServers"]); const AGENT_STDIO_KEYS = new Set(["type", "command", "args", "env", "cwd"]); const AGENT_HTTP_KEYS = new Set(["type", "url", "headers"]); function resolveBundleMcpConfigPaths(params: { raw: Record; rootDir: string; bundleFormat: PluginBundleFormat; }): string[] { if (params.bundleFormat === "agent") { return pluginCacheExistsSync(path.join(params.rootDir, "mcp.json")) ? ["mcp.json"] : []; } const declared = normalizeBundlePathList(params.raw.mcpServers); const defaults = pluginCacheExistsSync(path.join(params.rootDir, ".mcp.json")) ? [".mcp.json"] : []; return mergeBundlePathLists(defaults, declared); } export function extractMcpServerMap(raw: unknown): Record { if (!isRecord(raw)) { return {}; } const nested = isRecord(raw.mcpServers) ? raw.mcpServers : isRecord(raw.servers) ? raw.servers : raw; if (!isRecord(nested)) { return {}; } const result: Record = {}; for (const [serverName, serverRaw] of Object.entries(nested)) { if (!isRecord(serverRaw)) { continue; } result[serverName] = { ...serverRaw }; } return result; } function isExplicitRelativePath(value: string): boolean { return value === "." || value === ".." || value.startsWith("./") || value.startsWith("../"); } function expandBundleRootPlaceholders(params: { value: string; rootDir: string; pluginDataDir?: string; }): string { // One replacement pass prevents placeholders introduced by substituted paths from expanding. return params.value.replace(BUNDLE_PLACEHOLDER_PATTERN, (placeholder) => { if ( placeholder === CLAUDE_PLUGIN_ROOT_PLACEHOLDER || (placeholder === AGENT_PLUGIN_ROOT_PLACEHOLDER && params.pluginDataDir) ) { return params.rootDir; } return params.pluginDataDir ?? placeholder; }); } function normalizeBundlePath(targetPath: string): string { return path.normalize(path.resolve(targetPath)); } function normalizeExpandedAbsolutePath(value: string): string { return path.isAbsolute(value) ? path.normalize(value) : value; } function absolutizeBundleMcpServer(params: { rootDir: string; baseDir: string; server: BundleMcpServerConfig; pluginDataDir?: string; agentFormat?: boolean; }): BundleMcpServerConfig { const next: BundleMcpServerConfig = { ...params.server }; if ( typeof next.cwd !== "string" && typeof next.workingDirectory !== "string" && (!params.agentFormat || typeof next.command === "string") ) { next.cwd = params.baseDir; } const command = next.command; if (typeof command === "string") { const expanded = expandBundleRootPlaceholders({ value: command, rootDir: params.rootDir, pluginDataDir: params.pluginDataDir, }); next.command = isExplicitRelativePath(expanded) ? path.resolve(params.baseDir, expanded) : normalizeExpandedAbsolutePath(expanded); } const cwd = next.cwd; if (typeof cwd === "string") { const expanded = expandBundleRootPlaceholders({ value: cwd, rootDir: params.rootDir, pluginDataDir: params.pluginDataDir, }); next.cwd = path.isAbsolute(expanded) ? expanded : path.resolve(params.baseDir, expanded); } const workingDirectory = next.workingDirectory; if (typeof workingDirectory === "string") { const expanded = expandBundleRootPlaceholders({ value: workingDirectory, rootDir: params.rootDir, pluginDataDir: params.pluginDataDir, }); next.workingDirectory = path.isAbsolute(expanded) ? path.normalize(expanded) : path.resolve(params.baseDir, expanded); } if (Array.isArray(next.args)) { next.args = next.args.map((entry) => { if (typeof entry !== "string") { return entry; } const expanded = expandBundleRootPlaceholders({ value: entry, rootDir: params.rootDir, pluginDataDir: params.pluginDataDir, }); if (!isExplicitRelativePath(expanded)) { return normalizeExpandedAbsolutePath(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" ? normalizeExpandedAbsolutePath( expandBundleRootPlaceholders({ value, rootDir: params.rootDir, pluginDataDir: params.pluginDataDir, }), ) : value, ]), ); } if (params.pluginDataDir && typeof next.command === "string") { next.env = { ...(isRecord(next.env) ? next.env : {}), PLUGIN_ROOT: params.rootDir, PLUGIN_DATA: params.pluginDataDir, }; } return next; } function hasOnlyKeys(raw: Record, allowed: ReadonlySet): boolean { return Object.keys(raw).every((key) => allowed.has(key)); } function isValidAgentCommand(command: unknown, rootDir: string): command is string { if (typeof command !== "string" || command.length === 0) { return false; } if (command.startsWith("./")) { return command.length > 2 && isPathInside(rootDir, path.resolve(rootDir, command)); } return !/[\s/\\]/.test(command); } function isValidAgentCwd(cwd: unknown, rootDir: string, pluginDataDir: string): cwd is string { if (typeof cwd !== "string") { return false; } let baseDir: string; if (cwd.startsWith("./")) { baseDir = rootDir; } else if ( cwd === AGENT_PLUGIN_ROOT_PLACEHOLDER || cwd.startsWith(`${AGENT_PLUGIN_ROOT_PLACEHOLDER}/`) ) { baseDir = rootDir; } else if ( cwd === AGENT_PLUGIN_DATA_PLACEHOLDER || cwd.startsWith(`${AGENT_PLUGIN_DATA_PLACEHOLDER}/`) ) { baseDir = pluginDataDir; } else { return false; } const expanded = expandBundleRootPlaceholders({ value: cwd, rootDir, pluginDataDir }); return isPathInside(baseDir, path.resolve(baseDir, expanded)); } function validateAgentMcpServer(params: { raw: unknown; rootDir: string; pluginDataDir: string; }): { ok: true; server: BundleMcpServerConfig } | { ok: false; error: string } { if (!isRecord(params.raw) || typeof params.raw.type !== "string") { return { ok: false, error: "configuration must be an object with a supported type" }; } const type = params.raw.type; if (type === "stdio") { if (!hasOnlyKeys(params.raw, AGENT_STDIO_KEYS)) { return { ok: false, error: "stdio configuration contains unknown fields" }; } if (!isValidAgentCommand(params.raw.command, params.rootDir)) { return { ok: false, error: "stdio command must be a bare name or ./-relative path" }; } if ( params.raw.args !== undefined && (!Array.isArray(params.raw.args) || !params.raw.args.every((entry) => typeof entry === "string")) ) { return { ok: false, error: "stdio args must be an array of strings" }; } if (params.raw.env !== undefined && !isStringRecord(params.raw.env)) { return { ok: false, error: "stdio env must contain only string values" }; } if ( isRecord(params.raw.env) && (Object.hasOwn(params.raw.env, "PLUGIN_ROOT") || Object.hasOwn(params.raw.env, "PLUGIN_DATA")) ) { return { ok: false, error: "stdio env must not define PLUGIN_ROOT or PLUGIN_DATA" }; } if ( params.raw.cwd !== undefined && !isValidAgentCwd(params.raw.cwd, params.rootDir, params.pluginDataDir) ) { return { ok: false, error: "stdio cwd must remain within PLUGIN_ROOT or PLUGIN_DATA" }; } } else if (type === "streamable-http" || type === "sse") { if (!hasOnlyKeys(params.raw, AGENT_HTTP_KEYS)) { return { ok: false, error: `${type} configuration contains unknown fields` }; } if (typeof params.raw.url !== "string" || params.raw.url.length === 0) { return { ok: false, error: `${type} url must be a non-empty string` }; } if (params.raw.headers !== undefined && !isStringRecord(params.raw.headers)) { return { ok: false, error: `${type} headers must contain only string values` }; } } else { return { ok: false, error: `unsupported type: ${type}` }; } const server: BundleMcpServerConfig = { ...params.raw, transport: type }; delete server.type; return { ok: true, server }; } function resolveAgentPluginDataDir(pluginId: string): string { return path.join( resolveActivePluginInstallRoots().stateDir, "plugin-data", encodePluginInstallDirName(pluginId), ); } function extractAgentMcpServerMap(params: { raw: Record; pluginId: string; rootDir: string; }): { servers: Record; diagnostics: string[]; pluginDataDir?: string; } { if ( params.raw.$schema !== AGENT_MCP_SCHEMA || !hasOnlyKeys(params.raw, AGENT_MCP_TOP_LEVEL_KEYS) || !isRecord(params.raw.mcpServers) ) { return { servers: {}, diagnostics: [ `invalid mcp.json: expected only $schema=${AGENT_MCP_SCHEMA} and object mcpServers`, ], }; } const pluginDataDir = resolveAgentPluginDataDir(params.pluginId); const servers: Record = {}; const diagnostics: string[] = []; for (const [serverName, raw] of Object.entries(params.raw.mcpServers)) { const validated = validateAgentMcpServer({ raw, rootDir: params.rootDir, pluginDataDir }); if (!validated.ok) { diagnostics.push(`invalid MCP server "${serverName}" in mcp.json: ${validated.error}`); continue; } servers[serverName] = validated.server; } const hasStdioServer = Object.values(servers).some((server) => server.transport === "stdio"); if (!hasStdioServer) { return { servers, diagnostics }; } // The encoded install id makes this path stable before it exists. Creation belongs to // stdio launch so read-only inspection never mutates plugin state. return { servers, diagnostics, pluginDataDir }; } function loadBundleFileBackedMcpConfig(params: { pluginId: string; rootDir: string; relativePath: string; bundleFormat: PluginBundleFormat; }): { config: BundleMcpRuntimeConfig; diagnostics: string[]; } { const rootDir = params.bundleFormat === "agent" ? // SAFETY: The required Agent Plugins manifest already established this canonical root. pluginCacheRealpathSync(params.rootDir)! : normalizeBundlePath(params.rootDir); const absolutePath = path.resolve(rootDir, params.relativePath); const result = readBundleJsonObject({ rootDir, relativePath: params.relativePath, onOpenFailure: (failure) => resolveBundleJsonOpenFailure({ failure, relativePath: params.relativePath, allowMissing: params.bundleFormat !== "agent", }), }); if (!result.ok) { return { config: { mcpServers: {}, prepareDataDirsByServer: {} }, diagnostics: [ result.reason === "open" ? result.error : `unable to read ${params.relativePath}: ${result.error}`, ], }; } const agentLoaded = params.bundleFormat === "agent" ? extractAgentMcpServerMap({ raw: result.raw, pluginId: params.pluginId, rootDir, }) : undefined; const servers = agentLoaded?.servers ?? extractMcpServerMap(result.raw); const baseDir = normalizeBundlePath(path.dirname(absolutePath)); return { config: { mcpServers: Object.fromEntries( Object.entries(servers).map(([serverName, server]) => [ serverName, absolutizeBundleMcpServer({ rootDir, baseDir, server, pluginDataDir: agentLoaded?.pluginDataDir, agentFormat: params.bundleFormat === "agent", }), ]), ), prepareDataDirsByServer: Object.fromEntries( Object.entries(servers).map(([serverName, server]) => [ serverName, agentLoaded?.pluginDataDir && server.transport === "stdio" ? { pluginId: params.pluginId, dataDir: agentLoaded.pluginDataDir } : null, ]), ), }, diagnostics: agentLoaded?.diagnostics ?? [], }; } function loadRootRelativeMcpConfig(params: { rootDir: string; mcpServers: Record; }): { config: BundleMcpRuntimeConfig; diagnostics: string[] } { const rootDir = normalizeBundlePath(params.rootDir); return { config: { mcpServers: Object.fromEntries( Object.entries(params.mcpServers).map(([serverName, server]) => [ serverName, absolutizeBundleMcpServer({ rootDir, baseDir: rootDir, server }), ]), ), prepareDataDirsByServer: Object.fromEntries( Object.keys(params.mcpServers).map((serverName) => [serverName, null]), ), }, diagnostics: [], }; } function loadBundleMcpConfig(params: { pluginId: string; rootDir: string; bundleFormat: PluginBundleFormat; }): { config: BundleMcpRuntimeConfig; diagnostics: string[] } { const manifestRelativePath = MANIFEST_PATH_BY_FORMAT[params.bundleFormat]; const manifestLoaded = readBundleJsonObject({ rootDir: params.rootDir, relativePath: manifestRelativePath, onOpenFailure: (failure) => resolveBundleJsonOpenFailure({ failure, relativePath: manifestRelativePath, allowMissing: params.bundleFormat === "claude", }), }); if (!manifestLoaded.ok) { return { config: { mcpServers: {}, prepareDataDirsByServer: {} }, diagnostics: [manifestLoaded.error], }; } let merged: BundleMcpRuntimeConfig = { mcpServers: {}, prepareDataDirsByServer: {} }; const filePaths = resolveBundleMcpConfigPaths({ raw: manifestLoaded.raw, rootDir: params.rootDir, bundleFormat: params.bundleFormat, }); const diagnostics: string[] = []; for (const relativePath of filePaths) { const loaded = loadBundleFileBackedMcpConfig({ pluginId: params.pluginId, rootDir: params.rootDir, relativePath, bundleFormat: params.bundleFormat, }); diagnostics.push(...loaded.diagnostics); merged = applyMergePatch(merged, loaded.config) as BundleMcpRuntimeConfig; } if (params.bundleFormat !== "agent") { merged = applyMergePatch( merged, loadRootRelativeMcpConfig({ rootDir: params.rootDir, mcpServers: extractMcpServerMap(manifestLoaded.raw.mcpServers), }).config, ) as BundleMcpRuntimeConfig; } return { config: merged, diagnostics }; } export function inspectBundleMcpRuntimeSupport(params: { pluginId: string; rootDir: string; bundleFormat: PluginBundleFormat; }): BundleMcpRuntimeSupport { return inspectMcpServerRuntimeSupport(loadBundleMcpConfig(params)); } export function inspectNativePluginMcpRuntimeSupport(params: { rootDir: string; mcpServers: Record; }): BundleMcpRuntimeSupport { return inspectMcpServerRuntimeSupport(loadRootRelativeMcpConfig(params)); } function inspectMcpServerRuntimeSupport(loaded: { config: BundleMcpConfig; diagnostics: string[]; }): BundleMcpRuntimeSupport { const supportedServerNames: string[] = []; const stdioServerNames: string[] = []; const unsupportedServerNames: string[] = []; for (const [serverName, server] of Object.entries(loaded.config.mcpServers)) { const transport = resolveMcpTransportConfig(serverName, server, { logWarnings: false }); if (transport?.kind === "stdio") { supportedServerNames.push(serverName); stdioServerNames.push(serverName); continue; } if (transport?.kind === "http") { supportedServerNames.push(serverName); continue; } unsupportedServerNames.push(serverName); } return { hasSupportedStdioServer: stdioServerNames.length > 0, supportedServerNames, stdioServerNames, unsupportedServerNames, diagnostics: loaded.diagnostics, }; } export function loadEnabledBundleMcpConfig(params: { workspaceDir: string; cfg?: OpenClawConfig; manifestRegistry?: Pick; }): EnabledBundleMcpConfigResult { const loaded = loadEnabledBundleConfig({ workspaceDir: params.workspaceDir, cfg: params.cfg, manifestRegistry: params.manifestRegistry, createEmptyConfig: (): BundleMcpRuntimeConfig => ({ mcpServers: {}, prepareDataDirsByServer: {}, }), loadBundleConfig: loadBundleMcpConfig, loadNativePluginConfig: ({ record }) => record.mcpServers ? loadRootRelativeMcpConfig({ rootDir: record.rootDir, mcpServers: record.mcpServers, }) : undefined, createDiagnostic: (pluginId, message) => ({ pluginId, message }), }); return { config: { mcpServers: loaded.config.mcpServers }, diagnostics: loaded.diagnostics, prepareDataDirsByServer: Object.fromEntries( Object.entries(loaded.config.prepareDataDirsByServer).filter( (entry): entry is [string, BundleMcpDataDirOwnership] => entry[1] !== null, ), ), }; }