@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,146 @@
1
+ /**
2
+ * Shared OAuth run (plan-026): the single place that wraps
3
+ * `ServerClient.authenticate` — the SINGLE auth entry point — behind the
4
+ * `BorderedLoader` progress UI (tui.md Pattern 2), the loader's
5
+ * `onAbort` → `AbortController` cancellation, the "Opening browser…"
6
+ * notification for the authorization URL, and the post-auth
7
+ * close+reconnect that re-reads the freshly stored token from the keyring
8
+ * into the Bearer header.
9
+ *
10
+ * Call sites: the `/mcp auth` command (`commands-auth.ts`) and the inline
11
+ * auto-auth's UI branch (`auto-auth.ts`). Each maps the structured
12
+ * `AuthRunOutcome` onto its own notification/return strings, which differ
13
+ * between the two user-visible surfaces.
14
+ */
15
+ import {
16
+ BorderedLoader,
17
+ type ExtensionContext,
18
+ } from "@earendil-works/pi-coding-agent";
19
+ import open from "open";
20
+ import { recordClientOutcome } from "./metadata-cache.js";
21
+ import type { ServerClient, ServerStatus } from "./server-client.js";
22
+
23
+ /**
24
+ * Outcome of an auth attempt.
25
+ *
26
+ * - `cancelled` — the loader was esc-closed OR the flow rejected with
27
+ * exactly "OAuth cancelled" (an external abort). Both call sites treat
28
+ * these identically.
29
+ * - `flow-error` — the flow failed for a real reason; `error` carries the
30
+ * underlying message.
31
+ * - `reconnect-failed` — auth succeeded but close/connect threw; `error`
32
+ * carries the underlying message.
33
+ * - `reconnected` — close+connect succeeded; `status`/`tools` snapshot the
34
+ * client so callers can recheck (e.g. ADR 0001's needs-auth loop) and
35
+ * report the tool count without re-reading the client.
36
+ */
37
+ export type AuthRunOutcome =
38
+ | { kind: "cancelled" }
39
+ | { kind: "flow-error"; error: string }
40
+ | { kind: "reconnect-failed"; error: string }
41
+ | { kind: "reconnected"; status: ServerStatus; tools: number };
42
+
43
+ function toMessage(e: unknown): string {
44
+ return e instanceof Error ? e.message : String(e);
45
+ }
46
+
47
+ /**
48
+ * Open the browser for an authorization URL, swallowing failures (no
49
+ * browser available, headless). Callers decide how far to surface the URL.
50
+ */
51
+ export async function openAuthUrl(url: string): Promise<void> {
52
+ try {
53
+ await open(url);
54
+ } catch {
55
+ // No browser available — swallow; the caller's notification (if any)
56
+ // still shows the URL so the user can visit it manually.
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Post-auth step, shared by every call site: close + reconnect the client
62
+ * so the freshly stored token is used immediately (connect re-reads the
63
+ * keyring for the Bearer header), then recheck the client.
64
+ */
65
+ export async function reconnectAfterAuth(client: ServerClient): Promise<AuthRunOutcome> {
66
+ try {
67
+ try {
68
+ await client.close();
69
+ await client.connect();
70
+ } finally {
71
+ // ADR 0004 settle point: the post-auth close+reconnect is a genuine
72
+ // connection settle — record the outcome so a successful auth clears
73
+ // the stale "needs-auth" (and a failed reconnect leaves "error") in
74
+ // the persisted ledger instead of sticking across sessions.
75
+ recordClientOutcome(client);
76
+ }
77
+ } catch (e) {
78
+ return { kind: "reconnect-failed", error: toMessage(e) };
79
+ }
80
+ return { kind: "reconnected", status: client.status, tools: client.tools.length };
81
+ }
82
+
83
+ /**
84
+ * Run `ServerClient.authenticate` behind a `BorderedLoader` (esc aborts
85
+ * the flow; the authorization URL is opened in the browser and announced
86
+ * via an info notification) and, on success, perform the post-auth
87
+ * close+reconnect.
88
+ *
89
+ * `ctx` must have a UI — headless callers (print/RPC) run the flow plainly
90
+ * and reuse `openAuthUrl`/`reconnectAfterAuth` (see `autoAuthenticate`).
91
+ */
92
+ export async function runAuthWithLoader(
93
+ ctx: ExtensionContext,
94
+ client: ServerClient,
95
+ options: {
96
+ /** Loader label, e.g. `Authenticating <server>… (esc to cancel)`. */
97
+ loaderLabel: string;
98
+ },
99
+ ): Promise<AuthRunOutcome> {
100
+ const controller = new AbortController();
101
+ type LoaderOutcome =
102
+ | { kind: "done" }
103
+ | { kind: "error"; error: string }
104
+ | null; // null = esc-closed loader
105
+ const outcome = await ctx.ui.custom<LoaderOutcome>((tui, theme, _keybindings, done) => {
106
+ const loader = new BorderedLoader(tui, theme, options.loaderLabel);
107
+ let settled = false;
108
+ const settle = (value: LoaderOutcome) => {
109
+ if (!settled) {
110
+ settled = true;
111
+ done(value);
112
+ }
113
+ };
114
+ loader.onAbort = () => {
115
+ controller.abort();
116
+ settle(null);
117
+ };
118
+ void client
119
+ .authenticate({
120
+ signal: controller.signal,
121
+ onAuthorizationUrl: async (url: URL) => {
122
+ await openAuthUrl(url.toString());
123
+ ctx.ui.notify(
124
+ `Opening browser… if it didn't open, visit: ${url.toString()}`,
125
+ "info",
126
+ );
127
+ },
128
+ })
129
+ .then(
130
+ () => settle({ kind: "done" }),
131
+ // An esc-abort already settled `null`; this rejects with
132
+ // "OAuth cancelled" and is swallowed by the settle guard.
133
+ (e: unknown) => settle({ kind: "error", error: toMessage(e) }),
134
+ );
135
+ return loader;
136
+ });
137
+ if (outcome === null) return { kind: "cancelled" };
138
+ if (outcome.kind === "error") {
139
+ // A flow aborted from OUTSIDE the loader (agent abort) rejects with
140
+ // "OAuth cancelled" — that is a cancellation, not a failure.
141
+ if (outcome.error === "OAuth cancelled") return { kind: "cancelled" };
142
+ return { kind: "flow-error", error: outcome.error };
143
+ }
144
+
145
+ return reconnectAfterAuth(client);
146
+ }
@@ -0,0 +1,338 @@
1
+ import { createHash } from "node:crypto";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import type { AuthEntry } from "./oauth-types.js";
5
+
6
+ /**
7
+ * Mock semantics mirrored from the real @napi-rs/keyring 1.3.0 native
8
+ * binding (verified against the hoisted module and the binding source at
9
+ * the published gitHead):
10
+ * - `getPassword()` returns `null` for a missing entry and never throws in
11
+ * v1.3.0 (`throwGetMessage` below simulates a backend that DOES throw on
12
+ * miss, exercising the defensive classification branch).
13
+ * - `setPassword()` stores/overwrites; `deletePassword()` returns a boolean.
14
+ * - When the OS store is down (no libsecret / headless), entry construction
15
+ * throws — `keyring.unavailable` simulates that.
16
+ */
17
+ const keyring = vi.hoisted(() => ({
18
+ /** `${service}\u0000${account}` → password */
19
+ entries: new Map<string, string>(),
20
+ unavailable: false,
21
+ /** When set, `getPassword()` throws `new Error(throwGetMessage)` instead of returning. */
22
+ throwGetMessage: null as string | null,
23
+ }));
24
+
25
+ vi.mock("@napi-rs/keyring", () => {
26
+ class Entry {
27
+ service: string;
28
+ account: string;
29
+
30
+ constructor(service: string, account: string) {
31
+ if (keyring.unavailable) {
32
+ throw new Error("Secret Service is not running");
33
+ }
34
+ this.service = service;
35
+ this.account = account;
36
+ }
37
+
38
+ getPassword(): string | null {
39
+ if (keyring.unavailable) {
40
+ throw new Error("Secret Service is not running");
41
+ }
42
+ if (keyring.throwGetMessage !== null) {
43
+ throw new Error(keyring.throwGetMessage);
44
+ }
45
+ return keyring.entries.get(`${this.service}\u0000${this.account}`) ?? null;
46
+ }
47
+
48
+ setPassword(password: string): void {
49
+ if (keyring.unavailable) {
50
+ throw new Error("Secret Service is not running");
51
+ }
52
+ keyring.entries.set(`${this.service}\u0000${this.account}`, password);
53
+ }
54
+
55
+ deletePassword(): boolean {
56
+ if (keyring.unavailable) {
57
+ throw new Error("Secret Service is not running");
58
+ }
59
+ return keyring.entries.delete(`${this.service}\u0000${this.account}`);
60
+ }
61
+ }
62
+ return { Entry };
63
+ });
64
+
65
+ // Import after mocks are set up (the module under test holds an in-memory
66
+ // cache, so tests below use a unique server name each for independence).
67
+ const { deleteAuthEntry, getAuthEntry, saveAuthEntry } = await import("./auth-storage.js");
68
+
69
+ const SERVICE = "pi-archimedes-mcp.oauth";
70
+ const UNAVAILABLE_MESSAGE =
71
+ "OS credential store unavailable — cannot store OAuth tokens securely";
72
+
73
+ function expectedAccount(serverName: string): string {
74
+ return `sha256-${createHash("sha256").update(serverName, "utf8").digest("hex")}`;
75
+ }
76
+
77
+ function storeKeys(serverName: string): string[] {
78
+ const account = expectedAccount(serverName);
79
+ return Array.from(keyring.entries.keys()).filter((key) => {
80
+ const field = key.slice(key.indexOf("\u0000") + 1);
81
+ return field === account || field.startsWith(`${account}.chunk.`);
82
+ });
83
+ }
84
+
85
+ /**
86
+ * Write a raw string directly into the main account, simulating a payload
87
+ * corrupted out-of-process (or hand-tampered) without going through
88
+ * saveAuthEntry's JSON.stringify.
89
+ */
90
+ function seedRawMain(serverName: string, raw: string): void {
91
+ keyring.entries.set(`${SERVICE}\u0000${expectedAccount(serverName)}`, raw);
92
+ }
93
+
94
+ /** Write a raw string directly into one chunk account of a manifest group. */
95
+ function seedRawChunk(serverName: string, chunkDigest: string, index: number, raw: string): void {
96
+ const account = expectedAccount(serverName);
97
+ keyring.entries.set(`${SERVICE}\u0000${account}.chunk.${chunkDigest}.${index}`, raw);
98
+ }
99
+
100
+ function smallEntry(): AuthEntry {
101
+ return {
102
+ tokens: {
103
+ accessToken: "at_access",
104
+ refreshToken: "ot_refresh",
105
+ expiresAt: 1_893_456_000,
106
+ scope: "read:crossplane",
107
+ },
108
+ clientInfo: { clientId: "client-123" },
109
+ };
110
+ }
111
+
112
+ function bigEntry(tokenChar: string): AuthEntry {
113
+ return { tokens: { accessToken: tokenChar.repeat(4500) } };
114
+ }
115
+
116
+ beforeEach(() => {
117
+ keyring.entries.clear();
118
+ keyring.unavailable = false;
119
+ keyring.throwGetMessage = null;
120
+ });
121
+
122
+ describe("auth-storage", () => {
123
+ it("stores under the fixed service name and the sha256-hashed account", () => {
124
+ const entry = smallEntry();
125
+ saveAuthEntry("atlassian", entry);
126
+
127
+ expect(keyring.entries.size).toBe(1);
128
+ expect(keyring.entries.get(`${SERVICE}\u0000${expectedAccount("atlassian")}`)).toBe(
129
+ JSON.stringify(entry),
130
+ );
131
+ });
132
+
133
+ it("round-trips a small entry through save and get", () => {
134
+ const entry = smallEntry();
135
+ saveAuthEntry("notion", entry);
136
+ expect(getAuthEntry("notion")).toEqual(entry);
137
+ });
138
+
139
+ it("returns undefined for a server with no stored entry", () => {
140
+ expect(getAuthEntry("ghost")).toBeUndefined();
141
+ });
142
+
143
+ it("records the serverUrl when provided", () => {
144
+ saveAuthEntry("github", smallEntry(), "https://api.githubcopilot.com/mcp");
145
+ expect(getAuthEntry("github")?.serverUrl).toBe("https://api.githubcopilot.com/mcp");
146
+ });
147
+
148
+ it("chunks payloads over 1000 chars into indexed chunks plus a manifest", () => {
149
+ const entry = bigEntry("A");
150
+ saveAuthEntry("linear", entry);
151
+
152
+ const payload = JSON.stringify(entry);
153
+ const digest = createHash("sha256").update(payload, "utf8").digest("hex").slice(0, 16);
154
+ const count = Math.ceil(payload.length / 1000);
155
+ expect(count).toBeGreaterThan(1);
156
+
157
+ // Main account holds the manifest, not the payload.
158
+ const manifest = JSON.parse(keyring.entries.get(`${SERVICE}\u0000${expectedAccount("linear")}`)!);
159
+ expect(manifest).toEqual({ __chunks: 1, chunkCount: count, chunkDigest: digest });
160
+
161
+ // Each chunk lives at <account>.chunk.<digest>.<index>.
162
+ for (let i = 0; i < count; i++) {
163
+ expect(keyring.entries.get(`${SERVICE}\u0000${expectedAccount("linear")}.chunk.${digest}.${i}`)).toBe(
164
+ payload.slice(i * 1000, (i + 1) * 1000),
165
+ );
166
+ }
167
+ expect(storeKeys("linear")).toHaveLength(count + 1);
168
+
169
+ // Reads reassemble the chunks in order.
170
+ expect(getAuthEntry("linear")).toEqual(entry);
171
+ });
172
+
173
+ it("cleans stale chunks when a chunked entry is overwritten with a small one", () => {
174
+ const big = bigEntry("B");
175
+ saveAuthEntry("asana", big);
176
+ const bigDigest = createHash("sha256")
177
+ .update(JSON.stringify(big), "utf8")
178
+ .digest("hex")
179
+ .slice(0, 16);
180
+ expect(storeKeys("asana").some((key) => key.includes(bigDigest))).toBe(true);
181
+
182
+ const small = smallEntry();
183
+ saveAuthEntry("asana", small);
184
+
185
+ expect(storeKeys("asana")).toEqual([`${SERVICE}\u0000${expectedAccount("asana")}`]);
186
+ expect(getAuthEntry("asana")).toEqual(small);
187
+ });
188
+
189
+ it("leaves no stale chunks when a small entry is overwritten with a chunked one", () => {
190
+ const small = smallEntry();
191
+ saveAuthEntry("slack", small);
192
+
193
+ const big = bigEntry("C");
194
+ saveAuthEntry("slack", big);
195
+
196
+ const digest = createHash("sha256").update(JSON.stringify(big), "utf8").digest("hex").slice(0, 16);
197
+ const count = Math.ceil(JSON.stringify(big).length / 1000);
198
+ expect(storeKeys("slack")).toHaveLength(count + 1);
199
+ expect(storeKeys("slack").every((key) => key.includes(digest) || !key.includes(".chunk."))).toBe(
200
+ true,
201
+ );
202
+ expect(getAuthEntry("slack")).toEqual(big);
203
+ });
204
+
205
+ it("deletes the manifest and every chunk", () => {
206
+ saveAuthEntry("jira", bigEntry("D"));
207
+ expect(storeKeys("jira").length).toBeGreaterThan(1);
208
+
209
+ deleteAuthEntry("jira");
210
+
211
+ expect(storeKeys("jira")).toEqual([]);
212
+ expect(keyring.entries.size).toBe(0);
213
+ expect(getAuthEntry("jira")).toBeUndefined();
214
+ });
215
+
216
+ it("delete is a no-op for a server that was never saved", () => {
217
+ expect(() => deleteAuthEntry("never-existed")).not.toThrow();
218
+ expect(keyring.entries.size).toBe(0);
219
+ });
220
+
221
+ it("throws the clear unavailable error on save, never falling back to plaintext", () => {
222
+ keyring.unavailable = true;
223
+ expect(() => saveAuthEntry("hellobix", smallEntry())).toThrow(UNAVAILABLE_MESSAGE);
224
+ // Fail-closed: nothing was written anywhere (no plaintext fallback).
225
+ expect(keyring.entries.size).toBe(0);
226
+ });
227
+
228
+ it("throws the clear unavailable error on get", () => {
229
+ keyring.unavailable = true;
230
+ expect(() => getAuthEntry("intercom")).toThrow(UNAVAILABLE_MESSAGE);
231
+ });
232
+
233
+ it("throws the clear unavailable error on delete", () => {
234
+ keyring.unavailable = true;
235
+ expect(() => deleteAuthEntry("miro")).toThrow(UNAVAILABLE_MESSAGE);
236
+ });
237
+
238
+ it("clones on get so a caller cannot mutate the cached entry", () => {
239
+ const entry = smallEntry();
240
+ saveAuthEntry("figma", entry);
241
+
242
+ const first = getAuthEntry("figma")!;
243
+ first.tokens!.accessToken = "MUTATED";
244
+
245
+ const second = getAuthEntry("figma")!;
246
+ expect(second).not.toBe(first);
247
+ expect(second).toEqual(entry);
248
+ });
249
+
250
+ it("keeps storage isolated per server name", () => {
251
+ saveAuthEntry("alpha", { tokens: { accessToken: "alpha-token" } });
252
+ saveAuthEntry("beta", { tokens: { accessToken: "beta-token" } });
253
+
254
+ expect(getAuthEntry("alpha")).toEqual({ tokens: { accessToken: "alpha-token" } });
255
+ expect(getAuthEntry("beta")).toEqual({ tokens: { accessToken: "beta-token" } });
256
+
257
+ deleteAuthEntry("alpha");
258
+
259
+ expect(storeKeys("alpha")).toEqual([]);
260
+ expect(getAuthEntry("alpha")).toBeUndefined();
261
+ expect(getAuthEntry("beta")).toEqual({ tokens: { accessToken: "beta-token" } });
262
+ });
263
+
264
+ describe("defensive read path when the backend throws on miss", () => {
265
+ it.each([
266
+ // Canonical keyring-core NoEntry message — every store routes a
267
+ // missing credential through it.
268
+ "No matching credential found",
269
+ // "does not exist" backends.
270
+ "Item does not exist",
271
+ // Narrow specific miss phrasings.
272
+ "No such entry",
273
+ "no entry available for account",
274
+ ])("treats a thrown %j as a missing entry: getAuthEntry returns undefined without throwing", (message) => {
275
+ keyring.throwGetMessage = message;
276
+ let value: AuthEntry | undefined;
277
+ expect(() => {
278
+ value = getAuthEntry("fastly");
279
+ }).not.toThrow();
280
+ expect(value).toBeUndefined();
281
+ });
282
+
283
+ it("does NOT misclassify a generic store failure containing 'no such' as a missing entry", () => {
284
+ // "No such file or directory (os error 2)" is a real file-backed store
285
+ // failure, not an absent entry: fail-closed UNAVAILABLE must propagate.
286
+ keyring.throwGetMessage = "No such file or directory (os error 2)";
287
+ expect(() => getAuthEntry("huggingface")).toThrow(UNAVAILABLE_MESSAGE);
288
+ });
289
+
290
+ it("does NOT misclassify a D-Bus 'not found' failure as a missing entry", () => {
291
+ // "Match rule not found" is a real bus failure, not an absent entry.
292
+ keyring.throwGetMessage = "Match rule not found";
293
+ expect(() => getAuthEntry("canva")).toThrow(UNAVAILABLE_MESSAGE);
294
+ });
295
+
296
+ it("propagates UNAVAILABLE for a generic store failure with no miss wording", () => {
297
+ keyring.throwGetMessage = "keyring daemon unreachable";
298
+ expect(() => getAuthEntry("netflix")).toThrow(UNAVAILABLE_MESSAGE);
299
+ });
300
+ });
301
+
302
+ describe("corrupt stored payloads fail closed on read", () => {
303
+ it("throws a clear error when the main-account payload is not valid JSON", () => {
304
+ seedRawMain("corrupt-nonjson", "not-json{");
305
+ expect(() => getAuthEntry("corrupt-nonjson")).toThrow(
306
+ "Corrupt OAuth entry in the OS credential store: payload is not valid JSON",
307
+ );
308
+ });
309
+
310
+ it("throws a clear not-an-object error when the payload is a JSON array", () => {
311
+ seedRawMain("corrupt-json-array", "[1,2]");
312
+ expect(() => getAuthEntry("corrupt-json-array")).toThrow(
313
+ "Corrupt OAuth entry in the OS credential store: payload is not a JSON object",
314
+ );
315
+ });
316
+
317
+ it("throws a clear not-an-object error when the payload is a JSON string", () => {
318
+ seedRawMain("corrupt-json-string", JSON.stringify("a scalar is not an object"));
319
+ expect(() => getAuthEntry("corrupt-json-string")).toThrow(
320
+ "Corrupt OAuth entry in the OS credential store: payload is not a JSON object",
321
+ );
322
+ });
323
+
324
+ it("throws a clear missing-chunk error when a referenced chunk account is absent", () => {
325
+ const digest = "0123456789abcdef";
326
+ seedRawMain(
327
+ "corrupt-missing-chunk",
328
+ JSON.stringify({ __chunks: 1, chunkCount: 2, chunkDigest: digest }),
329
+ );
330
+ // Only chunk 0 exists — chunk 1 was deleted (or lost) out-of-process.
331
+ seedRawChunk("corrupt-missing-chunk", digest, 0, "{\"tokens\":{\"accessToken\":\"part");
332
+
333
+ expect(() => getAuthEntry("corrupt-missing-chunk")).toThrow(
334
+ "Corrupt OAuth entry in the OS credential store: missing chunk 1 of 2",
335
+ );
336
+ });
337
+ });
338
+ });