@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.
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/package.json +39 -0
- package/src/auth-flow.test.ts +583 -0
- package/src/auth-flow.ts +310 -0
- package/src/auth-run.test.ts +309 -0
- package/src/auth-run.ts +146 -0
- package/src/auth-storage.test.ts +338 -0
- package/src/auth-storage.ts +330 -0
- package/src/auto-auth.test.ts +231 -0
- package/src/auto-auth.ts +135 -0
- package/src/callback-server.test.ts +446 -0
- package/src/callback-server.ts +538 -0
- package/src/commands-auth.test.ts +320 -0
- package/src/commands-auth.ts +128 -0
- package/src/commands.test.ts +834 -0
- package/src/commands.ts +424 -0
- package/src/config-write.test.ts +213 -0
- package/src/config-write.ts +207 -0
- package/src/config.test.ts +468 -0
- package/src/config.ts +278 -0
- package/src/direct-tools.test.ts +473 -0
- package/src/direct-tools.ts +250 -0
- package/src/host-configs.test.ts +231 -0
- package/src/host-configs.ts +106 -0
- package/src/index.test.ts +689 -0
- package/src/index.ts +146 -0
- package/src/lifecycle.test.ts +274 -0
- package/src/lifecycle.ts +77 -0
- package/src/metadata-cache.test.ts +383 -0
- package/src/metadata-cache.ts +231 -0
- package/src/npx-resolver.test.ts +142 -0
- package/src/npx-resolver.ts +126 -0
- package/src/oauth-provider.test.ts +404 -0
- package/src/oauth-provider.ts +197 -0
- package/src/oauth-types.ts +54 -0
- package/src/panel-rows.ts +210 -0
- package/src/panel.test.ts +298 -0
- package/src/panel.ts +742 -0
- package/src/proxy-tool.ts +524 -0
- package/src/renderer.test.ts +326 -0
- package/src/renderer.ts +239 -0
- package/src/schema-validator.test.ts +56 -0
- package/src/schema-validator.ts +42 -0
- package/src/server-client.test.ts +1001 -0
- package/src/server-client.ts +576 -0
- package/src/server-manager.ts +139 -0
- package/src/setup-panel.test.ts +162 -0
- package/src/setup-panel.ts +715 -0
- package/src/tool-naming.test.ts +168 -0
- package/src/tool-naming.ts +114 -0
- 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
|
+
}
|