@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,576 @@
1
+ import process from "node:process";
2
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
4
+ import {
5
+ StreamableHTTPClientTransport,
6
+ StreamableHTTPError,
7
+ } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
8
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
9
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
10
+ import { McpError } from "@modelcontextprotocol/sdk/types.js";
11
+ import type { StdioServerDef, HttpServerDef, ServerDef, ToolPrefix, CachedTool } from "./types.js";
12
+ import {
13
+ authenticate as runOAuthFlow,
14
+ extractOAuthConfig,
15
+ getValidToken,
16
+ type AuthenticateOptions,
17
+ } from "./auth-flow.js";
18
+ import { resolveNpxBinary } from "./npx-resolver.js";
19
+ import { saveServerCache } from "./metadata-cache.js";
20
+ import { isHttpDef } from "./config.js";
21
+ import { TolerantJsonSchemaValidator } from "./schema-validator.js";
22
+
23
+ /**
24
+ * Build the request headers for an HTTP server from its full definition.
25
+ * Merges def.headers (shallow copy), then adds Authorization in this
26
+ * precedence: auth.token wins over any user-supplied Authorization header;
27
+ * otherwise bearerTokenEnv is read from the process environment and used
28
+ * only when non-empty. Returns an empty record when nothing is configured.
29
+ * Exported for unit tests.
30
+ */
31
+ export function buildAuthHeaders(def: HttpServerDef): Record<string, string> {
32
+ const headers = { ...(def.headers ?? {}) };
33
+ const token =
34
+ typeof def.auth === "object" && def.auth !== null && "token" in def.auth
35
+ ? def.auth.token
36
+ : undefined;
37
+ if (token !== undefined) {
38
+ headers.Authorization = `Bearer ${token}`;
39
+ } else {
40
+ const envName = def.bearerTokenEnv;
41
+ if (envName) {
42
+ const envToken = process.env[envName];
43
+ if (envToken) headers.Authorization = `Bearer ${envToken}`;
44
+ }
45
+ }
46
+ return headers;
47
+ }
48
+
49
+ /** Cap for the captured stderr tail (last 8 KiB of output). */
50
+ const MAX_STDERR_TAIL_BYTES = 8 * 1024;
51
+
52
+ /**
53
+ * Format the bounded stderr tail: at most the last 3 lines of the captured
54
+ * (already size-bounded) output. Returns "" when there is nothing to show.
55
+ */
56
+ function formatStderrTail(chunks: Buffer[]): string {
57
+ if (chunks.length === 0) return "";
58
+ const text = Buffer.concat(chunks).toString("utf8").trim();
59
+ if (!text) return "";
60
+ return text.split("\n").slice(-3).join("\n");
61
+ }
62
+
63
+ export type ServerStatus =
64
+ | "disconnected"
65
+ | "connecting"
66
+ | "connected"
67
+ | "error"
68
+ | "needs-auth";
69
+
70
+ /**
71
+ * Error surfaced when a server requires credentials we cannot provide yet.
72
+ * Guides to the two ways to fix it: a static bearer token in the server
73
+ * definition, or the OAuth flow via /mcp auth (plan-026).
74
+ */
75
+ const NEEDS_AUTH_MESSAGE =
76
+ "authentication required or token rejected — configure a static bearer token or authenticate via OAuth (/mcp auth <server>)";
77
+
78
+ /**
79
+ * Options for ServerClient. `clientFactory` is a testability seam: replace the
80
+ * SDK Client with a fake in tests (no real network/stdio is touched).
81
+ */
82
+ export interface ServerClientOptions {
83
+ clientFactory?: () => Client;
84
+ }
85
+
86
+ export interface McpTool extends Tool {
87
+ serverName: string;
88
+ }
89
+
90
+ /** A discovered resource (shaped like the cache entry in types.ts) */
91
+ export type DiscoveredResource = {
92
+ uri: string;
93
+ name?: string;
94
+ description?: string;
95
+ mimeType?: string;
96
+ };
97
+
98
+ /** A discovered prompt (shaped like the cache entry in types.ts) */
99
+ export type DiscoveredPrompt = { name: string; description?: string };
100
+
101
+ /**
102
+ * Project an SDK resource into the discovered shape, dropping optional keys
103
+ * that are undefined (exactOptionalPropertyTypes — the SDK types declare
104
+ * `prop?: string | undefined`, which is not assignable to `prop?: string`).
105
+ */
106
+ function toDiscoveredResource(res: {
107
+ uri: string;
108
+ name?: string | undefined;
109
+ description?: string | undefined;
110
+ mimeType?: string | undefined;
111
+ }): DiscoveredResource {
112
+ const out: DiscoveredResource = { uri: res.uri };
113
+ if (res.name !== undefined) out.name = res.name;
114
+ if (res.description !== undefined) out.description = res.description;
115
+ if (res.mimeType !== undefined) out.mimeType = res.mimeType;
116
+ return out;
117
+ }
118
+
119
+ /** Same projection for SDK prompts (see toDiscoveredResource). */
120
+ function toDiscoveredPrompt(p: { name: string; description?: string | undefined }): DiscoveredPrompt {
121
+ const out: DiscoveredPrompt = { name: p.name };
122
+ if (p.description !== undefined) out.description = p.description;
123
+ return out;
124
+ }
125
+
126
+ /** A simplified content block as returned by callTool */
127
+ export type ContentBlock =
128
+ | { type: "text"; text: string }
129
+ | { type: "image"; data: string; mimeType: string }
130
+ | { type: string; [key: string]: unknown };
131
+
132
+ export class ServerClient {
133
+ readonly name: string;
134
+ private _def: ServerDef;
135
+ private clientFactory: () => Client;
136
+ private client: Client | null = null;
137
+ private _status: ServerStatus = "disconnected";
138
+ private _tools: McpTool[] = [];
139
+ private _resources: DiscoveredResource[] = [];
140
+ private _prompts: DiscoveredPrompt[] = [];
141
+ private _instructions: string | undefined;
142
+ private _error: string | null = null;
143
+ private connectPromise: Promise<void> | null = null;
144
+ /**
145
+ * Generation counter for fencing: bumped on every close(). A connect that
146
+ * started under an older generation (and only finishes after the close)
147
+ * tears itself down instead of leaking a live connection.
148
+ */
149
+ private generation = 0;
150
+ /** Epoch ms of the last tool call dispatch; 0 = never used. */
151
+ lastUsedAt = 0;
152
+ /** Number of tool calls currently in flight. */
153
+ inFlight = 0;
154
+
155
+ constructor(name: string, def: ServerDef, options?: ServerClientOptions) {
156
+ this.name = name;
157
+ this._def = def;
158
+ this.clientFactory = options?.clientFactory ?? (() => {
159
+ // A single unsound server schema must not take down the whole tools list —
160
+ // see TolerantJsonSchemaValidator for the reason.
161
+ return new Client(
162
+ { name: "pi-archimedes-mcp", version: "1.0.0" },
163
+ { jsonSchemaValidator: new TolerantJsonSchemaValidator() },
164
+ );
165
+ });
166
+ }
167
+
168
+ get status(): ServerStatus {
169
+ return this._status;
170
+ }
171
+
172
+ /** The server definition this client was constructed from */
173
+ get def(): ServerDef {
174
+ return this._def;
175
+ }
176
+
177
+ get tools(): McpTool[] {
178
+ return this._tools;
179
+ }
180
+
181
+ get resources(): DiscoveredResource[] {
182
+ return this._resources;
183
+ }
184
+
185
+ get prompts(): DiscoveredPrompt[] {
186
+ return this._prompts;
187
+ }
188
+
189
+ get instructions(): string | undefined {
190
+ return this._instructions;
191
+ }
192
+
193
+ get error(): string | null {
194
+ return this._error;
195
+ }
196
+
197
+ /** Tool-name prefix mode for this server (per-server setting, defaults to "server") */
198
+ get toolPrefix(): ToolPrefix {
199
+ return this._def.toolPrefix ?? "server";
200
+ }
201
+
202
+ /**
203
+ * True when the client is connected, has no in-flight calls, and the last
204
+ * call finished more than `timeoutMs` ago. A never-used connection is idle
205
+ * by any timeout; a disconnected one is never idle.
206
+ */
207
+ isIdle(timeoutMs: number): boolean {
208
+ return (
209
+ this._status === "connected" &&
210
+ this.inFlight === 0 &&
211
+ Date.now() - this.lastUsedAt > timeoutMs
212
+ );
213
+ }
214
+
215
+ /**
216
+ * Authenticate via OAuth — the SINGLE auth entry point for this server
217
+ * (the `/mcp auth` command and auto-auth both call this; neither calls
218
+ * the auth-flow module directly, so url/config always come from the def).
219
+ *
220
+ * Rejects when the server isn't configured for OAuth (missing auth,
221
+ * static bearer, or stdio). Resolves when the flow finishes
222
+ * authenticated; rejects with a clear error for failed flows (with the
223
+ * underlying cause appended) and needs-manual-interaction, and rethrows a
224
+ * cancelled flow's "OAuth cancelled" error untouched so the caller can
225
+ * distinguish cancel from failure.
226
+ */
227
+ async authenticate(options?: AuthenticateOptions): Promise<void> {
228
+ const def = this._def;
229
+ if (!isHttpDef(def)) {
230
+ throw new Error(
231
+ `Server ${this.name} is not configured for OAuth (auth must be "oauth" or an oauth config object)`,
232
+ );
233
+ }
234
+ const httpDef = def as HttpServerDef;
235
+ const cfg = extractOAuthConfig(httpDef.auth);
236
+ if (!cfg) {
237
+ throw new Error(
238
+ `Server ${this.name} is not configured for OAuth (auth must be "oauth" or an oauth config object)`,
239
+ );
240
+ }
241
+ const status = await runOAuthFlow(this.name, httpDef.url, cfg, options);
242
+ if (status.status === "failed") {
243
+ // `status.error` is the underlying cause (network error, token-endpoint
244
+ // rejection, …) — carry it up so /mcp auth and auto-auth can show the
245
+ // user the real reason instead of a generic message.
246
+ throw new Error(`Authentication failed for ${this.name}: ${status.error}`);
247
+ }
248
+ if (status.status === "needs-interaction") {
249
+ throw new Error(`Authentication requires manual interaction for ${this.name}`);
250
+ }
251
+ }
252
+
253
+ /** Lazily connect — idempotent, safe to call multiple times */
254
+ async connect(): Promise<void> {
255
+ if (this._status === "connected") return;
256
+ // A needs-auth server 401'd on connect; retrying without OAuth would just
257
+ // 401 again. A close() (e.g. config change) resets the status and allows
258
+ // a fresh attempt.
259
+ if (this._status === "needs-auth") return;
260
+ if (this.connectPromise) return this.connectPromise;
261
+
262
+ this.connectPromise = this._doConnect().finally(() => {
263
+ this.connectPromise = null;
264
+ });
265
+ return this.connectPromise;
266
+ }
267
+
268
+ private async _doConnect(): Promise<void> {
269
+ const gen = this.generation;
270
+ this._status = "connecting";
271
+ this._error = null;
272
+
273
+ // Bounded stderr capture for the stdio child, populated only when the
274
+ // transport pipes stderr (non-debug mode). Kept outside the try so the
275
+ // catch block can surface the tail on connection failure.
276
+ const stderrChunks: Buffer[] = [];
277
+ let stderrSize = 0;
278
+ const pushStderr = (chunk: Buffer) => {
279
+ stderrChunks.push(chunk);
280
+ stderrSize += chunk.length;
281
+ while (stderrSize > MAX_STDERR_TAIL_BYTES && stderrChunks.length > 1) {
282
+ const dropped = stderrChunks.shift()!;
283
+ stderrSize -= dropped.length;
284
+ }
285
+ };
286
+
287
+ try {
288
+ this.client = this.clientFactory();
289
+ const primaryClient = this.client;
290
+
291
+ // Guarded by generation AND client identity: a stale transport's close
292
+ // can arrive (a) after a close()+reconnect under a new generation, or
293
+ // (b) from an ABANDONED transport whose client was replaced within the
294
+ // same generation — in the HTTP branch the SSE fallback creates a
295
+ // second client under the same generation, so a late onclose from the
296
+ // abandoned StreamableHTTP transport must not clobber the live client.
297
+ const oncloseFor = (c: Client) => () => {
298
+ if (this.generation === gen && this.client === c) {
299
+ this._status = "disconnected";
300
+ this.client = null;
301
+ }
302
+ };
303
+
304
+ if (!isHttpDef(this._def)) {
305
+ const def = this._def as StdioServerDef;
306
+ // Resolve npx/npm wrappers to the actual binary so we spawn it
307
+ // directly; null means "not an npx/npm command" → use the original.
308
+ const resolved = await resolveNpxBinary(def.command, def.args ?? []);
309
+ const command = resolved?.command ?? def.command;
310
+ const args = resolved?.args ?? (def.args ?? []);
311
+
312
+ const transport = new StdioClientTransport({
313
+ command,
314
+ args,
315
+ env: { ...process.env, ...(def.env ?? {}) } as Record<string, string>,
316
+ // Pipe stderr in non-debug mode so we can capture the tail; in debug
317
+ // mode inherit so the user sees raw output in the foreground.
318
+ stderr: def.debug ? "inherit" : "pipe",
319
+ });
320
+ transport.onclose = oncloseFor(primaryClient);
321
+ // Attach the stderr listener BEFORE connecting so early crash output
322
+ // (e.g. the server failing to start) is not lost. Guard against null.
323
+ if (transport.stderr) {
324
+ transport.stderr.on("data", (chunk: Buffer) => pushStderr(chunk));
325
+ }
326
+ await this.client.connect(transport);
327
+ } else {
328
+ const def = this._def as HttpServerDef;
329
+ let authHeaders = buildAuthHeaders(def);
330
+ // OAuth servers: attach a valid stored token (the helper refreshes
331
+ // via the SDK when the stored token is expired and may return null
332
+ // — no stored token, or the ADR 0001 config-stub guard — in which
333
+ // case the headers are left untouched and the 401 → needs-auth path
334
+ // guides the user to /mcp auth). Static `{ token }` servers are
335
+ // unaffected (extractOAuthConfig returns null for them).
336
+ const oauthConfig = extractOAuthConfig(def.auth);
337
+ if (oauthConfig) {
338
+ const token = await getValidToken(this.name, def.url, oauthConfig);
339
+ if (token !== null) {
340
+ authHeaders = { ...authHeaders, Authorization: `Bearer ${token}` };
341
+ }
342
+ }
343
+ // buildAuthHeaders merges arbitrary def.headers, so this flag really
344
+ // means "has ANY headers" (not just auth).
345
+ const hasHeaders = Object.keys(authHeaders).length > 0;
346
+
347
+ // Try StreamableHTTP first (modern standard), fall back to SSE for legacy servers
348
+ let connected = false;
349
+ try {
350
+ const transport = new StreamableHTTPClientTransport(
351
+ new URL(def.url),
352
+ hasHeaders ? { requestInit: { headers: authHeaders } } : undefined,
353
+ );
354
+ transport.onclose = oncloseFor(primaryClient);
355
+ // StreamableHTTPClientTransport.sessionId is `string | undefined` which conflicts
356
+ // with the Transport interface's `sessionId?: string` under exactOptionalPropertyTypes
357
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
358
+ await (this.client as any).connect(transport);
359
+ connected = true;
360
+ } catch (e) {
361
+ // 401 with no OAuth provider configured surfaces as StreamableHTTPError.
362
+ // We cannot resolve it in this plan — record needs-auth and stop
363
+ // (no SSE fallback, no throw).
364
+ if (e instanceof StreamableHTTPError && e.code === 401) {
365
+ const c = this.client;
366
+ this.client = null;
367
+ await c.close().catch(() => {});
368
+ this._status = "needs-auth";
369
+ this._error = NEEDS_AUTH_MESSAGE;
370
+ return;
371
+ }
372
+ // Fall through to SSE fallback
373
+ }
374
+ if (!connected) {
375
+ // Create a fresh Client for the SSE fallback — the previous instance may be in a
376
+ // partially-initialised or broken state after the failed StreamableHTTP attempt.
377
+ const sseClient = this.clientFactory();
378
+ this.client = sseClient;
379
+ const transport = new SSEClientTransport(
380
+ new URL(def.url),
381
+ hasHeaders ? { requestInit: { headers: authHeaders } } : undefined,
382
+ );
383
+ transport.onclose = oncloseFor(sseClient);
384
+ await sseClient.connect(transport);
385
+ }
386
+ }
387
+
388
+ // Generation fence: a close() raced ahead of this connect finishing.
389
+ // Tear down the just-created client without ever marking it connected.
390
+ if (this.generation !== gen) {
391
+ await this.tearDownClient();
392
+ return;
393
+ }
394
+
395
+ this._status = "connected";
396
+
397
+ // Discover tools, resources, and prompts immediately after connect.
398
+ // All three list calls are paginated via nextCursor — a server with
399
+ // more than one page is truncated otherwise. Resources and prompts
400
+ // are only called when the server advertises the capability; the SDK
401
+ // throws assertCapabilityForMethod otherwise.
402
+ const tools: Tool[] = [];
403
+ let toolCursor: string | undefined;
404
+ do {
405
+ const r = await this.client.listTools(toolCursor ? { cursor: toolCursor } : undefined);
406
+ tools.push(...r.tools);
407
+ toolCursor = r.nextCursor;
408
+ } while (toolCursor);
409
+ this._tools = tools.map((t) => ({ ...t, serverName: this.name }));
410
+
411
+ const caps = this.client.getServerCapabilities();
412
+ const resources: DiscoveredResource[] = [];
413
+ let resourceCursor: string | undefined;
414
+ if (caps?.resources) {
415
+ try {
416
+ do {
417
+ const r = await this.client.listResources(resourceCursor ? { cursor: resourceCursor } : undefined);
418
+ resources.push(...r.resources.map(toDiscoveredResource));
419
+ resourceCursor = r.nextCursor;
420
+ } while (resourceCursor);
421
+ } catch (e) {
422
+ // Some servers advertise the resources capability but don't implement
423
+ // resources/list (e.g. Atlassian MCP returns -32601). Treat this as
424
+ // "no resources" rather than a fatal connection error.
425
+ if (!(e instanceof McpError && e.code === -32601)) throw e;
426
+ }
427
+ }
428
+ const prompts: DiscoveredPrompt[] = [];
429
+ let promptCursor: string | undefined;
430
+ if (caps?.prompts) {
431
+ try {
432
+ do {
433
+ const r = await this.client.listPrompts(promptCursor ? { cursor: promptCursor } : undefined);
434
+ prompts.push(...r.prompts.map(toDiscoveredPrompt));
435
+ promptCursor = r.nextCursor;
436
+ } while (promptCursor);
437
+ } catch (e) {
438
+ // Same defensive pattern: ignore -32601 for prompts/list.
439
+ if (!(e instanceof McpError && e.code === -32601)) throw e;
440
+ }
441
+ }
442
+ this._resources = resources;
443
+ this._prompts = prompts;
444
+ // Server-level instructions come from the SDK accessor (which reads
445
+ // the initialize result), not from the raw response.
446
+ this._instructions = this.client.getInstructions();
447
+
448
+ // Persist tool/resource/prompt metadata so search/describe can work
449
+ // offline. Cache writes are best-effort — a failure must never break
450
+ // the connection.
451
+ try {
452
+ const cacheEntry: {
453
+ tools: CachedTool[];
454
+ resources: DiscoveredResource[];
455
+ prompts?: DiscoveredPrompt[];
456
+ instructions?: string;
457
+ } = {
458
+ tools: tools.map((t) => {
459
+ const cached: CachedTool = { name: t.name, inputSchema: t.inputSchema };
460
+ if (t.description !== undefined) cached.description = t.description;
461
+ return cached;
462
+ }),
463
+ resources,
464
+ };
465
+ // exactOptionalPropertyTypes: omit optional keys rather than
466
+ // assigning undefined
467
+ if (prompts.length > 0) cacheEntry.prompts = prompts;
468
+ if (this._instructions !== undefined) cacheEntry.instructions = this._instructions;
469
+ saveServerCache(this.name, this._def, cacheEntry);
470
+ } catch {
471
+ // Best-effort cache write — ignore
472
+ }
473
+ } catch (e) {
474
+ // A close() won the race: the failure belongs to a superseded attempt.
475
+ if (this.generation !== gen) {
476
+ await this.tearDownClient();
477
+ return;
478
+ }
479
+ this._status = "error";
480
+ const baseMessage = e instanceof Error ? e.message : String(e);
481
+ const tail = formatStderrTail(stderrChunks);
482
+ this._error = tail ? `${baseMessage}\n--- stderr ---\n${tail}` : baseMessage;
483
+ this.client = null;
484
+ throw e;
485
+ }
486
+ }
487
+
488
+ /**
489
+ * Null out and close our current client (if any), marking the client
490
+ * disconnected. Used by the generation fence to clean up a connect that
491
+ * finished after a close().
492
+ */
493
+ private async tearDownClient(): Promise<void> {
494
+ const c = this.client;
495
+ this.client = null;
496
+ if (this._status !== "needs-auth") this._status = "disconnected";
497
+ if (c) await c.close().catch(() => {});
498
+ }
499
+
500
+ /** Call a tool by name with arguments */
501
+ async callTool(
502
+ toolName: string,
503
+ args: Record<string, unknown>,
504
+ signal?: AbortSignal,
505
+ ): Promise<{ content: ContentBlock[]; isError: boolean }> {
506
+ await this.connect();
507
+ this.assertCallable();
508
+
509
+ // Check abort before calling
510
+ signal?.throwIfAborted();
511
+
512
+ this.lastUsedAt = Date.now();
513
+ this.inFlight++;
514
+ try {
515
+ try {
516
+ return await this.invokeTool(toolName, args);
517
+ } catch (e) {
518
+ // Expired HTTP session (server restarted, etc.): reconnect exactly
519
+ // once and retry the call. Any further failure surfaces as-is.
520
+ if (!(e instanceof StreamableHTTPError) || e.code !== 404) throw e;
521
+ await this.close();
522
+ await this.connect();
523
+ this.assertCallable();
524
+ signal?.throwIfAborted();
525
+ this.lastUsedAt = Date.now();
526
+ return await this.invokeTool(toolName, args);
527
+ }
528
+ } finally {
529
+ this.inFlight--;
530
+ }
531
+ }
532
+
533
+ /** Throw a clear error when no usable connection exists */
534
+ private assertCallable(): void {
535
+ if (this._status === "needs-auth") {
536
+ throw new Error(`Server ${this.name}: ${this._error ?? NEEDS_AUTH_MESSAGE}`);
537
+ }
538
+ if (!this.client) throw new Error(`Server ${this.name} not connected`);
539
+ }
540
+
541
+ /** Perform the SDK call and normalise the result shape */
542
+ private async invokeTool(
543
+ toolName: string,
544
+ args: Record<string, unknown>,
545
+ ): Promise<{ content: ContentBlock[]; isError: boolean }> {
546
+ const client = this.client;
547
+ if (!client) throw new Error(`Server ${this.name} not connected`);
548
+
549
+ const result = await client.callTool({ name: toolName, arguments: args });
550
+
551
+ // The SDK returns a union: one branch has `content`, the other has `toolResult` (legacy)
552
+ if ("content" in result) {
553
+ return {
554
+ content: result.content as ContentBlock[],
555
+ isError: result.isError === true,
556
+ };
557
+ }
558
+
559
+ // Legacy CompatibilityCallToolResult — wrap toolResult as text
560
+ return {
561
+ content: [{ type: "text", text: JSON.stringify(result.toolResult, null, 2) }],
562
+ isError: false,
563
+ };
564
+ }
565
+
566
+ async close(): Promise<void> {
567
+ // Bump the generation so any in-flight connect is fenced out on completion.
568
+ // Set the state synchronously BEFORE awaiting the SDK close so a
569
+ // concurrent callTool cannot observe a null client with a stale status.
570
+ this.generation++;
571
+ this._status = "disconnected";
572
+ const c = this.client;
573
+ this.client = null;
574
+ if (c) await c.close().catch(() => {});
575
+ }
576
+ }