@pi-archimedes/mcp 2.5.1 → 2.6.2

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.
@@ -7,17 +7,7 @@ import type { ServerManager } from "./server-manager.js";
7
7
  import type { HttpServerDef, ServerDef } from "./types.js";
8
8
 
9
9
  // ── mocks ────────────────────────────────────────────────────────────────────
10
- // The real BorderedLoader needs a live TUI; a stub with the same surface
11
- // (constructor message + onAbort) is enough to drive runMcpAuthCommand.
12
10
  vi.mock("@earendil-works/pi-coding-agent", () => ({
13
- BorderedLoader: class {
14
- message: string;
15
- onAbort?: () => void;
16
- constructor(_tui: unknown, _theme: unknown, message: string) {
17
- this.message = message;
18
- }
19
- dispose() {}
20
- },
21
11
  // core/settings-io builds its settings path at module load
22
12
  getAgentDir: () => `${process.env.TMPDIR ?? "/tmp"}/pi-archimedes-mock-agent`,
23
13
  }));
@@ -26,56 +16,19 @@ vi.mock("./auth-storage.js", () => ({ deleteAuthEntry: vi.fn() }));
26
16
 
27
17
  // ── fakes ────────────────────────────────────────────────────────────────────
28
18
 
29
- /** The loader shape `ui.custom`'s factory returns (BorderedLoader surface). */
30
- interface FakeLoader {
31
- message: string;
32
- onAbort?: () => void;
33
- }
34
-
35
- interface CtxState {
36
- notify: ReturnType<typeof vi.fn>;
37
- custom: ReturnType<typeof vi.fn>;
38
- lastLoader: FakeLoader | null;
39
- }
40
-
41
- /** Fake ExtensionCommandContext: notify is captured; ui.custom runs the
42
- * factory synchronously and resolves when done() is first called. */
43
- function makeCtx(hasUI: boolean): { ctx: ExtensionCommandContext; state: CtxState } {
44
- const state: CtxState = { notify: vi.fn(), custom: vi.fn(), lastLoader: null };
45
- const ui = {
46
- notify: (message: string, type?: "info" | "warning" | "error") =>
47
- state.notify(message, type),
48
- custom: (
49
- factory: (
50
- tui: unknown,
51
- theme: unknown,
52
- keybindings: unknown,
53
- done: (result: unknown) => void,
54
- ) => unknown,
55
- ) => {
56
- let resolve!: (result: unknown) => void;
57
- const pending = new Promise<unknown>((r) => (resolve = r));
58
- let settled = false;
59
- const done = (result: unknown) => {
60
- if (!settled) {
61
- settled = true;
62
- resolve(result);
63
- }
64
- };
65
- state.lastLoader = factory({}, {}, {}, done) as FakeLoader;
66
- return pending;
67
- },
68
- };
69
- return {
70
- ctx: { hasUI, ui } as unknown as ExtensionCommandContext,
71
- state,
72
- };
19
+ function makeCtx(hasUI: boolean): { ctx: ExtensionCommandContext; notify: ReturnType<typeof vi.fn>; setStatus: ReturnType<typeof vi.fn>; confirm: ReturnType<typeof vi.fn>; input: ReturnType<typeof vi.fn> } {
20
+ const notify = vi.fn();
21
+ const setStatus = vi.fn();
22
+ const confirm = vi.fn().mockResolvedValue(false);
23
+ const input = vi.fn().mockResolvedValue(undefined);
24
+ const ctx = {
25
+ hasUI,
26
+ ui: { notify, setStatus, confirm, input },
27
+ } as unknown as ExtensionCommandContext;
28
+ return { ctx, notify, setStatus, confirm, input };
73
29
  }
74
30
 
75
31
  interface FakeClientOpts {
76
- /** success: resolves (optionally after onAuthorizationUrl settles);
77
- * wait: neither settles nor rejects until the signal aborts;
78
- * throw: rejects with `error`. */
79
32
  outcome?: "success" | "wait" | "throw";
80
33
  error?: string;
81
34
  invokeAuthUrl?: boolean;
@@ -83,45 +36,29 @@ interface FakeClientOpts {
83
36
 
84
37
  const AUTH_URL = "https://as.example/authorize?state=xyz";
85
38
 
86
- /** Fake ServerClient: authenticate/close/connect are individually scripted. */
87
39
  function makeFakeClient(opts: FakeClientOpts = {}) {
88
40
  const client = {
41
+ name: "srv",
89
42
  status: "needs-auth" as string,
90
43
  tools: [
91
44
  { name: "t1", serverName: "srv" },
92
45
  { name: "t2", serverName: "srv" },
93
46
  ],
94
- connect: vi.fn().mockImplementation(async () => {
95
- client.status = "connected";
96
- }),
47
+ connect: vi.fn().mockImplementation(async () => { client.status = "connected"; }),
97
48
  close: vi.fn().mockResolvedValue(undefined),
98
49
  authenticate: null as unknown as ReturnType<typeof vi.fn>,
99
50
  };
100
51
  client.authenticate = vi.fn(
101
52
  (options?: { signal?: AbortSignal; onAuthorizationUrl?: (u: URL) => void | Promise<void> }) => {
102
- const { outcome = "success", error, invokeAuthUrl } = opts;
103
- if (options?.signal?.aborted) {
104
- return Promise.reject(new Error("OAuth cancelled"));
105
- }
106
- if (outcome === "wait") {
107
- // Hangs until the signal aborts — like a browser flow awaiting a callback.
53
+ if (options?.signal?.aborted) return Promise.reject(new Error("OAuth cancelled"));
54
+ if (opts.outcome === "wait") {
108
55
  return new Promise<void>((_resolve, reject) => {
109
- options?.signal?.addEventListener(
110
- "abort",
111
- () => reject(new Error("OAuth cancelled")),
112
- { once: true },
113
- );
56
+ options?.signal?.addEventListener("abort", () => reject(new Error("OAuth cancelled")), { once: true });
114
57
  });
115
58
  }
116
- if (outcome === "throw") {
117
- return Promise.reject(new Error(error ?? "boom"));
118
- }
119
- if (invokeAuthUrl) {
120
- // Resolve only after the URL hook settles, so `open()` and the
121
- // notification are guaranteed to have run before success.
122
- return Promise.resolve(options?.onAuthorizationUrl?.(new URL(AUTH_URL))).then(
123
- () => undefined,
124
- );
59
+ if (opts.outcome === "throw") return Promise.reject(new Error(opts.error ?? "boom"));
60
+ if (opts.invokeAuthUrl) {
61
+ return Promise.resolve(options?.onAuthorizationUrl?.(new URL(AUTH_URL))).then(() => undefined);
125
62
  }
126
63
  return Promise.resolve();
127
64
  },
@@ -129,147 +66,95 @@ function makeFakeClient(opts: FakeClientOpts = {}) {
129
66
  return client;
130
67
  }
131
68
 
132
- /** Deps mirroring the index.ts wiring: fresh config read, http/sse defs only. */
133
69
  function makeDeps(defs: Record<string, ServerDef>, client: unknown) {
134
70
  const manager = { getClient: vi.fn().mockReturnValue(client) } as unknown as ServerManager;
135
- // Mirrors the production index.ts wiring: shape-based (url) classification
136
71
  const getServerDef = (name: string): HttpServerDef | undefined => {
137
72
  const def = defs[name];
138
73
  return def !== undefined && isHttpDef(def) ? def : undefined;
139
74
  };
140
- return {
141
- deps: { getServerDef, getManager: () => manager },
142
- manager,
143
- };
75
+ return { deps: { getServerDef, getManager: () => manager }, manager };
144
76
  }
145
77
 
146
78
  const oauthDef: HttpServerDef = { type: "http", url: "https://mcps.example/mcp", auth: "oauth" };
147
79
 
148
- // ── runMcpAuthCommand (former /mcp-auth handler body) ──────────────────────
80
+ // ── runMcpAuthCommand ────────────────────────────────────────────────────────
149
81
 
150
82
  describe("runMcpAuthCommand", () => {
151
83
  it("rejects without an interactive TUI", async () => {
152
84
  const { deps } = makeDeps({ srv: oauthDef }, makeFakeClient());
153
- const { ctx, state } = makeCtx(false);
85
+ const { ctx, notify } = makeCtx(false);
154
86
  await runMcpAuthCommand("srv", ctx, deps);
155
- expect(state.notify).toHaveBeenCalledWith(
156
- expect.stringContaining("interactive TUI"),
157
- "error",
158
- );
159
- expect(state.custom).not.toHaveBeenCalled();
87
+ expect(notify).toHaveBeenCalledWith(expect.stringContaining("interactive TUI"), "error");
160
88
  });
161
89
 
162
90
  it("notifies unknown servers", async () => {
163
91
  const { deps } = makeDeps({ srv: oauthDef }, makeFakeClient());
164
- const { ctx, state } = makeCtx(true);
92
+ const { ctx, notify } = makeCtx(true);
165
93
  await runMcpAuthCommand("ghost", ctx, deps);
166
- expect(state.notify).toHaveBeenCalledWith("Unknown server: ghost", "error");
167
- expect(state.custom).not.toHaveBeenCalled();
94
+ expect(notify).toHaveBeenCalledWith("Unknown server: ghost", "error");
168
95
  });
169
96
 
170
97
  it("treats stdio servers as unknown (OAuth is http/sse only)", async () => {
171
- const { deps } = makeDeps(
172
- { cli: { type: "stdio", command: "true" } },
173
- makeFakeClient(),
174
- );
175
- const { ctx, state } = makeCtx(true);
98
+ const { deps } = makeDeps({ cli: { type: "stdio", command: "true" } }, makeFakeClient());
99
+ const { ctx, notify } = makeCtx(true);
176
100
  await runMcpAuthCommand("cli", ctx, deps);
177
- expect(state.notify).toHaveBeenCalledWith("Unknown server: cli", "error");
178
- expect(state.custom).not.toHaveBeenCalled();
101
+ expect(notify).toHaveBeenCalledWith("Unknown server: cli", "error");
179
102
  });
180
103
 
181
104
  it("finds a URL server without a type field (shape-based classification)", async () => {
182
105
  const client = makeFakeClient({ outcome: "success" });
183
- const { deps } = makeDeps(
184
- { srv: { url: "https://mcps.example/mcp", auth: "oauth" } },
185
- client,
186
- );
187
- const { ctx, state } = makeCtx(true);
106
+ const { deps } = makeDeps({ srv: { url: "https://mcps.example/mcp", auth: "oauth" } }, client);
107
+ const { ctx, notify } = makeCtx(true);
188
108
  await runMcpAuthCommand("srv", ctx, deps);
189
- expect(state.notify).not.toHaveBeenCalledWith("Unknown server: srv", "error");
190
- expect(state.notify).toHaveBeenCalledWith("✓ srv authenticated — 2 tools available", "info");
109
+ expect(notify).not.toHaveBeenCalledWith("Unknown server: srv", "error");
110
+ expect(notify).toHaveBeenCalledWith("✓ srv authenticated — 2 tools available", "info");
191
111
  });
192
112
 
193
- it("notifies machines configured for a static bearer token as not-OAuth", async () => {
113
+ it("notifies servers configured for a static bearer token as not-OAuth", async () => {
194
114
  const { deps } = makeDeps(
195
115
  { svc: { type: "http", url: "http://127.0.0.1:1/mcp", auth: { token: "t" } } },
196
116
  makeFakeClient(),
197
117
  );
198
- const { ctx, state } = makeCtx(true);
118
+ const { ctx, notify } = makeCtx(true);
199
119
  await runMcpAuthCommand("svc", ctx, deps);
200
- expect(state.notify).toHaveBeenCalledWith("Server svc is not configured for OAuth", "error");
201
- expect(state.custom).not.toHaveBeenCalled();
120
+ expect(notify).toHaveBeenCalledWith("Server svc is not configured for OAuth", "error");
202
121
  });
203
122
 
204
123
  it("notifies when the manager holds no client for the server", async () => {
205
124
  const { deps } = makeDeps({ srv: oauthDef }, undefined);
206
- const { ctx, state } = makeCtx(true);
125
+ const { ctx, notify } = makeCtx(true);
207
126
  await runMcpAuthCommand("srv", ctx, deps);
208
- expect(state.notify).toHaveBeenCalledWith(
209
- expect.stringContaining("srv"),
210
- "error",
211
- );
212
- expect(state.custom).not.toHaveBeenCalled();
127
+ expect(notify).toHaveBeenCalledWith(expect.stringContaining("srv"), "error");
213
128
  });
214
129
 
215
- it("runs authenticate on the client, opens the URL, reconnects, and reports tools", async () => {
130
+ it("runs authenticate, opens the URL visibly, reconnects, and reports tools", async () => {
216
131
  const client = makeFakeClient({ outcome: "success", invokeAuthUrl: true });
217
132
  const { deps } = makeDeps({ srv: oauthDef }, client);
218
- const { ctx, state } = makeCtx(true);
133
+ const { ctx, notify, setStatus } = makeCtx(true);
219
134
  await runMcpAuthCommand("srv", ctx, deps);
220
135
 
221
- // Single entry point: authenticate with an abort signal + URL hook
222
136
  expect(client.authenticate).toHaveBeenCalledTimes(1);
223
137
  expect(client.authenticate).toHaveBeenCalledWith(
224
- expect.objectContaining({
225
- signal: expect.any(AbortSignal),
226
- onAuthorizationUrl: expect.any(Function),
227
- }),
138
+ expect.objectContaining({ onAuthorizationUrl: expect.any(Function) }),
228
139
  );
229
- // Browser opened with the canonical URL + user notified of it
140
+ // Browser opened + URL notified
230
141
  expect(open).toHaveBeenCalledWith(AUTH_URL);
231
- expect(state.notify).toHaveBeenCalledWith(
232
- `Opening browser… if it didn't open, visit: ${AUTH_URL}`,
233
- "info",
234
- );
235
- // Loader label mentions the server
236
- expect(state.lastLoader?.message).toContain("srv");
237
- // Reconnect to pick up the new token, success with tool count
142
+ expect(notify).toHaveBeenCalledWith(expect.stringContaining(AUTH_URL), "info");
143
+ // Status set then cleared
144
+ expect(setStatus).toHaveBeenCalledWith(expect.stringContaining("srv"), expect.stringContaining("srv"));
145
+ expect(setStatus).toHaveBeenLastCalledWith(expect.stringContaining("srv"), undefined);
146
+ // Reconnect + success notification
238
147
  expect(client.close).toHaveBeenCalled();
239
148
  expect(client.connect).toHaveBeenCalled();
240
- expect(state.notify).toHaveBeenCalledWith("✓ srv authenticated — 2 tools available", "info");
241
- });
242
-
243
- it("Esc aborts the controller, cancels cleanly, and closes without reconnect", async () => {
244
- const client = makeFakeClient({ outcome: "wait" });
245
- const { deps } = makeDeps({ srv: oauthDef }, client);
246
- const { ctx, state } = makeCtx(true);
247
-
248
- const running = runMcpAuthCommand("srv", ctx, deps);
249
- await vi.waitFor(() => expect(client.authenticate).toHaveBeenCalledTimes(1));
250
- const signal = (client.authenticate.mock.calls[0]?.[0] as { signal: AbortSignal }).signal;
251
- expect(signal.aborted).toBe(false);
252
-
253
- // Simulate Esc in the loader
254
- state.lastLoader!.onAbort!();
255
- await running;
256
-
257
- expect(signal.aborted).toBe(true);
258
- expect(state.notify).toHaveBeenCalledWith("Authentication cancelled", "info");
259
- expect(state.notify).not.toHaveBeenCalledWith(expect.anything(), "error");
260
- expect(client.close).not.toHaveBeenCalled();
261
- expect(client.connect).not.toHaveBeenCalled();
149
+ expect(notify).toHaveBeenCalledWith("✓ srv authenticated — 2 tools available", "info");
262
150
  });
263
151
 
264
152
  it("surfaces flow failures as error notifications", async () => {
265
153
  const client = makeFakeClient({ outcome: "throw", error: "token endpoint refused" });
266
154
  const { deps } = makeDeps({ srv: oauthDef }, client);
267
- const { ctx, state } = makeCtx(true);
155
+ const { ctx, notify } = makeCtx(true);
268
156
  await runMcpAuthCommand("srv", ctx, deps);
269
- expect(state.notify).toHaveBeenCalledWith(
270
- expect.stringContaining("token endpoint refused"),
271
- "error",
272
- );
157
+ expect(notify).toHaveBeenCalledWith(expect.stringContaining("token endpoint refused"), "error");
273
158
  expect(client.connect).not.toHaveBeenCalled();
274
159
  });
275
160
 
@@ -277,22 +162,19 @@ describe("runMcpAuthCommand", () => {
277
162
  const client = makeFakeClient({ outcome: "success" });
278
163
  client.connect.mockRejectedValue(new Error("connection refused"));
279
164
  const { deps } = makeDeps({ srv: oauthDef }, client);
280
- const { ctx, state } = makeCtx(true);
165
+ const { ctx, notify } = makeCtx(true);
281
166
  await runMcpAuthCommand("srv", ctx, deps);
282
- expect(state.notify).toHaveBeenCalledWith(
283
- expect.stringContaining("connection refused"),
284
- "error",
285
- );
167
+ expect(notify).toHaveBeenCalledWith(expect.stringContaining("connection refused"), "error");
286
168
  });
287
169
  });
288
170
 
289
- // ── mcpLogoutServer (former /mcp-logout handler body) ──────────────────────
171
+ // ── mcpLogoutServer ──────────────────────────────────────────────────────────
290
172
 
291
173
  describe("mcpLogoutServer", () => {
292
174
  it("deletes the keyring entry and closes the connected client", async () => {
293
175
  const { deleteAuthEntry } = vi.mocked(await import("./auth-storage.js"));
294
176
  const client = makeFakeClient();
295
- const { deps, manager } = makeDeps({ srv: oauthDef }, client);
177
+ const { deps } = makeDeps({ srv: oauthDef }, client);
296
178
  const result = mcpLogoutServer("srv", deps.getManager);
297
179
  expect(deleteAuthEntry).toHaveBeenCalledWith("srv");
298
180
  expect(client.close).toHaveBeenCalled();
@@ -307,10 +189,10 @@ describe("mcpLogoutServer", () => {
307
189
  expect(result).toEqual({ ok: true });
308
190
  });
309
191
 
310
- it("reports a fail-closed keyring instead of throwing", async () => {
192
+ it("reports a fail-closed keyring error instead of throwing", async () => {
311
193
  const { deleteAuthEntry } = vi.mocked(await import("./auth-storage.js"));
312
194
  deleteAuthEntry.mockImplementationOnce(() => {
313
- throw new Error("OS credential store unavailable — cannot store OAuth tokens securely");
195
+ throw new Error("OS credential store unavailable");
314
196
  });
315
197
  const { deps } = makeDeps({}, makeFakeClient());
316
198
  const result = mcpLogoutServer("srv", deps.getManager);
@@ -3,16 +3,14 @@
3
3
  *
4
4
  * The standalone `/mcp-auth` and `/mcp-logout` commands are retired; the
5
5
  * `/mcp auth <server>` and `/mcp logout <server>` subcommands (dispatched in
6
- * `commands.ts`) call these two functions instead. The UX is unchanged from
7
- * plan-026.
6
+ * `commands.ts`) call these two functions instead.
8
7
  *
9
8
  * `runMcpAuthCommand` runs the OAuth flow through the server client's
10
- * SINGLE auth entry point (`ServerClient.authenticate`) while a
11
- * `BorderedLoader` (tui.md Pattern 2) shows progress. Esc fires the
12
- * loader's `onAbort`, which forwards to the flow's `AbortController`; the
13
- * cancelled flow rethrows "OAuth cancelled", so cancel and failure stay
14
- * distinguishable in the notification. On success the client is closed and
15
- * reconnected, which re-reads the freshly stored token.
9
+ * SINGLE auth entry point (`ServerClient.authenticate`) via
10
+ * `runAuthWithLoader`, which shows the authorization URL via notify (always
11
+ * visible), opens the browser, and provides a confirm+input fallback for
12
+ * remote/headless users. On success the client is closed and reconnected,
13
+ * which re-reads the freshly stored token.
16
14
  *
17
15
  * `mcpLogoutServer` deletes the keyring entry and closes the managed client
18
16
  * (if any) so the next connect re-evaluates auth.
@@ -41,9 +39,9 @@ export interface McpAuthCommandDeps {
41
39
 
42
40
  /**
43
41
  * The former `/mcp-auth <server>` handler body, extracted so the `/mcp auth`
44
- * subcommand reuses the full command-layer UX (BorderedLoader +
45
- * notifications) unchanged. (The management panel authenticates in-panel
46
- * instead — ADR 0005 — reusing only the shared plumbing in `auth-run.ts`.)
42
+ * subcommand reuses the full command-layer UX unchanged. (The management
43
+ * panel authenticates in-panel instead — ADR 0005 — reusing only the shared
44
+ * plumbing in `auth-run.ts`.)
47
45
  * `serverName` must be non-empty — the dispatcher enforces that.
48
46
  */
49
47
  export async function runMcpAuthCommand(
@@ -117,6 +117,9 @@ function makeCtx(cwd: string, hasUI: boolean = true): { ctx: ExtensionCommandCon
117
117
  const ui = {
118
118
  notify: (message: string, type?: "info" | "warning" | "error") =>
119
119
  state.notify(message, type),
120
+ setStatus: vi.fn(),
121
+ confirm: vi.fn().mockResolvedValue(false),
122
+ input: vi.fn().mockResolvedValue(undefined),
120
123
  custom: (
121
124
  factory: (
122
125
  tui: unknown,
@@ -662,17 +665,12 @@ describe("/mcp auth", () => {
662
665
  const env = setupEnv({ srv: httpOauthDef });
663
666
  // The auth entry point is scripted (the real flow needs a live browser/
664
667
  // callback server — exercised separately in the auth-flow tests); the
665
- // loader, reconnect, and notification machinery under test is real.
668
+ // status, reconnect, and notification machinery under test is real.
666
669
  const client = env.manager.getClient("srv")!;
667
670
  vi.spyOn(client, "authenticate").mockResolvedValue(undefined);
668
671
  await env.run("auth srv");
669
- // The BorderedLoader stub surfaced its label
670
- expect(env.state.lastLoader?.message).toContain("Authenticating srv");
671
- expect(env.state.lastLoader?.message).toContain("esc to cancel");
672
672
  // Success path: client reconnected, tool count reported
673
673
  expect(env.notify).toHaveBeenCalledWith("✓ srv authenticated — 1 tools available", "info");
674
- // ADR 0004: the post-auth reconnect is a settle point the panel task wires;
675
- // the text command itself does not record outcomes in this task.
676
674
  });
677
675
  });
678
676
 
package/src/index.test.ts CHANGED
@@ -524,7 +524,10 @@ describe("mcp proxy — command wiring", () => {
524
524
  return pending;
525
525
  },
526
526
  );
527
- const ctx = { hasUI: true, ui: { notify, custom } } as never;
527
+ const setStatus = vi.fn();
528
+ const confirm = vi.fn().mockResolvedValue(false);
529
+ const input = vi.fn().mockResolvedValue(undefined);
530
+ const ctx = { hasUI: true, ui: { notify, custom, setStatus, confirm, input } } as never;
528
531
  const handler = commands["mcp"]!.handler as (
529
532
  args: string,
530
533
  ctx: unknown,