[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
This commit is contained in:
Daniel Young
2026-08-18 08:49:10 -04:00
committed by GitHub
parent 644ad30d6e
commit a52c2fc2fb
2 changed files with 18 additions and 2 deletions
@@ -580,7 +580,9 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
...(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) }),
@@ -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;