@pi-archimedes/mcp 2.5.0 → 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.
- package/README.md +42 -33
- package/package.json +4 -4
- package/src/auth-flow.ts +87 -4
- package/src/auth-run.test.ts +41 -104
- package/src/auth-run.ts +75 -64
- package/src/auto-auth.test.ts +23 -81
- package/src/auto-auth.ts +8 -8
- package/src/commands-auth.test.ts +54 -172
- package/src/commands-auth.ts +9 -11
- package/src/commands.test.ts +4 -6
- package/src/index.test.ts +4 -1
- package/src/server-client.ts +4 -1
|
@@ -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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
103
|
-
if (
|
|
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
|
-
|
|
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
|
|
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,
|
|
85
|
+
const { ctx, notify } = makeCtx(false);
|
|
154
86
|
await runMcpAuthCommand("srv", ctx, deps);
|
|
155
|
-
expect(
|
|
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,
|
|
92
|
+
const { ctx, notify } = makeCtx(true);
|
|
165
93
|
await runMcpAuthCommand("ghost", ctx, deps);
|
|
166
|
-
expect(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
190
|
-
expect(
|
|
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
|
|
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,
|
|
118
|
+
const { ctx, notify } = makeCtx(true);
|
|
199
119
|
await runMcpAuthCommand("svc", ctx, deps);
|
|
200
|
-
expect(
|
|
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,
|
|
125
|
+
const { ctx, notify } = makeCtx(true);
|
|
207
126
|
await runMcpAuthCommand("srv", ctx, deps);
|
|
208
|
-
expect(
|
|
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
|
|
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,
|
|
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
|
|
140
|
+
// Browser opened + URL notified
|
|
230
141
|
expect(open).toHaveBeenCalledWith(AUTH_URL);
|
|
231
|
-
expect(
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
);
|
|
235
|
-
//
|
|
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(
|
|
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,
|
|
155
|
+
const { ctx, notify } = makeCtx(true);
|
|
268
156
|
await runMcpAuthCommand("srv", ctx, deps);
|
|
269
|
-
expect(
|
|
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,
|
|
165
|
+
const { ctx, notify } = makeCtx(true);
|
|
281
166
|
await runMcpAuthCommand("srv", ctx, deps);
|
|
282
|
-
expect(
|
|
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
|
|
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
|
|
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
|
|
195
|
+
throw new Error("OS credential store unavailable");
|
|
314
196
|
});
|
|
315
197
|
const { deps } = makeDeps({}, makeFakeClient());
|
|
316
198
|
const result = mcpLogoutServer("srv", deps.getManager);
|
package/src/commands-auth.ts
CHANGED
|
@@ -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.
|
|
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`)
|
|
11
|
-
* `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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 (
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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(
|
package/src/commands.test.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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,
|
package/src/server-client.ts
CHANGED
|
@@ -364,7 +364,10 @@ export class ServerClient {
|
|
|
364
364
|
if (e instanceof StreamableHTTPError && e.code === 401) {
|
|
365
365
|
const c = this.client;
|
|
366
366
|
this.client = null;
|
|
367
|
-
|
|
367
|
+
// A failed connect auto-closes the transport (SDK Client.connect())
|
|
368
|
+
// which fires onclose and may already have nulled this.client —
|
|
369
|
+
// without this guard the TypeError masked the needs-auth state.
|
|
370
|
+
if (c) await c.close().catch(() => {});
|
|
368
371
|
this._status = "needs-auth";
|
|
369
372
|
this._error = NEEDS_AUTH_MESSAGE;
|
|
370
373
|
return;
|