@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.
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/package.json +39 -0
- package/src/auth-flow.test.ts +583 -0
- package/src/auth-flow.ts +310 -0
- package/src/auth-run.test.ts +309 -0
- package/src/auth-run.ts +146 -0
- package/src/auth-storage.test.ts +338 -0
- package/src/auth-storage.ts +330 -0
- package/src/auto-auth.test.ts +231 -0
- package/src/auto-auth.ts +135 -0
- package/src/callback-server.test.ts +446 -0
- package/src/callback-server.ts +538 -0
- package/src/commands-auth.test.ts +320 -0
- package/src/commands-auth.ts +128 -0
- package/src/commands.test.ts +834 -0
- package/src/commands.ts +424 -0
- package/src/config-write.test.ts +213 -0
- package/src/config-write.ts +207 -0
- package/src/config.test.ts +468 -0
- package/src/config.ts +278 -0
- package/src/direct-tools.test.ts +473 -0
- package/src/direct-tools.ts +250 -0
- package/src/host-configs.test.ts +231 -0
- package/src/host-configs.ts +106 -0
- package/src/index.test.ts +689 -0
- package/src/index.ts +146 -0
- package/src/lifecycle.test.ts +274 -0
- package/src/lifecycle.ts +77 -0
- package/src/metadata-cache.test.ts +383 -0
- package/src/metadata-cache.ts +231 -0
- package/src/npx-resolver.test.ts +142 -0
- package/src/npx-resolver.ts +126 -0
- package/src/oauth-provider.test.ts +404 -0
- package/src/oauth-provider.ts +197 -0
- package/src/oauth-types.ts +54 -0
- package/src/panel-rows.ts +210 -0
- package/src/panel.test.ts +298 -0
- package/src/panel.ts +742 -0
- package/src/proxy-tool.ts +524 -0
- package/src/renderer.test.ts +326 -0
- package/src/renderer.ts +239 -0
- package/src/schema-validator.test.ts +56 -0
- package/src/schema-validator.ts +42 -0
- package/src/server-client.test.ts +1001 -0
- package/src/server-client.ts +576 -0
- package/src/server-manager.ts +139 -0
- package/src/setup-panel.test.ts +162 -0
- package/src/setup-panel.ts +715 -0
- package/src/tool-naming.test.ts +168 -0
- package/src/tool-naming.ts +114 -0
- 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
|
+
}
|