@pi-archimedes/mcp 2.5.1 → 2.6.3

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/src/auth-run.ts CHANGED
@@ -1,11 +1,20 @@
1
1
  /**
2
- * Shared OAuth run (plan-026): the single place that wraps
3
- * `ServerClient.authenticate` — the SINGLE auth entry point — behind the
4
- * `BorderedLoader` progress UI (tui.md Pattern 2), the loader's
5
- * `onAbort` `AbortController` cancellation, the "Opening browser…"
6
- * notification for the authorization URL, and the post-auth
7
- * close+reconnect that re-reads the freshly stored token from the keyring
8
- * into the Bearer header.
2
+ * Shared OAuth run: the single place that wraps `ServerClient.authenticate`
3
+ * — the SINGLE auth entry point — behind a visible status indicator and
4
+ * the post-auth close+reconnect that re-reads the freshly stored token from
5
+ * the keyring into the Bearer header.
6
+ *
7
+ * Design (fixes the BorderedLoader bug):
8
+ * - The authorization URL is surfaced via `ctx.ui.notify` BEFORE `open()` is
9
+ * called, so the user always sees it (the old BorderedLoader approach
10
+ * silently swallowed the notify because the loader re-painted over it).
11
+ * - Progress is shown via `ctx.ui.setStatus` (non-blocking) instead of a
12
+ * custom UI that owns the screen.
13
+ * - An `onAuthorizationInput` fallback is wired up so remote/headless users
14
+ * (where the browser redirect can't reach the local callback server) can
15
+ * paste the full callback URL and complete the flow.
16
+ * - Cancellation is driven by an AbortController tied to the session signal
17
+ * (headless) or a separate controller the caller can abort (UI path).
9
18
  *
10
19
  * Call sites: the `/mcp auth` command (`commands-auth.ts`) and the inline
11
20
  * auto-auth's UI branch (`auto-auth.ts`). Each maps the structured
@@ -13,19 +22,17 @@
13
22
  * between the two user-visible surfaces.
14
23
  */
15
24
  import {
16
- BorderedLoader,
17
25
  type ExtensionContext,
18
26
  } from "@earendil-works/pi-coding-agent";
19
27
  import open from "open";
20
28
  import { recordClientOutcome } from "./metadata-cache.js";
29
+ import type { AuthenticateOptions } from "./auth-flow.js";
21
30
  import type { ServerClient, ServerStatus } from "./server-client.js";
22
31
 
23
32
  /**
24
33
  * Outcome of an auth attempt.
25
34
  *
26
- * - `cancelled` — the loader was esc-closed OR the flow rejected with
27
- * exactly "OAuth cancelled" (an external abort). Both call sites treat
28
- * these identically.
35
+ * - `cancelled` — the flow was aborted (signal or "OAuth cancelled" error).
29
36
  * - `flow-error` — the flow failed for a real reason; `error` carries the
30
37
  * underlying message.
31
38
  * - `reconnect-failed` — auth succeeded but close/connect threw; `error`
@@ -52,8 +59,8 @@ export async function openAuthUrl(url: string): Promise<void> {
52
59
  try {
53
60
  await open(url);
54
61
  } catch {
55
- // No browser available — swallow; the caller's notification (if any)
56
- // still shows the URL so the user can visit it manually.
62
+ // No browser available — swallow; the caller's notification already
63
+ // shows the URL so the user can visit it manually.
57
64
  }
58
65
  }
59
66
 
@@ -81,66 +88,70 @@ export async function reconnectAfterAuth(client: ServerClient): Promise<AuthRunO
81
88
  }
82
89
 
83
90
  /**
84
- * Run `ServerClient.authenticate` behind a `BorderedLoader` (esc aborts
85
- * the flow; the authorization URL is opened in the browser and announced
86
- * via an info notification) and, on success, perform the post-auth
87
- * close+reconnect.
91
+ * Run `ServerClient.authenticate` with a visible status indicator and an
92
+ * `onAuthorizationInput` fallback for remote/headless environments.
93
+ *
94
+ * - The authorization URL is shown via `ctx.ui.notify` BEFORE `open()` fires,
95
+ * so it is always visible regardless of whether the browser opens.
96
+ * - `ctx.ui.setStatus` tracks progress without owning the screen.
97
+ * - `ctx.ui.confirm` + `ctx.ui.input` provide a manual-paste path for users
98
+ * whose browser redirect cannot reach the local callback server.
99
+ * - On success the client is closed + reconnected so the fresh token is read
100
+ * from the keyring immediately.
88
101
  *
89
- * `ctx` must have a UI — headless callers (print/RPC) run the flow plainly
90
- * and reuse `openAuthUrl`/`reconnectAfterAuth` (see `autoAuthenticate`).
102
+ * `ctx` must have a UI — headless callers (print/RPC) use `openAuthUrl` and
103
+ * `reconnectAfterAuth` directly (see `autoAuthenticate` in `auto-auth.ts`).
91
104
  */
92
105
  export async function runAuthWithLoader(
93
106
  ctx: ExtensionContext,
94
107
  client: ServerClient,
95
108
  options: {
96
- /** Loader label, e.g. `Authenticating <server>… (esc to cancel)`. */
109
+ /** Status label, e.g. `Authenticating <server>…`. */
97
110
  loaderLabel: string;
98
111
  },
99
112
  ): Promise<AuthRunOutcome> {
100
- const controller = new AbortController();
101
- type LoaderOutcome =
102
- | { kind: "done" }
103
- | { kind: "error"; error: string }
104
- | null; // null = esc-closed loader
105
- const outcome = await ctx.ui.custom<LoaderOutcome>((tui, theme, _keybindings, done) => {
106
- const loader = new BorderedLoader(tui, theme, options.loaderLabel);
107
- let settled = false;
108
- const settle = (value: LoaderOutcome) => {
109
- if (!settled) {
110
- settled = true;
111
- done(value);
112
- }
113
- };
114
- loader.onAbort = () => {
115
- controller.abort();
116
- settle(null);
113
+ const statusKey = `mcp-auth-${client.name}`;
114
+ ctx.ui.setStatus(statusKey, options.loaderLabel);
115
+
116
+ try {
117
+ const opts: AuthenticateOptions = {
118
+ onAuthorizationUrl: async (url: URL) => {
119
+ const urlStr = url.toString();
120
+ // Notify FIRST so the URL is visible before open() fires — the old
121
+ // BorderedLoader approach re-painted over this notification silently.
122
+ ctx.ui.notify(
123
+ `Opening browser for ${client.name}… if it didn't open, visit:\n${urlStr}`,
124
+ "info",
125
+ );
126
+ await openAuthUrl(urlStr);
127
+ },
128
+ onAuthorizationInput: async (url: URL, signal: AbortSignal) => {
129
+ // Fallback for remote/headless: ask the user to paste the callback URL.
130
+ const urlStr = url.toString();
131
+ const confirmed = await ctx.ui.confirm(
132
+ `Authenticate ${client.name}`,
133
+ `Open this URL in your browser:\n${urlStr}\n\nAfter approving, select Yes to paste the callback URL.`,
134
+ { signal },
135
+ );
136
+ if (!confirmed || signal.aborted) return undefined;
137
+ return ctx.ui.input(
138
+ `Complete ${client.name} OAuth`,
139
+ "Paste the full callback URL from your browser address bar",
140
+ { signal },
141
+ );
142
+ },
117
143
  };
118
- void client
119
- .authenticate({
120
- signal: controller.signal,
121
- onAuthorizationUrl: async (url: URL) => {
122
- await openAuthUrl(url.toString());
123
- ctx.ui.notify(
124
- `Opening browser… if it didn't open, visit: ${url.toString()}`,
125
- "info",
126
- );
127
- },
128
- })
129
- .then(
130
- () => settle({ kind: "done" }),
131
- // An esc-abort already settled `null`; this rejects with
132
- // "OAuth cancelled" and is swallowed by the settle guard.
133
- (e: unknown) => settle({ kind: "error", error: toMessage(e) }),
134
- );
135
- return loader;
136
- });
137
- if (outcome === null) return { kind: "cancelled" };
138
- if (outcome.kind === "error") {
139
- // A flow aborted from OUTSIDE the loader (agent abort) rejects with
140
- // "OAuth cancelled" — that is a cancellation, not a failure.
141
- if (outcome.error === "OAuth cancelled") return { kind: "cancelled" };
142
- return { kind: "flow-error", error: outcome.error };
143
- }
144
144
 
145
- return reconnectAfterAuth(client);
145
+ try {
146
+ await client.authenticate(opts);
147
+ } catch (e) {
148
+ const msg = toMessage(e);
149
+ if (msg === "OAuth cancelled") return { kind: "cancelled" };
150
+ return { kind: "flow-error", error: msg };
151
+ }
152
+
153
+ return reconnectAfterAuth(client);
154
+ } finally {
155
+ ctx.ui.setStatus(statusKey, undefined);
156
+ }
146
157
  }
@@ -4,17 +4,8 @@ import open from "open";
4
4
  import { autoAuthenticate, needsAuthToolResult } from "./auto-auth.js";
5
5
  import type { ServerClient } from "./server-client.js";
6
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
7
  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
- },
8
+ getAgentDir: () => `${process.env.TMPDIR ?? "/tmp"}/pi-archimedes-mock-agent`,
18
9
  }));
19
10
  vi.mock("open", () => ({ default: vi.fn().mockResolvedValue({}) }));
20
11
 
@@ -23,10 +14,8 @@ const AUTH_URL = "https://as.example/authorize?state=xyz";
23
14
  // ── fakes ────────────────────────────────────────────────────────────────────
24
15
 
25
16
  interface FakeClientOpts {
26
- /** success: resolves; wait: hangs until the signal aborts; throw: rejects with `error`. */
27
17
  outcome?: "success" | "wait" | "throw";
28
18
  error?: string;
29
- /** Status the client reports after close()+connect() (default: "connected"). */
30
19
  statusAfterReconnect?: string;
31
20
  }
32
21
 
@@ -40,7 +29,6 @@ interface FakeClient {
40
29
  authenticate: ReturnType<typeof vi.fn>;
41
30
  }
42
31
 
43
- /** Minimal needs-auth ServerClient fake — authenticate/close/connect are scripted. */
44
32
  function makeFakeClient(opts: FakeClientOpts = {}): FakeClient {
45
33
  const client: FakeClient = {
46
34
  name: "srv",
@@ -58,7 +46,6 @@ function makeFakeClient(opts: FakeClientOpts = {}): FakeClient {
58
46
  if (options?.signal?.aborted) return Promise.reject(new Error("OAuth cancelled"));
59
47
  switch (opts.outcome ?? "success") {
60
48
  case "wait":
61
- // Hangs until the signal aborts — like a browser flow awaiting a callback.
62
49
  return new Promise<void>((_resolve, reject) => {
63
50
  options?.signal?.addEventListener(
64
51
  "abort",
@@ -76,43 +63,17 @@ function makeFakeClient(opts: FakeClientOpts = {}): FakeClient {
76
63
  return client;
77
64
  }
78
65
 
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
- );
66
+ function makeCtx(hasUI: boolean) {
67
+ const notify = vi.fn();
68
+ const setStatus = vi.fn();
69
+ const confirm = vi.fn().mockResolvedValue(false);
70
+ const input = vi.fn().mockResolvedValue(undefined);
110
71
  const ctx = {
111
72
  hasUI,
112
73
  signal: new AbortController().signal,
113
- ui: { notify: state.notify, custom: state.custom },
74
+ ui: { notify, setStatus, confirm, input },
114
75
  } as unknown as ExtensionContext;
115
- return { ctx, state: { ...state, lastLoader: () => lastLoader } };
76
+ return { ctx, notify, setStatus };
116
77
  }
117
78
 
118
79
  // ── needsAuthToolResult ──────────────────────────────────────────────────────
@@ -140,71 +101,52 @@ describe("needsAuthToolResult", () => {
140
101
  // ── autoAuthenticate ─────────────────────────────────────────────────────────
141
102
 
142
103
  describe("autoAuthenticate", () => {
143
- it("runs the flow through a BorderedLoader, opens the URL, and reconnects on success", async () => {
104
+ it("with UI: runs authenticate, shows status, opens browser, reconnects on success", async () => {
144
105
  const client = makeFakeClient();
145
- const { ctx, state } = makeCtx(true);
106
+ const { ctx, notify, setStatus } = makeCtx(true);
146
107
  const outcome = await autoAuthenticate(ctx, client as unknown as ServerClient);
147
108
 
148
109
  expect(outcome.proceed).toBe(true);
149
110
  expect(outcome.error).toBeUndefined();
150
- expect(state.custom).toHaveBeenCalledTimes(1);
151
- expect(state.lastLoader()!.message).toContain("srv");
152
111
  expect(client.authenticate).toHaveBeenCalledTimes(1);
153
112
  const opts = client.authenticate.mock.calls[0]![0] as {
154
- signal: AbortSignal;
155
113
  onAuthorizationUrl: (u: URL) => Promise<void>;
114
+ onAuthorizationInput: unknown;
156
115
  };
157
- expect(opts.signal.aborted).toBe(false);
158
- // Authorization URL: open the browser and notify the user of it
116
+ expect(typeof opts.onAuthorizationInput).toBe("function");
117
+ // Authorization URL: notify first, then open browser
159
118
  await opts.onAuthorizationUrl(new URL(AUTH_URL));
119
+ expect(notify).toHaveBeenCalledWith(expect.stringContaining(AUTH_URL), "info");
160
120
  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)
121
+ // Status set + cleared
122
+ expect(setStatus).toHaveBeenCalledWith(expect.stringContaining("srv"), expect.any(String));
123
+ expect(setStatus).toHaveBeenLastCalledWith(expect.stringContaining("srv"), undefined);
124
+ // Reconnect to pick up the freshly stored token
163
125
  expect(client.close).toHaveBeenCalledTimes(1);
164
126
  expect(client.connect).toHaveBeenCalledTimes(1);
165
127
  });
166
128
 
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 () => {
129
+ it("with UI: surfaces flow failures without throwing and without reconnecting", async () => {
187
130
  const client = makeFakeClient({ outcome: "throw", error: "token endpoint refused" });
188
131
  const { ctx } = makeCtx(true);
189
132
  const outcome = await autoAuthenticate(ctx, client as unknown as ServerClient);
190
133
  expect(outcome.proceed).toBe(false);
191
134
  expect(outcome.error).toBe("token endpoint refused");
192
135
  expect(client.close).not.toHaveBeenCalled();
193
- expect(client.connect).not.toHaveBeenCalled();
194
136
  });
195
137
 
196
- it("runs headless (no loader) when the context has no UI", async () => {
138
+ it("headless: runs plainly (no setStatus) when the context has no UI", async () => {
197
139
  const client = makeFakeClient();
198
- const { ctx, state } = makeCtx(false);
140
+ const { ctx, setStatus } = makeCtx(false);
199
141
  const outcome = await autoAuthenticate(ctx, client as unknown as ServerClient);
200
142
  expect(outcome.proceed).toBe(true);
201
- expect(state.custom).not.toHaveBeenCalled();
143
+ expect(setStatus).not.toHaveBeenCalled();
202
144
  expect(client.authenticate).toHaveBeenCalledTimes(1);
203
145
  expect(client.close).toHaveBeenCalledTimes(1);
204
146
  expect(client.connect).toHaveBeenCalledTimes(1);
205
147
  });
206
148
 
207
- it("returns the error headless when the flow fails", async () => {
149
+ it("headless: returns the error when the flow fails", async () => {
208
150
  const client = makeFakeClient({ outcome: "throw", error: "keyring unavailable" });
209
151
  const { ctx } = makeCtx(false);
210
152
  const outcome = await autoAuthenticate(ctx, client as unknown as ServerClient);
package/src/auto-auth.ts CHANGED
@@ -9,10 +9,10 @@
9
9
  * telling the user to run `/mcp auth <server>`.
10
10
  * - autoAuth enabled: `ServerClient.authenticate` (the SINGLE auth entry
11
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.
12
+ * caller retries the tool call once. `runAuthWithLoader` shows progress,
13
+ * notifies the URL, and provides a manual-paste fallback when the execute
14
+ * context has UI; headless contexts run the flow plainly and open the URL
15
+ * directly.
16
16
  *
17
17
  * The loader/cancel/reconnect machinery is shared with `/mcp auth` in
18
18
  * `auth-run.ts`; this module maps the structured outcome onto the tool's
@@ -94,10 +94,10 @@ function toAutoAuthOutcome(outcome: AuthRunOutcome, serverName: string): AutoAut
94
94
  /**
95
95
  * Run `ServerClient.authenticate` inline.
96
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.
97
+ * With a UI context the flow runs through `runAuthWithLoader` (same path as
98
+ * `/mcp auth`): status shown via setStatus, URL surfaced via notify, manual
99
+ * paste fallback via confirm+input. Without UI (print/RPC mode) the flow
100
+ * runs plainly, tied to the agent's abort signal when streaming.
101
101
  *
102
102
  * On success the client is closed and reconnected so the freshly stored
103
103
  * token is re-read into the Bearer header (mirrors `/mcp auth`). Cancellation,