@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,139 @@
|
|
|
1
|
+
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import type { ServerDef, CachedTool } from "./types.js";
|
|
3
|
+
import { ServerClient } from "./server-client.js";
|
|
4
|
+
import type { McpTool } from "./server-client.js";
|
|
5
|
+
import { getCachedTools, computeServerHash } from "./metadata-cache.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Map entry for a managed server. `defHash` is the identity hash of the
|
|
9
|
+
* ServerDef at client-creation time; sync() compares it against the hash of
|
|
10
|
+
* the incoming def to detect config changes (url/auth/command, etc.).
|
|
11
|
+
*/
|
|
12
|
+
interface ManagedClient {
|
|
13
|
+
client: ServerClient;
|
|
14
|
+
defHash: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class ServerManager {
|
|
18
|
+
private clients = new Map<string, ManagedClient>();
|
|
19
|
+
private defs = new Map<string, ServerDef>();
|
|
20
|
+
private clientFactory: (() => Client) | undefined;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param options.clientFactory — testability seam; passed through to every
|
|
24
|
+
* ServerClient created by sync() so tests can inject a fake SDK client.
|
|
25
|
+
*/
|
|
26
|
+
constructor(options?: { clientFactory?: () => Client }) {
|
|
27
|
+
this.clientFactory = options?.clientFactory;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private makeClient(name: string, def: ServerDef): ServerClient {
|
|
31
|
+
return new ServerClient(
|
|
32
|
+
name,
|
|
33
|
+
def,
|
|
34
|
+
this.clientFactory ? { clientFactory: this.clientFactory } : undefined,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Sync the client map to a new set of server definitions */
|
|
39
|
+
sync(defs: Record<string, ServerDef>): void {
|
|
40
|
+
// Close and remove servers that are gone. close() bumps the client's
|
|
41
|
+
// generation fence, so a concurrent in-flight connect will tear itself
|
|
42
|
+
// down on completion instead of leaking; the client's null-guarded close
|
|
43
|
+
// makes a second close a no-op. We delete from the map before awaiting
|
|
44
|
+
// nothing here (close is fire-and-forget via void) — no double-close path.
|
|
45
|
+
for (const [name, managed] of this.clients) {
|
|
46
|
+
if (!(name in defs)) {
|
|
47
|
+
void managed.client.close();
|
|
48
|
+
this.clients.delete(name);
|
|
49
|
+
this.defs.delete(name);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Add or update servers (don't connect yet — lazy)
|
|
53
|
+
for (const [name, def] of Object.entries(defs)) {
|
|
54
|
+
this.defs.set(name, def);
|
|
55
|
+
const managed = this.clients.get(name);
|
|
56
|
+
if (managed) {
|
|
57
|
+
// The def changed for a still-configured server: the old client's
|
|
58
|
+
// generation fence is useless if it just holds a stale def — its
|
|
59
|
+
// next connect would use the old url/command and re-save the cache
|
|
60
|
+
// under the old hash. Close it (fences any in-flight connect) and
|
|
61
|
+
// replace with a fresh client built from the new def. Identical
|
|
62
|
+
// identity → leave the existing client alone (no reconnect churn).
|
|
63
|
+
const defHash = computeServerHash(def);
|
|
64
|
+
if (managed.defHash !== defHash) {
|
|
65
|
+
void managed.client.close();
|
|
66
|
+
this.clients.set(name, { client: this.makeClient(name, def), defHash });
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
this.clients.set(name, {
|
|
70
|
+
client: this.makeClient(name, def),
|
|
71
|
+
defHash: computeServerHash(def),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Server definition from the last sync, if any */
|
|
78
|
+
getDef(name: string): ServerDef | undefined {
|
|
79
|
+
return this.defs.get(name);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Tools from the live connection if connected, else from valid cache */
|
|
83
|
+
getToolsForServer(name: string, def: ServerDef): CachedTool[] {
|
|
84
|
+
const client = this.clients.get(name)?.client;
|
|
85
|
+
if (client && client.status === "connected") {
|
|
86
|
+
return client.tools.map((t) => {
|
|
87
|
+
const cached: CachedTool = { name: t.name, inputSchema: t.inputSchema };
|
|
88
|
+
if (t.description !== undefined) cached.description = t.description;
|
|
89
|
+
return cached;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return getCachedTools(name, def) ?? [];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** All tools across servers (live + cached), for offline search */
|
|
96
|
+
getAllToolsWithCache(defs: Record<string, ServerDef>): Array<CachedTool & { serverName: string }> {
|
|
97
|
+
return Object.entries(defs).flatMap(([name, def]) =>
|
|
98
|
+
this.getToolsForServer(name, def).map((t) => ({ ...t, serverName: name })),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
getClient(name: string): ServerClient | undefined {
|
|
103
|
+
return this.clients.get(name)?.client;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Whether a client is connected, idle, and past its idle timeout */
|
|
107
|
+
isIdle(name: string, timeoutMs: number): boolean {
|
|
108
|
+
return this.clients.get(name)?.client.isIdle(timeoutMs) ?? false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
getClients(): ServerClient[] {
|
|
112
|
+
return Array.from(this.clients.values()).map((m) => m.client);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** All currently cached tools across all connected servers */
|
|
116
|
+
getAllTools(): McpTool[] {
|
|
117
|
+
return this.getClients().flatMap((c) => c.tools);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Search tools by name/description substring (case-insensitive) */
|
|
121
|
+
searchTools(query: string, serverName?: string): McpTool[] {
|
|
122
|
+
const q = query.toLowerCase();
|
|
123
|
+
const clients = serverName
|
|
124
|
+
? ([this.clients.get(serverName)?.client].filter(Boolean) as ServerClient[])
|
|
125
|
+
: this.getClients();
|
|
126
|
+
return clients
|
|
127
|
+
.flatMap((c) => c.tools)
|
|
128
|
+
.filter(
|
|
129
|
+
(t) =>
|
|
130
|
+
t.name.toLowerCase().includes(q) ||
|
|
131
|
+
(t.description ?? "").toLowerCase().includes(q),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async closeAll(): Promise<void> {
|
|
136
|
+
await Promise.all(this.getClients().map((c) => c.close()));
|
|
137
|
+
this.clients.clear();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the `/mcp setup` import-preview logic (plan-027, Task 4).
|
|
3
|
+
*
|
|
4
|
+
* `computeImportPreview` is a pure function exported for testability
|
|
5
|
+
* (see its doc comment in setup-panel.ts): it unions the servers of the
|
|
6
|
+
* checked host configs in discovery order (first seen wins a name clash),
|
|
7
|
+
* then diffs the union against the names already present in `<cwd>/.mcp.json`
|
|
8
|
+
* (passed in as a plain `existingNames` list — no I/O here, so no temp dirs
|
|
9
|
+
* are needed the way the config-write tests require).
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
import { computeImportPreview } from "./setup-panel.js";
|
|
13
|
+
import type { HostConfig } from "./host-configs.js";
|
|
14
|
+
import type { ServerDef } from "./types.js";
|
|
15
|
+
|
|
16
|
+
const stdio = (command: string): ServerDef => ({ command });
|
|
17
|
+
const remote = (url: string): ServerDef => ({ url });
|
|
18
|
+
|
|
19
|
+
const host = (agent: HostConfig["agent"], servers: Record<string, ServerDef>): HostConfig => ({
|
|
20
|
+
agent,
|
|
21
|
+
path: `/${agent}.json`,
|
|
22
|
+
servers,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("computeImportPreview", () => {
|
|
26
|
+
it("returns null when no host is checked (empty selection)", () => {
|
|
27
|
+
const hosts = [
|
|
28
|
+
host("cursor", { a: stdio("npx") }),
|
|
29
|
+
host("claude-code", { b: remote("https://b.example/mcp") }),
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
expect(computeImportPreview(hosts, new Set<number>(), [])).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns null when the only checked source declares zero servers", () => {
|
|
36
|
+
const hosts = [
|
|
37
|
+
host("cursor", {}),
|
|
38
|
+
host("claude-code", { a: stdio("npx") }), // NOT checked
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
expect(computeImportPreview(hosts, new Set([0]), [])).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("lists every server of a single checked source in its declaration order, labelled by that agent", () => {
|
|
45
|
+
const servers: Record<string, ServerDef> = {
|
|
46
|
+
alpha: stdio("npx"),
|
|
47
|
+
beta: remote("https://beta.example/mcp"),
|
|
48
|
+
gamma: stdio("uvx"),
|
|
49
|
+
};
|
|
50
|
+
const hosts = [host("cursor", servers)];
|
|
51
|
+
|
|
52
|
+
const preview = computeImportPreview(hosts, new Set([0]), []);
|
|
53
|
+
|
|
54
|
+
expect(preview).not.toBeNull();
|
|
55
|
+
expect(preview!.sourceLabel).toBe("cursor");
|
|
56
|
+
expect(preview!.adding).toEqual(["alpha", "beta", "gamma"]);
|
|
57
|
+
expect(preview!.alreadyPresent).toBe(0);
|
|
58
|
+
// defs map every selected name to the exact def reference it came from.
|
|
59
|
+
expect(Object.keys(preview!.defs)).toEqual(["alpha", "beta", "gamma"]);
|
|
60
|
+
expect(preview!.defs.alpha).toBe(servers.alpha);
|
|
61
|
+
expect(preview!.defs.beta).toBe(servers.beta);
|
|
62
|
+
expect(preview!.defs.gamma).toBe(servers.gamma);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("resolves a name clash across two checked sources with first-seen-wins", () => {
|
|
66
|
+
const cursorDb: ServerDef = stdio("npx");
|
|
67
|
+
const claudeDb: ServerDef = remote("https://claude.example/db");
|
|
68
|
+
const claudeOwn: ServerDef = remote("https://own.example/mcp");
|
|
69
|
+
const hosts = [
|
|
70
|
+
host("cursor", { db: cursorDb, extra: stdio("uvx") }),
|
|
71
|
+
host("claude-code", { db: claudeDb, own: claudeOwn }),
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const preview = computeImportPreview(hosts, new Set([0, 1]), []);
|
|
75
|
+
|
|
76
|
+
// The FIRST source's def wins identity; the later one's is dropped
|
|
77
|
+
// (single `db` key, no duplication).
|
|
78
|
+
expect(Object.keys(preview!.defs)).toEqual(["db", "extra", "own"]);
|
|
79
|
+
expect(preview!.defs.db).toBe(cursorDb);
|
|
80
|
+
expect(preview!.defs.own).toBe(claudeOwn);
|
|
81
|
+
// First-seen INSERTION order drives `adding`, not per-source grouping.
|
|
82
|
+
expect(preview!.adding).toEqual(["db", "extra", "own"]);
|
|
83
|
+
expect(preview!.alreadyPresent).toBe(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("leaves a checked source's label in sourceLabel even when it contributes no new servers at all", () => {
|
|
87
|
+
const hosts = [
|
|
88
|
+
host("cursor", { x: stdio("npx"), y: stdio("uvx") }),
|
|
89
|
+
// Only server `y` — a pure clash, so this source adds nothing new.
|
|
90
|
+
host("claude-desktop", { y: remote("https://other.example/mcp") }),
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const preview = computeImportPreview(hosts, new Set([0, 1]), []);
|
|
94
|
+
|
|
95
|
+
expect(preview!.sourceLabel).toBe("cursor + claude-desktop");
|
|
96
|
+
expect(preview!.adding).toEqual(["x", "y"]);
|
|
97
|
+
expect(preview!.defs.y).toBe(hosts[0]!.servers.y);
|
|
98
|
+
expect(preview!.alreadyPresent).toBe(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("excludes servers already present in .mcp.json from adding and counts them in alreadyPresent", () => {
|
|
102
|
+
const hosts = [
|
|
103
|
+
host("cursor", {
|
|
104
|
+
kept: stdio("npx"),
|
|
105
|
+
alsoKept: remote("https://kept.example/mcp"),
|
|
106
|
+
fresh: stdio("uvx"),
|
|
107
|
+
}),
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
const preview = computeImportPreview(hosts, new Set([0]), ["kept", "alsoKept"]);
|
|
111
|
+
|
|
112
|
+
expect(preview!.adding).toEqual(["fresh"]);
|
|
113
|
+
expect(preview!.alreadyPresent).toBe(2);
|
|
114
|
+
// Kept defs STAY in `defs` — mergeServerDefinitions re-applies
|
|
115
|
+
// add-if-absent on the writer side, so dropping them here would lose them.
|
|
116
|
+
expect(Object.keys(preview!.defs).sort()).toEqual(["alsoKept", "fresh", "kept"]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("returns a (non-null) preview with an empty adding list when every selected server already exists", () => {
|
|
120
|
+
const hosts = [host("cursor", { a: stdio("npx"), b: stdio("uvx") })];
|
|
121
|
+
|
|
122
|
+
const preview = computeImportPreview(hosts, new Set([0]), ["a", "b"]);
|
|
123
|
+
|
|
124
|
+
expect(preview).not.toBeNull();
|
|
125
|
+
expect(preview!.adding).toEqual([]);
|
|
126
|
+
expect(preview!.alreadyPresent).toBe(2);
|
|
127
|
+
expect(preview!.sourceLabel).toBe("cursor");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("counts alreadyPresent exactly when multiple checked sources overlap existing names", () => {
|
|
131
|
+
const hosts = [
|
|
132
|
+
host("cursor", { a: stdio("npx"), c: stdio("uvx") }),
|
|
133
|
+
host("vscode", { b: remote("https://b.example/mcp"), d: remote("https://d.example/mcp") }),
|
|
134
|
+
// `c` clashes with cursor's — cursor keeps the def.
|
|
135
|
+
host("claude-desktop", { c: remote("https://other.example/c"), e: stdio("npx") }),
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
const preview = computeImportPreview(hosts, new Set([0, 1, 2]), ["a", "d"]);
|
|
139
|
+
|
|
140
|
+
// Unique union in first-seen order: a, c, b, d, e → add if not in {a, d}.
|
|
141
|
+
expect(preview!.adding).toEqual(["c", "b", "e"]);
|
|
142
|
+
expect(preview!.alreadyPresent).toBe(2);
|
|
143
|
+
expect(preview!.sourceLabel).toBe("cursor + vscode + claude-desktop");
|
|
144
|
+
expect(preview!.defs.c).toBe(hosts[0]!.servers.c);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("ignores unchecked hosts entirely and skips checked indices with no entry (sparse / out-of-range)", () => {
|
|
148
|
+
// Sparse: index 1 is a hole; checked 5 is out of range.
|
|
149
|
+
const hosts: HostConfig[] = new Array(4);
|
|
150
|
+
hosts[0] = host("cursor", { a: stdio("npx") });
|
|
151
|
+
hosts[3] = host("vscode", { b: remote("https://b.example/mcp") });
|
|
152
|
+
|
|
153
|
+
const preview = computeImportPreview(hosts, new Set([0, 3, 5]), []);
|
|
154
|
+
expect(preview).not.toBeNull();
|
|
155
|
+
expect(preview!.sourceLabel).toBe("cursor + vscode");
|
|
156
|
+
expect(preview!.adding).toEqual(["a", "b"]);
|
|
157
|
+
|
|
158
|
+
// All-checked-undefined → the documented empty-case result.
|
|
159
|
+
const holes: HostConfig[] = new Array(2);
|
|
160
|
+
expect(computeImportPreview(holes, new Set([0, 1, 9]), [])).toBeNull();
|
|
161
|
+
});
|
|
162
|
+
});
|