@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,310 @@
1
+ /**
2
+ * OAuth authentication flows: interactive authenticate (browser
3
+ * authorization_code + non-interactive client_credentials), SDK-driven
4
+ * refresh with the ADR 0001 config-stub guard, and auth-config extraction
5
+ * from a server definition.
6
+ *
7
+ * Ties together the pieces from earlier plan-026 tasks:
8
+ * - {@link McpOAuthProvider} — the SDK's OAuthClientProvider over keyring storage
9
+ * - {@link ensureCallbackServer}/{@link waitForCallback} — the local callback receiver
10
+ * - `auth()` from `@modelcontextprotocol/sdk/client/auth.js` — discovery,
11
+ * client registration, code exchange, and the refresh_token grant are all
12
+ * SDK-driven; this module only sequences the calls and surfaces state.
13
+ *
14
+ * Design decisions (see docs/decisions/0001-mcp-oauth-refresh-strategy.md):
15
+ * - Refresh is SDK-driven: an expired token re-runs `auth()` and lets the
16
+ * provider's stored refresh_token do the work.
17
+ * - Config-stub guard: a pre-registered public client (config `clientId`
18
+ * without `clientSecret`) hitting an expired token is NEVER auto-refreshed
19
+ * — the auth server would reject the refresh grant (invalid_client).
20
+ * `getValidToken` returns null so the caller can tell the user to re-run
21
+ * `/mcp auth`.
22
+ * - Cancellation: the callback window honors an `AbortSignal`; aborts are
23
+ * rethrown (error propagates) so the command layer can distinguish
24
+ * "cancelled" from "failed".
25
+ *
26
+ * The callback server is a shared singleton: a flow never stops it on exit
27
+ * (stopping would kill concurrently-running flows). States reserved via
28
+ * `reserveAuthState` are cleaned up by `waitForCallback` on settle; a state
29
+ * reserved but never waited on (bind failure) lingers in the accepted set —
30
+ * acceptable, since a later callback for it only gets a 200 hand-off page
31
+ * and no stored data is leaked.
32
+ */
33
+
34
+ import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
35
+
36
+ import { getAuthEntry } from "./auth-storage.js";
37
+ import { ensureCallbackServer, reserveAuthState, waitForCallback } from "./callback-server.js";
38
+ import { McpOAuthProvider, type OAuthCallbacks } from "./oauth-provider.js";
39
+ import { OAUTH_CONFIG_FIELDS, type McpOAuthConfig } from "./types.js";
40
+
41
+ /**
42
+ * Outcome of an authenticate attempt, as a discriminated union on `status`.
43
+ * - `{ status: "authenticated" }` — a token is stored and usable.
44
+ * - `{ status: "needs-interaction" }` — the callback server cannot bind
45
+ * (e.g. its port is taken) and completing the flow would require manual
46
+ * action.
47
+ * - `{ status: "failed"; error }` — the flow failed; `error` carries the
48
+ * underlying cause (token-endpoint rejection, network error, callback
49
+ * timeout, …) so callers can surface the real reason instead of a
50
+ * generic message.
51
+ */
52
+ export type AuthStatus =
53
+ | { status: "authenticated" }
54
+ | { status: "needs-interaction" }
55
+ | { status: "failed"; error: string };
56
+
57
+ export interface AuthenticateOptions {
58
+ /** Called once when the SDK builds the authorization URL (e.g. to open a browser). */
59
+ onAuthorizationUrl?: (url: URL) => void | Promise<void>;
60
+ /** Cancels the callback wait; the flow rethrows the abort error. */
61
+ signal?: AbortSignal;
62
+ }
63
+
64
+ /**
65
+ * Classify a server's `auth` value as an OAuth config.
66
+ *
67
+ * - `"oauth"` → default authorization_code config
68
+ * - a plain object whose known OAuth fields are preserved by validation →
69
+ * a validated config containing only the valid known fields
70
+ * - a static bearer object (`{ token }`), an object where validation drops
71
+ * every known field (e.g. `{ grantType: "bogus" }`), or any other
72
+ * value/shape → null
73
+ */
74
+ export function extractOAuthConfig(auth: unknown): McpOAuthConfig | null {
75
+ if (auth === "oauth") return { grantType: "authorization_code" };
76
+ if (typeof auth !== "object" || auth === null || Array.isArray(auth)) return null;
77
+
78
+ const record = auth as Record<string, unknown>;
79
+ if (!OAUTH_CONFIG_FIELDS.some((field) => record[field] !== undefined)) return null;
80
+
81
+ const config: McpOAuthConfig = {};
82
+ for (const field of OAUTH_CONFIG_FIELDS) {
83
+ const value = record[field];
84
+ if (value === undefined) continue;
85
+ if (field === "grantType") {
86
+ if (value === "authorization_code" || value === "client_credentials") {
87
+ config.grantType = value;
88
+ }
89
+ } else if (typeof value === "string" && value !== "") {
90
+ config[field] = value;
91
+ }
92
+ }
93
+ return Object.keys(config).length > 0 ? config : null;
94
+ }
95
+
96
+ /** 32 random bytes as a 64-char hex string — the CSRF state. */
97
+ function randomState(): string {
98
+ const bytes = new Uint8Array(32);
99
+ crypto.getRandomValues(bytes);
100
+ return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
101
+ }
102
+
103
+ /**
104
+ * Callback server bind options derived from a pre-registered `redirectUri`:
105
+ * when the redirect URI targets loopback (localhost/127.0.0.1) the server
106
+ * must bind that exact port so the arriving redirect lands here; a remote
107
+ * (non-loopback) URI falls back to the default dynamic bind, since the
108
+ * browser flow for remote redirect URIs is out of scope.
109
+ */
110
+ function callbackBindFromRedirectUri(
111
+ redirectUri: string | undefined,
112
+ ): { strictPort: true; port: number } | undefined {
113
+ if (redirectUri === undefined) return undefined;
114
+ let parsed: URL;
115
+ try {
116
+ parsed = new URL(redirectUri);
117
+ } catch {
118
+ return undefined;
119
+ }
120
+ if (parsed.hostname !== "127.0.0.1" && parsed.hostname !== "localhost") return undefined;
121
+ const port =
122
+ parsed.port !== ""
123
+ ? Number(parsed.port)
124
+ : parsed.protocol === "https:"
125
+ ? 443
126
+ : 80;
127
+ if (!Number.isInteger(port) || port < 0 || port > 65535) return undefined;
128
+ return { strictPort: true, port };
129
+ }
130
+
131
+ function describeError(error: unknown): string {
132
+ return error instanceof Error ? error.message : String(error);
133
+ }
134
+
135
+ /**
136
+ * Build the host callbacks for the provider. exactOptionalPropertyTypes:
137
+ * `OAuthCallbacks.onAuthorizationUrl` is an optional property whose type
138
+ * does not include `undefined`, so an absent callback must OMIT the key
139
+ * rather than assign undefined.
140
+ */
141
+ function hostCallbacks(
142
+ onAuthorizationUrl?: (url: URL) => void | Promise<void>,
143
+ ): OAuthCallbacks {
144
+ return onAuthorizationUrl ? { onAuthorizationUrl } : {};
145
+ }
146
+
147
+ /**
148
+ * Run the interactive OAuth flow for a server.
149
+ *
150
+ * - `authorization_code` (default): CSRF state → reserve + bind the callback
151
+ * server → SDK starts the authorization request (host's
152
+ * `onAuthorizationUrl` callback opens the browser) → wait for the redirect
153
+ * → SDK exchanges the code for tokens.
154
+ * - `client_credentials`: single non-interactive SDK call, no callback.
155
+ *
156
+ * Errors: an aborted signal rethrows the abort error so callers can tell
157
+ * cancellation apart; any other failure returns `{ status: "failed"; error }`
158
+ * (still `console.warn`ed for observability) — the error detail travels with
159
+ * the result so the caller can surface it. Returns
160
+ * `{ status: "needs-interaction" }` when the callback server cannot be bound.
161
+ */
162
+ export async function authenticate(
163
+ serverName: string,
164
+ serverUrl: string,
165
+ config: McpOAuthConfig,
166
+ options: AuthenticateOptions = {},
167
+ ): Promise<AuthStatus> {
168
+ if (options.signal?.aborted) {
169
+ throw new Error("OAuth cancelled");
170
+ }
171
+
172
+ if (config.grantType === "client_credentials") {
173
+ const provider = new McpOAuthProvider(
174
+ serverName,
175
+ serverUrl,
176
+ config,
177
+ hostCallbacks(options.onAuthorizationUrl),
178
+ );
179
+ try {
180
+ await auth(provider, { serverUrl });
181
+ return { status: "authenticated" };
182
+ } catch (error) {
183
+ if (options.signal?.aborted) throw error;
184
+ console.warn(
185
+ `[archimedes/mcp] OAuth authentication for ${serverName} failed: ${describeError(error)}`,
186
+ );
187
+ return { status: "failed", error: describeError(error) };
188
+ }
189
+ }
190
+
191
+ // authorization_code (default): interactive browser flow
192
+ const state = randomState();
193
+ reserveAuthState(state);
194
+
195
+ // The port the callback server actually bound to — passed to the provider
196
+ // so the advertised redirect URL (redirect_uri + DCR redirect_uris) lands
197
+ // on the live listener rather than a dead default/different port.
198
+ let boundPort: number;
199
+ try {
200
+ const bind = callbackBindFromRedirectUri(config.redirectUri);
201
+ if (bind === undefined) {
202
+ boundPort = await ensureCallbackServer();
203
+ } else {
204
+ boundPort = await ensureCallbackServer(bind);
205
+ }
206
+ } catch (error) {
207
+ if (options.signal?.aborted) throw error;
208
+ console.warn(
209
+ `[archimedes/mcp] Cannot bind the OAuth callback server for ${serverName}: ` +
210
+ `${describeError(error)}`,
211
+ );
212
+ return { status: "needs-interaction" };
213
+ }
214
+
215
+ const provider = new McpOAuthProvider(
216
+ serverName,
217
+ serverUrl,
218
+ config,
219
+ hostCallbacks(options.onAuthorizationUrl),
220
+ state,
221
+ boundPort,
222
+ );
223
+ try {
224
+ // SDK: discovery + (dynamic) client registration, then the
225
+ // authorization request — delivered to the host via
226
+ // redirectToAuthorization → onAuthorizationUrl (opens the browser).
227
+ await auth(provider, { serverUrl });
228
+
229
+ const resPromise = waitForCallback(state, options.signal);
230
+ // Eager no-op rejection sink: `waitForCallback` can reject before we
231
+ // reach the await (already-aborted signal), and an unhandled rejection
232
+ // during that window would crash the process. The awaited promise below
233
+ // still observes the same rejection.
234
+ resPromise.catch(() => undefined);
235
+ const { code } = await resPromise;
236
+
237
+ // SDK: exchange the code for tokens (persists via provider.saveTokens).
238
+ await auth(provider, { serverUrl, authorizationCode: code });
239
+ return { status: "authenticated" };
240
+ } catch (error) {
241
+ if (options.signal?.aborted) throw error;
242
+ console.warn(
243
+ `[archimedes/mcp] OAuth authentication for ${serverName} failed: ${describeError(error)}`,
244
+ );
245
+ return { status: "failed", error: describeError(error) };
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Return a currently-valid access token for the server, refreshing via the
251
+ * SDK when the stored token is expired.
252
+ *
253
+ * - no stored tokens → null
254
+ * - not expired (expiresAt absent or in the future) → accessToken
255
+ * - expired + config-stub guard (config `clientId` set without
256
+ * `clientSecret`) → null, no refresh attempt (ADR 0001)
257
+ * - otherwise → SDK-driven refresh, then re-read storage: fresh token, or
258
+ * null when the refresh produced no valid token
259
+ *
260
+ * Best-effort for connect-time bearer attachment: any error (storage
261
+ * failure, network, invalid grant) degrades to null rather than breaking
262
+ * the connection — a missing token surfaces as 401 → needs-auth.
263
+ */
264
+ export async function getValidToken(
265
+ serverName: string,
266
+ serverUrl: string,
267
+ config: McpOAuthConfig,
268
+ ): Promise<string | null> {
269
+ try {
270
+ const tokens = getAuthEntry(serverName)?.tokens;
271
+ if (!tokens) return null;
272
+
273
+ const nowSeconds = Math.floor(Date.now() / 1000);
274
+ const notExpired = tokens.expiresAt === undefined || tokens.expiresAt > nowSeconds;
275
+ if (notExpired) return tokens.accessToken;
276
+
277
+ // ADR 0001 config-stub guard: a pre-registered public client (clientId
278
+ // without secret) gets rejected at the token endpoint for a refresh
279
+ // (invalid_client). Do not attempt the refresh — tell the user to
280
+ // re-authenticate via /mcp auth instead.
281
+ if (config.clientId && !config.clientSecret) return null;
282
+
283
+ // SDK-driven refresh: tokens() surfaces the expired stored token
284
+ // (expires_in 0), the orchestrator runs the refresh_token grant, and
285
+ // saveTokens persists the replacement.
286
+ // Refresh never redirects, so the callback server is never bound and no
287
+ // callbackPort is passed — the default-port fallback is moot here.
288
+ const provider = new McpOAuthProvider(serverName, serverUrl, config, {});
289
+ await auth(provider, { serverUrl });
290
+
291
+ const refreshed = getAuthEntry(serverName)?.tokens;
292
+ const refreshedNow = Math.floor(Date.now() / 1000);
293
+ const stillBad =
294
+ refreshed === undefined ||
295
+ (refreshed.expiresAt !== undefined && refreshed.expiresAt <= refreshedNow);
296
+ if (stillBad) {
297
+ console.warn(
298
+ `[archimedes/mcp] OAuth token refresh for ${serverName} did not produce a valid token ` +
299
+ `— run /mcp auth to re-authenticate`,
300
+ );
301
+ return null;
302
+ }
303
+ return refreshed.accessToken;
304
+ } catch (error) {
305
+ console.warn(
306
+ `[archimedes/mcp] OAuth token check for ${serverName} failed: ${describeError(error)}`,
307
+ );
308
+ return null;
309
+ }
310
+ }
@@ -0,0 +1,309 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import open from "open";
7
+ import { openAuthUrl, reconnectAfterAuth, runAuthWithLoader } from "./auth-run.js";
8
+ import { loadMetadataCache, setCachePathForTest } from "./metadata-cache.js";
9
+ import type { ServerClient } from "./server-client.js";
10
+
11
+ // The real BorderedLoader needs a live TUI; a stub with the same surface
12
+ // (constructor message + onAbort) is enough to drive the auth runner.
13
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
14
+ BorderedLoader: class {
15
+ message: string;
16
+ onAbort?: () => void;
17
+ constructor(_tui: unknown, _theme: unknown, message: string) {
18
+ this.message = message;
19
+ }
20
+ dispose() {}
21
+ },
22
+ }));
23
+ vi.mock("open", () => ({ default: vi.fn().mockResolvedValue({}) }));
24
+
25
+ // The post-auth reconnect settles the ADR 0004 ledger (recordClientOutcome),
26
+ // which writes the cache file — point it at a temp dir so the real
27
+ // ~/.pi/agent/mcp-cache.json is never touched.
28
+ let tempDir: string;
29
+
30
+ beforeEach(() => {
31
+ tempDir = mkdtempSync(join(tmpdir(), "mcp-authrun-test-"));
32
+ setCachePathForTest(join(tempDir, "cache.json"));
33
+ });
34
+
35
+ afterEach(() => {
36
+ setCachePathForTest(null);
37
+ rmSync(tempDir, { recursive: true, force: true });
38
+ });
39
+
40
+ const AUTH_URL = "https://as.example/authorize?state=xyz";
41
+ const LABEL = "Authenticating srv… (esc to cancel)";
42
+
43
+ // ── fakes ────────────────────────────────────────────────────────────────────
44
+
45
+ interface FakeClientOpts {
46
+ /** success: resolves (optionally after onAuthorizationUrl settles);
47
+ * wait: hangs until the signal aborts (rejecting "OAuth cancelled");
48
+ * throw: rejects with `error`. */
49
+ outcome?: "success" | "wait" | "throw";
50
+ error?: string;
51
+ invokeAuthUrl?: boolean;
52
+ /** Status reported after close()+connect() (default: "connected"). */
53
+ statusAfterReconnect?: string;
54
+ toolCount?: number;
55
+ /** Make connect() reject (reconnect-failed case). */
56
+ reconnectError?: string;
57
+ }
58
+
59
+ function makeFakeClient(opts: FakeClientOpts = {}) {
60
+ const client = {
61
+ name: "srv",
62
+ status: "needs-auth" as string,
63
+ error: undefined as string | undefined,
64
+ tools: Array.from({ length: opts.toolCount ?? 0 }, (_, i) => ({
65
+ name: `t${i + 1}`,
66
+ serverName: "srv",
67
+ })),
68
+ close: vi.fn().mockResolvedValue(undefined),
69
+ connect: vi.fn().mockImplementation(async () => {
70
+ if (opts.reconnectError) {
71
+ // Mirror the real client: a thrown connect settles into "error".
72
+ client.status = "error";
73
+ client.error = opts.reconnectError;
74
+ throw new Error(opts.reconnectError);
75
+ }
76
+ client.status = opts.statusAfterReconnect ?? "connected";
77
+ client.error = undefined;
78
+ }),
79
+ authenticate: null as unknown as ReturnType<typeof vi.fn>,
80
+ };
81
+ client.authenticate = vi.fn(
82
+ (options?: { signal?: AbortSignal; onAuthorizationUrl?: (u: URL) => void | Promise<void> }) => {
83
+ if (options?.signal?.aborted) {
84
+ return Promise.reject(new Error("OAuth cancelled"));
85
+ }
86
+ switch (opts.outcome ?? "success") {
87
+ case "wait":
88
+ return new Promise<void>((_resolve, reject) => {
89
+ options?.signal?.addEventListener(
90
+ "abort",
91
+ () => reject(new Error("OAuth cancelled")),
92
+ { once: true },
93
+ );
94
+ });
95
+ case "throw":
96
+ return Promise.reject(new Error(opts.error ?? "boom"));
97
+ default:
98
+ if (opts.invokeAuthUrl) {
99
+ // Resolve only after the URL hook settles, so `open()` and the
100
+ // notification are guaranteed to have run before success.
101
+ return Promise.resolve(options?.onAuthorizationUrl?.(new URL(AUTH_URL))).then(
102
+ () => undefined,
103
+ );
104
+ }
105
+ return Promise.resolve();
106
+ }
107
+ },
108
+ );
109
+ return client;
110
+ }
111
+
112
+ interface FakeLoader {
113
+ message: string;
114
+ onAbort?: () => void;
115
+ }
116
+
117
+ interface CtxState {
118
+ notify: ReturnType<typeof vi.fn>;
119
+ custom: ReturnType<typeof vi.fn>;
120
+ lastLoader: () => FakeLoader | null;
121
+ }
122
+
123
+ /** Fake ExtensionContext: custom() runs the factory synchronously and
124
+ * resolves when done() is first called; the loader is captured. */
125
+ function makeCtx(): { ctx: ExtensionContext; state: CtxState } {
126
+ const state: Omit<CtxState, "lastLoader"> = { notify: vi.fn(), custom: vi.fn() };
127
+ let lastLoader: FakeLoader | null = null;
128
+ state.custom.mockImplementation(
129
+ (factory: (
130
+ tui: unknown,
131
+ theme: unknown,
132
+ keybindings: unknown,
133
+ done: (result: unknown) => void,
134
+ ) => unknown) => {
135
+ let resolve!: (result: unknown) => void;
136
+ const pending = new Promise<unknown>((r) => (resolve = r));
137
+ let settled = false;
138
+ const done = (result: unknown) => {
139
+ if (!settled) {
140
+ settled = true;
141
+ resolve(result);
142
+ }
143
+ };
144
+ lastLoader = factory({}, {}, {}, done) as FakeLoader | null;
145
+ return pending;
146
+ },
147
+ );
148
+ const ctx = {
149
+ hasUI: true,
150
+ ui: { notify: state.notify, custom: state.custom },
151
+ } as unknown as ExtensionContext;
152
+ return { ctx, state: { ...state, lastLoader: () => lastLoader } };
153
+ }
154
+
155
+ // ── runAuthWithLoader ────────────────────────────────────────────────────────
156
+
157
+ describe("runAuthWithLoader", () => {
158
+ it("opens the URL, notifies, reconnects, and returns the reconnected status", async () => {
159
+ const client = makeFakeClient({ outcome: "success", invokeAuthUrl: true, toolCount: 3 });
160
+ const { ctx, state } = makeCtx();
161
+ const outcome = await runAuthWithLoader(ctx, client as unknown as ServerClient, {
162
+ loaderLabel: LABEL,
163
+ });
164
+
165
+ expect(outcome).toEqual({ kind: "reconnected", status: "connected", tools: 3 });
166
+ // Loader shown with the caller-supplied label
167
+ expect(state.custom).toHaveBeenCalledTimes(1);
168
+ expect(state.lastLoader()!.message).toBe(LABEL);
169
+ // authenticate called once with an (unaborted) abort signal + URL hook
170
+ expect(client.authenticate).toHaveBeenCalledTimes(1);
171
+ const opts = client.authenticate.mock.calls[0]![0] as {
172
+ signal: AbortSignal;
173
+ onAuthorizationUrl: (u: URL) => Promise<void>;
174
+ };
175
+ expect(opts.signal.aborted).toBe(false);
176
+ // Browser opened for the auth URL, user notified of it
177
+ expect(open).toHaveBeenCalledWith(AUTH_URL);
178
+ expect(state.notify).toHaveBeenCalledWith(
179
+ `Opening browser… if it didn't open, visit: ${AUTH_URL}`,
180
+ "info",
181
+ );
182
+ // Reconnect to pick up the freshly stored token
183
+ expect(client.close).toHaveBeenCalledTimes(1);
184
+ expect(client.connect).toHaveBeenCalledTimes(1);
185
+ });
186
+
187
+ it("reports cancelled (no reconnect) when the loader is esc-closed", async () => {
188
+ const client = makeFakeClient({ outcome: "wait" });
189
+ const { ctx, state } = makeCtx();
190
+ const running = runAuthWithLoader(ctx, client as unknown as ServerClient, {
191
+ loaderLabel: LABEL,
192
+ });
193
+ await vi.waitFor(() => expect(client.authenticate).toHaveBeenCalledTimes(1));
194
+ const opts = client.authenticate.mock.calls[0]![0] as { signal: AbortSignal };
195
+ expect(opts.signal.aborted).toBe(false);
196
+
197
+ // Simulate Esc in the loader
198
+ state.lastLoader()!.onAbort!();
199
+ const outcome = await running;
200
+
201
+ expect(outcome).toEqual({ kind: "cancelled" });
202
+ expect(opts.signal.aborted).toBe(true);
203
+ expect(client.close).not.toHaveBeenCalled();
204
+ expect(client.connect).not.toHaveBeenCalled();
205
+ });
206
+
207
+ it("treats a flow rejection of exactly 'OAuth cancelled' as cancelled", async () => {
208
+ // Externally aborted flow rejects "OAuth cancelled" without the loader
209
+ // ever settling via onAbort.
210
+ const client = makeFakeClient({ outcome: "throw", error: "OAuth cancelled" });
211
+ const { ctx, state: s } = makeCtx();
212
+ const running = runAuthWithLoader(ctx, client as unknown as ServerClient, {
213
+ loaderLabel: LABEL,
214
+ });
215
+ // Esc is NOT pressed — only the flow rejection decides.
216
+ const outcome = await running;
217
+ expect(outcome).toEqual({ kind: "cancelled" });
218
+ expect(client.close).not.toHaveBeenCalled();
219
+ expect(s.notify).not.toHaveBeenCalled();
220
+ });
221
+
222
+ it("surfaces other flow failures as flow-error without reconnecting", async () => {
223
+ const client = makeFakeClient({ outcome: "throw", error: "token endpoint refused" });
224
+ const { ctx } = makeCtx();
225
+ const outcome = await runAuthWithLoader(ctx, client as unknown as ServerClient, {
226
+ loaderLabel: LABEL,
227
+ });
228
+ expect(outcome).toEqual({ kind: "flow-error", error: "token endpoint refused" });
229
+ expect(client.close).not.toHaveBeenCalled();
230
+ expect(client.connect).not.toHaveBeenCalled();
231
+ });
232
+
233
+ it("reports a failed reconnect as reconnect-failed with the error message", async () => {
234
+ const client = makeFakeClient({ reconnectError: "connection refused" });
235
+ const { ctx } = makeCtx();
236
+ const outcome = await runAuthWithLoader(ctx, client as unknown as ServerClient, {
237
+ loaderLabel: LABEL,
238
+ });
239
+ expect(outcome).toEqual({ kind: "reconnect-failed", error: "connection refused" });
240
+ expect(client.close).toHaveBeenCalledTimes(1);
241
+ });
242
+
243
+ it("carries the post-reconnect status (needs-auth recheck for ADR 0001)", async () => {
244
+ const client = makeFakeClient({ statusAfterReconnect: "needs-auth" });
245
+ const { ctx } = makeCtx();
246
+ const outcome = await runAuthWithLoader(ctx, client as unknown as ServerClient, {
247
+ loaderLabel: LABEL,
248
+ });
249
+ expect(outcome).toEqual({ kind: "reconnected", status: "needs-auth", tools: 0 });
250
+ });
251
+ });
252
+
253
+ // ── reconnectAfterAuth ───────────────────────────────────────────────────────
254
+
255
+ describe("reconnectAfterAuth", () => {
256
+ it("closes, reconnects, and snapshots status + tool count", async () => {
257
+ const client = makeFakeClient({ toolCount: 2 });
258
+ const outcome = await reconnectAfterAuth(client as unknown as ServerClient);
259
+ expect(outcome).toEqual({ kind: "reconnected", status: "connected", tools: 2 });
260
+ expect(client.close).toHaveBeenCalledTimes(1);
261
+ expect(client.connect).toHaveBeenCalledTimes(1);
262
+ });
263
+
264
+ it("returns the close error as reconnect-failed", async () => {
265
+ const client = makeFakeClient();
266
+ client.close.mockRejectedValue(new Error("socket hang up"));
267
+ const outcome = await reconnectAfterAuth(client as unknown as ServerClient);
268
+ expect(outcome).toEqual({ kind: "reconnect-failed", error: "socket hang up" });
269
+ expect(client.connect).not.toHaveBeenCalled();
270
+ });
271
+
272
+ it("records 'connected' in the ADR 0004 ledger after a successful reconnect", async () => {
273
+ const client = makeFakeClient({ statusAfterReconnect: "connected", toolCount: 2 });
274
+ const outcome = await reconnectAfterAuth(client as unknown as ServerClient);
275
+ expect(outcome).toEqual({ kind: "reconnected", status: "connected", tools: 2 });
276
+ // The persisted outcome ledger (ADR 0004) must reflect the settled
277
+ // connection — a stale "needs-auth" here would stick across sessions.
278
+ const rec = loadMetadataCache().serverStatuses?.["srv"];
279
+ expect(rec?.status).toBe("connected");
280
+ expect(rec?.at).toBeTypeOf("number");
281
+ expect(rec?.error).toBeUndefined();
282
+ });
283
+
284
+ it("records 'error' with the failure message when the post-auth reconnect fails", async () => {
285
+ const client = makeFakeClient({ reconnectError: "connection refused" });
286
+ const outcome = await reconnectAfterAuth(client as unknown as ServerClient);
287
+ expect(outcome).toEqual({ kind: "reconnect-failed", error: "connection refused" });
288
+ // The fake settles into "error" like the real client does for a thrown
289
+ // connect — recordClientOutcome maps that to a recorded failure.
290
+ const rec = loadMetadataCache().serverStatuses?.["srv"];
291
+ expect(rec?.status).toBe("error");
292
+ expect(rec?.error).toBe("connection refused");
293
+ expect(rec?.at).toBeTypeOf("number");
294
+ });
295
+ });
296
+
297
+ // ── openAuthUrl ──────────────────────────────────────────────────────────────
298
+
299
+ describe("openAuthUrl", () => {
300
+ it("opens the URL in the browser", async () => {
301
+ await expect(openAuthUrl(AUTH_URL)).resolves.toBeUndefined();
302
+ expect(open).toHaveBeenCalledWith(AUTH_URL);
303
+ });
304
+
305
+ it("swallows browser-open failures", async () => {
306
+ vi.mocked(open).mockRejectedValueOnce(new Error("no browser"));
307
+ await expect(openAuthUrl(AUTH_URL)).resolves.toBeUndefined();
308
+ });
309
+ });