From a52c2fc2fb68e2a3b31f0d6c6fb465e44470e5fe Mon Sep 17 00:00:00 2001 From: Daniel Young Date: Tue, 18 Aug 2026 08:49:10 -0400 Subject: [PATCH] [mcp] Expand Claude plugin stdio env (#1) Claude marketplace plugins may reference runtime secrets in stdio server environment values. Resolve those placeholders after plugin-root substitution so child processes receive credentials instead of literal template strings. Solves: Stdio MCP credentials remain unexpanded Tests: Claude plugin discovery tests; coding-agent check; live CH auth --- .../coding-agent/src/discovery/claude-plugins.ts | 4 +++- .../test/discovery/claude-plugins.test.ts | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/discovery/claude-plugins.ts b/packages/coding-agent/src/discovery/claude-plugins.ts index 50d4dea38..8c3e8ed4b 100644 --- a/packages/coding-agent/src/discovery/claude-plugins.ts +++ b/packages/coding-agent/src/discovery/claude-plugins.ts @@ -580,7 +580,9 @@ async function loadMCPServers(ctx: LoadContext): Promise> ...(raw.timeout !== undefined && { timeout: raw.timeout }), ...(rooted.command !== undefined && { command: rooted.command }), ...(raw.args !== undefined && { args: substitutePluginRoot(raw.args, root.path) }), - ...(raw.env !== undefined && { env: substitutePluginRoot(raw.env, root.path) }), + ...(raw.env !== undefined && { + env: expandEnvVarsDeep(substitutePluginRoot(raw.env, root.path)), + }), ...(rooted.cwd !== undefined && { cwd: rooted.cwd }), ...(raw.url !== undefined && { url: expandEnvVarsDeep(raw.url) }), ...(raw.headers !== undefined && { headers: expandEnvVarsDeep(raw.headers) }), diff --git a/packages/coding-agent/test/discovery/claude-plugins.test.ts b/packages/coding-agent/test/discovery/claude-plugins.test.ts index aa28719dd..ce99e7fcd 100644 --- a/packages/coding-agent/test/discovery/claude-plugins.test.ts +++ b/packages/coding-agent/test/discovery/claude-plugins.test.ts @@ -527,7 +527,7 @@ describe("listClaudePluginRoots", () => { ); }); - test("expands env placeholders in marketplace plugin MCP url and headers", async () => { + test("expands env placeholders throughout marketplace plugin MCP configuration", async () => { const pluginsDir = path.join(tempDir, ".claude", "plugins"); const pluginPath = path.join(tempDir, "plugins", "context7"); const originalApiKey = process.env.OMP_PLUGIN_MCP_API_KEY; @@ -566,6 +566,14 @@ describe("listClaudePluginRoots", () => { CONTEXT7_API_KEY: envPlaceholder("OMP_PLUGIN_MCP_API_KEY"), }, }, + local: { + type: "stdio", + command: "${CLAUDE_PLUGIN_ROOT}/bin/server", + env: { + API_KEY: envPlaceholder("OMP_PLUGIN_MCP_API_KEY"), + CONFIG_PATH: "${CLAUDE_PLUGIN_ROOT}/config.json", + }, + }, }), ); @@ -577,6 +585,12 @@ describe("listClaudePluginRoots", () => { expect(server?.url).toBe("https://mcp.context7.example/mcp"); expect(server?.headers).toEqual({ CONTEXT7_API_KEY: "ctx7sk-test-key" }); + const localServer = result.all.find(item => item.name === "context7:local"); + expect(localServer?.command).toBe(path.join(pluginPath, "bin", "server")); + expect(localServer?.env).toEqual({ + API_KEY: "ctx7sk-test-key", + CONFIG_PATH: path.join(pluginPath, "config.json"), + }); } finally { if (originalApiKey === undefined) delete process.env.OMP_PLUGIN_MCP_API_KEY; else process.env.OMP_PLUGIN_MCP_API_KEY = originalApiKey;