@pi-archimedes/mcp 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -0
  3. package/package.json +39 -0
  4. package/src/auth-flow.test.ts +583 -0
  5. package/src/auth-flow.ts +310 -0
  6. package/src/auth-run.test.ts +309 -0
  7. package/src/auth-run.ts +146 -0
  8. package/src/auth-storage.test.ts +338 -0
  9. package/src/auth-storage.ts +330 -0
  10. package/src/auto-auth.test.ts +231 -0
  11. package/src/auto-auth.ts +135 -0
  12. package/src/callback-server.test.ts +446 -0
  13. package/src/callback-server.ts +538 -0
  14. package/src/commands-auth.test.ts +320 -0
  15. package/src/commands-auth.ts +128 -0
  16. package/src/commands.test.ts +834 -0
  17. package/src/commands.ts +424 -0
  18. package/src/config-write.test.ts +213 -0
  19. package/src/config-write.ts +207 -0
  20. package/src/config.test.ts +468 -0
  21. package/src/config.ts +278 -0
  22. package/src/direct-tools.test.ts +473 -0
  23. package/src/direct-tools.ts +250 -0
  24. package/src/host-configs.test.ts +231 -0
  25. package/src/host-configs.ts +106 -0
  26. package/src/index.test.ts +689 -0
  27. package/src/index.ts +146 -0
  28. package/src/lifecycle.test.ts +274 -0
  29. package/src/lifecycle.ts +77 -0
  30. package/src/metadata-cache.test.ts +383 -0
  31. package/src/metadata-cache.ts +231 -0
  32. package/src/npx-resolver.test.ts +142 -0
  33. package/src/npx-resolver.ts +126 -0
  34. package/src/oauth-provider.test.ts +404 -0
  35. package/src/oauth-provider.ts +197 -0
  36. package/src/oauth-types.ts +54 -0
  37. package/src/panel-rows.ts +210 -0
  38. package/src/panel.test.ts +298 -0
  39. package/src/panel.ts +742 -0
  40. package/src/proxy-tool.ts +524 -0
  41. package/src/renderer.test.ts +326 -0
  42. package/src/renderer.ts +239 -0
  43. package/src/schema-validator.test.ts +56 -0
  44. package/src/schema-validator.ts +42 -0
  45. package/src/server-client.test.ts +1001 -0
  46. package/src/server-client.ts +576 -0
  47. package/src/server-manager.ts +139 -0
  48. package/src/setup-panel.test.ts +162 -0
  49. package/src/setup-panel.ts +715 -0
  50. package/src/tool-naming.test.ts +168 -0
  51. package/src/tool-naming.ts +114 -0
  52. package/src/types.ts +162 -0
@@ -0,0 +1,250 @@
1
+ import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { BUILTIN_NAMES, findFormattingCollisions, formatToolName } from "./tool-naming.js";
4
+ import { autoAuthenticate, needsAuthToolResult } from "./auto-auth.js";
5
+ import type { CachedTool, ToolPrefix } from "./types.js";
6
+ import type { ServerClient } from "./server-client.js";
7
+ import { renderDirectCall, renderDirectResult, type RenderContext } from "./renderer.js";
8
+
9
+ /**
10
+ * Track which final prefixed tool names have been registered, at module
11
+ * level, keyed by SERVER name. Registration is cache-driven (no live client
12
+ * to key on anymore), and pi's tool registry cannot accept the same name
13
+ * twice. Each registration pass REPLACES the server's set with the names
14
+ * claimed in that pass, so stale entries from shrunken tool lists are
15
+ * evicted and a later session_start can re-register (pi can never
16
+ * unregister; the module-level set only gates re-registration). Entries for
17
+ * servers that are no longer configured are pruned by
18
+ * pruneRegisteredNames() at session_start — without that, a removed server's
19
+ * claimed names would block a surviving server from claiming the same final
20
+ * name (e.g. under toolPrefix "none"). Within one process the dedup check
21
+ * still spans ALL servers' sets, so two servers that would format to the
22
+ * same final name (e.g. identical raw names under toolPrefix "none") never
23
+ * double-register. The module is re-imported by the extension loader on
24
+ * /reload, which resets this map for the fresh pi registry.
25
+ */
26
+ const registeredNames = new Map<string, Set<string>>();
27
+
28
+ /**
29
+ * Per-server formatted-name collisions already warned about (module-level so
30
+ * repeated registration passes don't re-warn for the same collision). Key:
31
+ * serverName + finalName + raw names. Reset by clearRegisteredForTest.
32
+ */
33
+ const warnedCollisions = new Set<string>();
34
+
35
+ /** Test-only: reset the module-level registration state. */
36
+ export function clearRegisteredForTest(): void {
37
+ registeredNames.clear();
38
+ warnedCollisions.clear();
39
+ }
40
+
41
+ /** Test-only: snapshot of the per-server registered (prefixed) names. */
42
+ export function getRegisteredNamesForTest(): ReadonlyMap<string, ReadonlySet<string>> {
43
+ return registeredNames;
44
+ }
45
+
46
+ /** True when ANY server has already claimed this final prefixed name */
47
+ function isClaimed(name: string): boolean {
48
+ for (const set of registeredNames.values()) {
49
+ if (set.has(name)) return true;
50
+ }
51
+ return false;
52
+ }
53
+
54
+ /**
55
+ * Drop tracked entries for servers that are no longer configured, freeing
56
+ * their claimed final names for re-registration by surviving servers
57
+ * (e.g. a removed server that claimed "foo" under toolPrefix "none" must
58
+ * not block a remaining server that exposes a raw tool named "foo").
59
+ * Call at session_start with the set of currently configured server names,
60
+ * before the registration pass.
61
+ */
62
+ export function pruneRegisteredNames(activeServerNames: ReadonlySet<string>): void {
63
+ for (const name of [...registeredNames.keys()]) {
64
+ if (!activeServerNames.has(name)) registeredNames.delete(name);
65
+ }
66
+ }
67
+
68
+ /** Per-server direct-tool filtering knobs (subset of EffectiveServerSettings) */
69
+ export interface DirectToolFilter {
70
+ /** true = all, string[] = subset of raw tool names */
71
+ directTools: boolean | string[];
72
+ /** Only expose these tools (whitelist) */
73
+ includeTools?: string[];
74
+ /** Never expose these tools (blacklist) */
75
+ excludeTools?: string[];
76
+ }
77
+
78
+ /**
79
+ * Apply per-server direct-tool filtering to a tool list:
80
+ * directTools (true → passthrough, string[] → subset), then includeTools
81
+ * (intersect), then excludeTools (subtract).
82
+ */
83
+ export function filterDirectTools(
84
+ tools: CachedTool[],
85
+ filter: DirectToolFilter,
86
+ ): CachedTool[] {
87
+ if (filter.directTools === false) return [];
88
+ let out = tools;
89
+ if (Array.isArray(filter.directTools)) {
90
+ const subset = new Set(filter.directTools);
91
+ out = out.filter((t) => subset.has(t.name));
92
+ }
93
+ if (filter.includeTools !== undefined) {
94
+ const include = new Set(filter.includeTools);
95
+ out = out.filter((t) => include.has(t.name));
96
+ }
97
+ if (filter.excludeTools !== undefined) {
98
+ const exclude = new Set(filter.excludeTools);
99
+ out = out.filter((t) => !exclude.has(t.name));
100
+ }
101
+ return out;
102
+ }
103
+
104
+ export interface RegisterDirectToolsOptions {
105
+ serverName: string;
106
+ /** Tool-name prefix strategy already resolved over the global config */
107
+ prefix: ToolPrefix;
108
+ /** Tools to register (raw server tool names, from cache or discovery) */
109
+ tools: CachedTool[];
110
+ /**
111
+ * Whether a needs-auth server auto-triggers interactive OAuth at call time.
112
+ * Read at CALL time (fresh config), not registration time — mirrors the
113
+ * proxy call action.
114
+ */
115
+ autoAuth?: () => boolean;
116
+ /** Lazily resolve (and connect, if needed) the owning client at call time */
117
+ resolveClient: (serverName: string) => Promise<ServerClient>;
118
+ }
119
+
120
+ /**
121
+ * Register a server's tools as individual pi tools.
122
+ * Skips tools whose final prefixed name was already claimed (by this server
123
+ * in an earlier pass OR by any other server) — the returned list includes
124
+ * those skipped names, so callers see the full intended set.
125
+ * Skips tools whose final prefixed name would shadow a pi builtin (e.g. a bare
126
+ * "read" under toolPrefix "none") — warns instead so the collision is visible.
127
+ * Warns once per distinct collision when TWO raw tools of this server format
128
+ * to the same final name (e.g. "a.b" and "a_b") — an inherent, non-injective
129
+ * ambiguity of the name format, not a bug in registration.
130
+ * The executor resolves the live client LAZILY at call time via
131
+ * options.resolveClient — no client is connected at registration time.
132
+ * After the pass, the server's tracked set is REPLACED by the names claimed
133
+ * in this pass (evicting stale entries from removed servers / shrunken lists).
134
+ * Returns the list of (claimed) prefixed tool names so they can be tracked.
135
+ */
136
+ export function registerDirectTools(
137
+ pi: ExtensionAPI,
138
+ options: RegisterDirectToolsOptions,
139
+ ): string[] {
140
+ const { serverName, prefix, tools, resolveClient, autoAuth = () => false } = options;
141
+ const registered: string[] = [];
142
+
143
+ // Surface ambiguous raw tool names: formatToolName is not injective, so
144
+ // e.g. "a.b" and "a_b" both format to the same final name and name
145
+ // resolution would be first-match-wins. Warn once per distinct collision.
146
+ for (const { finalName, rawNames } of findFormattingCollisions(serverName, prefix, tools)) {
147
+ const key = `${serverName}\u0000${finalName}\u0000${rawNames.join(",")}`;
148
+ if (warnedCollisions.has(key)) continue;
149
+ warnedCollisions.add(key);
150
+ console.warn(
151
+ `[mcp] server "${serverName}": tools ${rawNames.map((n) => `"${n}"`).join(" and ")} both format to "${finalName}" — name resolution is ambiguous (first match wins)`,
152
+ );
153
+ }
154
+
155
+ // The names this pass INTENDS to own (non-builtin, formatted). After the
156
+ // pass, this becomes the server's tracked set — evicting stale entries
157
+ // from earlier passes.
158
+ const intendedThisPass = new Set<string>();
159
+
160
+ for (const tool of tools) {
161
+ const prefixedName = formatToolName(tool.name, serverName, prefix);
162
+
163
+ // Never shadow a pi builtin tool name (matters mainly for toolPrefix "none")
164
+ if (BUILTIN_NAMES.has(prefixedName)) {
165
+ console.warn(
166
+ `[mcp] skipping tool "${tool.name}" from server "${serverName}": "${prefixedName}" collides with a built-in tool name`,
167
+ );
168
+ continue;
169
+ }
170
+ intendedThisPass.add(prefixedName);
171
+
172
+ // Skip registration if this final prefixed name was already claimed — by
173
+ // this server in an earlier pass OR by another server (guards against
174
+ // repeated session_start / probe passes and cross-server collisions)
175
+ if (isClaimed(prefixedName)) {
176
+ registered.push(prefixedName);
177
+ continue;
178
+ }
179
+
180
+ // Accept any object — we let the MCP server validate args against its own schema
181
+ const parameters = Type.Object({}, { additionalProperties: true });
182
+
183
+ pi.registerTool({
184
+ name: prefixedName,
185
+ label: `MCP: ${tool.name}`,
186
+ description: `[${serverName}] ${tool.description ?? "(no description)"}`,
187
+ parameters,
188
+
189
+ renderCall(args: unknown, theme: Theme, context: unknown) {
190
+ const typedContext = context as RenderContext;
191
+ return renderDirectCall(
192
+ prefixedName,
193
+ args as Record<string, unknown>,
194
+ theme,
195
+ typedContext,
196
+ );
197
+ },
198
+
199
+ renderResult(result: unknown, options: unknown, theme: Theme, context: unknown) {
200
+ const typedResult = result as {
201
+ content: Array<{ type: string; text?: string }>;
202
+ details?: Record<string, unknown>;
203
+ };
204
+ const typedOptions = options as { expanded?: boolean; isPartial?: boolean };
205
+ const typedContext = context as RenderContext;
206
+ return renderDirectResult(prefixedName, typedResult, typedOptions, theme, typedContext);
207
+ },
208
+
209
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
210
+ const args = params as Record<string, unknown>;
211
+ // Lazy connect: resolve (and connect, if needed) the owning client NOW,
212
+ // not at registration time.
213
+ const client = await resolveClient(serverName);
214
+ // needs-auth at call time: guidance by default, inline auto-auth +
215
+ // one retry when enabled (mirrors the proxy call action).
216
+ if (client.status === "needs-auth") {
217
+ if (!autoAuth()) {
218
+ return needsAuthToolResult(serverName);
219
+ }
220
+ const outcome = await autoAuthenticate(ctx, client);
221
+ if (!outcome.proceed) {
222
+ return needsAuthToolResult(serverName, outcome.error);
223
+ }
224
+ }
225
+ // The call below is the (single) retry after a successful auto-auth
226
+ const result = await client.callTool(tool.name, args, signal);
227
+ // Cast MCP ContentBlock[] to pi's (TextContent | ImageContent)[]
228
+ // Both are discriminated unions on `type`; we only surface text + image blocks
229
+ const content = result.content as Array<
230
+ | { type: "text"; text: string }
231
+ | { type: "image"; data: string; mimeType: string }
232
+ >;
233
+ return {
234
+ content,
235
+ details: { server: client.name, tool: tool.name },
236
+ isError: result.isError,
237
+ };
238
+ },
239
+ });
240
+
241
+ registered.push(prefixedName);
242
+ }
243
+
244
+ // Replace this server's tracked set with the names intended in this pass,
245
+ // evicting stale entries from earlier (larger) passes so a later
246
+ // session_start can re-register shrunken/changed tool lists.
247
+ registeredNames.set(serverName, intendedThisPass);
248
+
249
+ return registered;
250
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Tests for host-config discovery (`/mcp setup` → "Import from another tool",
3
+ * plan-027, Task 4).
4
+ *
5
+ * HOME is pointed at a temp dir by setting `process.env.HOME` (node's
6
+ * `os.homedir()` honours it on POSIX) and restored afterwards; the project
7
+ * `cwd` is a separate temp dir. Every case is JSON-only discovery — no
8
+ * network, no real HOME, no real project files.
9
+ *
10
+ * Coverage:
11
+ * - finds a `~/.cursor/mcp.json` config AND a `<cwd>/.vscode/mcp.json`
12
+ * config (VSCode's key is top-level `servers`, NOT `mcpServers`) with
13
+ * the correct `agent` labels and extracted server records
14
+ * - `~/.claude.json` — only the `mcpServers` key is taken, other top-level
15
+ * keys (the file is a general Claude state file) are ignored
16
+ * - returns [] when no candidate paths exist
17
+ * - ignores malformed JSON (no throw) and nonexistent paths
18
+ * - tolerates `//` comments via stripJsonComments
19
+ * - NEVER returns configs under `<cwd>/.pi/` (pi's own layer — self-import
20
+ * loop) or at `~/.config/mcp/mcp.json` (pi's global layer)
21
+ */
22
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
23
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
24
+ import { tmpdir } from "node:os";
25
+ import { join } from "node:path";
26
+ import { discoverHostConfigs } from "./host-configs.js";
27
+
28
+ let home: string;
29
+ let cwd: string;
30
+ const previousHome = process.env.HOME;
31
+
32
+ beforeEach(() => {
33
+ home = mkdtempSync(join(tmpdir(), "mcp-hostcfg-home-"));
34
+ cwd = mkdtempSync(join(tmpdir(), "mcp-hostcfg-cwd-"));
35
+ process.env.HOME = home;
36
+ });
37
+
38
+ afterEach(() => {
39
+ process.env.HOME = previousHome;
40
+ rmSync(home, { recursive: true, force: true });
41
+ rmSync(cwd, { recursive: true, force: true });
42
+ });
43
+
44
+ function writeJson(path: string, content: string): void {
45
+ mkdirSync(join(path, ".."), { recursive: true });
46
+ writeFileSync(path, content, "utf-8");
47
+ }
48
+
49
+ // ── discovery ────────────────────────────────────────────────────────────────
50
+
51
+ describe("discoverHostConfigs", () => {
52
+ it("finds a cursor config in HOME and a vscode config in cwd, with labels", () => {
53
+ writeJson(join(home, ".cursor", "mcp.json"), JSON.stringify({
54
+ mcpServers: { alpha: { url: "https://alpha.example/mcp" } },
55
+ }));
56
+ writeJson(join(cwd, ".vscode", "mcp.json"), JSON.stringify({
57
+ // VSCode uses the top-level "servers" key (per VSCode docs) — NOT "mcpServers".
58
+ servers: { beta: { command: "npx", args: ["-y", "beta-mcp"] } },
59
+ }));
60
+
61
+ const found = discoverHostConfigs(cwd);
62
+
63
+ expect(found).toHaveLength(2);
64
+ const [first, second] = found;
65
+ expect(first).toEqual({
66
+ agent: "cursor",
67
+ path: join(home, ".cursor", "mcp.json"),
68
+ servers: { alpha: { url: "https://alpha.example/mcp" } },
69
+ });
70
+ expect(second).toEqual({
71
+ agent: "vscode",
72
+ path: join(cwd, ".vscode", "mcp.json"),
73
+ servers: { beta: { command: "npx", args: ["-y", "beta-mcp"] } },
74
+ });
75
+ });
76
+
77
+ it("labels project-scoped cursor configs (<cwd>/.cursor/mcp.json) as cursor too", () => {
78
+ writeJson(join(cwd, ".cursor", "mcp.json"), JSON.stringify({
79
+ mcpServers: { proj: { command: "uvx", args: ["proj-mcp"] } },
80
+ }));
81
+
82
+ const found = discoverHostConfigs(cwd);
83
+
84
+ expect(found).toHaveLength(1);
85
+ expect(found[0]).toEqual({
86
+ agent: "cursor",
87
+ path: join(cwd, ".cursor", "mcp.json"),
88
+ servers: { proj: { command: "uvx", args: ["proj-mcp"] } },
89
+ });
90
+ });
91
+
92
+ it("takes only mcpServers from ~/.claude.json (other top-level keys ignored)", () => {
93
+ writeJson(join(home, ".claude.json"), JSON.stringify({
94
+ version: 1,
95
+ userID: "some-user",
96
+ mcpServers: { gamma: { command: "uvx", args: ["gamma-mcp"] } },
97
+ }));
98
+
99
+ const found = discoverHostConfigs(cwd);
100
+
101
+ expect(found).toHaveLength(1);
102
+ expect(found[0]?.agent).toBe("claude-code");
103
+ expect(found[0]?.path).toBe(join(home, ".claude.json"));
104
+ expect(found[0]?.servers).toEqual({ gamma: { command: "uvx", args: ["gamma-mcp"] } });
105
+ });
106
+
107
+ it("labels ~/.claude/mcp.json as claude-code", () => {
108
+ writeJson(join(home, ".claude", "mcp.json"), JSON.stringify({
109
+ mcpServers: { delta: { url: "https://delta.example/mcp" } },
110
+ }));
111
+
112
+ const found = discoverHostConfigs(cwd);
113
+
114
+ expect(found).toHaveLength(1);
115
+ expect(found[0]?.agent).toBe("claude-code");
116
+ expect(found[0]?.path).toBe(join(home, ".claude", "mcp.json"));
117
+ });
118
+
119
+ it("labels ~/.claude/claude_desktop_config.json as claude-desktop", () => {
120
+ writeJson(join(home, ".claude", "claude_desktop_config.json"), JSON.stringify({
121
+ mcpServers: { desktop: { command: "npx", args: ["-y", "desktop-mcp"] } },
122
+ }));
123
+
124
+ const found = discoverHostConfigs(cwd);
125
+
126
+ expect(found).toHaveLength(1);
127
+ expect(found[0]?.agent).toBe("claude-desktop");
128
+ expect(found[0]?.path).toBe(join(home, ".claude", "claude_desktop_config.json"));
129
+ });
130
+
131
+ it("returns results in the documented stable order", () => {
132
+ writeJson(join(home, ".claude", "claude_desktop_config.json"), JSON.stringify({ mcpServers: { d: { url: "https://d.example" } } }));
133
+ writeJson(join(cwd, ".vscode", "mcp.json"), JSON.stringify({ servers: { v: { url: "https://v.example" } } }));
134
+ writeJson(join(home, ".claude", "mcp.json"), JSON.stringify({ mcpServers: { c: { url: "https://c.example" } } }));
135
+ writeJson(join(home, ".claude.json"), JSON.stringify({ mcpServers: { cc: { url: "https://cc.example" } } }));
136
+ writeJson(join(cwd, ".cursor", "mcp.json"), JSON.stringify({ mcpServers: { cp: { url: "https://cp.example" } } }));
137
+ writeJson(join(home, ".cursor", "mcp.json"), JSON.stringify({ mcpServers: { ch: { url: "https://ch.example" } } }));
138
+
139
+ const found = discoverHostConfigs(cwd);
140
+
141
+ expect(found.map((f) => f.path)).toEqual([
142
+ join(home, ".cursor", "mcp.json"), // cursor (home)
143
+ join(cwd, ".cursor", "mcp.json"), // cursor (project)
144
+ join(home, ".claude", "mcp.json"), // claude-code (dir)
145
+ join(home, ".claude.json"), // claude-code (file)
146
+ join(home, ".claude", "claude_desktop_config.json"), // claude-desktop
147
+ join(cwd, ".vscode", "mcp.json"), // vscode (project)
148
+ ]);
149
+ });
150
+ });
151
+
152
+ // ── tolerance ────────────────────────────────────────────────────────────────
153
+
154
+ describe("tolerance", () => {
155
+ it("returns [] when nothing exists", () => {
156
+ expect(discoverHostConfigs(cwd)).toEqual([]);
157
+ });
158
+
159
+ it("ignores nonexistent candidate paths without throwing", () => {
160
+ mkdirSync(join(home, ".cursor"), { recursive: true }); // empty dir, no mcp.json
161
+ mkdirSync(join(cwd, ".vscode"), { recursive: true });
162
+ expect(() => discoverHostConfigs(cwd)).not.toThrow();
163
+ expect(discoverHostConfigs(cwd)).toEqual([]);
164
+ });
165
+
166
+ it("ignores malformed JSON (no throw) and keeps the valid configs", () => {
167
+ writeJson(join(home, ".cursor", "mcp.json"), "{ this is not json !!");
168
+ writeJson(join(cwd, ".vscode", "mcp.json"), JSON.stringify({
169
+ servers: { beta: { command: "npx", args: ["-y", "beta-mcp"] } },
170
+ }));
171
+
172
+ expect(() => discoverHostConfigs(cwd)).not.toThrow();
173
+ const found = discoverHostConfigs(cwd);
174
+ expect(found).toHaveLength(1);
175
+ expect(found[0]?.agent).toBe("vscode");
176
+ });
177
+
178
+ it("skips a file whose top level is not an object", () => {
179
+ writeJson(join(home, ".cursor", "mcp.json"), JSON.stringify(["not", "an", "object"]));
180
+ expect(discoverHostConfigs(cwd)).toEqual([]);
181
+ });
182
+
183
+ it("skips a file whose mcpServers key is not an object", () => {
184
+ writeJson(join(home, ".cursor", "mcp.json"), JSON.stringify({ mcpServers: "nope" }));
185
+ writeJson(join(cwd, ".vscode", "mcp.json"), JSON.stringify({ servers: "nope" }));
186
+ expect(discoverHostConfigs(cwd)).toEqual([]);
187
+ });
188
+
189
+ it("skips non-object server entries but keeps the valid ones", () => {
190
+ writeJson(join(home, ".cursor", "mcp.json"), JSON.stringify({
191
+ mcpServers: { good: { url: "https://good.example" }, bad: "scalar" },
192
+ }));
193
+ const found = discoverHostConfigs(cwd);
194
+ expect(found[0]?.servers).toEqual({ good: { url: "https://good.example" } });
195
+ });
196
+
197
+ it("tolerates // comments in host config files", () => {
198
+ writeJson(join(home, ".cursor", "mcp.json"), [
199
+ "// cursor project mcp",
200
+ '{"mcpServers": {"alpha": {"url": "https://alpha.example/mcp"}},}',
201
+ ].join("\n"));
202
+
203
+ const found = discoverHostConfigs(cwd);
204
+ expect(found[0]?.servers).toEqual({ alpha: { url: "https://alpha.example/mcp" } });
205
+ });
206
+ });
207
+
208
+ // ── exclusions ───────────────────────────────────────────────────────────────
209
+
210
+ describe("exclusions", () => {
211
+ it("never returns pi-owned layers: <cwd>/.pi/… and ~/.config/mcp/…", () => {
212
+ writeJson(join(cwd, ".pi", "mcp.json"), JSON.stringify({
213
+ mcpServers: { piOverride: { url: "https://pi.example" } },
214
+ }));
215
+ writeJson(join(home, ".config", "mcp", "mcp.json"), JSON.stringify({
216
+ mcpServers: { piGlobal: { url: "https://pi-global.example" } },
217
+ }));
218
+ writeJson(join(home, ".cursor", "mcp.json"), JSON.stringify({
219
+ mcpServers: { alpha: { url: "https://alpha.example/mcp" } },
220
+ }));
221
+
222
+ const found = discoverHostConfigs(cwd);
223
+
224
+ expect(found).toHaveLength(1);
225
+ expect(found[0]?.agent).toBe("cursor");
226
+ for (const f of found) {
227
+ expect(f.path.startsWith(join(cwd, ".pi"))).toBe(false);
228
+ expect(f.path === join(home, ".config", "mcp", "mcp.json")).toBe(false);
229
+ }
230
+ });
231
+ });
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Discovery of MCP server definitions owned by OTHER tools, for the
3
+ * `/mcp setup` → "Import from another tool" flow (plan-027, Task 4).
4
+ *
5
+ * JSON-only (Codex/TOML is intentionally deferred). Candidate paths, in the
6
+ * DOCUMENTED STABLE ORDER returned by `discoverHostConfigs`:
7
+ *
8
+ * 1. cursor ~/.cursor/mcp.json key `mcpServers`
9
+ * 2. cursor <cwd>/.cursor/mcp.json key `mcpServers`
10
+ * 3. claude-code ~/.claude/mcp.json key `mcpServers`
11
+ * 4. claude-code ~/.claude.json key `mcpServers`
12
+ * 5. claude-desktop ~/.claude/claude_desktop_config.json key `mcpServers`
13
+ * 6. vscode <cwd>/.vscode/mcp.json key `servers`
14
+ *
15
+ * Notes:
16
+ * - `~/.claude.json` is Claude's general state file with many unrelated
17
+ * top-level keys — only its `mcpServers` key is ever taken.
18
+ * - VSCode's `.vscode/mcp.json` uses the top-level key `servers` (per
19
+ * VSCode docs), NOT `mcpServers` — do not "fix" this to mcpServers.
20
+ * - DELIBERATE EXCLUSIONS: `~/.config/mcp/mcp.json` (pi's own global layer)
21
+ * and anything under `<cwd>/.pi/` (pi's own override layer) are never
22
+ * candidate paths — importing from them would be a self-import loop. The
23
+ * candidate list below is the single source of truth, so exclusions are
24
+ * inherent; the test suite asserts them anyway.
25
+ * - Never throws: files that don't exist, don't parse, or have the wrong
26
+ * shape are skipped silently (they surface as "not found" in the panel).
27
+ */
28
+ import { existsSync, readFileSync } from "node:fs";
29
+ import { homedir } from "node:os";
30
+ import { join, sep } from "node:path";
31
+ import { stripJsonComments } from "./config.js";
32
+ import type { ServerDef } from "./types.js";
33
+
34
+ export type HostAgent = "cursor" | "claude-code" | "claude-desktop" | "vscode";
35
+
36
+ export interface HostConfig {
37
+ agent: HostAgent;
38
+ path: string;
39
+ servers: Record<string, ServerDef>;
40
+ }
41
+
42
+ interface Candidate {
43
+ agent: HostAgent;
44
+ /** The top-level JSON key holding the server definitions for this host. */
45
+ key: string;
46
+ pathFor: (home: string, cwd: string) => string;
47
+ }
48
+
49
+ /** The candidate list — also the documented stable order of the results. */
50
+ const CANDIDATES: Candidate[] = [
51
+ { agent: "cursor", key: "mcpServers", pathFor: (home) => join(home, ".cursor", "mcp.json") },
52
+ { agent: "cursor", key: "mcpServers", pathFor: (_home, cwd) => join(cwd, ".cursor", "mcp.json") },
53
+ { agent: "claude-code", key: "mcpServers", pathFor: (home) => join(home, ".claude", "mcp.json") },
54
+ { agent: "claude-code", key: "mcpServers", pathFor: (home) => join(home, ".claude.json") },
55
+ { agent: "claude-desktop", key: "mcpServers", pathFor: (home) => join(home, ".claude", "claude_desktop_config.json") },
56
+ { agent: "vscode", key: "servers", pathFor: (_home, cwd) => join(cwd, ".vscode", "mcp.json") },
57
+ ];
58
+
59
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
60
+ return typeof v === "object" && v !== null && !Array.isArray(v);
61
+ }
62
+
63
+ /**
64
+ * Parse one candidate file into a server record, or null when the file is
65
+ * missing, unparseable, or has the wrong shape (never throws). Server
66
+ * entries that are not objects are dropped; the file still counts as found
67
+ * when its key is a valid object.
68
+ */
69
+ function readHostServers(path: string, key: string): Record<string, ServerDef> | null {
70
+ if (!existsSync(path)) return null;
71
+ let parsed: unknown;
72
+ try {
73
+ parsed = JSON.parse(stripJsonComments(readFileSync(path, "utf-8")));
74
+ } catch {
75
+ return null; // malformed JSON — skip silently
76
+ }
77
+ if (!isPlainObject(parsed)) return null;
78
+ const bucket = parsed[key];
79
+ if (!isPlainObject(bucket)) return null;
80
+ const servers: Record<string, ServerDef> = {};
81
+ for (const [name, def] of Object.entries(bucket)) {
82
+ if (isPlainObject(def)) servers[name] = def as unknown as ServerDef;
83
+ }
84
+ return servers;
85
+ }
86
+
87
+ /**
88
+ * Discover MCP configs owned by other tools. `cwd` is the project root
89
+ * (supplies the project-scoped candidates); `~` comes from `os.homedir()`
90
+ * at call time. Returns found configs in the documented candidate order;
91
+ * empty when nothing is found.
92
+ */
93
+ export function discoverHostConfigs(cwd: string): HostConfig[] {
94
+ const home = homedir();
95
+ const found: HostConfig[] = [];
96
+ for (const candidate of CANDIDATES) {
97
+ const path = candidate.pathFor(home, cwd);
98
+ // Hard guarantee for the documented exclusions even if the candidate
99
+ // list ever drifts: never return pi's own layers.
100
+ if (path.startsWith(join(cwd, ".pi") + sep)) continue;
101
+ if (path === join(home, ".config", "mcp", "mcp.json")) continue;
102
+ const servers = readHostServers(path, candidate.key);
103
+ if (servers !== null) found.push({ agent: candidate.agent, path, servers });
104
+ }
105
+ return found;
106
+ }