diff --git a/src/mcp/config/expandPlaceholders.ts b/src/mcp/config/expandPlaceholders.ts new file mode 100644 index 000000000..e62f6d6b3 --- /dev/null +++ b/src/mcp/config/expandPlaceholders.ts @@ -0,0 +1,31 @@ +import { homedir } from "node:os"; + +/** + * Expand MCP config placeholders in a single string: + * - `${env:NAME}` → process.env[NAME] (fallback empty string) + * - `${userHome}` → user home directory + * - `~/` or `~\` prefix → user home directory (kept for backward compat) + */ +export function expandMcpString(value: string): string { + let result = value + .replace(/\$\{env:([^}]+)\}/g, (_m, name: string) => process.env[name] ?? "") + .replace(/\$\{userHome\}/g, process.env.HOME ?? process.env.USERPROFILE ?? homedir()); + if (result.startsWith("~/") || result.startsWith("~\\")) { + result = homedir() + result.slice(1); + } else if (result === "~") { + result = homedir(); + } + return result; +} + +/** Recursively expand all string values in an object/array tree. */ +export function expandMcpConfig(value: unknown): unknown { + if (typeof value === "string") return expandMcpString(value); + if (Array.isArray(value)) return value.map(expandMcpConfig); + if (typeof value === "object" && value !== null) { + return Object.fromEntries( + Object.entries(value).map(([k, v]) => [k, expandMcpConfig(v)]), + ); + } + return value; +} diff --git a/src/mcp/config/loadMcpServerConfig.ts b/src/mcp/config/loadMcpServerConfig.ts index 4bf168d7b..17653e725 100644 --- a/src/mcp/config/loadMcpServerConfig.ts +++ b/src/mcp/config/loadMcpServerConfig.ts @@ -1,7 +1,8 @@ import { existsSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; import { resolve } from "node:path"; +import { expandMcpConfig } from "./expandPlaceholders.js"; + export const MCP_CONFIG_FILE_NAME = "mcp.json"; export type LoadMcpServerConfigResult = { @@ -64,26 +65,7 @@ function readMcpConfig( return {}; } - return { mcpServers: expandConfig(rawServers) as Record }; -} - -function expandConfig(value: unknown): unknown { - if (typeof value === "string") { - return expandString(value); - } - if (Array.isArray(value)) { - return value.map(expandConfig); - } - if (isRecord(value)) { - return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, expandConfig(entry)])); - } - return value; -} - -function expandString(value: string): string { - return value - .replace(/\$\{env:([^}]+)\}/g, (_match, name: string) => process.env[name] ?? "") - .replace(/\$\{userHome\}/g, process.env.HOME ?? process.env.USERPROFILE ?? homedir()); + return { mcpServers: expandMcpConfig(rawServers) as Record }; } function isRecord(value: unknown): value is Record { diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 8b31ae69e..1420cc066 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -15,6 +15,10 @@ export { loadMcpServerConfig, type LoadMcpServerConfigResult, } from "./config/loadMcpServerConfig.js"; +export { + expandMcpConfig, + expandMcpString, +} from "./config/expandPlaceholders.js"; export { createMcpToolDefinitionsFromRuntime, type CreateToolDefinitionsOptions, diff --git a/src/mcp/runtime/parsePluginMcpServers.ts b/src/mcp/runtime/parsePluginMcpServers.ts index 83c4524a5..4541836db 100644 --- a/src/mcp/runtime/parsePluginMcpServers.ts +++ b/src/mcp/runtime/parsePluginMcpServers.ts @@ -10,14 +10,13 @@ * a single misconfigured plugin entry can't take down the gateway. */ -import { homedir } from "node:os"; +import { expandMcpString } from "../config/expandPlaceholders.js"; import type { PilotDeckMcpServerSpec } from "../protocol/types.js"; -function expandHome(s: string): string { - if (s.startsWith("~/")) return homedir() + s.slice(1); - if (s.startsWith("~\\")) return homedir() + s.slice(1); - if (s === "~") return homedir(); - return s; +function expandStringRecord(rec: Record): Record { + return Object.fromEntries( + Object.entries(rec).map(([k, v]) => [k, expandMcpString(v)]), + ); } export type ParsePluginMcpServersResult = { @@ -45,10 +44,10 @@ export function parsePluginMcpServers( transport: "stdio", command: v.command, args: Array.isArray(v.args) - ? (v.args.filter((a): a is string => typeof a === "string").map(expandHome)) + ? (v.args.filter((a): a is string => typeof a === "string").map(expandMcpString)) : undefined, - env: isStringRecord(v.env) ? (v.env as Record) : undefined, - cwd: typeof v.cwd === "string" ? v.cwd : undefined, + env: isStringRecord(v.env) ? expandStringRecord(v.env as Record) : undefined, + cwd: typeof v.cwd === "string" ? expandMcpString(v.cwd) : undefined, perSession: v.perSession === true ? true : undefined, }); continue; @@ -58,8 +57,8 @@ export function parsePluginMcpServers( servers.push({ id, transport: "streamable_http", - url, - headers: isStringRecord(v.headers) ? (v.headers as Record) : undefined, + url: expandMcpString(url), + headers: isStringRecord(v.headers) ? expandStringRecord(v.headers as Record) : undefined, }); continue; } diff --git a/tests/mcp/expand-placeholders.spec.ts b/tests/mcp/expand-placeholders.spec.ts new file mode 100644 index 000000000..104c04c07 --- /dev/null +++ b/tests/mcp/expand-placeholders.spec.ts @@ -0,0 +1,164 @@ +import assert from "node:assert/strict"; +import { homedir } from "node:os"; +import test from "node:test"; + +import { expandMcpString, expandMcpConfig } from "../../src/mcp/config/expandPlaceholders.js"; +import { parsePluginMcpServers } from "../../src/mcp/runtime/parsePluginMcpServers.js"; + +test("expandMcpString expands ${env:*} placeholders", () => { + process.env.PILOTDECK_TEST_TOKEN = "secret123"; + try { + assert.equal(expandMcpString("Bearer ${env:PILOTDECK_TEST_TOKEN}"), "Bearer secret123"); + } finally { + delete process.env.PILOTDECK_TEST_TOKEN; + } +}); + +test("expandMcpString expands ${userHome} placeholder", () => { + const result = expandMcpString("${userHome}/my-tools"); + assert.equal(result, homedir() + "/my-tools"); +}); + +test("expandMcpString expands ~ prefix", () => { + assert.equal(expandMcpString("~/server.js"), homedir() + "/server.js"); + assert.equal(expandMcpString("~\\server.js"), homedir() + "\\server.js"); + assert.equal(expandMcpString("~"), homedir()); +}); + +test("expandMcpString leaves unknown env vars as empty string", () => { + delete process.env.__PILOTDECK_NONEXISTENT_VAR__; + assert.equal(expandMcpString("${env:__PILOTDECK_NONEXISTENT_VAR__}"), ""); +}); + +test("expandMcpString handles combined placeholders", () => { + process.env.PILOTDECK_TEST_PORT = "8080"; + try { + const result = expandMcpString("http://localhost:${env:PILOTDECK_TEST_PORT}${userHome}/path"); + assert.equal(result, `http://localhost:8080${homedir()}/path`); + } finally { + delete process.env.PILOTDECK_TEST_PORT; + } +}); + +test("expandMcpConfig recursively expands objects and arrays", () => { + process.env.PILOTDECK_TEST_VAL = "expanded"; + try { + const input = { + key: "${env:PILOTDECK_TEST_VAL}", + nested: { inner: "${userHome}/dir" }, + list: ["~/a", "${env:PILOTDECK_TEST_VAL}"], + number: 42, + }; + const result = expandMcpConfig(input) as Record; + assert.equal(result.key, "expanded"); + assert.equal((result.nested as Record).inner, homedir() + "/dir"); + assert.deepEqual(result.list, [homedir() + "/a", "expanded"]); + assert.equal(result.number, 42); + } finally { + delete process.env.PILOTDECK_TEST_VAL; + } +}); + +test("parsePluginMcpServers expands ${env:*} in stdio env", () => { + process.env.PILOTDECK_TEST_TOKEN = "tok_abc"; + try { + const { servers } = parsePluginMcpServers({ + myServer: { + command: "node", + args: ["server.js"], + env: { API_TOKEN: "${env:PILOTDECK_TEST_TOKEN}" }, + }, + }); + assert.equal(servers.length, 1); + const s = servers[0]!; + assert.equal(s.transport, "stdio"); + if (s.transport === "stdio") { + assert.equal(s.env?.API_TOKEN, "tok_abc"); + } + } finally { + delete process.env.PILOTDECK_TEST_TOKEN; + } +}); + +test("parsePluginMcpServers expands ${userHome} in stdio cwd", () => { + const { servers } = parsePluginMcpServers({ + myServer: { + command: "node", + cwd: "${userHome}/my-tools", + }, + }); + assert.equal(servers.length, 1); + const s = servers[0]!; + if (s.transport === "stdio") { + assert.equal(s.cwd, homedir() + "/my-tools"); + } +}); + +test("parsePluginMcpServers expands ~ in stdio args (backward compat)", () => { + const { servers } = parsePluginMcpServers({ + myServer: { + command: "node", + args: ["~/server.js"], + }, + }); + assert.equal(servers.length, 1); + const s = servers[0]!; + if (s.transport === "stdio") { + assert.deepEqual(s.args, [homedir() + "/server.js"]); + } +}); + +test("parsePluginMcpServers expands ${env:*} in streamable_http url", () => { + process.env.PILOTDECK_TEST_URL = "https://mcp.example.com"; + try { + const { servers } = parsePluginMcpServers({ + httpServer: { + url: "${env:PILOTDECK_TEST_URL}/mcp", + }, + }); + assert.equal(servers.length, 1); + const s = servers[0]!; + if (s.transport === "streamable_http") { + assert.equal(s.url, "https://mcp.example.com/mcp"); + } + } finally { + delete process.env.PILOTDECK_TEST_URL; + } +}); + +test("parsePluginMcpServers expands ${env:*} in streamable_http headers", () => { + process.env.PILOTDECK_TEST_AUTH = "Bearer sk-123"; + try { + const { servers } = parsePluginMcpServers({ + httpServer: { + url: "https://mcp.example.com/mcp", + headers: { Authorization: "${env:PILOTDECK_TEST_AUTH}" }, + }, + }); + assert.equal(servers.length, 1); + const s = servers[0]!; + if (s.transport === "streamable_http") { + assert.equal(s.headers?.Authorization, "Bearer sk-123"); + } + } finally { + delete process.env.PILOTDECK_TEST_AUTH; + } +}); + +test("user config and plugin config resolve same placeholders consistently", () => { + process.env.PILOTDECK_TEST_CONSISTENCY = "same_value"; + try { + const userExpanded = expandMcpString("${env:PILOTDECK_TEST_CONSISTENCY}"); + const { servers } = parsePluginMcpServers({ + srv: { + command: "node", + env: { VAR: "${env:PILOTDECK_TEST_CONSISTENCY}" }, + }, + }); + const pluginExpanded = servers[0]!.transport === "stdio" ? servers[0]!.env?.VAR : undefined; + assert.equal(userExpanded, pluginExpanded); + assert.equal(userExpanded, "same_value"); + } finally { + delete process.env.PILOTDECK_TEST_CONSISTENCY; + } +});