Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/mcp/config/expandPlaceholders.ts
Original file line number Diff line number Diff line change
@@ -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;
}
24 changes: 3 additions & 21 deletions src/mcp/config/loadMcpServerConfig.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -64,26 +65,7 @@ function readMcpConfig(
return {};
}

return { mcpServers: expandConfig(rawServers) as Record<string, unknown> };
}

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<string, unknown> };
}

function isRecord(value: unknown): value is Record<string, unknown> {
Expand Down
4 changes: 4 additions & 0 deletions src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ export {
loadMcpServerConfig,
type LoadMcpServerConfigResult,
} from "./config/loadMcpServerConfig.js";
export {
expandMcpConfig,
expandMcpString,
} from "./config/expandPlaceholders.js";
export {
createMcpToolDefinitionsFromRuntime,
type CreateToolDefinitionsOptions,
Expand Down
21 changes: 10 additions & 11 deletions src/mcp/runtime/parsePluginMcpServers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>): Record<string, string> {
return Object.fromEntries(
Object.entries(rec).map(([k, v]) => [k, expandMcpString(v)]),
);
}

export type ParsePluginMcpServersResult = {
Expand Down Expand Up @@ -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<string, string>) : undefined,
cwd: typeof v.cwd === "string" ? v.cwd : undefined,
env: isStringRecord(v.env) ? expandStringRecord(v.env as Record<string, string>) : undefined,
cwd: typeof v.cwd === "string" ? expandMcpString(v.cwd) : undefined,
perSession: v.perSession === true ? true : undefined,
});
continue;
Expand All @@ -58,8 +57,8 @@ export function parsePluginMcpServers(
servers.push({
id,
transport: "streamable_http",
url,
headers: isStringRecord(v.headers) ? (v.headers as Record<string, string>) : undefined,
url: expandMcpString(url),
headers: isStringRecord(v.headers) ? expandStringRecord(v.headers as Record<string, string>) : undefined,
});
continue;
}
Expand Down
164 changes: 164 additions & 0 deletions tests/mcp/expand-placeholders.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
assert.equal(result.key, "expanded");
assert.equal((result.nested as Record<string, string>).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;
}
});