@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,1001 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { mkdtempSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6
+ import { StreamableHTTPError, StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
7
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
8
+ import { readFileSync } from "node:fs";
9
+ import { ServerClient, buildAuthHeaders } from "./server-client.js";
10
+ import { ServerManager } from "./server-manager.js";
11
+ import { setCachePathForTest } from "./metadata-cache.js";
12
+ import type { AuthStatus } from "./auth-flow.js";
13
+ import type { StdioServerDef, HttpServerDef, ServerDef } from "./types.js";
14
+
15
+ /**
16
+ * Mocks the auth-flow module so ServerClient is exercised without the real
17
+ * OAuth plumbing (keyring / SDK / callback server). `state` drives the
18
+ * per-test behaviour of the two seam functions the client uses:
19
+ * - `authenticate` → status or thrown cancel, per test
20
+ * - `getValidToken` → the bearer token to attach (null = none)
21
+ * `extractOAuthConfig` mirrors the real classification closely enough
22
+ * (`"oauth"` → default config, static `{ token }` → null, other objects →
23
+ * identity).
24
+ */
25
+ const authFlow = vi.hoisted(() => {
26
+ const state: {
27
+ authenticateImpl: () => Promise<AuthStatus> | never;
28
+ validToken: string | null;
29
+ } = {
30
+ authenticateImpl: async () => ({ status: "authenticated" }),
31
+ validToken: null,
32
+ };
33
+
34
+ const extractOAuthConfig = vi.fn((auth: unknown): unknown => {
35
+ if (auth === "oauth") return { grantType: "authorization_code" };
36
+ if (typeof auth === "object" && auth !== null) {
37
+ const rec = auth as Record<string, unknown>;
38
+ if ("token" in rec) return null; // static bearer is not OAuth
39
+ if (Object.keys(rec).length === 0) return null;
40
+ return { ...rec };
41
+ }
42
+ return null;
43
+ });
44
+
45
+ const authenticate = vi.fn(async (): Promise<AuthStatus> => await state.authenticateImpl(),
46
+ );
47
+
48
+ const getValidToken = vi.fn(
49
+ async (_name: string, _url: string): Promise<string | null> => state.validToken,
50
+ );
51
+
52
+ return { state, extractOAuthConfig, authenticate, getValidToken };
53
+ });
54
+
55
+ vi.mock("./auth-flow.js", () => ({
56
+ extractOAuthConfig: authFlow.extractOAuthConfig,
57
+ authenticate: authFlow.authenticate,
58
+ getValidToken: authFlow.getValidToken,
59
+ }));
60
+
61
+ /**
62
+ * Test seam: ServerClient accepts a `clientFactory` option that replaces the
63
+ * SDK Client. Fakes are cast to `Client` — only the surface ServerClient
64
+ * touches (connect/listTools/callTool/close) is implemented.
65
+ */
66
+
67
+ interface CallToolResult {
68
+ content: Array<{ type: string; [key: string]: unknown }>;
69
+ isError?: boolean;
70
+ }
71
+
72
+ interface FakeResource {
73
+ uri: string;
74
+ name?: string;
75
+ description?: string;
76
+ mimeType?: string;
77
+ }
78
+
79
+ interface FakePrompt {
80
+ name: string;
81
+ description?: string;
82
+ }
83
+
84
+ interface FakeCapabilities {
85
+ resources?: boolean;
86
+ prompts?: boolean;
87
+ tools?: boolean;
88
+ }
89
+
90
+ interface FakeClient {
91
+ connectCalls: number;
92
+ closeCalls: number;
93
+ closed: boolean;
94
+ listToolsCalls: Array<string | undefined>;
95
+ listResourcesCalls: Array<string | undefined>;
96
+ listPromptsCalls: Array<string | undefined>;
97
+ connect(transport: unknown): Promise<void>;
98
+ listTools(params?: { cursor?: string }): Promise<{ tools: unknown[]; nextCursor?: string }>;
99
+ listResources(params?: { cursor?: string }): Promise<{ resources: FakeResource[]; nextCursor?: string }>;
100
+ listPrompts(params?: { cursor?: string }): Promise<{ prompts: FakePrompt[]; nextCursor?: string }>;
101
+ getServerCapabilities(): FakeCapabilities | undefined;
102
+ getInstructions(): string | undefined;
103
+ callTool(params: { name: string; arguments: Record<string, unknown> }): Promise<CallToolResult>;
104
+ close(): Promise<void>;
105
+ }
106
+
107
+ interface FakeOptions {
108
+ onConnect?: (fake: FakeClient, transport: unknown) => Promise<void> | void;
109
+ listTools?: () => unknown[];
110
+ /** Pages of tools; each page but the last advertises a nextCursor ("cursor-N") */
111
+ toolPages?: unknown[][];
112
+ resourcePages?: FakeResource[][];
113
+ promptPages?: FakePrompt[][];
114
+ capabilities?: FakeCapabilities;
115
+ instructions?: string;
116
+ callTool?: (
117
+ fake: FakeClient,
118
+ params: { name: string; arguments: Record<string, unknown> },
119
+ ) => Promise<CallToolResult>;
120
+ }
121
+
122
+ function makeFakeClient(opts: FakeOptions = {}): FakeClient {
123
+ const toolPages = opts.toolPages ?? [];
124
+ const resourcePages = opts.resourcePages ?? [];
125
+ const promptPages = opts.promptPages ?? [];
126
+ let toolPage = 0;
127
+ let resourcePage = 0;
128
+ let promptPage = 0;
129
+ const fake: FakeClient = {
130
+ connectCalls: 0,
131
+ closeCalls: 0,
132
+ closed: false,
133
+ listToolsCalls: [],
134
+ listResourcesCalls: [],
135
+ listPromptsCalls: [],
136
+ async connect(transport) {
137
+ this.connectCalls++;
138
+ await opts.onConnect?.(this, transport);
139
+ },
140
+ async listTools(params) {
141
+ this.listToolsCalls.push(params?.cursor);
142
+ if (opts.listTools) return { tools: opts.listTools() };
143
+ const page = toolPages[toolPage];
144
+ const hasMore = toolPages[toolPage + 1] !== undefined;
145
+ toolPage++;
146
+ const r: { tools: unknown[]; nextCursor?: string } = { tools: page ?? [] };
147
+ if (hasMore) r.nextCursor = `cursor-${toolPage}`;
148
+ return r;
149
+ },
150
+ async listResources(params) {
151
+ this.listResourcesCalls.push(params?.cursor);
152
+ const page = resourcePages[resourcePage];
153
+ const hasMore = resourcePages[resourcePage + 1] !== undefined;
154
+ resourcePage++;
155
+ const r: { resources: FakeResource[]; nextCursor?: string } = { resources: page ?? [] };
156
+ if (hasMore) r.nextCursor = `cursor-${resourcePage}`;
157
+ return r;
158
+ },
159
+ async listPrompts(params) {
160
+ this.listPromptsCalls.push(params?.cursor);
161
+ const page = promptPages[promptPage];
162
+ const hasMore = promptPages[promptPage + 1] !== undefined;
163
+ promptPage++;
164
+ const r: { prompts: FakePrompt[]; nextCursor?: string } = { prompts: page ?? [] };
165
+ if (hasMore) r.nextCursor = `cursor-${promptPage}`;
166
+ return r;
167
+ },
168
+ getServerCapabilities() {
169
+ return opts.capabilities;
170
+ },
171
+ getInstructions() {
172
+ return opts.instructions;
173
+ },
174
+ async callTool(params) {
175
+ return opts.callTool
176
+ ? opts.callTool(this, params)
177
+ : Promise.resolve({ content: [{ type: "text", text: "ok" }] });
178
+ },
179
+ async close() {
180
+ this.closeCalls++;
181
+ this.closed = true;
182
+ },
183
+ };
184
+ return fake;
185
+ }
186
+
187
+ /** Harmless stdio def — the fake client never actually spawns anything. */
188
+ const stdioDef: StdioServerDef = { command: "true" };
189
+
190
+ /** Harmless HTTP def — the fake client never touches the network. */
191
+ const httpDef: HttpServerDef = { type: "http", url: "http://127.0.0.1:1/mcp" };
192
+
193
+ let tmp: string;
194
+ beforeEach(() => {
195
+ tmp = mkdtempSync(join(tmpdir(), "mcp-client-test-"));
196
+ setCachePathForTest(join(tmp, "cache.json"));
197
+ authFlow.state.authenticateImpl = async () => ({ status: "authenticated" });
198
+ authFlow.state.validToken = null;
199
+ authFlow.extractOAuthConfig.mockClear();
200
+ authFlow.authenticate.mockClear();
201
+ authFlow.getValidToken.mockClear();
202
+ });
203
+
204
+ afterEach(() => {
205
+ setCachePathForTest(null);
206
+ });
207
+
208
+ describe("buildAuthHeaders", () => {
209
+ const base: HttpServerDef = { type: "http", url: "http://127.0.0.1:1/mcp" };
210
+
211
+ it("returns an empty record when no auth-related fields are set", () => {
212
+ expect(buildAuthHeaders(base)).toEqual({});
213
+ });
214
+
215
+ it("merges def.headers into the result", () => {
216
+ const def: HttpServerDef = {
217
+ ...base,
218
+ headers: { "X-Api": "1", Accept: "application/json" },
219
+ };
220
+ expect(buildAuthHeaders(def)).toEqual({
221
+ "X-Api": "1",
222
+ Accept: "application/json",
223
+ });
224
+ });
225
+
226
+ it("sets Authorization from auth.token, overriding a user-supplied Authorization header", () => {
227
+ const def: HttpServerDef = {
228
+ ...base,
229
+ auth: { token: "tok" },
230
+ headers: { "X-Api": "1", Authorization: "Bearer user-wins-not" },
231
+ bearerTokenEnv: "MCP_TEST_BEARER",
232
+ };
233
+ vi.stubEnv("MCP_TEST_BEARER", "env-token");
234
+ try {
235
+ expect(buildAuthHeaders(def)).toEqual({
236
+ "X-Api": "1",
237
+ Authorization: "Bearer tok",
238
+ });
239
+ } finally {
240
+ vi.unstubAllEnvs();
241
+ }
242
+ });
243
+
244
+ it("falls back to bearerTokenEnv when auth.token is absent", () => {
245
+ const def: HttpServerDef = {
246
+ ...base,
247
+ headers: { "X-Api": "1" },
248
+ bearerTokenEnv: "MCP_TEST_BEARER",
249
+ };
250
+ vi.stubEnv("MCP_TEST_BEARER", "env-token");
251
+ try {
252
+ expect(buildAuthHeaders(def)).toEqual({
253
+ "X-Api": "1",
254
+ Authorization: "Bearer env-token",
255
+ });
256
+ } finally {
257
+ vi.unstubAllEnvs();
258
+ }
259
+ });
260
+
261
+ it("does not set Authorization when the bearer env var is empty or unset", () => {
262
+ const def: HttpServerDef = { ...base, bearerTokenEnv: "MCP_TEST_BEARER_EMPTY" };
263
+ vi.stubEnv("MCP_TEST_BEARER_EMPTY", "");
264
+ try {
265
+ expect(buildAuthHeaders(def)).toEqual({});
266
+ } finally {
267
+ vi.unstubAllEnvs();
268
+ }
269
+ // Also: env var never set at all
270
+ delete process.env.MCP_TEST_BEARER_MISSING;
271
+ const missing: HttpServerDef = { ...base, bearerTokenEnv: "MCP_TEST_BEARER_MISSING" };
272
+ expect(buildAuthHeaders(missing)).toEqual({});
273
+ });
274
+
275
+ it("does not mutate the caller's def.headers", () => {
276
+ const def: HttpServerDef = {
277
+ ...base,
278
+ auth: { token: "tok" },
279
+ headers: { "X-Api": "1" },
280
+ };
281
+ buildAuthHeaders(def);
282
+ expect(def.headers).toEqual({ "X-Api": "1" });
283
+ });
284
+ });
285
+
286
+ describe("ServerClient — generation fencing", () => {
287
+ it("tears down a connect that resolves after close(); no connection leaks", async () => {
288
+ let fake: FakeClient | null = null;
289
+ let resolveConnect: () => void = () => {};
290
+ const client = new ServerClient("srv", stdioDef, {
291
+ clientFactory: () => {
292
+ fake = makeFakeClient({
293
+ onConnect: () => new Promise<void>((r) => (resolveConnect = r)),
294
+ });
295
+ return fake as unknown as Client;
296
+ },
297
+ });
298
+ // TS narrows the captured `fake` to null in the outer flow; read via a
299
+ // helper so assertions use the declared type.
300
+ const fakeNow = (): FakeClient => {
301
+ if (!fake) throw new Error("fake client not created yet");
302
+ return fake;
303
+ };
304
+
305
+ const pending = client.connect();
306
+ // Wait until the (fake) SDK connect is actually in flight
307
+ await vi.waitFor(() => expect(fake?.connectCalls).toBe(1));
308
+ expect(client.status).toBe("connecting");
309
+
310
+ // Close races ahead of the slow connect
311
+ await client.close();
312
+ expect(client.status).toBe("disconnected");
313
+ expect(fakeNow().closed).toBe(true);
314
+
315
+ // Now the in-flight connect resolves — it must be fenced out
316
+ resolveConnect();
317
+ await pending; // must resolve, not throw
318
+
319
+ expect(client.status).toBe("disconnected");
320
+ expect(client.tools).toEqual([]);
321
+ // The stale client must not be reusable: a fresh connect makes a new fake
322
+ const racedFake = fakeNow();
323
+ const pending2 = client.connect(); // do NOT await — the fake connect is held back
324
+ await vi.waitFor(() => {
325
+ expect(fakeNow()).not.toBe(racedFake);
326
+ expect(fake?.connectCalls).toBe(1);
327
+ });
328
+ // The factory reassigned resolveConnect for the new fake's connect
329
+ resolveConnect();
330
+ await pending2;
331
+ await vi.waitFor(() => expect(client.status).toBe("connected"));
332
+ // The old (raced) fake was not the one left connected
333
+ expect(fakeNow()).not.toBe(racedFake);
334
+ expect(racedFake.closed).toBe(true);
335
+ expect(client.status).toBe("connected");
336
+ });
337
+
338
+ it("survives close() before connect() with no client assigned", async () => {
339
+ const client = new ServerClient("srv", stdioDef, {
340
+ clientFactory: () => makeFakeClient() as unknown as Client,
341
+ });
342
+ await client.close();
343
+ expect(client.status).toBe("disconnected");
344
+ });
345
+ });
346
+
347
+ describe("ServerClient — needs-auth", () => {
348
+ it("sets needs-auth on HTTP 401 during connect without throwing", async () => {
349
+ const client = new ServerClient("auth-srv", httpDef, {
350
+ clientFactory: () =>
351
+ makeFakeClient({
352
+ onConnect: async () => {
353
+ throw new StreamableHTTPError(401, "Unauthorized");
354
+ },
355
+ }) as unknown as Client,
356
+ });
357
+
358
+ await expect(client.connect()).resolves.toBeUndefined();
359
+ expect(client.status).toBe("needs-auth");
360
+ expect(client.error).toMatch(/OAuth/);
361
+
362
+ // A tool call on a needs-auth client throws a clear error (no retry loop)
363
+ await expect(client.callTool("t", {})).rejects.toThrow(
364
+ /authentication required or token rejected/i,
365
+ );
366
+ });
367
+
368
+ it("authenticate() rejects when the server has no oauth config (stub replaced in plan-026)", async () => {
369
+ const client = new ServerClient("auth-srv", httpDef, {
370
+ clientFactory: () => makeFakeClient() as unknown as Client,
371
+ });
372
+ await expect(client.authenticate()).rejects.toThrow(
373
+ "Server auth-srv is not configured for OAuth (auth must be \"oauth\" or an oauth config object)",
374
+ );
375
+ expect(authFlow.authenticate).not.toHaveBeenCalled();
376
+
377
+ // A static { token } bearer server is also not an OAuth server
378
+ const bearerDef: HttpServerDef = { ...httpDef, auth: { token: "static" } };
379
+ const bearerClient = new ServerClient("bearer-srv", bearerDef, {
380
+ clientFactory: () => makeFakeClient() as unknown as Client,
381
+ });
382
+ await expect(bearerClient.authenticate()).rejects.toThrow("not configured for OAuth");
383
+ expect(authFlow.authenticate).not.toHaveBeenCalled();
384
+ });
385
+
386
+ it("close() clears needs-auth so a later connect can retry", async () => {
387
+ let calls = 0;
388
+ const client = new ServerClient("auth-srv", httpDef, {
389
+ clientFactory: () =>
390
+ makeFakeClient({
391
+ onConnect: async () => {
392
+ calls++;
393
+ if (calls === 1) throw new StreamableHTTPError(401, "Unauthorized");
394
+ },
395
+ }) as unknown as Client,
396
+ });
397
+ await client.connect();
398
+ expect(client.status).toBe("needs-auth");
399
+ await client.close();
400
+ expect(client.status).toBe("disconnected");
401
+ await client.connect();
402
+ expect(calls).toBe(2);
403
+ expect(client.status).toBe("connected");
404
+ });
405
+ });
406
+
407
+ describe("ServerClient — OAuth authenticate (real flow)", () => {
408
+ const oauthDef: HttpServerDef = {
409
+ type: "http",
410
+ url: "https://mcp.example.com/mcp",
411
+ auth: "oauth",
412
+ };
413
+ const factory = () => makeFakeClient() as unknown as Client;
414
+
415
+ it("runs the flow with the def's url and extracted config, forwarding options", async () => {
416
+ const client = new ServerClient("oauth-srv", oauthDef, { clientFactory: factory });
417
+ const ac = new AbortController();
418
+ const onAuthorizationUrl = vi.fn();
419
+
420
+ await client.authenticate({ signal: ac.signal, onAuthorizationUrl });
421
+
422
+ expect(authFlow.authenticate).toHaveBeenCalledTimes(1);
423
+ expect(authFlow.authenticate).toHaveBeenCalledWith(
424
+ "oauth-srv",
425
+ "https://mcp.example.com/mcp",
426
+ { grantType: "authorization_code" },
427
+ { signal: ac.signal, onAuthorizationUrl },
428
+ );
429
+ });
430
+
431
+ it("Extracts object oauth configs and passes them through", async () => {
432
+ const def: HttpServerDef = {
433
+ type: "http",
434
+ url: "https://mcp.example.com/mcp",
435
+ auth: { grantType: "client_credentials", clientSecret: "shh" },
436
+ };
437
+ const client = new ServerClient("cc-srv", def, { clientFactory: factory });
438
+ await client.authenticate();
439
+ expect(authFlow.authenticate).toHaveBeenCalledWith(
440
+ "cc-srv",
441
+ "https://mcp.example.com/mcp",
442
+ { grantType: "client_credentials", clientSecret: "shh" },
443
+ undefined,
444
+ );
445
+ });
446
+
447
+ it("throws a clear error when the flow reports failed", async () => {
448
+ authFlow.state.authenticateImpl = async () => ({
449
+ status: "failed",
450
+ error: "network unreachable",
451
+ });
452
+ const client = new ServerClient("oauth-srv", oauthDef, { clientFactory: factory });
453
+ await expect(client.authenticate()).rejects.toThrow(
454
+ "Authentication failed for oauth-srv",
455
+ );
456
+ });
457
+
458
+ it("surfaces the underlying failure cause in the thrown error", async () => {
459
+ authFlow.state.authenticateImpl = async () => ({
460
+ status: "failed",
461
+ error: "invalid_grant: token endpoint rejected the grant",
462
+ });
463
+ const client = new ServerClient("oauth-srv", oauthDef, { clientFactory: factory });
464
+ // /mcp auth and auto-auth show this message — the cause must be in it,
465
+ // not just the generic part
466
+ await expect(client.authenticate()).rejects.toThrow(
467
+ "Authentication failed for oauth-srv",
468
+ );
469
+ await expect(client.authenticate()).rejects.toThrow(
470
+ "invalid_grant: token endpoint rejected the grant",
471
+ );
472
+ });
473
+
474
+ it("throws a clear error when the flow needs manual interaction", async () => {
475
+ authFlow.state.authenticateImpl = async () => ({ status: "needs-interaction" });
476
+ const client = new ServerClient("oauth-srv", oauthDef, { clientFactory: factory });
477
+ await expect(client.authenticate()).rejects.toThrow(
478
+ "Authentication requires manual interaction for oauth-srv",
479
+ );
480
+ });
481
+
482
+ it("rethrows a cancelled flow's error untouched (no wrapping)", async () => {
483
+ authFlow.state.authenticateImpl = async () => {
484
+ throw new Error("OAuth cancelled");
485
+ };
486
+ const client = new ServerClient("oauth-srv", oauthDef, { clientFactory: factory });
487
+ // The cancel error must not be re-wrapped into a failed-flow error
488
+ await expect(client.authenticate()).rejects.toThrow("OAuth cancelled");
489
+ await expect(client.authenticate()).rejects.not.toThrow("Authentication failed");
490
+ });
491
+ });
492
+
493
+ describe("ServerClient — OAuth bearer on connect", () => {
494
+ const oauthDef: HttpServerDef = {
495
+ type: "http",
496
+ url: "https://mcp.example.com/mcp",
497
+ auth: "oauth",
498
+ };
499
+
500
+ function captureTransport(onCaptured: (t: { _requestInit?: { headers?: Record<string, string> } }) => void) {
501
+ return () =>
502
+ makeFakeClient({
503
+ onConnect: (_fake, transport) => {
504
+ onCaptured(transport as { _requestInit?: { headers?: Record<string, string> } });
505
+ },
506
+ }) as unknown as Client;
507
+ }
508
+
509
+ it("attaches the valid OAuth token as Authorization and keeps existing headers", async () => {
510
+ authFlow.state.validToken = "stored-tok";
511
+ let transport: { _requestInit?: { headers?: Record<string, string> } } | undefined;
512
+ const client = new ServerClient("oauth-srv", { ...oauthDef, headers: { "X-Api": "1" } }, {
513
+ clientFactory: captureTransport((t) => {
514
+ transport = t;
515
+ }),
516
+ });
517
+
518
+ await client.connect();
519
+ expect(client.status).toBe("connected");
520
+
521
+ expect(authFlow.getValidToken).toHaveBeenCalledTimes(1);
522
+ expect(authFlow.getValidToken).toHaveBeenCalledWith(
523
+ "oauth-srv",
524
+ "https://mcp.example.com/mcp",
525
+ { grantType: "authorization_code" },
526
+ );
527
+ expect(transport?._requestInit?.headers).toEqual({
528
+ "X-Api": "1",
529
+ Authorization: "Bearer stored-tok",
530
+ });
531
+ });
532
+
533
+ it("sends no Authorization header when there is no valid stored token", async () => {
534
+ authFlow.state.validToken = null;
535
+ let transport: { _requestInit?: { headers?: Record<string, string> } } | undefined;
536
+ const client = new ServerClient("oauth-srv", oauthDef, {
537
+ clientFactory: captureTransport((t) => {
538
+ transport = t;
539
+ }),
540
+ });
541
+
542
+ await client.connect();
543
+ expect(client.status).toBe("connected");
544
+ expect(authFlow.getValidToken).toHaveBeenCalledTimes(1);
545
+ // Nothing to send: no requestInit at all (def.headers unset as well)
546
+ expect(transport?._requestInit).toBeUndefined();
547
+ });
548
+
549
+ it("leaves static { token } servers untouched — no OAuth flow, static header wins", async () => {
550
+ let transport: { _requestInit?: { headers?: Record<string, string> } } | undefined;
551
+ const def: HttpServerDef = {
552
+ type: "http",
553
+ url: "https://mcp.example.com/mcp",
554
+ auth: { token: "static" },
555
+ headers: { "X-Api": "1" },
556
+ };
557
+ const client = new ServerClient("bearer-srv", def, {
558
+ clientFactory: captureTransport((t) => {
559
+ transport = t;
560
+ }),
561
+ });
562
+
563
+ await client.connect();
564
+ expect(client.status).toBe("connected");
565
+ expect(authFlow.getValidToken).not.toHaveBeenCalled();
566
+ expect(transport?._requestInit?.headers).toEqual({
567
+ "X-Api": "1",
568
+ Authorization: "Bearer static",
569
+ });
570
+ });
571
+
572
+ it("an OAuth token overrides an env-bearer fallback (bearerTokenEnv)", async () => {
573
+ authFlow.state.validToken = "oauth-tok";
574
+ vi.stubEnv("MCP_TEST_BEARER_CC", "env-token");
575
+ let transport: { _requestInit?: { headers?: Record<string, string> } } | undefined;
576
+ const def: HttpServerDef = { ...oauthDef, bearerTokenEnv: "MCP_TEST_BEARER_CC" };
577
+ const client = new ServerClient("oauth-srv", def, {
578
+ clientFactory: captureTransport((t) => {
579
+ transport = t;
580
+ }),
581
+ });
582
+ try {
583
+ await client.connect();
584
+ expect(transport?._requestInit?.headers).toEqual({
585
+ Authorization: "Bearer oauth-tok",
586
+ });
587
+ } finally {
588
+ vi.unstubAllEnvs();
589
+ }
590
+ });
591
+ });
592
+
593
+ describe("ServerClient — shape-based classification (typeless url defs)", () => {
594
+ it("connects a URL server without a type field via StreamableHTTP (never stdio)", async () => {
595
+ const seen: unknown[] = [];
596
+ // The user's real-config shape: url + headers, no `type` field at all
597
+ const def: ServerDef = { url: "http://127.0.0.1:1/mcp", auth: { token: "tok" } };
598
+ const client = new ServerClient("srv", def, {
599
+ clientFactory: () =>
600
+ makeFakeClient({
601
+ onConnect: (_fake, transport) => {
602
+ seen.push(transport);
603
+ },
604
+ }) as unknown as Client,
605
+ });
606
+
607
+ await client.connect();
608
+ expect(client.status).toBe("connected");
609
+ expect(seen).toHaveLength(1);
610
+ // The key assertion: HTTP transport, not StdioClientTransport
611
+ expect(seen[0]).toBeInstanceOf(StreamableHTTPClientTransport);
612
+ expect(seen[0]).not.toBeInstanceOf(StdioClientTransport);
613
+ // The http path must have attached the bearer token
614
+ const headers = (seen[0] as { _requestInit?: { headers?: Record<string, string> } })._requestInit
615
+ ?.headers;
616
+ expect(headers).toEqual({ Authorization: "Bearer tok" });
617
+ });
618
+
619
+ it("runs OAuth authenticate for a URL server without a type field", async () => {
620
+ const def: ServerDef = { url: "https://mcp.example.com/mcp", auth: "oauth" };
621
+ const client = new ServerClient("oauth-srv", def, {
622
+ clientFactory: () => makeFakeClient() as unknown as Client,
623
+ });
624
+ await client.authenticate();
625
+ expect(authFlow.authenticate).toHaveBeenCalledTimes(1);
626
+ expect(authFlow.authenticate).toHaveBeenCalledWith(
627
+ "oauth-srv",
628
+ "https://mcp.example.com/mcp",
629
+ { grantType: "authorization_code" },
630
+ undefined,
631
+ );
632
+ });
633
+ });
634
+
635
+ describe("ServerClient — onclose client identity", () => {
636
+ it("ignores a delayed onclose from an abandoned StreamableHTTP transport after the SSE fallback connects", async () => {
637
+ let abandonedOnClose: (() => void) | undefined;
638
+ let liveOnClose: (() => void) | undefined;
639
+ // First factory call → StreamableHTTP transport (abandoned), second → SSE fallback
640
+ const fakeA = makeFakeClient({
641
+ onConnect: (_fake, transport) => {
642
+ abandonedOnClose = (transport as { onclose?: () => void }).onclose;
643
+ throw new Error("streamable http unsupported");
644
+ },
645
+ });
646
+ const fakeB = makeFakeClient({
647
+ listTools: () => [{ name: "t1", inputSchema: {} }],
648
+ onConnect: (_fake, transport) => {
649
+ liveOnClose = (transport as { onclose?: () => void }).onclose;
650
+ },
651
+ });
652
+ let created = 0;
653
+ const client = new ServerClient("srv", httpDef, {
654
+ clientFactory: () =>
655
+ (created++ === 0 ? fakeA : fakeB) as unknown as Client,
656
+ });
657
+
658
+ await client.connect();
659
+ expect(client.status).toBe("connected");
660
+ expect(client.tools.map((t) => t.name)).toEqual(["t1"]);
661
+
662
+ // The abandoned StreamableHTTP transport fires onclose late — it must
663
+ // NOT clobber the healthy SSE client (different instance, same generation)
664
+ expect(abandonedOnClose).toBeDefined();
665
+ abandonedOnClose?.();
666
+ expect(client.status).toBe("connected");
667
+
668
+ // The LIVE SSE transport's close still disconnects
669
+ liveOnClose?.();
670
+ expect(client.status).toBe("disconnected");
671
+ });
672
+ });
673
+
674
+ describe("ServerClient — session recovery (404)", () => {
675
+ it("reconnects exactly once on 404 during callTool and retries the call", async () => {
676
+ const fakes: FakeClient[] = [];
677
+ const client = new ServerClient("sess-srv", httpDef, {
678
+ clientFactory: () => {
679
+ const fake = makeFakeClient({
680
+ callTool: (_fake, params) => {
681
+ // First session is expired; second session is healthy
682
+ if (fakes.length === 1) {
683
+ return Promise.reject(new StreamableHTTPError(404, "Session not found"));
684
+ }
685
+ return Promise.resolve({
686
+ content: [{ type: "text", text: `ok:${params.name}` }],
687
+ });
688
+ },
689
+ });
690
+ fakes.push(fake);
691
+ return fake as unknown as Client;
692
+ },
693
+ });
694
+
695
+ const result = await client.callTool("echo", { x: 1 });
696
+ expect(result.content).toEqual([{ type: "text", text: "ok:echo" }]);
697
+ expect(result.isError).toBe(false);
698
+
699
+ // Exactly one reconnect: two clients were created
700
+ expect(fakes).toHaveLength(2);
701
+ // The expired session was closed; the fresh one is kept
702
+ expect(fakes[0]?.closed).toBe(true);
703
+ expect(fakes[1]?.closeCalls).toBe(0);
704
+ expect(client.status).toBe("connected");
705
+ });
706
+
707
+ it("surfaces the retry's error when the 404 persists after reconnect", async () => {
708
+ const fakes: FakeClient[] = [];
709
+ const client = new ServerClient("sess-srv", httpDef, {
710
+ clientFactory: () => {
711
+ const fake = makeFakeClient({
712
+ callTool: () =>
713
+ Promise.reject(new StreamableHTTPError(404, "Session not found")),
714
+ });
715
+ fakes.push(fake);
716
+ return fake as unknown as Client;
717
+ },
718
+ });
719
+
720
+ await expect(client.callTool("echo", {})).rejects.toMatchObject({ code: 404 });
721
+ // Still only one reconnect attempt — no retry loop
722
+ expect(fakes).toHaveLength(2);
723
+ });
724
+ });
725
+
726
+ describe("ServerClient — idle tracking", () => {
727
+ it("isIdle is false while a call is in flight and true after the idle timeout", async () => {
728
+ let fake: FakeClient | null = null;
729
+ const client = new ServerClient("idle-srv", stdioDef, {
730
+ clientFactory: () => {
731
+ fake = makeFakeClient();
732
+ return fake as unknown as Client;
733
+ },
734
+ });
735
+
736
+ // Disconnected: never idle
737
+ expect(client.isIdle(0)).toBe(false);
738
+
739
+ await client.connect();
740
+ expect(client.status).toBe("connected");
741
+ // Never used (lastUsedAt = 0): idle by any timeout
742
+ expect(client.isIdle(1)).toBe(true);
743
+
744
+ // Kick off a call that never finishes
745
+ let done: () => void = () => {};
746
+ const pendingCall = new Promise<void>((r) => (done = r));
747
+ fake!.callTool = async () => {
748
+ await pendingCall;
749
+ return { content: [{ type: "text", text: "ok" }] };
750
+ };
751
+ const inFlight = client.callTool("slow", {});
752
+ await vi.waitFor(() => expect(client.inFlight).toBe(1));
753
+ // In-flight: not idle even with an absurd timeout
754
+ expect(client.isIdle(Number.MAX_SAFE_INTEGER)).toBe(false);
755
+
756
+ done();
757
+ const result = await inFlight;
758
+ expect(result.content).toEqual([{ type: "text", text: "ok" }]);
759
+ expect(client.inFlight).toBe(0);
760
+ // Just used: not yet idle for a realistic timeout
761
+ expect(client.isIdle(10_000)).toBe(false);
762
+ // After the timeout elapses: idle
763
+ await new Promise((r) => setTimeout(r, 25));
764
+ expect(client.isIdle(10)).toBe(true);
765
+ });
766
+ });
767
+
768
+ describe("ServerClient — discovery and pagination", () => {
769
+ it("combines multiple pages of tools and passes the cursor back", async () => {
770
+ const fake = makeFakeClient({
771
+ toolPages: [
772
+ [{ name: "a" }, { name: "b" }],
773
+ [{ name: "c" }],
774
+ ],
775
+ });
776
+ const client = new ServerClient("pag", stdioDef, {
777
+ clientFactory: () => fake as unknown as Client,
778
+ });
779
+ await client.connect();
780
+
781
+ // All tools from both pages, in order, stamped with the server name
782
+ expect(client.tools.map((t) => t.name)).toEqual(["a", "b", "c"]);
783
+ expect(client.tools[0]).toMatchObject({ name: "a", serverName: "pag" });
784
+
785
+ // Two requests; the second carries page 1's nextCursor back
786
+ expect(fake.listToolsCalls).toEqual([undefined, "cursor-1"]);
787
+ });
788
+
789
+ it("paginates resources when the resources capability is advertised", async () => {
790
+ const fake = makeFakeClient({
791
+ capabilities: { resources: true },
792
+ resourcePages: [
793
+ [{ uri: "res://1", name: "one" }],
794
+ [{ uri: "res://2", mimeType: "text/plain" }],
795
+ ],
796
+ });
797
+ const client = new ServerClient("res", stdioDef, {
798
+ clientFactory: () => fake as unknown as Client,
799
+ });
800
+ await client.connect();
801
+
802
+ expect(client.resources).toEqual([
803
+ { uri: "res://1", name: "one" },
804
+ { uri: "res://2", mimeType: "text/plain" },
805
+ ]);
806
+ expect(fake.listResourcesCalls).toEqual([undefined, "cursor-1"]);
807
+ // prompts not advertised → never called
808
+ expect(fake.listPromptsCalls).toEqual([]);
809
+ expect(client.prompts).toEqual([]);
810
+ });
811
+
812
+ it("paginates prompts when the prompts capability is advertised", async () => {
813
+ const fake = makeFakeClient({
814
+ capabilities: { prompts: true },
815
+ promptPages: [[{ name: "p1", description: "d1" }], [{ name: "p2" }]],
816
+ });
817
+ const client = new ServerClient("prm", stdioDef, {
818
+ clientFactory: () => fake as unknown as Client,
819
+ });
820
+ await client.connect();
821
+
822
+ expect(client.prompts).toEqual([
823
+ { name: "p1", description: "d1" },
824
+ { name: "p2" },
825
+ ]);
826
+ expect(fake.listPromptsCalls).toEqual([undefined, "cursor-1"]);
827
+ expect(fake.listResourcesCalls).toEqual([]);
828
+ expect(client.resources).toEqual([]);
829
+ });
830
+
831
+ it("never calls listResources/listPrompts when capabilities are not advertised", async () => {
832
+ const fake = makeFakeClient({ toolPages: [[{ name: "t" }]] });
833
+ const client = new ServerClient("nocap", stdioDef, {
834
+ clientFactory: () => fake as unknown as Client,
835
+ });
836
+ await client.connect();
837
+
838
+ expect(fake.listResourcesCalls).toEqual([]);
839
+ expect(fake.listPromptsCalls).toEqual([]);
840
+ expect(client.resources).toEqual([]);
841
+ expect(client.prompts).toEqual([]);
842
+ // tools are still discovered
843
+ expect(client.tools.map((t) => t.name)).toEqual(["t"]);
844
+ });
845
+
846
+ it("exposes server instructions from getInstructions()", async () => {
847
+ const withInstr = makeFakeClient({ instructions: "be concise" });
848
+ const client = new ServerClient("ins", stdioDef, {
849
+ clientFactory: () => withInstr as unknown as Client,
850
+ });
851
+ await client.connect();
852
+ expect(client.instructions).toBe("be concise");
853
+
854
+ const withoutInstr = makeFakeClient();
855
+ const client2 = new ServerClient("ins2", stdioDef, {
856
+ clientFactory: () => withoutInstr as unknown as Client,
857
+ });
858
+ await client2.connect();
859
+ expect(client2.instructions).toBeUndefined();
860
+ });
861
+
862
+ it("writes discovered tools, resources, prompts, and instructions to the server cache", async () => {
863
+ const cacheFile = join(tmp, "cache.json");
864
+ const fake = makeFakeClient({
865
+ toolPages: [[{ name: "t1" }], [{ name: "t2" }]],
866
+ capabilities: { resources: true, prompts: true },
867
+ resourcePages: [[{ uri: "res://a", name: "A" }]],
868
+ promptPages: [[{ name: "p1" }]],
869
+ instructions: "the instructions",
870
+ });
871
+ const client = new ServerClient("dsc", stdioDef, {
872
+ clientFactory: () => fake as unknown as Client,
873
+ });
874
+ await client.connect();
875
+ expect(client.status).toBe("connected");
876
+
877
+ const onDisk = JSON.parse(readFileSync(cacheFile, "utf-8")) as {
878
+ servers: Record<string, { tools?: Array<{ name: string }>; resources?: unknown[]; prompts?: unknown[]; instructions?: string }>;
879
+ };
880
+ const entry = onDisk.servers["dsc"];
881
+ expect(entry).toBeDefined();
882
+ expect(entry?.tools?.map((t) => t.name)).toEqual(["t1", "t2"]);
883
+ expect(entry?.resources).toEqual([{ uri: "res://a", name: "A" }]);
884
+ expect(entry?.prompts).toEqual([{ name: "p1" }]);
885
+ expect(entry?.instructions).toBe("the instructions");
886
+ });
887
+
888
+ it("omits optional keys from the cache entry when nothing was discovered", async () => {
889
+ const cacheFile = join(tmp, "cache.json");
890
+ const fake = makeFakeClient({});
891
+ const client = new ServerClient("bare", stdioDef, {
892
+ clientFactory: () => fake as unknown as Client,
893
+ });
894
+ await client.connect();
895
+
896
+ const onDisk = JSON.parse(readFileSync(cacheFile, "utf-8")) as {
897
+ servers: Record<string, Record<string, unknown>>;
898
+ };
899
+ const entry = onDisk.servers["bare"];
900
+ expect(entry).toBeDefined();
901
+ expect(entry?.resources).toEqual([]);
902
+ // exactOptionalPropertyTypes: omitted, not present with an undefined value
903
+ expect("prompts" in (entry ?? {})).toBe(false);
904
+ expect("instructions" in (entry ?? {})).toBe(false);
905
+ });
906
+ });
907
+
908
+ describe("ServerManager", () => {
909
+ it("isIdle delegates to the client and is false for unknown servers", () => {
910
+ const fake = makeFakeClient();
911
+ const mgr = new ServerManager({ clientFactory: () => fake as unknown as Client });
912
+ mgr.sync({ a: stdioDef });
913
+ // Client is not connected yet → not idle
914
+ expect(mgr.isIdle("a", 1_000_000)).toBe(false);
915
+ expect(mgr.isIdle("missing", 1_000_000)).toBe(false);
916
+ });
917
+
918
+ it("sync replaces a client whose server def changed (old client closed, new def used)", async () => {
919
+ const fakes: FakeClient[] = [];
920
+ const mgr = new ServerManager({
921
+ clientFactory: () => {
922
+ const fake = makeFakeClient();
923
+ fakes.push(fake);
924
+ return fake as unknown as Client;
925
+ },
926
+ });
927
+
928
+ const defA: StdioServerDef = { command: "true" };
929
+ mgr.sync({ a: defA });
930
+ const clientA = mgr.getClient("a")!;
931
+ await clientA.connect();
932
+ expect(fakes).toHaveLength(1);
933
+ expect(clientA.status).toBe("connected");
934
+
935
+ // Change the def (different command) → old client must be closed and
936
+ // replaced by a fresh client constructed from the new def.
937
+ const defB: StdioServerDef = { command: "false" };
938
+ mgr.sync({ a: defB });
939
+
940
+ const clientB = mgr.getClient("a")!;
941
+ expect(clientB).not.toBe(clientA);
942
+ expect(clientB.def).toBe(defB);
943
+ // close() sets state synchronously before awaiting the SDK close
944
+ expect(clientA.status).toBe("disconnected");
945
+ await vi.waitFor(() => expect(fakes[0]!.closed).toBe(true));
946
+
947
+ // The replacement client connects using the NEW def (a fresh SDK client,
948
+ // not the old fake that was built for defA).
949
+ await clientB.connect();
950
+ expect(fakes).toHaveLength(2);
951
+ expect(fakes[1]!.closed).toBe(false);
952
+ expect(clientB.status).toBe("connected");
953
+ });
954
+
955
+ it("sync does not recreate a client when the same def is synced again", async () => {
956
+ const fakes: FakeClient[] = [];
957
+ const mgr = new ServerManager({
958
+ clientFactory: () => {
959
+ const fake = makeFakeClient();
960
+ fakes.push(fake);
961
+ return fake as unknown as Client;
962
+ },
963
+ });
964
+
965
+ const defA: StdioServerDef = { command: "true" };
966
+ mgr.sync({ a: defA });
967
+ const clientA = mgr.getClient("a")!;
968
+ await clientA.connect();
969
+
970
+ // Structurally equal but a fresh object → same identity hash → keep client
971
+ mgr.sync({ a: { ...defA } });
972
+ expect(mgr.getClient("a")).toBe(clientA);
973
+ expect(clientA.status).toBe("connected");
974
+ expect(fakes).toHaveLength(1);
975
+
976
+ // Runtime-only fields (toolPrefix) don't affect the identity hash either
977
+ mgr.sync({ a: { ...defA, toolPrefix: "none" } });
978
+ expect(mgr.getClient("a")).toBe(clientA);
979
+ expect(clientA.status).toBe("connected");
980
+ expect(fakes).toHaveLength(1);
981
+ });
982
+
983
+ it("sync closes removed clients exactly once (no double close)", async () => {
984
+ const fake = makeFakeClient();
985
+ const mgr = new ServerManager({ clientFactory: () => fake as unknown as Client });
986
+ mgr.sync({ gone: stdioDef });
987
+ const client = mgr.getClient("gone")!;
988
+ await client.connect();
989
+ expect(client.status).toBe("connected");
990
+
991
+ // Sync with the server removed
992
+ mgr.sync({});
993
+ expect(mgr.getClient("gone")).toBeUndefined();
994
+ await vi.waitFor(() => expect(fake.closeCalls).toBe(1));
995
+ await vi.waitFor(() => expect(client.status).toBe("disconnected"));
996
+
997
+ // A second close (e.g. from a pending reference) is a no-op
998
+ await client.close();
999
+ expect(fake.closeCalls).toBe(1);
1000
+ });
1001
+ });