@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,320 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
3
+ import open from "open";
4
+ import { isHttpDef } from "./config.js";
5
+ import { mcpLogoutServer, runMcpAuthCommand } from "./commands-auth.js";
6
+ import type { ServerManager } from "./server-manager.js";
7
+ import type { HttpServerDef, ServerDef } from "./types.js";
8
+
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
+ 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
+ // core/settings-io builds its settings path at module load
22
+ getAgentDir: () => `${process.env.TMPDIR ?? "/tmp"}/pi-archimedes-mock-agent`,
23
+ }));
24
+ vi.mock("open", () => ({ default: vi.fn().mockResolvedValue({}) }));
25
+ vi.mock("./auth-storage.js", () => ({ deleteAuthEntry: vi.fn() }));
26
+
27
+ // ── fakes ────────────────────────────────────────────────────────────────────
28
+
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
+ };
73
+ }
74
+
75
+ 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
+ outcome?: "success" | "wait" | "throw";
80
+ error?: string;
81
+ invokeAuthUrl?: boolean;
82
+ }
83
+
84
+ const AUTH_URL = "https://as.example/authorize?state=xyz";
85
+
86
+ /** Fake ServerClient: authenticate/close/connect are individually scripted. */
87
+ function makeFakeClient(opts: FakeClientOpts = {}) {
88
+ const client = {
89
+ status: "needs-auth" as string,
90
+ tools: [
91
+ { name: "t1", serverName: "srv" },
92
+ { name: "t2", serverName: "srv" },
93
+ ],
94
+ connect: vi.fn().mockImplementation(async () => {
95
+ client.status = "connected";
96
+ }),
97
+ close: vi.fn().mockResolvedValue(undefined),
98
+ authenticate: null as unknown as ReturnType<typeof vi.fn>,
99
+ };
100
+ client.authenticate = vi.fn(
101
+ (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.
108
+ return new Promise<void>((_resolve, reject) => {
109
+ options?.signal?.addEventListener(
110
+ "abort",
111
+ () => reject(new Error("OAuth cancelled")),
112
+ { once: true },
113
+ );
114
+ });
115
+ }
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
+ );
125
+ }
126
+ return Promise.resolve();
127
+ },
128
+ );
129
+ return client;
130
+ }
131
+
132
+ /** Deps mirroring the index.ts wiring: fresh config read, http/sse defs only. */
133
+ function makeDeps(defs: Record<string, ServerDef>, client: unknown) {
134
+ const manager = { getClient: vi.fn().mockReturnValue(client) } as unknown as ServerManager;
135
+ // Mirrors the production index.ts wiring: shape-based (url) classification
136
+ const getServerDef = (name: string): HttpServerDef | undefined => {
137
+ const def = defs[name];
138
+ return def !== undefined && isHttpDef(def) ? def : undefined;
139
+ };
140
+ return {
141
+ deps: { getServerDef, getManager: () => manager },
142
+ manager,
143
+ };
144
+ }
145
+
146
+ const oauthDef: HttpServerDef = { type: "http", url: "https://mcps.example/mcp", auth: "oauth" };
147
+
148
+ // ── runMcpAuthCommand (former /mcp-auth handler body) ──────────────────────
149
+
150
+ describe("runMcpAuthCommand", () => {
151
+ it("rejects without an interactive TUI", async () => {
152
+ const { deps } = makeDeps({ srv: oauthDef }, makeFakeClient());
153
+ const { ctx, state } = makeCtx(false);
154
+ await runMcpAuthCommand("srv", ctx, deps);
155
+ expect(state.notify).toHaveBeenCalledWith(
156
+ expect.stringContaining("interactive TUI"),
157
+ "error",
158
+ );
159
+ expect(state.custom).not.toHaveBeenCalled();
160
+ });
161
+
162
+ it("notifies unknown servers", async () => {
163
+ const { deps } = makeDeps({ srv: oauthDef }, makeFakeClient());
164
+ const { ctx, state } = makeCtx(true);
165
+ await runMcpAuthCommand("ghost", ctx, deps);
166
+ expect(state.notify).toHaveBeenCalledWith("Unknown server: ghost", "error");
167
+ expect(state.custom).not.toHaveBeenCalled();
168
+ });
169
+
170
+ 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);
176
+ await runMcpAuthCommand("cli", ctx, deps);
177
+ expect(state.notify).toHaveBeenCalledWith("Unknown server: cli", "error");
178
+ expect(state.custom).not.toHaveBeenCalled();
179
+ });
180
+
181
+ it("finds a URL server without a type field (shape-based classification)", async () => {
182
+ 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);
188
+ 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");
191
+ });
192
+
193
+ it("notifies machines configured for a static bearer token as not-OAuth", async () => {
194
+ const { deps } = makeDeps(
195
+ { svc: { type: "http", url: "http://127.0.0.1:1/mcp", auth: { token: "t" } } },
196
+ makeFakeClient(),
197
+ );
198
+ const { ctx, state } = makeCtx(true);
199
+ await runMcpAuthCommand("svc", ctx, deps);
200
+ expect(state.notify).toHaveBeenCalledWith("Server svc is not configured for OAuth", "error");
201
+ expect(state.custom).not.toHaveBeenCalled();
202
+ });
203
+
204
+ it("notifies when the manager holds no client for the server", async () => {
205
+ const { deps } = makeDeps({ srv: oauthDef }, undefined);
206
+ const { ctx, state } = makeCtx(true);
207
+ await runMcpAuthCommand("srv", ctx, deps);
208
+ expect(state.notify).toHaveBeenCalledWith(
209
+ expect.stringContaining("srv"),
210
+ "error",
211
+ );
212
+ expect(state.custom).not.toHaveBeenCalled();
213
+ });
214
+
215
+ it("runs authenticate on the client, opens the URL, reconnects, and reports tools", async () => {
216
+ const client = makeFakeClient({ outcome: "success", invokeAuthUrl: true });
217
+ const { deps } = makeDeps({ srv: oauthDef }, client);
218
+ const { ctx, state } = makeCtx(true);
219
+ await runMcpAuthCommand("srv", ctx, deps);
220
+
221
+ // Single entry point: authenticate with an abort signal + URL hook
222
+ expect(client.authenticate).toHaveBeenCalledTimes(1);
223
+ expect(client.authenticate).toHaveBeenCalledWith(
224
+ expect.objectContaining({
225
+ signal: expect.any(AbortSignal),
226
+ onAuthorizationUrl: expect.any(Function),
227
+ }),
228
+ );
229
+ // Browser opened with the canonical URL + user notified of it
230
+ 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
238
+ expect(client.close).toHaveBeenCalled();
239
+ 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();
262
+ });
263
+
264
+ it("surfaces flow failures as error notifications", async () => {
265
+ const client = makeFakeClient({ outcome: "throw", error: "token endpoint refused" });
266
+ const { deps } = makeDeps({ srv: oauthDef }, client);
267
+ const { ctx, state } = makeCtx(true);
268
+ await runMcpAuthCommand("srv", ctx, deps);
269
+ expect(state.notify).toHaveBeenCalledWith(
270
+ expect.stringContaining("token endpoint refused"),
271
+ "error",
272
+ );
273
+ expect(client.connect).not.toHaveBeenCalled();
274
+ });
275
+
276
+ it("reports a failed reconnect after successful authentication", async () => {
277
+ const client = makeFakeClient({ outcome: "success" });
278
+ client.connect.mockRejectedValue(new Error("connection refused"));
279
+ const { deps } = makeDeps({ srv: oauthDef }, client);
280
+ const { ctx, state } = makeCtx(true);
281
+ await runMcpAuthCommand("srv", ctx, deps);
282
+ expect(state.notify).toHaveBeenCalledWith(
283
+ expect.stringContaining("connection refused"),
284
+ "error",
285
+ );
286
+ });
287
+ });
288
+
289
+ // ── mcpLogoutServer (former /mcp-logout handler body) ──────────────────────
290
+
291
+ describe("mcpLogoutServer", () => {
292
+ it("deletes the keyring entry and closes the connected client", async () => {
293
+ const { deleteAuthEntry } = vi.mocked(await import("./auth-storage.js"));
294
+ const client = makeFakeClient();
295
+ const { deps, manager } = makeDeps({ srv: oauthDef }, client);
296
+ const result = mcpLogoutServer("srv", deps.getManager);
297
+ expect(deleteAuthEntry).toHaveBeenCalledWith("srv");
298
+ expect(client.close).toHaveBeenCalled();
299
+ expect(result).toEqual({ ok: true });
300
+ });
301
+
302
+ it("still deletes for a server the manager does not hold", async () => {
303
+ const { deleteAuthEntry } = vi.mocked(await import("./auth-storage.js"));
304
+ const { deps } = makeDeps({}, undefined);
305
+ const result = mcpLogoutServer("ghost", deps.getManager);
306
+ expect(deleteAuthEntry).toHaveBeenCalledWith("ghost");
307
+ expect(result).toEqual({ ok: true });
308
+ });
309
+
310
+ it("reports a fail-closed keyring instead of throwing", async () => {
311
+ const { deleteAuthEntry } = vi.mocked(await import("./auth-storage.js"));
312
+ deleteAuthEntry.mockImplementationOnce(() => {
313
+ throw new Error("OS credential store unavailable — cannot store OAuth tokens securely");
314
+ });
315
+ const { deps } = makeDeps({}, makeFakeClient());
316
+ const result = mcpLogoutServer("srv", deps.getManager);
317
+ expect(result.ok).toBe(false);
318
+ expect(result.error).toContain("OS credential store unavailable");
319
+ });
320
+ });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Shared OAuth helpers for the `/mcp` command family.
3
+ *
4
+ * The standalone `/mcp-auth` and `/mcp-logout` commands are retired; the
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.
8
+ *
9
+ * `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.
16
+ *
17
+ * `mcpLogoutServer` deletes the keyring entry and closes the managed client
18
+ * (if any) so the next connect re-evaluates auth.
19
+ *
20
+ * The command layer never calls the auth-flow module directly: the
21
+ * url/oauth config always come from the client's server definition.
22
+ */
23
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
24
+
25
+ import { deleteAuthEntry } from "./auth-storage.js";
26
+ import { extractOAuthConfig } from "./auth-flow.js";
27
+ import { runAuthWithLoader } from "./auth-run.js";
28
+ import type { ServerManager } from "./server-manager.js";
29
+ import type { HttpServerDef } from "./types.js";
30
+
31
+ export interface McpAuthCommandDeps {
32
+ /**
33
+ * Fresh-config lookup. Only http/sse defs are returned (stdio servers
34
+ * cannot OAuth) — anything else, including unknown or stdio servers,
35
+ * yields undefined.
36
+ */
37
+ getServerDef: (name: string) => HttpServerDef | undefined;
38
+ /** Module-level server manager (session-resilient getter). */
39
+ getManager: () => ServerManager;
40
+ }
41
+
42
+ /**
43
+ * 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`.)
47
+ * `serverName` must be non-empty — the dispatcher enforces that.
48
+ */
49
+ export async function runMcpAuthCommand(
50
+ serverName: string,
51
+ ctx: ExtensionCommandContext,
52
+ deps: McpAuthCommandDeps,
53
+ ): Promise<void> {
54
+ if (!ctx.hasUI) {
55
+ ctx.ui.notify("/mcp auth requires an interactive TUI", "error");
56
+ return;
57
+ }
58
+
59
+ const def = deps.getServerDef(serverName);
60
+ if (!def) {
61
+ ctx.ui.notify(`Unknown server: ${serverName}`, "error");
62
+ return;
63
+ }
64
+ if (!extractOAuthConfig(def.auth)) {
65
+ ctx.ui.notify(`Server ${serverName} is not configured for OAuth`, "error");
66
+ return;
67
+ }
68
+ const client = deps.getManager().getClient(serverName);
69
+ if (!client) {
70
+ ctx.ui.notify(
71
+ `Server ${serverName} is not managed yet — start a new session and try again`,
72
+ "error",
73
+ );
74
+ return;
75
+ }
76
+
77
+ // Esc in the loader aborts the flow; aborts (esc or the agent's own)
78
+ // surface as "cancelled", other failures keep their message.
79
+ const outcome = await runAuthWithLoader(ctx, client, {
80
+ loaderLabel: `Authenticating ${serverName}… (esc to cancel)`,
81
+ });
82
+ if (outcome.kind === "cancelled") {
83
+ ctx.ui.notify("Authentication cancelled", "info");
84
+ return;
85
+ }
86
+ if (outcome.kind === "flow-error") {
87
+ ctx.ui.notify(outcome.error, "error");
88
+ return;
89
+ }
90
+ if (outcome.kind === "reconnect-failed") {
91
+ // Close + reconnect failed after a successful flow.
92
+ ctx.ui.notify(
93
+ `${serverName} is authenticated, but reconnecting failed: ${outcome.error}`,
94
+ "error",
95
+ );
96
+ return;
97
+ }
98
+ // Success: the client was closed + reconnected so the fresh token is
99
+ // used immediately (connect re-reads the keyring for the Bearer
100
+ // header); the outcome snapshots its post-reconnect status.
101
+ if (outcome.status === "connected") {
102
+ ctx.ui.notify(
103
+ `✓ ${serverName} authenticated — ${outcome.tools} tools available`,
104
+ "info",
105
+ );
106
+ } else {
107
+ ctx.ui.notify(`✓ ${serverName} authenticated and reconnected`, "info");
108
+ }
109
+ }
110
+
111
+ /**
112
+ * The former `/mcp-logout <server>` handler body, extracted so the
113
+ * `/mcp logout` subcommand reuses it.
114
+ * Deletes the keyring entry, closes the managed client (if any) so the next
115
+ * connect re-evaluates auth with the entry gone. Fail-closed: a keyring
116
+ * error is returned, not thrown.
117
+ */
118
+ export function mcpLogoutServer(name: string, getManager: () => ServerManager): { ok: boolean; error?: string } {
119
+ try {
120
+ deleteAuthEntry(name);
121
+ } catch (e) {
122
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
123
+ }
124
+ // Close the managed client (if any) so the next connect re-evaluates
125
+ // auth with the entry now gone.
126
+ getManager().getClient(name)?.close();
127
+ return { ok: true };
128
+ }