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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,12 +241,12 @@ Already have MCP set up elsewhere? Import it:

```json
{
"imports": ["cursor", "claude-code", "claude-desktop"],
"imports": ["cursor", "claude-code", "claude-desktop", "opencode"],
"mcpServers": { }
}
```

Supported: `cursor`, `claude-code`, `claude-desktop`, `vscode`, `windsurf`, `codex`
Supported: `cursor`, `claude-code`, `claude-desktop`, `opencode`, `vscode`, `windsurf`, `codex`

### Project Config

Expand Down
101 changes: 101 additions & 0 deletions __tests__/config-imports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";

function writeJson(path: string, value: unknown) {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(value, null, 2), "utf-8");
}

describe("loadMcpConfig imports", () => {
let homeDir: string;
let cwd: string;
let previousCwd: string;

beforeEach(() => {
homeDir = mkdtempSync(join(tmpdir(), "pi-mcp-home-"));
cwd = mkdtempSync(join(tmpdir(), "pi-mcp-cwd-"));
previousCwd = process.cwd();
process.chdir(cwd);

vi.resetModules();
vi.doMock("node:os", async () => {
const actual = await vi.importActual<typeof import("node:os")>("node:os");
return {
...actual,
homedir: () => homeDir,
};
});
});

afterEach(() => {
process.chdir(previousCwd);
vi.doUnmock("node:os");
rmSync(homeDir, { recursive: true, force: true });
rmSync(cwd, { recursive: true, force: true });
});

it("imports opencode servers from ~/.config/opencode/opencode.json", async () => {
writeJson(join(homeDir, ".pi", "agent", "mcp.json"), {
imports: ["opencode"],
mcpServers: {
localOverride: {
command: "node",
args: ["server.js"],
},
},
});

writeJson(join(homeDir, ".config", "opencode", "opencode.json"), {
mcp: {
localServer: {
type: "local",
command: ["npx", "-y", "example-mcp"],
environment: {
API_KEY: "secret",
},
enabled: true,
},
remoteServer: {
type: "remote",
url: "https://example.com/mcp",
headers: {
Authorization: "Bearer token",
},
oauth: {
clientId: "client-id",
},
},
disabledServer: {
type: "local",
command: ["npx", "disabled-mcp"],
enabled: false,
},
},
});

const { loadMcpConfig } = await import("../config.ts");
const config = loadMcpConfig();

expect(config.mcpServers.localOverride).toEqual({
command: "node",
args: ["server.js"],
});
expect(config.mcpServers.localServer).toEqual({
command: "npx",
args: ["-y", "example-mcp"],
env: {
API_KEY: "secret",
},
});
expect(config.mcpServers.remoteServer).toEqual({
url: "https://example.com/mcp",
headers: {
Authorization: "Bearer token",
},
auth: "oauth",
});
expect(config.mcpServers.disabledServer).toBeUndefined();
});
});
121 changes: 121 additions & 0 deletions __tests__/env-interpolation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { resolveEnv, resolveHeaders, toStringRecord } from "../utils.ts";

describe("toStringRecord", () => {
it("returns undefined for null/undefined/non-object", () => {
expect(toStringRecord(null)).toBeUndefined();
expect(toStringRecord(undefined)).toBeUndefined();
expect(toStringRecord("string")).toBeUndefined();
expect(toStringRecord(42)).toBeUndefined();
expect(toStringRecord([1, 2])).toBeUndefined();
});

it("extracts string values and drops non-strings", () => {
expect(toStringRecord({ a: "hello", b: 42, c: true, d: "world" }))
.toEqual({ a: "hello", d: "world" });
});

it("returns undefined for objects with no string values", () => {
expect(toStringRecord({ a: 42, b: true })).toBeUndefined();
});

it("returns undefined for empty objects", () => {
expect(toStringRecord({})).toBeUndefined();
});

it("returns all entries when all values are strings", () => {
expect(toStringRecord({ KEY: "val", OTHER: "val2" }))
.toEqual({ KEY: "val", OTHER: "val2" });
});
});

describe("resolveEnv", () => {
beforeEach(() => {
process.env.TEST_VAR = "test-value";
process.env.API_KEY = "sk-123";
});

afterEach(() => {
delete process.env.TEST_VAR;
delete process.env.API_KEY;
});

it("returns process.env when no custom env provided", () => {
const result = resolveEnv();
expect(result.TEST_VAR).toBe("test-value");
});

it("interpolates ${VAR} syntax", () => {
const result = resolveEnv({ MY_KEY: "${TEST_VAR}" });
expect(result.MY_KEY).toBe("test-value");
});

it("interpolates $env:VAR syntax", () => {
const result = resolveEnv({ MY_KEY: "$env:TEST_VAR" });
expect(result.MY_KEY).toBe("test-value");
});

it("interpolates {env:VAR} OpenCode syntax", () => {
const result = resolveEnv({ MY_KEY: "{env:TEST_VAR}" });
expect(result.MY_KEY).toBe("test-value");
});

it("interpolates {env:VAR} within a string", () => {
const result = resolveEnv({ AUTH: "Bearer {env:API_KEY}" });
expect(result.AUTH).toBe("Bearer sk-123");
});

it("resolves missing vars to empty string", () => {
const result = resolveEnv({ MY_KEY: "{env:NONEXISTENT}" });
expect(result.MY_KEY).toBe("");
});

it("handles multiple interpolations in one value", () => {
const result = resolveEnv({ COMBINED: "{env:TEST_VAR}-${API_KEY}" });
expect(result.COMBINED).toBe("test-value-sk-123");
});

it("passes through plain values unchanged", () => {
const result = resolveEnv({ PLAIN: "literal-value" });
expect(result.PLAIN).toBe("literal-value");
});
});

describe("resolveHeaders", () => {
beforeEach(() => {
process.env.MY_TOKEN = "tok-abc";
});

afterEach(() => {
delete process.env.MY_TOKEN;
});

it("returns undefined for undefined input", () => {
expect(resolveHeaders(undefined)).toBeUndefined();
});

it("interpolates ${VAR} syntax", () => {
const result = resolveHeaders({ Authorization: "Bearer ${MY_TOKEN}" });
expect(result).toEqual({ Authorization: "Bearer tok-abc" });
});

it("interpolates $env:VAR syntax", () => {
const result = resolveHeaders({ Authorization: "Bearer $env:MY_TOKEN" });
expect(result).toEqual({ Authorization: "Bearer tok-abc" });
});

it("interpolates {env:VAR} OpenCode syntax", () => {
const result = resolveHeaders({ Authorization: "Bearer {env:MY_TOKEN}" });
expect(result).toEqual({ Authorization: "Bearer tok-abc" });
});

it("resolves missing vars to empty string", () => {
const result = resolveHeaders({ "X-Key": "{env:MISSING}" });
expect(result).toEqual({ "X-Key": "" });
});

it("passes through plain headers unchanged", () => {
const result = resolveHeaders({ "Content-Type": "application/json" });
expect(result).toEqual({ "Content-Type": "application/json" });
});
});
39 changes: 39 additions & 0 deletions config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "
import { homedir } from "node:os";
import { join, resolve, dirname } from "node:path";
import type { McpConfig, ServerEntry, McpSettings, ImportKind, ServerProvenance } from "./types.js";
import { toStringRecord } from "./utils.js";

const DEFAULT_CONFIG_PATH = join(homedir(), ".pi", "agent", "mcp.json");
const PROJECT_CONFIG_NAME = ".pi/mcp.json";
Expand All @@ -13,6 +14,7 @@ const IMPORT_PATHS: Record<ImportKind, string> = {
"claude-code": join(homedir(), ".claude", "claude_desktop_config.json"),
"claude-desktop": join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json"),
"codex": join(homedir(), ".codex", "config.json"),
"opencode": join(homedir(), ".config", "opencode", "opencode.json"),
"windsurf": join(homedir(), ".windsurf", "mcp.json"),
"vscode": ".vscode/mcp.json", // Relative to project
};
Expand Down Expand Up @@ -117,6 +119,8 @@ function extractServers(config: unknown, kind: ImportKind): Record<string, Serve
case "vscode":
servers = obj.mcpServers ?? obj["mcp-servers"];
break;
case "opencode":
return extractOpencodeServers(obj.mcp);
default:
return {};
}
Expand All @@ -128,6 +132,41 @@ function extractServers(config: unknown, kind: ImportKind): Record<string, Serve
return servers as Record<string, ServerEntry>;
}

function extractOpencodeServers(rawMcp: unknown): Record<string, ServerEntry> {
if (!rawMcp || typeof rawMcp !== "object" || Array.isArray(rawMcp)) {
return {};
}

const result: Record<string, ServerEntry> = {};

for (const [name, entry] of Object.entries(rawMcp as Record<string, unknown>)) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;

const server = entry as Record<string, unknown>;
if (server.enabled === false) continue;

if (server.type === "local" && Array.isArray(server.command) && typeof server.command[0] === "string") {
const command = server.command.filter((value): value is string => typeof value === "string");
result[name] = {
command: command[0],
args: command.slice(1),
env: toStringRecord(server.environment),
};
continue;
}

if (server.type === "remote" && typeof server.url === "string") {
result[name] = {
url: server.url,
headers: toStringRecord(server.headers),
auth: server.oauth && typeof server.oauth === "object" ? "oauth" : undefined,
};
}
}

return result;
}

export function getServerProvenance(overridePath?: string): Map<string, ServerProvenance> {
const provenance = new Map<string, ServerProvenance>();
const userPath = overridePath ? resolve(overridePath) : DEFAULT_CONFIG_PATH;
Expand Down
40 changes: 1 addition & 39 deletions server-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
import { serverStreamResultPatchNotificationSchema } from "./types.js";
import { getStoredTokens } from "./oauth-handler.js";
import { resolveNpxBinary } from "./npx-resolver.js";
import { resolveEnv, resolveHeaders } from "./utils.js";
import { logger } from "./logger.js";

interface ServerConnection {
Expand Down Expand Up @@ -289,42 +290,3 @@ export class McpServerManager {
return (Date.now() - connection.lastUsedAt) > timeoutMs;
}
}

/**
* Resolve environment variables with interpolation.
*/
function resolveEnv(env?: Record<string, string>): Record<string, string> {
// Copy process.env, filtering out undefined values
const resolved: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined) {
resolved[key] = value;
}
}

if (!env) return resolved;

for (const [key, value] of Object.entries(env)) {
// Support ${VAR} and $env:VAR interpolation
resolved[key] = value
.replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] ?? "")
.replace(/\$env:(\w+)/g, (_, name) => process.env[name] ?? "");
}

return resolved;
}

/**
* Resolve headers with environment variable interpolation.
*/
function resolveHeaders(headers?: Record<string, string>): Record<string, string> | undefined {
if (!headers) return undefined;

const resolved: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
resolved[key] = value
.replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] ?? "")
.replace(/\$env:(\w+)/g, (_, name) => process.env[name] ?? "");
}
return resolved;
}
1 change: 1 addition & 0 deletions types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type ImportKind =
| "claude-code"
| "claude-desktop"
| "codex"
| "opencode"
| "windsurf"
| "vscode";

Expand Down
Loading