@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,330 @@
1
+ /**
2
+ * OAuth credential storage backed by the OS keyring.
3
+ *
4
+ * Tokens are persisted via @napi-rs/keyring (macOS Keychain, Windows
5
+ * Credential Manager, or the Linux Secret Service). Storage is fail-closed:
6
+ * when the credential store is unavailable (no libsecret, headless session,
7
+ * revoked keyring) every operation throws a clear error — there is never a
8
+ * plaintext fallback.
9
+ *
10
+ * Windows Credential Manager caps each value at 1280 characters, so payloads
11
+ * over {@link AUTH_SECRET_CHUNK_SIZE} are split into 1000-char chunks:
12
+ * - chunk i → account `<account>.chunk.<digest>.<i>`
13
+ * - main account → manifest `{ __chunks: 1, chunkCount, chunkDigest }`
14
+ * where `digest` is the first 16 hex chars of sha256(payload). Overwrites and
15
+ * deletes remove stale chunks from the previous group (known from the
16
+ * previous manifest, or defensibly from the previous plain payload's own
17
+ * digest when no manifest is present).
18
+ */
19
+
20
+ import { createHash } from "node:crypto";
21
+
22
+ import { Entry } from "@napi-rs/keyring";
23
+
24
+ import type { AuthEntry } from "./oauth-types.js";
25
+
26
+ /**
27
+ * Service name for all auth entries. Deliberately different from the
28
+ * reference adapter's `pi-mcp-adapter.oauth` so both adapters can coexist
29
+ * on the same machine.
30
+ */
31
+ const AUTH_SECRET_SERVICE = "pi-archimedes-mcp.oauth";
32
+
33
+ /** Windows Credential Manager value cap is 1280 chars; keep headroom. */
34
+ const AUTH_SECRET_CHUNK_SIZE = 1000;
35
+
36
+ /** Marker on the manifest JSON stored at the main account for chunked payloads. */
37
+ const AUTH_CHUNK_MANIFEST_KEY = "__chunks";
38
+
39
+ const UNAVAILABLE_MESSAGE =
40
+ "OS credential store unavailable — cannot store OAuth tokens securely";
41
+
42
+ interface ChunkManifest {
43
+ __chunks: 1;
44
+ chunkCount: number;
45
+ chunkDigest: string;
46
+ }
47
+
48
+ /** A group of chunk accounts that a main-account payload may reference. */
49
+ interface ChunkGroup {
50
+ digest: string;
51
+ count: number;
52
+ }
53
+
54
+ // In-memory cache keyed by server name. Caches both presence and absence so
55
+ // the SDK's per-request token reads don't hammer the credential daemon.
56
+ // save/delete update it explicitly; out-of-process changes are not observed
57
+ // until the process restarts.
58
+ const authEntryCache = new Map<string, AuthEntry | undefined>();
59
+
60
+ function cloneAuthEntry(entry: AuthEntry | undefined): AuthEntry | undefined {
61
+ return entry === undefined ? undefined : structuredClone(entry);
62
+ }
63
+
64
+ /** Deterministic per-server account: `sha256-<hex sha256 of serverName>`. */
65
+ function getAuthEntryAccount(serverName: string): string {
66
+ return `sha256-${createHash("sha256").update(serverName, "utf8").digest("hex")}`;
67
+ }
68
+
69
+ function getChunkAccount(account: string, chunkDigest: string, index: number): string {
70
+ return `${account}.chunk.${chunkDigest}.${index}`;
71
+ }
72
+
73
+ /** First 16 hex chars of sha256(payload) — identifies a chunk group. */
74
+ function digestPayload(payload: string): string {
75
+ return createHash("sha256").update(payload, "utf8").digest("hex").slice(0, 16);
76
+ }
77
+
78
+ function chunkCountForPayload(payload: string): number {
79
+ return Math.ceil(payload.length / AUTH_SECRET_CHUNK_SIZE);
80
+ }
81
+
82
+ /**
83
+ * A main-account payload's chunk group. A manifest references its own
84
+ * chunkDigest/count; a plain payload references no chunks of its own, but
85
+ * grouping it under (its own digest, ceil(len/CHUNK_SIZE)) lets overwrites
86
+ * and deletes defensively sweep orphans when no manifest is present.
87
+ */
88
+ function chunkGroupForPayload(payload: string): ChunkGroup {
89
+ const manifest = parseManifest(payload);
90
+ if (manifest) {
91
+ return { digest: manifest.chunkDigest, count: manifest.chunkCount };
92
+ }
93
+ return { digest: digestPayload(payload), count: chunkCountForPayload(payload) };
94
+ }
95
+
96
+ function removeChunkGroup(account: string, group: ChunkGroup): void {
97
+ for (let i = 0; i < group.count; i++) {
98
+ removeSecret(getChunkAccount(account, group.digest, i));
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Classify an error thrown by Entry.getPassword() as "entry absent".
104
+ *
105
+ * @napi-rs/keyring v1.3.0 never throws from the read path — its Rust
106
+ * `get_password()` is `inner.get_password().ok()`, so a missing entry is
107
+ * the `null` return handled in {@link readSecret}. This is therefore a
108
+ * DEFENSIVE fallback for backends (or future versions) that throw on miss.
109
+ *
110
+ * The patterns are the specific "item absent" phrasings of this stack —
111
+ * keyring-core's canonical `NoEntry` message ("No matching credential
112
+ * found"), plus narrow legacy equivalents. Deliberately EXCLUDED because
113
+ * they appear in genuine store failures that must stay fail-closed:
114
+ * bare "no such" ("No such file or directory (os error 2)"), bare
115
+ * "not found" (D-Bus "Match rule not found"), and generic "missing".
116
+ *
117
+ * Accepted false-positive tradeoff: if a genuine store failure on the READ
118
+ * path coincidentally contains one of these phrasings, it is misclassified
119
+ * as "no entry" — getAuthEntry returns undefined and the user re-auths
120
+ * (fail-open on read only). The write and delete paths never run this
121
+ * classification and remain fail-closed (removeSecret is deliberately
122
+ * best-effort by design).
123
+ */
124
+ function isMissingEntryError(error: unknown): boolean {
125
+ const message = error instanceof Error ? error.message : String(error);
126
+ return /no matching credential found|does not exist|no such entry|no entry/i.test(message);
127
+ }
128
+
129
+ function unavailableError(cause?: unknown): Error {
130
+ return new Error(UNAVAILABLE_MESSAGE, cause ? { cause } : undefined);
131
+ }
132
+
133
+ /**
134
+ * Read a secret. Returns undefined when the entry does not exist. Throws
135
+ * {@link UNAVAILABLE_MESSAGE} when the keyring itself is inaccessible.
136
+ */
137
+ function readSecret(account: string): string | undefined {
138
+ let entry: Entry;
139
+ try {
140
+ entry = new Entry(AUTH_SECRET_SERVICE, account);
141
+ } catch (error) {
142
+ throw unavailableError(error);
143
+ }
144
+ try {
145
+ // @napi-rs/keyring v1.3.0 returns null for a missing entry and never
146
+ // throws from the read path — the catch below is a defensive fallback
147
+ // for backends that do throw on miss (see isMissingEntryError).
148
+ const password = entry.getPassword();
149
+ return password === null || password === undefined ? undefined : password;
150
+ } catch (error) {
151
+ if (isMissingEntryError(error)) return undefined;
152
+ throw unavailableError(error);
153
+ }
154
+ }
155
+
156
+ function writeSecret(account: string, password: string): void {
157
+ let entry: Entry;
158
+ try {
159
+ entry = new Entry(AUTH_SECRET_SERVICE, account);
160
+ } catch (error) {
161
+ throw unavailableError(error);
162
+ }
163
+ try {
164
+ entry.setPassword(password);
165
+ } catch (error) {
166
+ throw unavailableError(error);
167
+ }
168
+ }
169
+
170
+ /** Best-effort delete for stale chunks: a missing entry must not fail the caller. */
171
+ function removeSecret(account: string): void {
172
+ try {
173
+ new Entry(AUTH_SECRET_SERVICE, account).deletePassword();
174
+ } catch {
175
+ // Missing entry (or transiently unavailable store) — best-effort cleanup.
176
+ }
177
+ }
178
+
179
+ /** Parse a main-account payload as a chunk manifest, if it is one. */
180
+ function parseManifest(payload: string): ChunkManifest | undefined {
181
+ let parsed: unknown;
182
+ try {
183
+ parsed = JSON.parse(payload);
184
+ } catch {
185
+ return undefined;
186
+ }
187
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
188
+ return undefined;
189
+ }
190
+ const candidate = parsed as Partial<ChunkManifest> & Record<string, unknown>;
191
+ if (candidate[AUTH_CHUNK_MANIFEST_KEY] !== 1) return undefined;
192
+ if (
193
+ typeof candidate.chunkCount !== "number" ||
194
+ !Number.isInteger(candidate.chunkCount) ||
195
+ candidate.chunkCount <= 0
196
+ ) {
197
+ return undefined;
198
+ }
199
+ if (typeof candidate.chunkDigest !== "string" || candidate.chunkDigest.length === 0) {
200
+ return undefined;
201
+ }
202
+ return candidate as ChunkManifest;
203
+ }
204
+
205
+ function parseAuthEntry(payload: string): AuthEntry {
206
+ let parsed: unknown;
207
+ try {
208
+ parsed = JSON.parse(payload);
209
+ } catch {
210
+ throw new Error(`Corrupt OAuth entry in the OS credential store: payload is not valid JSON`);
211
+ }
212
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
213
+ throw new Error(`Corrupt OAuth entry in the OS credential store: payload is not a JSON object`);
214
+ }
215
+ return parsed as AuthEntry;
216
+ }
217
+
218
+ /** Read every chunk of a manifest group and reassemble the payload in order. */
219
+ function readChunkedEntry(account: string, manifest: ChunkManifest): AuthEntry {
220
+ let payload = "";
221
+ for (let i = 0; i < manifest.chunkCount; i++) {
222
+ const chunk = readSecret(getChunkAccount(account, manifest.chunkDigest, i));
223
+ if (chunk === undefined) {
224
+ throw new Error(
225
+ `Corrupt OAuth entry in the OS credential store: missing chunk ${i} of ${manifest.chunkCount}`,
226
+ );
227
+ }
228
+ payload += chunk;
229
+ }
230
+ return parseAuthEntry(payload);
231
+ }
232
+
233
+ /**
234
+ * Read the stored auth entry for a server. Returns undefined when the server
235
+ * has no stored entry. Throws when the credential store is unavailable
236
+ * (fail-closed) or the stored payload is corrupt.
237
+ */
238
+ export function getAuthEntry(serverName: string): AuthEntry | undefined {
239
+ if (authEntryCache.has(serverName)) {
240
+ return cloneAuthEntry(authEntryCache.get(serverName));
241
+ }
242
+
243
+ const account = getAuthEntryAccount(serverName);
244
+ const payload = readSecret(account);
245
+ let entry: AuthEntry | undefined;
246
+ if (payload !== undefined) {
247
+ const manifest = parseManifest(payload);
248
+ entry = manifest
249
+ ? readChunkedEntry(account, manifest)
250
+ : parseAuthEntry(payload);
251
+ }
252
+
253
+ // Cache the result — presence AND absence — to short-circuit later reads.
254
+ authEntryCache.set(serverName, entry);
255
+ return cloneAuthEntry(entry);
256
+ }
257
+
258
+ /**
259
+ * Persist the auth entry for a server, chunking large payloads. Replaces any
260
+ * previously stored entry and removes its stale chunks. Throws when the
261
+ * credential store is unavailable (fail-closed, no plaintext fallback).
262
+ */
263
+ export function saveAuthEntry(serverName: string, entry: AuthEntry, serverUrl?: string): void {
264
+ if (serverUrl) entry.serverUrl = serverUrl;
265
+
266
+ const account = getAuthEntryAccount(serverName);
267
+ const payload = JSON.stringify(entry);
268
+
269
+ // Read the previous state before overwriting: a newer smaller payload
270
+ // replaces the manifest, so the old chunk group must be learned from the
271
+ // previous main-account content while it is still readable.
272
+ const previousPayload = readSecret(account);
273
+ const previousGroup =
274
+ previousPayload !== undefined ? chunkGroupForPayload(previousPayload) : undefined;
275
+
276
+ const newGroup: ChunkGroup | undefined =
277
+ payload.length > AUTH_SECRET_CHUNK_SIZE
278
+ ? { digest: digestPayload(payload), count: chunkCountForPayload(payload) }
279
+ : undefined;
280
+
281
+ try {
282
+ if (newGroup) {
283
+ const manifest: ChunkManifest = {
284
+ __chunks: 1,
285
+ chunkCount: newGroup.count,
286
+ chunkDigest: newGroup.digest,
287
+ };
288
+ for (let i = 0; i < newGroup.count; i++) {
289
+ writeSecret(
290
+ getChunkAccount(account, newGroup.digest, i),
291
+ payload.slice(i * AUTH_SECRET_CHUNK_SIZE, (i + 1) * AUTH_SECRET_CHUNK_SIZE),
292
+ );
293
+ }
294
+ // Manifest last: a crash mid-write leaves the previous consistent state.
295
+ writeSecret(account, JSON.stringify(manifest));
296
+ } else {
297
+ writeSecret(account, payload);
298
+ }
299
+ } catch (error) {
300
+ // Incomplete write: clean up any chunks of the new group already written.
301
+ if (newGroup) removeChunkGroup(account, newGroup);
302
+ throw error;
303
+ }
304
+
305
+ // Stale-chunk cleanup: only when the chunk group changed. Equal digests
306
+ // mean an identical payload, whose chunks, if any, were just rewritten.
307
+ const newDigest = newGroup ? newGroup.digest : digestPayload(payload);
308
+ if (previousGroup && previousGroup.digest !== newDigest) {
309
+ removeChunkGroup(account, previousGroup);
310
+ }
311
+
312
+ authEntryCache.set(serverName, cloneAuthEntry(entry));
313
+ }
314
+
315
+ /**
316
+ * Delete the stored auth entry for a server: manifest, all of its chunks,
317
+ * and the main account. Idempotent for servers that were never saved.
318
+ * Throws when the credential store is unavailable (fail-closed).
319
+ */
320
+ export function deleteAuthEntry(serverName: string): void {
321
+ const account = getAuthEntryAccount(serverName);
322
+
323
+ const payload = readSecret(account);
324
+ if (payload !== undefined) {
325
+ removeChunkGroup(account, chunkGroupForPayload(payload));
326
+ }
327
+ removeSecret(account);
328
+
329
+ authEntryCache.delete(serverName);
330
+ }
@@ -0,0 +1,231 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import open from "open";
4
+ import { autoAuthenticate, needsAuthToolResult } from "./auto-auth.js";
5
+ import type { ServerClient } from "./server-client.js";
6
+
7
+ // The real BorderedLoader needs a live TUI; a stub with the same surface
8
+ // (constructor message + onAbort) is enough to drive the auto-auth flow.
9
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
10
+ BorderedLoader: class {
11
+ message: string;
12
+ onAbort?: () => void;
13
+ constructor(_tui: unknown, _theme: unknown, message: string) {
14
+ this.message = message;
15
+ }
16
+ dispose() {}
17
+ },
18
+ }));
19
+ vi.mock("open", () => ({ default: vi.fn().mockResolvedValue({}) }));
20
+
21
+ const AUTH_URL = "https://as.example/authorize?state=xyz";
22
+
23
+ // ── fakes ────────────────────────────────────────────────────────────────────
24
+
25
+ interface FakeClientOpts {
26
+ /** success: resolves; wait: hangs until the signal aborts; throw: rejects with `error`. */
27
+ outcome?: "success" | "wait" | "throw";
28
+ error?: string;
29
+ /** Status the client reports after close()+connect() (default: "connected"). */
30
+ statusAfterReconnect?: string;
31
+ }
32
+
33
+ interface FakeClient {
34
+ name: string;
35
+ status: string;
36
+ error: string | null;
37
+ tools: never[];
38
+ close: ReturnType<typeof vi.fn>;
39
+ connect: ReturnType<typeof vi.fn>;
40
+ authenticate: ReturnType<typeof vi.fn>;
41
+ }
42
+
43
+ /** Minimal needs-auth ServerClient fake — authenticate/close/connect are scripted. */
44
+ function makeFakeClient(opts: FakeClientOpts = {}): FakeClient {
45
+ const client: FakeClient = {
46
+ name: "srv",
47
+ status: "needs-auth",
48
+ error: "authentication required or token rejected",
49
+ tools: [],
50
+ close: vi.fn(async () => {}),
51
+ connect: vi.fn(async () => {
52
+ client.status = opts.statusAfterReconnect ?? "connected";
53
+ }),
54
+ authenticate: vi.fn(),
55
+ };
56
+ client.authenticate.mockImplementation(
57
+ (options?: { signal?: AbortSignal; onAuthorizationUrl?: (u: URL) => void | Promise<void> }) => {
58
+ if (options?.signal?.aborted) return Promise.reject(new Error("OAuth cancelled"));
59
+ switch (opts.outcome ?? "success") {
60
+ case "wait":
61
+ // Hangs until the signal aborts — like a browser flow awaiting a callback.
62
+ return new Promise<void>((_resolve, reject) => {
63
+ options?.signal?.addEventListener(
64
+ "abort",
65
+ () => reject(new Error("OAuth cancelled")),
66
+ { once: true },
67
+ );
68
+ });
69
+ case "throw":
70
+ return Promise.reject(new Error(opts.error ?? "boom"));
71
+ default:
72
+ return Promise.resolve();
73
+ }
74
+ },
75
+ );
76
+ return client;
77
+ }
78
+
79
+ interface CtxState {
80
+ notify: ReturnType<typeof vi.fn>;
81
+ custom: ReturnType<typeof vi.fn>;
82
+ lastLoader: () => { message: string; onAbort?: () => void } | null;
83
+ }
84
+
85
+ /** Fake ExtensionContext: custom() runs the factory synchronously and
86
+ * resolves when done() is first called; the loader is captured. */
87
+ function makeCtx(hasUI: boolean): { ctx: ExtensionContext; state: CtxState } {
88
+ const state: Omit<CtxState, "lastLoader"> = { notify: vi.fn(), custom: vi.fn() };
89
+ let lastLoader: { message: string; onAbort?: () => void } | null = null;
90
+ state.custom.mockImplementation(
91
+ (factory: (
92
+ tui: unknown,
93
+ theme: unknown,
94
+ keybindings: unknown,
95
+ done: (result: unknown) => void,
96
+ ) => unknown) => {
97
+ let resolve!: (result: unknown) => void;
98
+ const pending = new Promise<unknown>((r) => (resolve = r));
99
+ let settled = false;
100
+ const done = (result: unknown) => {
101
+ if (!settled) {
102
+ settled = true;
103
+ resolve(result);
104
+ }
105
+ };
106
+ lastLoader = factory({}, {}, {}, done) as { message: string; onAbort?: () => void } | null;
107
+ return pending;
108
+ },
109
+ );
110
+ const ctx = {
111
+ hasUI,
112
+ signal: new AbortController().signal,
113
+ ui: { notify: state.notify, custom: state.custom },
114
+ } as unknown as ExtensionContext;
115
+ return { ctx, state: { ...state, lastLoader: () => lastLoader } };
116
+ }
117
+
118
+ // ── needsAuthToolResult ──────────────────────────────────────────────────────
119
+
120
+ describe("needsAuthToolResult", () => {
121
+ it("is guidance (isError false) pointing at /mcp auth <server>", () => {
122
+ const r = needsAuthToolResult("auth-srv");
123
+ expect(r.isError).toBe(false);
124
+ expect(r.details).toEqual({ server: "auth-srv", status: "needs-auth" });
125
+ const text = r.content[0]!.text;
126
+ expect(text).toContain("requires authentication");
127
+ expect(text).toContain("/mcp auth auth-srv");
128
+ expect(text).not.toContain("Auto-auth failed");
129
+ });
130
+
131
+ it("includes the auto-auth error when provided", () => {
132
+ const r = needsAuthToolResult("auth-srv", "OAuth cancelled");
133
+ const text = r.content[0]!.text;
134
+ expect(text).toContain("OAuth cancelled");
135
+ expect(text).toContain("/mcp auth auth-srv");
136
+ expect(r.isError).toBe(false);
137
+ });
138
+ });
139
+
140
+ // ── autoAuthenticate ─────────────────────────────────────────────────────────
141
+
142
+ describe("autoAuthenticate", () => {
143
+ it("runs the flow through a BorderedLoader, opens the URL, and reconnects on success", async () => {
144
+ const client = makeFakeClient();
145
+ const { ctx, state } = makeCtx(true);
146
+ const outcome = await autoAuthenticate(ctx, client as unknown as ServerClient);
147
+
148
+ expect(outcome.proceed).toBe(true);
149
+ expect(outcome.error).toBeUndefined();
150
+ expect(state.custom).toHaveBeenCalledTimes(1);
151
+ expect(state.lastLoader()!.message).toContain("srv");
152
+ expect(client.authenticate).toHaveBeenCalledTimes(1);
153
+ const opts = client.authenticate.mock.calls[0]![0] as {
154
+ signal: AbortSignal;
155
+ onAuthorizationUrl: (u: URL) => Promise<void>;
156
+ };
157
+ expect(opts.signal.aborted).toBe(false);
158
+ // Authorization URL: open the browser and notify the user of it
159
+ await opts.onAuthorizationUrl(new URL(AUTH_URL));
160
+ expect(open).toHaveBeenCalledWith(AUTH_URL);
161
+ expect(state.notify).toHaveBeenCalledWith(expect.stringContaining(AUTH_URL), "info");
162
+ // Reconnect to pick up the freshly stored token (mirrors /mcp auth)
163
+ expect(client.close).toHaveBeenCalledTimes(1);
164
+ expect(client.connect).toHaveBeenCalledTimes(1);
165
+ });
166
+
167
+ it("esc aborts the flow, reports cancellation, and does not reconnect", async () => {
168
+ const client = makeFakeClient({ outcome: "wait" });
169
+ const { ctx, state } = makeCtx(true);
170
+ const running = autoAuthenticate(ctx, client as unknown as ServerClient);
171
+ await vi.waitFor(() => expect(client.authenticate).toHaveBeenCalledTimes(1));
172
+ const opts = client.authenticate.mock.calls[0]![0] as { signal: AbortSignal };
173
+ expect(opts.signal.aborted).toBe(false);
174
+
175
+ // Simulate Esc in the loader
176
+ state.lastLoader()!.onAbort!();
177
+ const outcome = await running;
178
+
179
+ expect(opts.signal.aborted).toBe(true);
180
+ expect(outcome.proceed).toBe(false);
181
+ expect(outcome.error).toBe("OAuth cancelled");
182
+ expect(client.close).not.toHaveBeenCalled();
183
+ expect(client.connect).not.toHaveBeenCalled();
184
+ });
185
+
186
+ it("surfaces flow failures without throwing and without reconnecting", async () => {
187
+ const client = makeFakeClient({ outcome: "throw", error: "token endpoint refused" });
188
+ const { ctx } = makeCtx(true);
189
+ const outcome = await autoAuthenticate(ctx, client as unknown as ServerClient);
190
+ expect(outcome.proceed).toBe(false);
191
+ expect(outcome.error).toBe("token endpoint refused");
192
+ expect(client.close).not.toHaveBeenCalled();
193
+ expect(client.connect).not.toHaveBeenCalled();
194
+ });
195
+
196
+ it("runs headless (no loader) when the context has no UI", async () => {
197
+ const client = makeFakeClient();
198
+ const { ctx, state } = makeCtx(false);
199
+ const outcome = await autoAuthenticate(ctx, client as unknown as ServerClient);
200
+ expect(outcome.proceed).toBe(true);
201
+ expect(state.custom).not.toHaveBeenCalled();
202
+ expect(client.authenticate).toHaveBeenCalledTimes(1);
203
+ expect(client.close).toHaveBeenCalledTimes(1);
204
+ expect(client.connect).toHaveBeenCalledTimes(1);
205
+ });
206
+
207
+ it("returns the error headless when the flow fails", async () => {
208
+ const client = makeFakeClient({ outcome: "throw", error: "keyring unavailable" });
209
+ const { ctx } = makeCtx(false);
210
+ const outcome = await autoAuthenticate(ctx, client as unknown as ServerClient);
211
+ expect(outcome).toEqual({ proceed: false, error: "keyring unavailable" });
212
+ expect(client.close).not.toHaveBeenCalled();
213
+ });
214
+
215
+ it("reports when the reconnected server still needs auth (ADR 0001 re-auth loop)", async () => {
216
+ const client = makeFakeClient({ statusAfterReconnect: "needs-auth" });
217
+ const { ctx } = makeCtx(false);
218
+ const outcome = await autoAuthenticate(ctx, client as unknown as ServerClient);
219
+ expect(outcome.proceed).toBe(false);
220
+ expect(outcome.error).toMatch(/still/i);
221
+ });
222
+
223
+ it("reports a failed reconnect after successful authentication", async () => {
224
+ const client = makeFakeClient();
225
+ client.connect.mockRejectedValue(new Error("connection refused"));
226
+ const { ctx } = makeCtx(false);
227
+ const outcome = await autoAuthenticate(ctx, client as unknown as ServerClient);
228
+ expect(outcome.proceed).toBe(false);
229
+ expect(outcome.error).toContain("connection refused");
230
+ });
231
+ });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Tool-call-time `needs-auth` handling, shared by the `mcp` proxy's call
3
+ * action and the direct-tool executor (plan-026, Task 7).
4
+ *
5
+ * When the owning server's client is in the `needs-auth` state at call time:
6
+ *
7
+ * - autoAuth disabled (default): no authentication is initiated; the caller
8
+ * returns guidance content (`isError: false` — guidance, not a crash)
9
+ * telling the user to run `/mcp auth <server>`.
10
+ * - autoAuth enabled: `ServerClient.authenticate` (the SINGLE auth entry
11
+ * point — never `auth-flow.authenticate` directly) is called inline and the
12
+ * caller retries the tool call once. A `BorderedLoader` (tui.md Pattern 2,
13
+ * same pattern as `/mcp auth`) shows progress when the execute context has
14
+ * UI; esc aborts the flow. Headless contexts run the flow plainly and the
15
+ * authorization URL is opened directly.
16
+ *
17
+ * The loader/cancel/reconnect machinery is shared with `/mcp auth` in
18
+ * `auth-run.ts`; this module maps the structured outcome onto the tool's
19
+ * `{ proceed, error }` guidance result.
20
+ */
21
+ import { type ExtensionContext } from "@earendil-works/pi-coding-agent";
22
+ import {
23
+ openAuthUrl,
24
+ reconnectAfterAuth,
25
+ runAuthWithLoader,
26
+ type AuthRunOutcome,
27
+ } from "./auth-run.js";
28
+ import type { AuthenticateOptions } from "./auth-flow.js";
29
+ import type { ServerClient } from "./server-client.js";
30
+
31
+ /** Tool result for a needs-auth server: guidance, not a crash. */
32
+ export interface NeedsAuthToolResult {
33
+ content: Array<{ type: "text"; text: string }>;
34
+ details: { server: string; status: "needs-auth" };
35
+ isError: false;
36
+ }
37
+
38
+ /**
39
+ * Guidance result for a needs-auth server. `autoAuthError` is only set when
40
+ * autoAuth was enabled but the inline flow was cancelled or failed.
41
+ */
42
+ export function needsAuthToolResult(
43
+ serverName: string,
44
+ autoAuthError?: string,
45
+ ): NeedsAuthToolResult {
46
+ const lines = [`MCP server "${serverName}" requires authentication.`];
47
+ if (autoAuthError !== undefined) lines.push(`Auto-auth failed: ${autoAuthError}`);
48
+ lines.push(`Run /mcp auth ${serverName} to authenticate, then retry this call.`);
49
+ return {
50
+ content: [{ type: "text" as const, text: lines.join("\n") }],
51
+ details: { server: serverName, status: "needs-auth" },
52
+ isError: false,
53
+ };
54
+ }
55
+
56
+ /** Outcome of an inline auto-auth attempt. */
57
+ export interface AutoAuthOutcome {
58
+ /** True when the flow succeeded AND the client was reconnected — the caller should retry the call once. */
59
+ proceed: boolean;
60
+ /** Error text for the guidance result when proceed is false. */
61
+ error?: string;
62
+ }
63
+
64
+ function toMessage(e: unknown): string {
65
+ return e instanceof Error ? e.message : String(e);
66
+ }
67
+
68
+ /** Map the shared auth-run outcome onto the tool's guidance result. */
69
+ function toAutoAuthOutcome(outcome: AuthRunOutcome, serverName: string): AutoAuthOutcome {
70
+ switch (outcome.kind) {
71
+ case "cancelled":
72
+ return { proceed: false, error: "OAuth cancelled" };
73
+ case "flow-error":
74
+ return { proceed: false, error: outcome.error };
75
+ case "reconnect-failed":
76
+ return {
77
+ proceed: false,
78
+ error: `authenticated, but reconnecting ${serverName} failed: ${outcome.error}`,
79
+ };
80
+ case "reconnected":
81
+ if (outcome.status === "needs-auth") {
82
+ // The freshly stored token was rejected immediately — e.g. a
83
+ // pre-registered public client whose session needs interactive
84
+ // re-auth (ADR 0001). Guidance points back at /mcp auth.
85
+ return {
86
+ proceed: false,
87
+ error: "auth succeeded, but the server still requires authentication",
88
+ };
89
+ }
90
+ return { proceed: true };
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Run `ServerClient.authenticate` inline.
96
+ *
97
+ * With a UI context the flow is wrapped in a `BorderedLoader` (same pattern
98
+ * as `/mcp auth`): esc aborts the flow and the cancellation is reported as
99
+ * "OAuth cancelled". Without one (print/RPC mode) the flow runs plainly,
100
+ * tied to the agent's abort signal when streaming.
101
+ *
102
+ * On success the client is closed and reconnected so the freshly stored
103
+ * token is re-read into the Bearer header (mirrors `/mcp auth`). Cancellation,
104
+ * flow failure, and reconnect failure are returned as errors rather than
105
+ * thrown into the tool call.
106
+ */
107
+ export async function autoAuthenticate(
108
+ ctx: ExtensionContext | undefined,
109
+ client: ServerClient,
110
+ ): Promise<AutoAuthOutcome> {
111
+ if (ctx?.hasUI) {
112
+ return toAutoAuthOutcome(
113
+ await runAuthWithLoader(ctx, client, {
114
+ loaderLabel: `Authenticating ${client.name}… (esc to cancel)`,
115
+ }),
116
+ client.name,
117
+ );
118
+ }
119
+
120
+ try {
121
+ // Headless: no loader to cancel with — when the agent is streaming,
122
+ // tie the flow to its abort signal instead.
123
+ const opts: AuthenticateOptions = {
124
+ onAuthorizationUrl: (url: URL) => openAuthUrl(url.toString()),
125
+ };
126
+ if (ctx?.signal) opts.signal = ctx.signal;
127
+ await client.authenticate(opts);
128
+ } catch (e) {
129
+ return { proceed: false, error: toMessage(e) };
130
+ }
131
+
132
+ // Success: close + reconnect so the fresh token is used immediately
133
+ // (connect re-reads the keyring for the Bearer header — mirrors /mcp auth)
134
+ return toAutoAuthOutcome(await reconnectAfterAuth(client), client.name);
135
+ }