fix(discovery): honored inline plugin MCP manifest maps
Treated an object-valued mcpServers manifest field as the server map, rooting relative stdio paths at the plugin root, so plugins declaring servers inline no longer fell through to .mcp.json. Fixes #6871
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed marketplace plugin MCP discovery ignoring `mcpServers` file pointers in `.omp-plugin/plugin.json` and `.claude-plugin/plugin.json`; OMP-specific manifests now take precedence before the conventional root `.mcp.json` fallback ([#6871](https://github.com/can1357/oh-my-pi/issues/6871)).
|
||||
- Fixed marketplace plugin MCP discovery ignoring the `mcpServers` manifest field in `.omp-plugin/plugin.json` and `.claude-plugin/plugin.json`; both an inline server-map object and a file-path pointer are now honored (OMP manifest first) before the conventional root `.mcp.json` fallback ([#6871](https://github.com/can1357/oh-my-pi/issues/6871)).
|
||||
|
||||
## [17.1.7] - 2026-07-27
|
||||
|
||||
|
||||
@@ -42,7 +42,14 @@ interface ResolvedPluginDir {
|
||||
}
|
||||
|
||||
interface ResolvedMCPConfig {
|
||||
/** On-disk config file to read, or null when servers are inline or nothing applies. */
|
||||
path: string | null;
|
||||
/** Server map declared inline in the plugin manifest, or null when the source is a file. */
|
||||
inlineServers: Record<string, unknown> | null;
|
||||
/** Path recorded as each discovered server's capability source. */
|
||||
sourcePath: string;
|
||||
/** Directory that relative stdio `command`/`cwd` values resolve against. */
|
||||
baseDir: string;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
@@ -380,7 +387,30 @@ async function loadTools(ctx: LoadContext): Promise<LoadResult<CustomTool>> {
|
||||
// MCP Servers
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Unwrap a parsed MCP config file to its server map. Supports the nested
|
||||
* `{ mcpServers: { … } }` project shape and the flat `{ name: cfg, … }`
|
||||
* marketplace-plugin shape. Returns null when a `mcpServers` field is present
|
||||
* but not an object map (malformed) so the caller skips the file.
|
||||
*/
|
||||
function extractServerMap(obj: Record<string, unknown>): Record<string, unknown> | null {
|
||||
if (isRecord(obj.mcpServers)) return obj.mcpServers;
|
||||
if (!("mcpServers" in obj)) return obj;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve where a plugin's MCP servers come from, honoring the manifest's
|
||||
* `mcpServers` field before the conventional root `.mcp.json`.
|
||||
*
|
||||
* `.omp-plugin/plugin.json` takes precedence over `.claude-plugin/plugin.json`.
|
||||
* The field may be an inline object (the server map itself) or a string path to
|
||||
* a config file within the plugin root; a path escaping the root is rejected
|
||||
* with a warning. When no manifest declares the field, `<root>/.mcp.json` is the
|
||||
* fallback source.
|
||||
*/
|
||||
async function resolvePluginMCPConfig(root: ClaudePluginRoot): Promise<ResolvedMCPConfig> {
|
||||
const fallback = path.join(root.path, ".mcp.json");
|
||||
for (const manifestDir of [".omp-plugin", ".claude-plugin"]) {
|
||||
const manifestPath = path.join(root.path, manifestDir, "plugin.json");
|
||||
const raw = await readFile(manifestPath);
|
||||
@@ -392,21 +422,40 @@ async function resolvePluginMCPConfig(root: ClaudePluginRoot): Promise<ResolvedM
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!isRecord(parsed) || typeof parsed.mcpServers !== "string") continue;
|
||||
if (!isRecord(parsed)) continue;
|
||||
const pointer = parsed.mcpServers;
|
||||
|
||||
const configured = parsed.mcpServers.trim();
|
||||
if (configured.length === 0) continue;
|
||||
const resolved = path.resolve(root.path, configured);
|
||||
if (!isWithinPluginRoot(root.path, resolved)) {
|
||||
// Inline object form: the manifest value is the server map itself, rooted
|
||||
// at the plugin directory (Claude's ${CLAUDE_PLUGIN_ROOT} base).
|
||||
if (isRecord(pointer)) {
|
||||
return { path: null, inlineServers: pointer, sourcePath: manifestPath, baseDir: root.path, warnings: [] };
|
||||
}
|
||||
|
||||
// File-pointer form: resolve the named config file within the plugin root.
|
||||
if (typeof pointer === "string") {
|
||||
const configured = pointer.trim();
|
||||
if (configured.length === 0) continue;
|
||||
const resolved = path.resolve(root.path, configured);
|
||||
if (!isWithinPluginRoot(root.path, resolved)) {
|
||||
return {
|
||||
path: null,
|
||||
inlineServers: null,
|
||||
sourcePath: manifestPath,
|
||||
baseDir: root.path,
|
||||
warnings: [`[claude-plugins] Ignoring mcpServers path outside plugin root for ${root.id}: ${configured}`],
|
||||
};
|
||||
}
|
||||
return {
|
||||
path: null,
|
||||
warnings: [`[claude-plugins] Ignoring mcpServers path outside plugin root for ${root.id}: ${configured}`],
|
||||
path: resolved,
|
||||
inlineServers: null,
|
||||
sourcePath: resolved,
|
||||
baseDir: path.dirname(resolved),
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
return { path: resolved, warnings: [] };
|
||||
}
|
||||
|
||||
return { path: path.join(root.path, ".mcp.json"), warnings: [] };
|
||||
return { path: fallback, inlineServers: null, sourcePath: fallback, baseDir: path.dirname(fallback), warnings: [] };
|
||||
}
|
||||
|
||||
async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>> {
|
||||
@@ -419,43 +468,35 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
|
||||
for (const root of roots) {
|
||||
const resolved = await resolvePluginMCPConfig(root);
|
||||
warnings.push(...resolved.warnings);
|
||||
if (resolved.path === null) continue;
|
||||
const mcpPath = resolved.path;
|
||||
const raw = await readFile(mcpPath);
|
||||
if (raw === null) continue; // file absent — skip silently
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
warnings.push(`[claude-plugins] Invalid JSON in ${mcpPath}`);
|
||||
logger.warn(`[claude-plugins] Invalid JSON in ${mcpPath}`);
|
||||
continue;
|
||||
}
|
||||
let servers: Record<string, unknown> | null;
|
||||
if (resolved.inlineServers) {
|
||||
servers = resolved.inlineServers;
|
||||
} else if (resolved.path !== null) {
|
||||
const raw = await readFile(resolved.path);
|
||||
if (raw === null) continue; // file absent — skip silently
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
|
||||
// Two shapes are supported:
|
||||
// nested: { "mcpServers": { name: cfg, ... } } (OMP/Claude Code project shape)
|
||||
// flat: { name: cfg, ... } (Claude marketplace plugin shape)
|
||||
// If "mcpServers" is present and an object, treat it as the canonical map.
|
||||
// Otherwise, treat the whole object as the server map.
|
||||
let servers: Record<string, unknown>;
|
||||
if (
|
||||
obj.mcpServers !== undefined &&
|
||||
obj.mcpServers !== null &&
|
||||
typeof obj.mcpServers === "object" &&
|
||||
!Array.isArray(obj.mcpServers)
|
||||
) {
|
||||
servers = obj.mcpServers as Record<string, unknown>;
|
||||
} else if (!("mcpServers" in obj)) {
|
||||
servers = obj;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
warnings.push(`[claude-plugins] Invalid JSON in ${resolved.path}`);
|
||||
logger.warn(`[claude-plugins] Invalid JSON in ${resolved.path}`);
|
||||
continue;
|
||||
}
|
||||
// Two file shapes are supported:
|
||||
// nested: { "mcpServers": { name: cfg, ... } } (OMP/Claude Code project shape)
|
||||
// flat: { name: cfg, ... } (Claude marketplace plugin shape)
|
||||
if (!isRecord(parsed)) continue;
|
||||
servers = extractServerMap(parsed);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
if (servers === null) continue;
|
||||
|
||||
for (const [serverName, serverCfg] of Object.entries(servers)) {
|
||||
const { sourcePath, baseDir } = resolved;
|
||||
for (const serverName in servers) {
|
||||
const serverCfg = servers[serverName];
|
||||
if (!serverCfg || typeof serverCfg !== "object" || Array.isArray(serverCfg)) continue;
|
||||
const raw = serverCfg as {
|
||||
enabled?: boolean;
|
||||
@@ -474,7 +515,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
|
||||
// occasionally ship .mcp.json entries with neither, which would register a useless
|
||||
// server and surface as a connection error at runtime.
|
||||
if (typeof raw.command !== "string" && typeof raw.url !== "string") {
|
||||
warnings.push(`[claude-plugins] Skipping MCP server "${serverName}" in ${mcpPath}: missing command or url`);
|
||||
warnings.push(`[claude-plugins] Skipping MCP server "${serverName}" in ${sourcePath}: missing command or url`);
|
||||
continue;
|
||||
}
|
||||
const namespacedName = root.plugin ? `${root.plugin}:${serverName}` : serverName;
|
||||
@@ -483,10 +524,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
|
||||
const substitutedCwd = raw.cwd !== undefined ? substitutePluginRoot(raw.cwd, root.path) : undefined;
|
||||
// Root relative command/cwd at the plugin's config directory, not the
|
||||
// session cwd (MCP stdio spawning resolves relative values there).
|
||||
const rooted = resolvePluginStdioPaths(
|
||||
{ command: substitutedCommand, cwd: substitutedCwd },
|
||||
path.dirname(mcpPath),
|
||||
);
|
||||
const rooted = resolvePluginStdioPaths({ command: substitutedCommand, cwd: substitutedCwd }, baseDir);
|
||||
const server: MCPServer = {
|
||||
name: namespacedName,
|
||||
...(raw.enabled !== undefined && { enabled: raw.enabled }),
|
||||
@@ -500,7 +538,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
|
||||
...(raw.auth !== undefined && { auth: raw.auth }),
|
||||
...(raw.oauth !== undefined && { oauth: raw.oauth }),
|
||||
...(raw.type !== undefined && { transport: raw.type as MCPServer["transport"] }),
|
||||
_source: createSourceMeta(PROVIDER_ID, mcpPath, root.scope),
|
||||
_source: createSourceMeta(PROVIDER_ID, sourcePath, root.scope),
|
||||
};
|
||||
items.push(server);
|
||||
}
|
||||
|
||||
@@ -565,6 +565,47 @@ describe("listClaudePluginRoots", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("loads inline manifest mcpServers object and roots relative stdio at plugin root", async () => {
|
||||
const pluginsDir = path.join(tempDir, ".claude", "plugins");
|
||||
const pluginPath = path.join(tempDir, "plugins", "inline-mcp");
|
||||
await fs.mkdir(pluginsDir, { recursive: true });
|
||||
await fs.mkdir(path.join(pluginPath, ".omp-plugin"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(pluginsDir, "installed_plugins.json"),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
plugins: {
|
||||
"inline-mcp@market": [
|
||||
{
|
||||
scope: "user",
|
||||
installPath: pluginPath,
|
||||
version: "1.0.0",
|
||||
installedAt: "2026-07-28T00:00:00Z",
|
||||
lastUpdated: "2026-07-28T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
// Inline object form: the manifest carries the server map directly, and no
|
||||
// root .mcp.json exists, so the pre-fix fallback would register nothing.
|
||||
await fs.writeFile(
|
||||
path.join(pluginPath, ".omp-plugin", "plugin.json"),
|
||||
JSON.stringify({ mcpServers: { local: { command: "./bin/server", args: ["run"] } } }),
|
||||
);
|
||||
|
||||
const result = await loadCapability<MCPServer>(mcpCapability.id, {
|
||||
cwd: tempDir,
|
||||
providers: ["claude-plugins"],
|
||||
});
|
||||
|
||||
expect(result.warnings).toEqual([]);
|
||||
const server = result.all.find(item => item.name === "inline-mcp:local");
|
||||
expect(server).toBeDefined();
|
||||
expect(server?.command).toBe(path.join(pluginPath, "bin", "server"));
|
||||
expect(server?.args).toEqual(["run"]);
|
||||
});
|
||||
|
||||
test("deduplicates a plugin alias of a directly configured MCP connection", async () => {
|
||||
const pluginsDir = path.join(tempDir, ".claude", "plugins");
|
||||
const pluginPath = path.join(tempDir, "plugins", "context7");
|
||||
|
||||
Reference in New Issue
Block a user