Skip to content

Commit 662595e

Browse files
committed
feat: expand MCP server cwd paths
1 parent 4ca3799 commit 662595e

9 files changed

Lines changed: 166 additions & 32 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [2.5.3] - 2026-05-01
11+
12+
### Added
13+
- Added environment variable and `~` expansion for stdio server `cwd` values.
14+
1015
## [2.5.2] - 2026-04-29
1116

1217
### Fixed

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,10 @@ Pi-specific files are the write targets for imported or shared global servers wh
120120
|-------|-------------|
121121
| `command` | Executable for stdio transport |
122122
| `args` | Command arguments |
123-
| `env` | Environment variables (`${VAR}` interpolation) |
124-
| `cwd` | Working directory |
123+
| `env` | Environment variables; supports `${VAR}` and `$env:VAR` interpolation |
124+
| `cwd` | Working directory; supports `${VAR}`, `$env:VAR`, and `~` expansion |
125125
| `url` | HTTP endpoint (StreamableHTTP with SSE fallback) |
126+
| `headers` | HTTP headers; supports `${VAR}` and `$env:VAR` interpolation |
126127
| `auth` | `"bearer"` or `"oauth"` |
127128
| `oauth.grantType` | `"authorization_code"` (default) or `"client_credentials"` for non-interactive machine auth |
128129
| `bearerToken` / `bearerTokenEnv` | Token or env var name |

__tests__/direct-tools.test.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,29 @@
1-
import { describe, expect, it } from "vitest";
1+
import { afterEach, describe, expect, it } from "vitest";
2+
import { homedir } from "node:os";
3+
import { join } from "node:path";
24
import { buildProxyDescription, resolveDirectTools } from "../direct-tools.js";
35
import { computeServerHash, type MetadataCache } from "../metadata-cache.js";
46
import { buildToolMetadata } from "../tool-metadata.js";
57
import type { McpConfig } from "../types.js";
68
import { reconstructToolMetadata } from "../metadata-cache.js";
79

10+
const originalHashEnv = {
11+
MCP_HASH_CWD: process.env.MCP_HASH_CWD,
12+
MCP_HASH_ENV: process.env.MCP_HASH_ENV,
13+
MCP_HASH_HEADER: process.env.MCP_HASH_HEADER,
14+
MCP_HASH_TOKEN: process.env.MCP_HASH_TOKEN,
15+
};
16+
17+
afterEach(() => {
18+
for (const [key, value] of Object.entries(originalHashEnv)) {
19+
if (value === undefined) {
20+
delete process.env[key];
21+
} else {
22+
process.env[key] = value;
23+
}
24+
}
25+
});
26+
827
describe("buildProxyDescription", () => {
928
it("documents the ui-messages action", () => {
1029
const config: McpConfig = {
@@ -79,6 +98,66 @@ describe("buildProxyDescription", () => {
7998
});
8099
});
81100

101+
describe("metadata cache hashing", () => {
102+
it("hashes interpolated cwd", () => {
103+
process.env.MCP_HASH_CWD = "/tmp/mcp-one";
104+
const first = computeServerHash({ command: "node", cwd: "${MCP_HASH_CWD}/server" });
105+
106+
process.env.MCP_HASH_CWD = "/tmp/mcp-two";
107+
const second = computeServerHash({ command: "node", cwd: "${MCP_HASH_CWD}/server" });
108+
109+
expect(first).not.toBe(second);
110+
expect(computeServerHash({ command: "node", cwd: "${MCP_HASH_CWD}/server" })).toBe(
111+
computeServerHash({ command: "node", cwd: "/tmp/mcp-two/server" }),
112+
);
113+
});
114+
115+
it("hashes interpolated env values", () => {
116+
process.env.MCP_HASH_ENV = "/tmp/data-one";
117+
const first = computeServerHash({ command: "node", env: { DATA_DIR: "${MCP_HASH_ENV}" } });
118+
119+
process.env.MCP_HASH_ENV = "/tmp/data-two";
120+
const second = computeServerHash({ command: "node", env: { DATA_DIR: "${MCP_HASH_ENV}" } });
121+
122+
expect(first).not.toBe(second);
123+
expect(computeServerHash({ command: "node", env: { DATA_DIR: "${MCP_HASH_ENV}" } })).toBe(
124+
computeServerHash({ command: "node", env: { DATA_DIR: "/tmp/data-two" } }),
125+
);
126+
});
127+
128+
it("hashes interpolated header values", () => {
129+
process.env.MCP_HASH_HEADER = "header-one";
130+
const first = computeServerHash({ url: "https://example.test/mcp", headers: { "x-root": "$env:MCP_HASH_HEADER" } });
131+
132+
process.env.MCP_HASH_HEADER = "header-two";
133+
const second = computeServerHash({ url: "https://example.test/mcp", headers: { "x-root": "$env:MCP_HASH_HEADER" } });
134+
135+
expect(first).not.toBe(second);
136+
expect(computeServerHash({ url: "https://example.test/mcp", headers: { "x-root": "$env:MCP_HASH_HEADER" } })).toBe(
137+
computeServerHash({ url: "https://example.test/mcp", headers: { "x-root": "header-two" } }),
138+
);
139+
});
140+
141+
it("hashes tilde cwd as the home directory", () => {
142+
expect(computeServerHash({ command: "node", cwd: "~/server" })).toBe(
143+
computeServerHash({ command: "node", cwd: join(homedir(), "server") }),
144+
);
145+
});
146+
147+
it("hashes the effective bearerTokenEnv value", () => {
148+
process.env.MCP_HASH_TOKEN = "token-one";
149+
const first = computeServerHash({ url: "https://example.test/mcp", auth: "bearer", bearerTokenEnv: "MCP_HASH_TOKEN" });
150+
151+
process.env.MCP_HASH_TOKEN = "token-two";
152+
const second = computeServerHash({ url: "https://example.test/mcp", auth: "bearer", bearerTokenEnv: "MCP_HASH_TOKEN" });
153+
154+
expect(first).not.toBe(second);
155+
expect(computeServerHash({ url: "https://example.test/mcp", auth: "bearer", bearerTokenEnv: "MCP_HASH_TOKEN" })).toBe(
156+
computeServerHash({ url: "https://example.test/mcp", auth: "bearer", bearerToken: "token-two", bearerTokenEnv: "MCP_HASH_TOKEN" }),
157+
);
158+
});
159+
});
160+
82161
describe("excludeTools filtering", () => {
83162
it("filters excluded tools from live and cached metadata", () => {
84163
const definition = {

__tests__/server-manager-sampling.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { beforeEach, describe, expect, it, vi } from "vitest";
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import { homedir } from "node:os";
3+
import { join } from "node:path";
24

35
const mocks = vi.hoisted(() => ({
46
clients: [] as any[],
@@ -40,11 +42,21 @@ vi.mock("../npx-resolver.js", () => ({
4042
}));
4143

4244
describe("McpServerManager sampling", () => {
45+
const originalMcpTestCwd = process.env.MCP_TEST_CWD;
46+
4347
beforeEach(() => {
4448
mocks.clients.length = 0;
4549
mocks.transports.length = 0;
4650
});
4751

52+
afterEach(() => {
53+
if (originalMcpTestCwd === undefined) {
54+
delete process.env.MCP_TEST_CWD;
55+
} else {
56+
process.env.MCP_TEST_CWD = originalMcpTestCwd;
57+
}
58+
});
59+
4860
it("advertises sampling and registers the handler before connecting", async () => {
4961
const { McpServerManager } = await import("../server-manager.ts");
5062
const manager = new McpServerManager();
@@ -75,4 +87,26 @@ describe("McpServerManager sampling", () => {
7587
expect(client.options).toBeUndefined();
7688
expect(client.setRequestHandler).not.toHaveBeenCalled();
7789
});
90+
91+
it("expands environment variables and tilde in stdio cwd", async () => {
92+
const { McpServerManager } = await import("../server-manager.ts");
93+
process.env.MCP_TEST_CWD = "/tmp/pi-mcp-cwd";
94+
95+
const envManager = new McpServerManager();
96+
await envManager.connect("env-cwd", {
97+
command: "node",
98+
args: ["server.js"],
99+
cwd: "${MCP_TEST_CWD}/nested",
100+
});
101+
102+
const homeManager = new McpServerManager();
103+
await homeManager.connect("home-cwd", {
104+
command: "node",
105+
args: ["server.js"],
106+
cwd: "~/nested",
107+
});
108+
109+
expect(mocks.transports[0].options).toMatchObject({ cwd: "/tmp/pi-mcp-cwd/nested" });
110+
expect(mocks.transports[1].options).toMatchObject({ cwd: join(homedir(), "nested") });
111+
});
78112
});

metadata-cache.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge"
77
import type { McpTool, McpResource, ServerEntry, ToolMetadata } from "./types.js";
88
import { formatToolName, isToolExcluded } from "./types.js";
99
import { resourceNameToToolName } from "./resource-tools.js";
10-
import { extractToolUiStreamMode } from "./utils.js";
10+
import { extractToolUiStreamMode, interpolateEnvRecord, resolveConfigPath } from "./utils.js";
1111

1212
const CACHE_VERSION = 1;
1313
const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
@@ -88,12 +88,12 @@ export function computeServerHash(definition: ServerEntry): string {
8888
const identity: Record<string, unknown> = {
8989
command: definition.command,
9090
args: definition.args,
91-
env: definition.env,
92-
cwd: definition.cwd,
91+
env: interpolateEnvRecord(definition.env),
92+
cwd: resolveConfigPath(definition.cwd),
9393
url: definition.url,
94-
headers: definition.headers,
94+
headers: interpolateEnvRecord(definition.headers),
9595
auth: definition.auth,
96-
bearerToken: definition.bearerToken,
96+
bearerToken: definition.bearerToken ?? (definition.bearerTokenEnv ? process.env[definition.bearerTokenEnv] : undefined),
9797
bearerTokenEnv: definition.bearerTokenEnv,
9898
exposeResources: definition.exposeResources,
9999
excludeTools: definition.excludeTools,

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "pi-mcp-adapter",
3-
"version": "2.5.2",
3+
"version": "2.5.3",
44
"description": "MCP (Model Context Protocol) adapter extension for Pi coding agent",
55
"type": "module",
66
"license": "MIT",

server-manager.ts

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { logger } from "./logger.js";
1717
import { McpOAuthProvider } from "./mcp-oauth-provider.js";
1818
import { supportsOAuth } from "./mcp-auth-flow.js";
1919
import { registerSamplingHandler, type ServerSamplingConfig } from "./sampling-handler.js";
20+
import { interpolateEnvRecord, resolveConfigPath } from "./utils.js";
2021

2122
interface ServerConnection {
2223
client: Client;
@@ -91,7 +92,7 @@ export class McpServerManager {
9192
command,
9293
args,
9394
env: resolveEnv(definition.env),
94-
cwd: definition.cwd,
95+
cwd: resolveConfigPath(definition.cwd),
9596
stderr: definition.debug ? "inherit" : "ignore",
9697
});
9798
} else if (definition.url) {
@@ -362,28 +363,14 @@ function resolveEnv(env?: Record<string, string>): Record<string, string> {
362363
}
363364

364365
if (!env) return resolved;
365-
366-
for (const [key, value] of Object.entries(env)) {
367-
// Support ${VAR} and $env:VAR interpolation
368-
resolved[key] = value
369-
.replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] ?? "")
370-
.replace(/\$env:(\w+)/g, (_, name) => process.env[name] ?? "");
371-
}
372-
373-
return resolved;
366+
367+
const overrides = interpolateEnvRecord(env);
368+
return overrides ? { ...resolved, ...overrides } : resolved;
374369
}
375370

376371
/**
377372
* Resolve headers with environment variable interpolation.
378373
*/
379374
function resolveHeaders(headers?: Record<string, string>): Record<string, string> | undefined {
380-
if (!headers) return undefined;
381-
382-
const resolved: Record<string, string> = {};
383-
for (const [key, value] of Object.entries(headers)) {
384-
resolved[key] = value
385-
.replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] ?? "")
386-
.replace(/\$env:(\w+)/g, (_, name) => process.env[name] ?? "");
387-
}
388-
return resolved;
375+
return interpolateEnvRecord(headers);
389376
}

utils.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2-
import { platform } from "node:os";
2+
import { homedir, platform } from "node:os";
3+
import { join } from "node:path";
34
import type { McpConfig } from "./types.js";
45

56
async function execOpen(pi: ExtensionAPI, target: string, browser?: string) {
@@ -58,6 +59,33 @@ export function getConfigPathFromArgv(): string | undefined {
5859
return undefined;
5960
}
6061

62+
export function interpolateEnvVars(value: string): string {
63+
return value
64+
.replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] ?? "")
65+
.replace(/\$env:(\w+)/g, (_, name) => process.env[name] ?? "");
66+
}
67+
68+
export function interpolateEnvRecord(values: Record<string, string> | undefined): Record<string, string> | undefined {
69+
if (!values) return undefined;
70+
71+
const resolved: Record<string, string> = {};
72+
for (const [key, value] of Object.entries(values)) {
73+
resolved[key] = interpolateEnvVars(value);
74+
}
75+
return resolved;
76+
}
77+
78+
export function resolveConfigPath(value: string | undefined): string | undefined {
79+
if (value === undefined) return undefined;
80+
81+
const resolved = interpolateEnvVars(value);
82+
if (resolved === "~") return homedir();
83+
if (resolved.startsWith("~/") || resolved.startsWith("~\\")) {
84+
return join(homedir(), resolved.slice(2));
85+
}
86+
return resolved;
87+
}
88+
6189
export function truncateAtWord(text: string, target: number): string {
6290
if (!text || text.length <= target) return text;
6391

0 commit comments

Comments
 (0)