@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
package/src/auth-flow.ts
ADDED
|
@@ -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
|
+
});
|