@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/README.md CHANGED
@@ -1,39 +1,42 @@
1
1
  # @pi-archimedes/mcp
2
2
 
3
- Full-featured MCP client adapter with a pi-native TUI — feature parity with pi-mcp-adapter.
3
+ **Bring the tools you already use.**
4
4
 
5
- Connect any MCP server (stdio or HTTP/SSE), call its tools through a single `mcp` proxy tool or per-server direct tools, manage servers and OAuth flows interactively, and browse cached tool metadata offline all without leaving the terminal.
6
-
7
- ## What you get
8
-
9
- - **`mcp` gateway tool** — search, describe, and call tools across all configured servers; also handles `status`, per-server tool listing, and eager `connect` without opening every server upfront
10
- - **Per-server direct tools** — each server's tools registered as `{server}_{tool}` for token-efficient calls; a per-server `directTools` array narrows the set to named tools only
11
- - **`/mcp` command family** — status, tools, prompts, reconnect, enable/disable, logout, auth, management panel, and setup panel — everything in one command namespace
12
- - **OAuth 2.1 + PKCE** — browser auth flow for protected servers (Atlassian, Notion, GitHub, …), OS credential-store persistence, SDK-driven token refresh
13
- - **Lifecycle management** — `keep-alive`, `lazy`, `lazy-keep-alive`, or `eager` per server, with configurable idle timeout
14
- - **Metadata cache** — `~/.pi/agent/mcp-cache.json` (7-day validity) lets search/describe work offline and persists each server's last connection outcome so `needs-auth`/errors survive restarts
15
- - **Compact two-line tool rendering** — `mcp <target>` header (cyan + orange) plus a key-arg summary; full args and output hidden until expanded with `ctrl+o`
16
- - **Layered config** — six config files, lowest → highest precedence; safe single-field write-back that never touches credentials or unrelated servers
5
+ Your MCP servers stdio or HTTP/SSE can talk to Pi without leaving the terminal. One `mcp` tool reaches every server (or per-server direct tools for token-efficient calls), `/mcp` is a single command namespace for management and auth, and the setup wizard imports server definitions from Cursor, Claude Code, Claude Desktop, and VS Code. Start with `/mcp setup`; manage with `/mcp`.
17
6
 
18
7
  ## Install
19
8
 
9
+ Standalone:
10
+
20
11
  ```bash
21
12
  pi install npm:@pi-archimedes/mcp
22
13
  ```
23
14
 
24
- Or install the full meta package:
15
+ Or the full suite instead:
25
16
 
26
17
  ```bash
27
18
  pi install npm:pi-archimedes
28
19
  ```
29
20
 
21
+ New to Pi? Pi itself is a one-time global install and needs Node.js ≥ 22.19.0 — `npm install -g --ignore-scripts @earendil-works/pi-coding-agent`; Pi's [quickstart](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/quickstart.md) covers authentication and [provider docs](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/providers.md) list the supported providers. After installing Pi, choose one installation command above, then `cd` into your project and run `pi`, and in the session use `/login` + `/model` — the [setup section](https://github.com/danielcherubini/pi-archimedes#setup) covers the first run. `/reload` picks up both new extensions and new server configs.
22
+
23
+ ## What you get
24
+
25
+ - **`mcp` gateway tool** — search, describe, and call tools across all configured servers; also `status`, per-server tool listing, and eager `connect`, without opening every server upfront.
26
+ - **Per-server direct tools** — each server's tools registered as `{server}_{tool}` for direct, token-efficient calls; a per-server `directTools` array narrows the set to named tools.
27
+ - **`/mcp` command family** — status, tools, prompts, reconnect, enable/disable, logout, auth, management panel, setup panel — one namespace.
28
+ - **OAuth 2.1 + PKCE** — interactive browser auth for protected servers, with OS credential-store persistence and SDK-driven refresh.
29
+ - **Lifecycle management** — `keep-alive`, `lazy` (default), `lazy-keep-alive`, or `eager` per server, with an idle timeout.
30
+ - **Metadata cache** — `~/.pi/agent/mcp-cache.json` (7-day validity) lets search/describe work offline and persists each server's last connection outcome, so `needs-auth`/errors survive restarts.
31
+ - **Compact two-line tool rendering** — an `mcp <target>` header with a key-argument summary; full args and output expand with `ctrl+o`.
32
+ - **Layered config** — six config files, lowest → highest precedence, with a safe single-field write-back that never touches credentials or unrelated servers.
33
+
30
34
  ## Quick start
31
35
 
32
- 1. Create or edit a config file the project-shared one is usually the right place:
36
+ 1. **`/mcp setup`** the recommended path. It scaffolds `.mcp.json`, adds curated presets (context7, chrome-devtools, deepwiki, fetch), and imports server definitions from Cursor, Claude Code, Claude Desktop, and VS Code — with a preview of which names will be added before anything is written.
37
+ 2. Or edit a config file by hand. The project-shared `<project>/.mcp.json` is usually the right place. **Merge deliberately — do not `cat > .mcp.json`**, which silently destroys settings for other servers:
33
38
 
34
- ```bash
35
- # in your project root
36
- cat > .mcp.json <<'EOF'
39
+ ```json
37
40
  {
38
41
  "mcpServers": {
39
42
  "context7": {
@@ -42,14 +45,10 @@ pi install npm:pi-archimedes
42
45
  }
43
46
  }
44
47
  }
45
- EOF
46
48
  ```
47
49
 
48
- Or use `/mcp setup` to scaffold it interactively (see below).
49
-
50
- 2. `/reload` to pick up the new config.
51
-
52
- 3. The `mcp` tool and direct tools (`context7_*`) are now available. Check with `/mcp status`.
50
+ 3. `/reload` to pick up the new config.
51
+ 4. The `mcp` tool and direct tools (`context7_*`) are now available; check with `/mcp status`.
53
52
 
54
53
  ## `/mcp` command reference
55
54
 
@@ -95,7 +94,7 @@ Onboarding for a new project. All writes target the project-shared `.mcp.json`.
95
94
 
96
95
  - **Scaffold** — writes `{ "mcpServers": {} }` only when the file is absent
97
96
  - **Add a known server** — curated preset list (context7, chrome-devtools, deepwiki, fetch); existing entries are never overwritten
98
- - **Import from another tool** — discovers MCP configs from Cursor, Claude Code, Claude Desktop, and VSCode; shows a preview of which server names will be added before writing; names already in `.mcp.json` are kept untouched
97
+ - **Import from another tool** — discovers MCP configs from Cursor, Claude Code, Claude Desktop, and VS Code. The six candidate files, in scan order, are `~/.cursor/mcp.json` and `<project>/.cursor/mcp.json` (`mcpServers` key), `~/.claude/mcp.json` and `~/.claude.json` (`mcpServers`), `~/.claude/claude_desktop_config.json` (`mcpServers`), and `<project>/.vscode/mcp.json` (its key is `servers`, not `mcpServers`); shows a preview of which server names will be added before writing; names already in `.mcp.json` are kept untouched
99
98
 
100
99
  ## OAuth
101
100
 
@@ -107,9 +106,19 @@ Three paths reach the same auth entry point:
107
106
 
108
107
  Token details:
109
108
 
110
- - Tokens persist in the OS credential store (macOS Keychain / Windows Credential Manager / Linux libsecret) — no plaintext fallback
111
- - Token refresh is SDK-driven; a pre-registered public client (`clientId` without `clientSecret`) is never auto-refreshed — re-run `/mcp auth <server>` when its token expires
112
- - The `auth` field on http/sse servers accepts `{ "token": "…" }` (static bearer), `"oauth"` (defaults), or a full `McpOAuthConfig` object
109
+ - Tokens persist in the OS credential store (macOS Keychain / Windows Credential Manager / Linux Secret Service). Storage is **fail-closed**: if the keyring is unavailable, auth operations throw a clear error there is never a plaintext fallback.
110
+ - Token refresh is SDK-driven; a pre-registered public client (`clientId` without `clientSecret`) is never auto-refreshed — re-run `/mcp auth <server>` when its token expires.
111
+ - The `auth` field on http/sse servers accepts `{ "token": "…" }` (static bearer), `"oauth"`, or a full `McpOAuthConfig` object. `auth: "oauth"` (or a config object with at least one field below) is what enables OAuth with the default grant settings; a valid object can override them. **Omitting `auth` does not automatically enable OAuth** — there is no implicit OAuth default, but static authentication can still be supplied through `bearerTokenEnv` or `headers`:
112
+
113
+ | `McpOAuthConfig` field | Meaning |
114
+ |------------------------|---------|
115
+ | `grantType` | `"authorization_code"` (default) or `"client_credentials"` |
116
+ | `clientId` | client identifier |
117
+ | `clientSecret` | string, literal only — no `!command` resolution |
118
+ | `scope` | space-separated scopes |
119
+ | `redirectUri` | pre-registered clients only |
120
+ | `clientName` | human name, shown in consent screens |
121
+ | `authorizationServerUrl` | reserved — parsed but not yet used |
113
122
 
114
123
  ## Config files & write-back
115
124
 
@@ -120,11 +129,11 @@ Six layers load in order, lowest → highest precedence (per-server field-level
120
129
  | 1 | `~/.config/mcp/mcp.json` | Global (standard MCP location) |
121
130
  | 2 | `~/.agents/mcp.json` | Cross-agent (home) |
122
131
  | 3 | `~/.agents/mcp/mcp.json` | Cross-agent (home, alternate) |
123
- | 4 | `~/.pi/agent/mcp.json` | Pi agent directory |
132
+ | 4 | `<agentDir>/mcp.json` (agent directory is `$PI_CODING_AGENT_DIR`, defaulting to `~/.pi/agent`) | Pi agent directory |
124
133
  | 5 | `<project>/.mcp.json` | Project-shared (committable) |
125
134
  | 6 | `<project>/.pi/mcp.json` | Pi override — highest precedence |
126
135
 
127
- Files accept `//` comments and trailing commas. When a higher-precedence layer changes a server's `url`, inherited `auth`/`headers`/`bearerTokenEnv` from lower layers are dropped — credentials are never sent to an endpoint you didn't explicitly configure them for.
136
+ The `mcp.json` files (including layer 4's `<agentDir>/mcp.json`) accept `//` comments and trailing commas (**JSONC**). That does not apply to Archimedes settings — `~/.pi/agent/settings.json` is **strict JSON**, parsed without comment support. When a higher-precedence layer changes a server's `url`, inherited `auth`/`headers`/`bearerTokenEnv` from lower layers are dropped — credentials are never sent to an endpoint you didn't explicitly configure them for.
128
137
 
129
138
  Write-back targets:
130
139
 
@@ -135,7 +144,7 @@ Changes take effect on the next `/reload`.
135
144
 
136
145
  ## Settings
137
146
 
138
- Settings are stored in `~/.pi/agent/settings.json` under the `archimedes.mcp` namespace.
147
+ `~/.pi/agent/settings.json`, under `archimedes.mcp` (strict JSON):
139
148
 
140
149
  | Setting | Type | Default | Description |
141
150
  |---------|------|---------|-------------|
@@ -165,6 +174,6 @@ Per-server overrides (in the `mcp.json` server definition):
165
174
 
166
175
  ## Integration
167
176
 
168
- When installed via `pi-archimedes` (the meta package), the MCP adapter is automatically registered. Tool rendering uses Core's chrome and color palette. Standalone installation works independently the full feature set is available without the meta package.
177
+ In the [suite](https://github.com/danielcherubini/pi-archimedes), the MCP adapter is registered with the rest — tool rendering uses core's chrome and colour palette, and a blocking OAuth loader triggers the notify extension's prompts. Standalone, the full feature set works independently. On/off in the suite is managed by `/plugins` (`archimedes.mcp.enabled`, default on).
169
178
 
170
- ← Back to [pi-archimedes](../../README.md)
179
+ [Back to pi-archimedes](https://github.com/danielcherubini/pi-archimedes)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/mcp",
3
- "version": "2.5.1",
3
+ "version": "2.6.3",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -17,7 +17,7 @@
17
17
  "@modelcontextprotocol/sdk": "^1.30.0",
18
18
  "@napi-rs/keyring": "^1.3.0",
19
19
  "open": "^10.2.0",
20
- "@pi-archimedes/core": "2.5.1"
20
+ "@pi-archimedes/core": "2.6.3"
21
21
  },
22
22
  "peerDependencies": {
23
23
  "@earendil-works/pi-coding-agent": ">=0.1.0",
@@ -25,8 +25,8 @@
25
25
  "typebox": ">=1.1.0"
26
26
  },
27
27
  "devDependencies": {
28
- "@earendil-works/pi-coding-agent": "^0.84.4",
29
- "@earendil-works/pi-tui": "^0.84.4",
28
+ "@earendil-works/pi-coding-agent": "^0.85.1",
29
+ "@earendil-works/pi-tui": "^0.85.1",
30
30
  "@types/node": "^22.0.0",
31
31
  "typebox": "^1.1.38",
32
32
  "typescript": "^6.0.3"
package/src/auth-flow.ts CHANGED
@@ -33,7 +33,7 @@
33
33
 
34
34
  import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
35
35
 
36
- import { getAuthEntry } from "./auth-storage.js";
36
+ import { getAuthEntry, saveAuthEntry } from "./auth-storage.js";
37
37
  import { ensureCallbackServer, reserveAuthState, waitForCallback } from "./callback-server.js";
38
38
  import { McpOAuthProvider, type OAuthCallbacks } from "./oauth-provider.js";
39
39
  import { OAUTH_CONFIG_FIELDS, type McpOAuthConfig } from "./types.js";
@@ -57,6 +57,15 @@ export type AuthStatus =
57
57
  export interface AuthenticateOptions {
58
58
  /** Called once when the SDK builds the authorization URL (e.g. to open a browser). */
59
59
  onAuthorizationUrl?: (url: URL) => void | Promise<void>;
60
+ /**
61
+ * Called after the authorization URL is opened, giving the caller a chance
62
+ * to prompt the user to paste the full callback URL (for remote/headless
63
+ * environments where the browser cannot reach the local callback server).
64
+ * Should resolve with the pasted text, or undefined/empty to cancel.
65
+ * When provided, it races with the local callback server; whichever wins
66
+ * first completes the flow.
67
+ */
68
+ onAuthorizationInput?: (url: URL, signal: AbortSignal) => Promise<string | undefined>;
60
69
  /** Cancels the callback wait; the flow rethrows the abort error. */
61
70
  signal?: AbortSignal;
62
71
  }
@@ -132,6 +141,52 @@ function describeError(error: unknown): string {
132
141
  return error instanceof Error ? error.message : String(error);
133
142
  }
134
143
 
144
+ /**
145
+ * Parse an authorization code from a pasted raw code or full callback URL.
146
+ * Returns `{ code, iss? }` on success, throws a descriptive error otherwise.
147
+ */
148
+ function parseCallbackInput(input: string, expectedState?: string): { code: string; iss?: string } {
149
+ const trimmed = input.trim();
150
+ if (!trimmed) throw new Error("Authorization code or redirect URL is required");
151
+
152
+ // Try to parse as a URL (full callback redirect)
153
+ let params: URLSearchParams | undefined;
154
+ try {
155
+ params = new URL(trimmed).searchParams;
156
+ } catch {
157
+ // Not a URL — try treating it as a query string
158
+ if (trimmed.includes("code=")) {
159
+ try {
160
+ params = new URLSearchParams(trimmed.includes("?") ? trimmed.slice(trimmed.indexOf("?") + 1) : trimmed);
161
+ } catch {
162
+ // ignore
163
+ }
164
+ }
165
+ }
166
+
167
+ if (params) {
168
+ const error = params.get("error");
169
+ if (error) {
170
+ const description = params.get("error_description");
171
+ throw new Error(description ? `${error}: ${description}` : error);
172
+ }
173
+ const state = params.get("state");
174
+ if (expectedState && state && state !== expectedState) {
175
+ throw new Error("OAuth state mismatch — potential CSRF attack");
176
+ }
177
+ const code = params.get("code");
178
+ if (code) {
179
+ const iss = params.get("iss") ?? undefined;
180
+ return { code, ...(iss !== undefined ? { iss } : {}) };
181
+ }
182
+ }
183
+
184
+ // Treat as a raw authorization code
185
+ if (/^[A-Za-z0-9._~+/=:-]+$/.test(trimmed)) return { code: trimmed };
186
+
187
+ throw new Error("Could not find an authorization code in the provided input");
188
+ }
189
+
135
190
  /**
136
191
  * Build the host callbacks for the provider. exactOptionalPropertyTypes:
137
192
  * `OAuthCallbacks.onAuthorizationUrl` is an optional property whose type
@@ -212,6 +267,17 @@ export async function authenticate(
212
267
  return { status: "needs-interaction" };
213
268
  }
214
269
 
270
+ // If there is stored clientInfo but no tokens, the previous DCR
271
+ // registration was never completed (or tokens were cleared). Wipe the
272
+ // clientInfo so the SDK runs a fresh DCR with the current boundPort —
273
+ // otherwise it reuses the old redirectUri (a different port) and
274
+ // Atlassian/other servers redirect back to a listener that isn't running.
275
+ const existing = getAuthEntry(serverName);
276
+ if (existing?.clientInfo && !existing.tokens) {
277
+ const { clientInfo: _drop, ...withoutClient } = existing;
278
+ saveAuthEntry(serverName, withoutClient, serverUrl);
279
+ }
280
+
215
281
  const provider = new McpOAuthProvider(
216
282
  serverName,
217
283
  serverUrl,
@@ -226,16 +292,44 @@ export async function authenticate(
226
292
  // redirectToAuthorization → onAuthorizationUrl (opens the browser).
227
293
  await auth(provider, { serverUrl });
228
294
 
229
- const resPromise = waitForCallback(state, options.signal);
295
+ const callbackPromise = waitForCallback(state, options.signal);
230
296
  // Eager no-op rejection sink: `waitForCallback` can reject before we
231
297
  // reach the await (already-aborted signal), and an unhandled rejection
232
298
  // during that window would crash the process. The awaited promise below
233
299
  // still observes the same rejection.
234
- resPromise.catch(() => undefined);
235
- const { code } = await resPromise;
300
+ callbackPromise.catch(() => undefined);
301
+
302
+ let result: { code: string; iss?: string };
303
+ if (options.onAuthorizationInput) {
304
+ // Race the local callback server against manual user input (for remote
305
+ // or headless environments where the browser redirect can't reach us).
306
+ const inputController = new AbortController();
307
+ const authorizationUrl = new URL(
308
+ `http://localhost:${boundPort}/callback?state=${encodeURIComponent(state)}`,
309
+ );
310
+ try {
311
+ const winner = await Promise.race([
312
+ callbackPromise.then((r) => ({ source: "callback" as const, result: r })),
313
+ options
314
+ .onAuthorizationInput(authorizationUrl, inputController.signal)
315
+ .then((pasted) => ({ source: "manual" as const, pasted })),
316
+ ]);
317
+ if (winner.source === "callback") {
318
+ result = winner.result;
319
+ } else {
320
+ // Manual input won — parse the pasted text
321
+ if (!winner.pasted?.trim()) throw new Error("OAuth authentication cancelled");
322
+ result = parseCallbackInput(winner.pasted, state);
323
+ }
324
+ } finally {
325
+ inputController.abort();
326
+ }
327
+ } else {
328
+ result = await callbackPromise;
329
+ }
236
330
 
237
331
  // SDK: exchange the code for tokens (persists via provider.saveTokens).
238
- await auth(provider, { serverUrl, authorizationCode: code });
332
+ await auth(provider, { serverUrl, authorizationCode: result.code });
239
333
  return { status: "authenticated" };
240
334
  } catch (error) {
241
335
  if (options.signal?.aborted) throw error;
@@ -8,17 +8,8 @@ import { openAuthUrl, reconnectAfterAuth, runAuthWithLoader } from "./auth-run.j
8
8
  import { loadMetadataCache, setCachePathForTest } from "./metadata-cache.js";
9
9
  import type { ServerClient } from "./server-client.js";
10
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
11
  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
- },
12
+ getAgentDir: () => `${process.env.TMPDIR ?? "/tmp"}/pi-archimedes-mock-agent`,
22
13
  }));
23
14
  vi.mock("open", () => ({ default: vi.fn().mockResolvedValue({}) }));
24
15
 
@@ -38,7 +29,7 @@ afterEach(() => {
38
29
  });
39
30
 
40
31
  const AUTH_URL = "https://as.example/authorize?state=xyz";
41
- const LABEL = "Authenticating srv… (esc to cancel)";
32
+ const LABEL = "Authenticating srv…";
42
33
 
43
34
  // ── fakes ────────────────────────────────────────────────────────────────────
44
35
 
@@ -68,7 +59,6 @@ function makeFakeClient(opts: FakeClientOpts = {}) {
68
59
  close: vi.fn().mockResolvedValue(undefined),
69
60
  connect: vi.fn().mockImplementation(async () => {
70
61
  if (opts.reconnectError) {
71
- // Mirror the real client: a thrown connect settles into "error".
72
62
  client.status = "error";
73
63
  client.error = opts.reconnectError;
74
64
  throw new Error(opts.reconnectError);
@@ -96,8 +86,6 @@ function makeFakeClient(opts: FakeClientOpts = {}) {
96
86
  return Promise.reject(new Error(opts.error ?? "boom"));
97
87
  default:
98
88
  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
89
  return Promise.resolve(options?.onAuthorizationUrl?.(new URL(AUTH_URL))).then(
102
90
  () => undefined,
103
91
  );
@@ -109,114 +97,60 @@ function makeFakeClient(opts: FakeClientOpts = {}) {
109
97
  return client;
110
98
  }
111
99
 
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
- );
100
+ function makeCtx() {
101
+ const notify = vi.fn();
102
+ const setStatus = vi.fn();
103
+ const confirm = vi.fn().mockResolvedValue(false);
104
+ const input = vi.fn().mockResolvedValue(undefined);
148
105
  const ctx = {
149
106
  hasUI: true,
150
- ui: { notify: state.notify, custom: state.custom },
107
+ ui: { notify, setStatus, confirm, input },
151
108
  } as unknown as ExtensionContext;
152
- return { ctx, state: { ...state, lastLoader: () => lastLoader } };
109
+ return { ctx, notify, setStatus, confirm, input };
153
110
  }
154
111
 
155
112
  // ── runAuthWithLoader ────────────────────────────────────────────────────────
156
113
 
157
114
  describe("runAuthWithLoader", () => {
158
- it("opens the URL, notifies, reconnects, and returns the reconnected status", async () => {
115
+ it("notifies the URL first, opens the browser, reconnects, returns reconnected", async () => {
159
116
  const client = makeFakeClient({ outcome: "success", invokeAuthUrl: true, toolCount: 3 });
160
- const { ctx, state } = makeCtx();
117
+ const { ctx, notify, setStatus } = makeCtx();
161
118
  const outcome = await runAuthWithLoader(ctx, client as unknown as ServerClient, {
162
119
  loaderLabel: LABEL,
163
120
  });
164
121
 
165
122
  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}`,
123
+ // Status set during, cleared after
124
+ expect(setStatus).toHaveBeenCalledWith("mcp-auth-srv", LABEL);
125
+ expect(setStatus).toHaveBeenLastCalledWith("mcp-auth-srv", undefined);
126
+ // Notification fired with URL (before open)
127
+ expect(notify).toHaveBeenCalledWith(
128
+ expect.stringContaining(AUTH_URL),
180
129
  "info",
181
130
  );
131
+ // Browser opened
132
+ expect(open).toHaveBeenCalledWith(AUTH_URL);
182
133
  // Reconnect to pick up the freshly stored token
183
134
  expect(client.close).toHaveBeenCalledTimes(1);
184
135
  expect(client.connect).toHaveBeenCalledTimes(1);
185
136
  });
186
137
 
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();
138
+ it("clears setStatus even when the flow fails", async () => {
139
+ const client = makeFakeClient({ outcome: "throw", error: "boom" });
140
+ const { ctx, setStatus } = makeCtx();
141
+ await runAuthWithLoader(ctx, client as unknown as ServerClient, { loaderLabel: LABEL });
142
+ expect(setStatus).toHaveBeenLastCalledWith("mcp-auth-srv", undefined);
205
143
  });
206
144
 
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.
145
+ it("treats 'OAuth cancelled' rejection as cancelled (no reconnect)", async () => {
210
146
  const client = makeFakeClient({ outcome: "throw", error: "OAuth cancelled" });
211
- const { ctx, state: s } = makeCtx();
212
- const running = runAuthWithLoader(ctx, client as unknown as ServerClient, {
147
+ const { ctx } = makeCtx();
148
+ const outcome = await runAuthWithLoader(ctx, client as unknown as ServerClient, {
213
149
  loaderLabel: LABEL,
214
150
  });
215
- // Esc is NOT pressed — only the flow rejection decides.
216
- const outcome = await running;
217
151
  expect(outcome).toEqual({ kind: "cancelled" });
218
152
  expect(client.close).not.toHaveBeenCalled();
219
- expect(s.notify).not.toHaveBeenCalled();
153
+ expect(client.connect).not.toHaveBeenCalled();
220
154
  });
221
155
 
222
156
  it("surfaces other flow failures as flow-error without reconnecting", async () => {
@@ -227,10 +161,9 @@ describe("runAuthWithLoader", () => {
227
161
  });
228
162
  expect(outcome).toEqual({ kind: "flow-error", error: "token endpoint refused" });
229
163
  expect(client.close).not.toHaveBeenCalled();
230
- expect(client.connect).not.toHaveBeenCalled();
231
164
  });
232
165
 
233
- it("reports a failed reconnect as reconnect-failed with the error message", async () => {
166
+ it("reports a failed reconnect as reconnect-failed", async () => {
234
167
  const client = makeFakeClient({ reconnectError: "connection refused" });
235
168
  const { ctx } = makeCtx();
236
169
  const outcome = await runAuthWithLoader(ctx, client as unknown as ServerClient, {
@@ -248,6 +181,16 @@ describe("runAuthWithLoader", () => {
248
181
  });
249
182
  expect(outcome).toEqual({ kind: "reconnected", status: "needs-auth", tools: 0 });
250
183
  });
184
+
185
+ it("passes onAuthorizationInput to authenticate", async () => {
186
+ const client = makeFakeClient({ outcome: "success" });
187
+ const { ctx } = makeCtx();
188
+ await runAuthWithLoader(ctx, client as unknown as ServerClient, { loaderLabel: LABEL });
189
+ const opts = client.authenticate.mock.calls[0]![0] as {
190
+ onAuthorizationInput?: unknown;
191
+ };
192
+ expect(typeof opts.onAuthorizationInput).toBe("function");
193
+ });
251
194
  });
252
195
 
253
196
  // ── reconnectAfterAuth ───────────────────────────────────────────────────────
@@ -271,10 +214,7 @@ describe("reconnectAfterAuth", () => {
271
214
 
272
215
  it("records 'connected' in the ADR 0004 ledger after a successful reconnect", async () => {
273
216
  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.
217
+ await reconnectAfterAuth(client as unknown as ServerClient);
278
218
  const rec = loadMetadataCache().serverStatuses?.["srv"];
279
219
  expect(rec?.status).toBe("connected");
280
220
  expect(rec?.at).toBeTypeOf("number");
@@ -283,10 +223,7 @@ describe("reconnectAfterAuth", () => {
283
223
 
284
224
  it("records 'error' with the failure message when the post-auth reconnect fails", async () => {
285
225
  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.
226
+ await reconnectAfterAuth(client as unknown as ServerClient);
290
227
  const rec = loadMetadataCache().serverStatuses?.["srv"];
291
228
  expect(rec?.status).toBe("error");
292
229
  expect(rec?.error).toBe("connection refused");