@haikit/mcp 0.32.0 → 0.33.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/src/index.ts CHANGED
@@ -18,22 +18,29 @@ import { createRequire } from "node:module";
18
18
  import type { Progress, Schema } from "@haikit/core";
19
19
  import {
20
20
  Client,
21
+ InsufficientScopeError,
21
22
  ProtocolError,
22
23
  ProtocolErrorCode,
23
24
  SdkError,
24
25
  SdkErrorCode,
26
+ SdkHttpError,
27
+ UnauthorizedError,
28
+ type AuthProvider,
25
29
  type CallToolResult,
26
30
  type ContentBlock,
27
31
  type Tool as ServerTool,
28
32
  type Transport,
29
33
  type VersionNegotiationMode,
30
34
  } from "@modelcontextprotocol/client";
31
- import { McpError, McpToolError, isMcpError, type McpErrorReason } from "./errors.js";
35
+ import { BearerCredential, LookupFailed, NeedsAuth, configOf, type AuthConfig, type Credential, type McpServerAuth } from "./auth.js";
36
+ import { McpError, McpToolError, isMcpError, type McpAuthNeed, type McpErrorReason } from "./errors.js";
37
+ import { madeByHttp } from "./transports.js";
32
38
 
33
- export { McpError, McpToolError, isMcpError, isMcpToolError, type McpErrorReason } from "./errors.js";
39
+ export { createMcpAuth, type McpAuth, type McpAuthOptions, type McpServerAuth } from "./auth.js";
40
+ export { McpError, McpToolError, isMcpError, isMcpToolError, type McpAuthNeed, type McpErrorReason } from "./errors.js";
34
41
  export { textOf, type TextOptions } from "./text.js";
35
42
  export { http, stdio, type HttpOptions } from "./transports.js";
36
- export type { ContentBlock, Transport } from "@modelcontextprotocol/client";
43
+ export type { AuthProvider, ContentBlock, Transport } from "@modelcontextprotocol/client";
37
44
 
38
45
  const { version: VERSION } = createRequire(import.meta.url)("../package.json") as { version: string };
39
46
 
@@ -42,6 +49,8 @@ const TIMEOUT_MS = 30_000;
42
49
  const MAX_TOTAL_MS = 90_000;
43
50
  /** Past the longest timer Node keeps, it fires after about a millisecond. */
44
51
  const MAX_TIMER_MS = 2_147_483_647;
52
+ const MAX_CONNECTIONS = 100;
53
+ const IDLE_MS = 300_000;
45
54
  /**
46
55
  * The SDK's own checks of a tool's result against the output schema the
47
56
  * server declared, as of @modelcontextprotocol/client 2.4: `callTool` throws
@@ -59,13 +68,28 @@ export interface McpServerOptions {
59
68
  name: string;
60
69
  /**
61
70
  * Makes a connection: called by the first `connect()` or call, and again
62
- * for each reconnect. `http(url)` and `stdio({ command })` make one.
71
+ * for each reconnect. `http(url)` and `stdio({ command })` make one. For a
72
+ * server with `auth`, it's given the user's `AuthProvider`, and must be
73
+ * `http()`'s.
63
74
  */
64
- transport: () => Transport | Promise<Transport>;
75
+ transport: (authProvider?: AuthProvider) => Transport | Promise<Transport>;
76
+ /**
77
+ * Makes each call as the user it runs for, with their own credential, over
78
+ * a connection of their own: `createMcpAuth({ userOf }).bearer(token)`.
79
+ * Without it, every call shares one connection and one identity.
80
+ */
81
+ auth?: McpServerAuth;
82
+ /**
83
+ * With `auth`: the most users connected at once. Past it, the least recently
84
+ * used connection with no call in flight is closed. Default 100.
85
+ */
86
+ maxConnections?: number;
87
+ /** With `auth`: a user's connection is closed after this long with no call, in milliseconds. Default 300,000. */
88
+ idleMs?: number;
65
89
  /**
66
90
  * How long the server may go without answering a call or reporting progress
67
- * on it, in milliseconds. Also bounds making the transport, connecting and
68
- * listing tools. Default 30,000.
91
+ * on it, in milliseconds. Also bounds making the transport, connecting,
92
+ * listing tools and, with `auth`, looking up a user's token. Default 30,000.
69
93
  */
70
94
  timeoutMs?: number;
71
95
  /**
@@ -86,6 +110,8 @@ export interface McpServerOptions {
86
110
 
87
111
  export interface McpToolChange {
88
112
  server: string;
113
+ /** With `auth`: the user whose connection saw the change. Each user's tools are compared apart. */
114
+ user?: string;
89
115
  /** Declared tools the server no longer has. */
90
116
  removed: string[];
91
117
  /** Declared tools whose input or output schema, or task support, changed. */
@@ -121,10 +147,16 @@ export interface McpResult {
121
147
  */
122
148
  export interface McpCallCtx {
123
149
  /**
124
- * The conversation the call is for. Nothing reads it yet: it is here so a
125
- * later version can pick a connection per user without calls changing.
150
+ * The conversation the call is for. A server with `auth` calls as its
151
+ * owner, from `userOf`.
126
152
  */
127
153
  readonly conversationId?: string;
154
+ /**
155
+ * For a server with `auth`: the user to call as, for a call made outside any
156
+ * conversation, such as a job. With a `conversationId` too, it must be the
157
+ * conversation's owner.
158
+ */
159
+ readonly userId?: string;
128
160
  /** Hears the server's progress notifications, as `ToolCtx.progress` does. */
129
161
  progress?(progress: Progress): void;
130
162
  }
@@ -153,48 +185,75 @@ export interface McpConnection {
153
185
  * Connects, and checks every declared tool: it throws if the server has no
154
186
  * tool of that name, or one that can only run as an MCP task. Call it at
155
187
  * startup, so a mistake stops the app there; the first call connects
156
- * otherwise, and fails with the same error.
188
+ * otherwise, and fails with the same error. A server with `auth` needs
189
+ * the user to connect as, and checks the tools that user sees.
190
+ */
191
+ connect(userId?: string): Promise<void>;
192
+ /**
193
+ * For a server with `auth`: closes the user's connection, if they have
194
+ * one. Their next call makes a new one, and looks up their credential again.
157
195
  */
158
- connect(): Promise<void>;
196
+ forget(userId: string): Promise<void>;
159
197
  /** Closes the connection, and stops a `stdio` server. Final: a later call fails with reason `closed`. */
160
198
  close(): Promise<void>;
161
199
  }
162
200
 
163
201
  /** The connection to an MCP server. Nothing connects until `connect()` or the first call. */
164
202
  export function mcpServer(options: McpServerOptions): McpConnection {
165
- return new Connection(options);
203
+ return new Server(options);
204
+ }
205
+
206
+ interface Detail {
207
+ cause?: unknown;
208
+ text?: string;
209
+ code?: number;
210
+ auth?: McpAuthNeed;
211
+ /** For reason `user`: why there's no user to call as. */
212
+ user?: "none" | "lookup" | "other";
166
213
  }
167
214
 
168
- const MESSAGES: Record<McpErrorReason, (who: string) => string> = {
215
+ const NO_USER = {
216
+ none: "there is no user to call it as",
217
+ lookup: "the user it runs for could not be looked up",
218
+ other: "its userId is not the conversation's owner",
219
+ };
220
+
221
+ const MESSAGES: Record<McpErrorReason, (who: string, detail: Detail) => string> = {
169
222
  args: (who) => `${who} was not called: its args do not match the schema`,
170
223
  result: (who) => `${who} returned a result that does not match the schema`,
171
224
  timeout: (who) => `${who} did not answer in time`,
172
225
  connection: (who) => `${who} could not be reached`,
173
226
  protocol: (who) => `${who} was refused by the server`,
174
227
  closed: (who) => `${who}: the connection was closed`,
228
+ user: (who, { user = "none" }) => `${who} was not called: ${NO_USER[user]}`,
229
+ auth: (who, { auth }) =>
230
+ auth?.kind === "connect"
231
+ ? `${who}: the user has not connected an account`
232
+ : auth?.kind === "scope"
233
+ ? `${who}: the user's account needs more access`
234
+ : `${who}: the server refused the user's credential`,
175
235
  };
176
236
 
177
- class Connection implements McpConnection {
237
+ class Server implements McpConnection {
178
238
  readonly name: string;
179
- private readonly options: McpServerOptions;
180
- private readonly timeoutMs: number;
239
+ readonly options: McpServerOptions;
240
+ readonly timeoutMs: number;
181
241
  private readonly maxTotalMs: number;
182
- /** The connected client, or null between connections. */
183
- private client: Client | null = null;
184
- private dialing: Promise<Client> | null = null;
185
- /** While a connection is being made: its client, once there is one, so `close()` can stop it. */
186
- private connecting: Client | null = null;
187
- /** While `transport()` is pending: gives up on it, for `close()`. */
188
- private abandon: (() => void) | null = null;
242
+ /** With `auth`: whose calls are made as whom, and with what. */
243
+ readonly auth: AuthConfig | null;
244
+ private readonly maxConnections: number;
245
+ readonly idleMs: number;
246
+ /** Without `auth`: the one connection every call shares. */
247
+ private readonly shared: Link | null;
248
+ /** With `auth`: each user's connection, the least recently used first. */
249
+ private readonly links = new Map<string, Link>();
189
250
  private closed = false;
251
+ /** Aborted by `close()`: a call waiting on anything stops with reason `closed`. */
252
+ private readonly shutdown = new AbortController();
190
253
  /** The first `close()`, which the others wait for. */
191
254
  private closing: Promise<void> | null = null;
192
- /** Clients whose connection has gone. */
193
- private readonly dropped = new WeakSet<Client>();
194
- /** The server's tools at the last listing, by name. Null until a first connect passes the check. */
195
- private listed: Map<string, ServerTool> | null = null;
196
255
  /** The tools `tool()` declared: the only ones that can be called. */
197
- private readonly declared = new Set<string>();
256
+ readonly declared = new Set<string>();
198
257
 
199
258
  constructor(options: McpServerOptions) {
200
259
  if (typeof options.name !== "string" || options.name === "") {
@@ -207,6 +266,36 @@ class Connection implements McpConnection {
207
266
  this.options = options;
208
267
  this.timeoutMs = checkMs("timeoutMs", options.timeoutMs ?? TIMEOUT_MS);
209
268
  this.maxTotalMs = checkMs("maxTotalMs", options.maxTotalMs ?? MAX_TOTAL_MS);
269
+
270
+ if (options.auth === undefined) {
271
+ if (options.maxConnections !== undefined || options.idleMs !== undefined) {
272
+ throw new TypeError("maxConnections and idleMs are for a server with auth: without it, every call shares one connection");
273
+ }
274
+ this.auth = null;
275
+ this.maxConnections = 1;
276
+ this.idleMs = IDLE_MS;
277
+ this.shared = new Link(this, undefined, null);
278
+ return;
279
+ }
280
+ const auth = configOf(options.auth);
281
+ if (!auth) throw new TypeError("auth must come from createMcpAuth(), such as mcpAuth.bearer(token)");
282
+ const made = madeByHttp.get(options.transport);
283
+ if (!made) {
284
+ throw new TypeError("auth needs a transport made by http(url): stdio() and other transports can't carry each user's credential");
285
+ }
286
+ if (made.authProvider) throw new TypeError("auth gives the transport each user's credential: http() can't be given an authProvider as well");
287
+ if (made.authorization) throw new TypeError("auth sets each user's Authorization header: http() can't send one of its own as well");
288
+ const max = options.maxConnections ?? MAX_CONNECTIONS;
289
+ if (typeof max !== "number" || !Number.isSafeInteger(max) || max < 1) {
290
+ throw new RangeError(`maxConnections must be a whole number, 1 or more (got ${String(max)})`);
291
+ }
292
+ this.idleMs = checkMs("idleMs", options.idleMs ?? IDLE_MS);
293
+ this.maxConnections = max;
294
+ // last, so a server refused above leaves its name free
295
+ if (auth.names.has(this.name)) throw new TypeError(`another server from the same createMcpAuth is named "${this.name}"`);
296
+ auth.names.add(this.name);
297
+ this.auth = auth;
298
+ this.shared = null;
210
299
  }
211
300
 
212
301
  tool(name: string, def: McpToolDef<any, any>): McpTool<any, any> {
@@ -219,10 +308,9 @@ class Connection implements McpConnection {
219
308
  }
220
309
  if (def.timeoutMs !== undefined) checkMs("timeoutMs", def.timeoutMs);
221
310
  if (def.maxTotalMs !== undefined) checkMs("maxTotalMs", def.maxTotalMs);
222
- if (this.listed) {
223
- const problem = problemWith(name, this.listed);
224
- if (problem) throw new Error(`${this.name}: ${problem}`);
225
- }
311
+ // checked against the latest tools, even if declared before; each user's
312
+ // connection checks a tool declared late on its first call to it
313
+ this.shared?.verify(name, true);
226
314
  this.declared.add(name);
227
315
  return Object.freeze({
228
316
  server: this.name,
@@ -231,8 +319,31 @@ class Connection implements McpConnection {
231
319
  });
232
320
  }
233
321
 
234
- async connect(): Promise<void> {
235
- await this.open();
322
+ async connect(userId?: string): Promise<void> {
323
+ if (this.shared) {
324
+ if (userId !== undefined) throw new TypeError("connect() takes a user only for a server with auth");
325
+ await this.shared.open();
326
+ return;
327
+ }
328
+ if (typeof userId !== "string" || userId === "") {
329
+ throw new TypeError("connect() needs a userId for a server with auth: each user's connection is checked as it's made");
330
+ }
331
+ if (this.closed) throw this.error("closed", undefined);
332
+ const link = this.take(userId);
333
+ try {
334
+ await link.ready();
335
+ } finally {
336
+ link.release();
337
+ }
338
+ }
339
+
340
+ async forget(userId: string): Promise<void> {
341
+ if (!this.auth) throw new TypeError("forget() is for a server with auth: without it, every call shares one connection");
342
+ if (typeof userId !== "string" || userId === "") throw new TypeError("forget() needs a userId");
343
+ const link = this.links.get(userId);
344
+ if (!link) return;
345
+ this.links.delete(userId);
346
+ await link.close();
236
347
  }
237
348
 
238
349
  close(): Promise<void> {
@@ -243,17 +354,252 @@ class Connection implements McpConnection {
243
354
 
244
355
  private async shut(): Promise<void> {
245
356
  this.closed = true;
246
- const client = this.client;
247
- this.client = null;
248
- // a connection still being made is stopped where it is, and sees `closed`
249
- this.abandon?.();
250
- await this.connecting?.close().catch(() => {});
251
- await this.dialing?.catch(() => {});
252
- await client?.close();
357
+ this.shutdown.abort();
358
+ this.auth?.names.delete(this.name);
359
+ const links = this.shared ? [this.shared] : [...this.links.values()];
360
+ this.links.clear();
361
+ await Promise.all(links.map((link) => link.close()));
362
+ }
363
+
364
+ /** The user's connection, made if need be, marked most recently used, and held for a call. */
365
+ private take(user: string): Link {
366
+ let link = this.links.get(user);
367
+ if (link) {
368
+ this.links.delete(user);
369
+ } else {
370
+ this.trim(this.maxConnections - 1);
371
+ link = new Link(this, user, new BearerCredential(this.auth!.token, user, this.timeoutMs));
372
+ }
373
+ this.links.set(user, link);
374
+ link.hold();
375
+ return link;
376
+ }
377
+
378
+ /** Closes the least recently used connections with no call in flight, down to `limit`. */
379
+ trim(limit = this.maxConnections): void {
380
+ for (const [user, link] of this.links) {
381
+ if (this.links.size <= limit) return;
382
+ // when every link is busy, the pool grows past the cap until one is free
383
+ if (link.busy === 0) this.drop(user, link);
384
+ }
385
+ }
386
+
387
+ /** Closes a user's connection, unless another has replaced it. */
388
+ drop(user: string, link: Link): void {
389
+ if (this.links.get(user) !== link) return;
390
+ this.links.delete(user);
391
+ link.close().catch(() => {});
392
+ }
393
+
394
+ /**
395
+ * Who a call on a server with `auth` runs as: the conversation's owner, or
396
+ * the `userId` of a call made outside any conversation. Never anyone else.
397
+ */
398
+ private async userFor(ctx: McpCallCtx | undefined, tool: string): Promise<string> {
399
+ const conversationId = nonEmpty(ctx?.conversationId);
400
+ const given = nonEmpty(ctx?.userId);
401
+ if (conversationId === undefined) {
402
+ if (given === undefined) throw this.error("user", tool);
403
+ return given;
404
+ }
405
+ let owner: unknown;
406
+ try {
407
+ owner = await this.auth!.userOf(conversationId);
408
+ } catch (err) {
409
+ throw this.error("user", tool, { cause: err, user: "lookup" });
410
+ }
411
+ if (typeof owner !== "string" || owner === "") throw this.error("user", tool);
412
+ if (given !== undefined && given !== owner) throw this.error("user", tool, { user: "other" });
413
+ return owner;
414
+ }
415
+
416
+ private async call(tool: string, def: McpToolDef<unknown, unknown>, args: unknown, ctx: McpCallCtx | undefined) {
417
+ let input: unknown;
418
+ try {
419
+ input = def.args.parse(args);
420
+ } catch (err) {
421
+ throw this.error("args", tool, { cause: err });
422
+ }
423
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
424
+ throw new McpError("args", `${this.name}/${tool} was not called: its args must be an object`, {
425
+ server: this.name,
426
+ tool,
427
+ });
428
+ }
429
+
430
+ // maxTotalMs bounds the whole call, connecting included, and is ours to
431
+ // keep: the SDK checks its own maxTotalTimeout only when progress comes
432
+ const deadline = new AbortController();
433
+ const timer = setTimeout(() => deadline.abort(), def.maxTotalMs ?? this.maxTotalMs);
434
+ let link: Link | undefined;
435
+ let result: CallToolResult;
436
+ try {
437
+ if (this.shared) {
438
+ link = this.shared;
439
+ } else {
440
+ if (this.closed) throw this.error("closed", tool);
441
+ const user = await this.until(deadline.signal, tool, this.userFor(ctx, tool));
442
+ if (this.closed) throw this.error("closed", tool);
443
+ link = this.take(user);
444
+ }
445
+ let client: Client;
446
+ try {
447
+ client = await this.until(deadline.signal, tool, link.ready());
448
+ } catch (err) {
449
+ // named again for this call, with what the server said; the
450
+ // declared-tool check throws as it is
451
+ if (!isMcpError(err) || err.tool === tool) throw err;
452
+ throw this.error(err.reason, tool, { cause: err.cause ?? err, text: err.text, code: err.code, auth: err.auth });
453
+ }
454
+ link.verify(tool);
455
+ try {
456
+ result = await client.callTool(
457
+ { name: tool, arguments: input as Record<string, unknown> },
458
+ {
459
+ timeout: def.timeoutMs ?? this.timeoutMs,
460
+ // cancels the request, and rejects it as a timeout
461
+ signal: deadline.signal,
462
+ // asked for whether or not anyone hears it, so progress always
463
+ // keeps a long call alive
464
+ resetTimeoutOnProgress: true,
465
+ onprogress: (p) => {
466
+ ctx?.progress?.({
467
+ done: p.progress,
468
+ ...(p.total === undefined ? {} : { total: p.total }),
469
+ ...(p.message === undefined ? {} : { message: p.message }),
470
+ });
471
+ },
472
+ },
473
+ );
474
+ } catch (err) {
475
+ throw link.failure(err, tool, client);
476
+ }
477
+ } finally {
478
+ clearTimeout(timer);
479
+ if (link !== this.shared) link?.release();
480
+ }
481
+
482
+ const content = result.content ?? [];
483
+ if (result.isError) throw new McpToolError(this.name, tool, content);
484
+ if (!def.result) {
485
+ return result.structuredContent === undefined ? { content } : { content, structuredContent: result.structuredContent };
486
+ }
487
+
488
+ let value: unknown;
489
+ if (result.structuredContent !== undefined) {
490
+ value = result.structuredContent;
491
+ } else {
492
+ const only = content.length === 1 ? content[0] : undefined;
493
+ if (only?.type !== "text") {
494
+ throw this.error("result", tool, {
495
+ cause: new Error("the result has no structured content, and isn't one text block"),
496
+ });
497
+ }
498
+ try {
499
+ value = JSON.parse(only.text);
500
+ } catch (err) {
501
+ throw this.error("result", tool, { cause: err });
502
+ }
503
+ }
504
+ try {
505
+ return def.result.parse(value);
506
+ } catch (err) {
507
+ throw this.error("result", tool, { cause: err });
508
+ }
509
+ }
510
+
511
+ /** What `promise` resolves to, unless `close()` comes first (`closed`), or `deadline` aborts (`timeout`). */
512
+ private until<T>(deadline: AbortSignal, tool: string, promise: Promise<T>): Promise<T> {
513
+ return unless(promise, [
514
+ [this.shutdown.signal, () => this.error("closed", tool)],
515
+ [deadline, () => this.error("timeout", tool)],
516
+ ]);
253
517
  }
254
518
 
255
- private open(): Promise<Client> {
256
- if (this.closed) return Promise.reject(this.error("closed", undefined));
519
+ error(reason: McpErrorReason, tool: string | undefined, detail: Detail = {}): McpError {
520
+ const who = tool === undefined ? this.name : `${this.name}/${tool}`;
521
+ const { user, ...kept } = detail;
522
+ return new McpError(reason, MESSAGES[reason](who, detail), { server: this.name, tool, ...kept });
523
+ }
524
+ }
525
+
526
+ /**
527
+ * One SDK client over one transport, for one identity: the server's own, or
528
+ * one user's. It connects when first needed, and again after it drops.
529
+ */
530
+ class Link {
531
+ /** The connected client, or null between connections. */
532
+ private client: Client | null = null;
533
+ private dialing: Promise<Client> | null = null;
534
+ /** While a connection is being made: its client, once there is one, so `close()` can stop it. */
535
+ private connecting: Client | null = null;
536
+ /** While `transport()` is pending: gives up on it, for `close()`. */
537
+ private abandon: (() => void) | null = null;
538
+ private closed = false;
539
+ /** Aborted by `close()`: stops a wait for the credential, which no transport is there to stop. */
540
+ private readonly ending = new AbortController();
541
+ /** The first `close()`, which the others wait for. */
542
+ private closing: Promise<void> | null = null;
543
+ /** Clients whose connection has gone. */
544
+ private readonly dropped = new WeakSet<Client>();
545
+ /** The server's tools at the last listing, by name. Null until a first connect passes the check. */
546
+ private listed: Map<string, ServerTool> | null = null;
547
+ /** The declared tools checked against this connection's tools. */
548
+ private readonly checked = new Set<string>();
549
+ /** Calls and `connect()`s using it now: a busy link is never closed for room or for idling. */
550
+ busy = 0;
551
+ private idle: ReturnType<typeof setTimeout> | null = null;
552
+
553
+ constructor(
554
+ private readonly server: Server,
555
+ /** The user it calls as; undefined for the server's own identity. */
556
+ private readonly user: string | undefined,
557
+ private readonly credential: Credential | null,
558
+ ) {}
559
+
560
+ hold(): void {
561
+ this.busy++;
562
+ if (this.idle) clearTimeout(this.idle);
563
+ this.idle = null;
564
+ }
565
+
566
+ release(): void {
567
+ if (--this.busy > 0 || this.user === undefined || this.closed) return;
568
+ const user = this.user;
569
+ this.idle = setTimeout(() => this.server.drop(user, this), this.server.idleMs);
570
+ this.idle.unref?.();
571
+ // a pool grown past its cap shrinks as soon as a connection is free
572
+ this.server.trim();
573
+ }
574
+
575
+ /** Connected, with a credential if it needs one. */
576
+ async ready(): Promise<Client> {
577
+ if (this.credential && !this.closed) {
578
+ try {
579
+ await unless(this.credential.ensure(), [[this.ending.signal, () => this.server.error("closed", undefined)]]);
580
+ } catch (err) {
581
+ // closed, whatever the lookup did meanwhile
582
+ if (this.closed) throw isMcpError(err) ? err : this.server.error("closed", undefined, { cause: err });
583
+ throw this.authFailure(err, undefined) ?? err;
584
+ }
585
+ }
586
+ return this.open();
587
+ }
588
+
589
+ /**
590
+ * A tool declared after this connection's first check is checked on its
591
+ * first call, and throws as `connect` would. `again` checks it against the
592
+ * latest tools even if it was checked before.
593
+ */
594
+ verify(tool: string, again = false): void {
595
+ if (!this.listed || (!again && this.checked.has(tool))) return;
596
+ const problem = problemWith(tool, this.listed);
597
+ if (problem) throw new Error(`${this.server.name}: ${problem}`);
598
+ this.checked.add(tool);
599
+ }
600
+
601
+ open(): Promise<Client> {
602
+ if (this.closed) return Promise.reject(this.server.error("closed", undefined));
257
603
  if (this.client) return Promise.resolve(this.client);
258
604
  // one connection at a time: calls that arrive meanwhile share it
259
605
  this.dialing ??= this.dial().finally(() => {
@@ -263,15 +609,39 @@ class Connection implements McpConnection {
263
609
  return this.dialing;
264
610
  }
265
611
 
612
+ close(): Promise<void> {
613
+ // every close() resolves once the first has closed everything
614
+ this.closing ??= this.shut();
615
+ return this.closing;
616
+ }
617
+
618
+ private async shut(): Promise<void> {
619
+ this.closed = true;
620
+ this.ending.abort();
621
+ // a lookup that never answers would keep its timer, and the process, going
622
+ this.credential?.close();
623
+ if (this.idle) clearTimeout(this.idle);
624
+ this.idle = null;
625
+ const client = this.client;
626
+ this.client = null;
627
+ // a connection still being made is stopped where it is, and sees `closed`
628
+ this.abandon?.();
629
+ await this.connecting?.close().catch(() => {});
630
+ await this.dialing?.catch(() => {});
631
+ await client?.close();
632
+ }
633
+
266
634
  /**
267
635
  * What `transport()` makes, within `timeoutMs` and unless `close()` comes
268
636
  * first. A transport made after that is closed when it comes.
269
637
  */
270
638
  private async made(): Promise<Transport> {
271
- const making = Promise.resolve().then(() => this.options.transport());
639
+ const { options, timeoutMs } = this.server;
640
+ const authProvider = this.credential?.provider;
641
+ const making = Promise.resolve().then(() => (authProvider ? options.transport(authProvider) : options.transport()));
272
642
  let timer: ReturnType<typeof setTimeout> | undefined;
273
643
  const cut = new Promise<never>((_, reject) => {
274
- timer = setTimeout(() => reject(new Error(`no transport within ${this.timeoutMs} ms`)), this.timeoutMs);
644
+ timer = setTimeout(() => reject(new Error(`no transport within ${timeoutMs} ms`)), timeoutMs);
275
645
  this.abandon = () => reject(new Error("closed while the transport was being made"));
276
646
  });
277
647
  try {
@@ -286,6 +656,7 @@ class Connection implements McpConnection {
286
656
  }
287
657
 
288
658
  private async dial(): Promise<Client> {
659
+ const { server } = this;
289
660
  let client: Client | undefined;
290
661
  try {
291
662
  const transport = await this.made();
@@ -301,9 +672,9 @@ class Connection implements McpConnection {
301
672
  // this client for input; one that does gets the call refused,
302
673
  // rather than a reply nobody wrote.
303
674
  inputRequired: { autoFulfill: false },
304
- ...(this.options.versionNegotiation === undefined
675
+ ...(server.options.versionNegotiation === undefined
305
676
  ? {}
306
- : { versionNegotiation: { mode: this.options.versionNegotiation } }),
677
+ : { versionNegotiation: { mode: server.options.versionNegotiation } }),
307
678
  listChanged: {
308
679
  tools: {
309
680
  onChanged: (error, tools) => {
@@ -319,15 +690,16 @@ class Connection implements McpConnection {
319
690
  this.dropped.add(self);
320
691
  if (this.client === self) this.client = null;
321
692
  };
322
- await self.connect(transport, { timeout: this.timeoutMs });
693
+ await self.connect(transport, { timeout: server.timeoutMs });
323
694
  } catch (err) {
324
695
  await client?.close().catch(() => {});
325
- throw this.error(this.closed ? "closed" : "connection", undefined, { cause: err });
696
+ if (this.closed) throw server.error("closed", undefined, { cause: err });
697
+ throw this.authFailure(err, undefined) ?? server.error("connection", undefined, { cause: err });
326
698
  }
327
699
 
328
700
  let tools: ServerTool[];
329
701
  try {
330
- ({ tools } = await client.listTools(undefined, { timeout: this.timeoutMs }));
702
+ ({ tools } = await client.listTools(undefined, { timeout: server.timeoutMs }));
331
703
  } catch (err) {
332
704
  await client.close().catch(() => {});
333
705
  throw this.failure(err, undefined, client);
@@ -335,21 +707,22 @@ class Connection implements McpConnection {
335
707
 
336
708
  if (this.listed === null) {
337
709
  const listing = new Map(tools.map((t) => [t.name, t]));
338
- const problems = [...this.declared].flatMap((name) => problemWith(name, listing) ?? []);
710
+ const problems = [...server.declared].flatMap((name) => problemWith(name, listing) ?? []);
339
711
  if (problems.length) {
340
712
  await client.close().catch(() => {});
341
- throw new Error(`${this.name}: ${problems.join("; ")}`);
713
+ throw new Error(`${server.name}: ${problems.join("; ")}`);
342
714
  }
343
715
  this.listed = listing;
716
+ for (const name of server.declared) this.checked.add(name);
344
717
  } else {
345
718
  this.compare(tools);
346
719
  }
347
720
 
348
721
  if (this.closed) {
349
722
  await client.close().catch(() => {});
350
- throw this.error("closed", undefined);
723
+ throw server.error("closed", undefined);
351
724
  }
352
- if (this.dropped.has(client)) throw this.error("connection", undefined);
725
+ if (this.dropped.has(client)) throw server.error("connection", undefined);
353
726
  this.client = client;
354
727
  return client;
355
728
  }
@@ -361,7 +734,7 @@ class Connection implements McpConnection {
361
734
  this.listed = next;
362
735
  const removed: string[] = [];
363
736
  const changed: string[] = [];
364
- for (const name of this.declared) {
737
+ for (const name of this.server.declared) {
365
738
  const was = before.get(name);
366
739
  const now = next.get(name);
367
740
  if (!was) continue;
@@ -369,143 +742,103 @@ class Connection implements McpConnection {
369
742
  else if (shape(was) !== shape(now)) changed.push(name);
370
743
  }
371
744
  if (!removed.length && !changed.length) return;
745
+ const change: McpToolChange =
746
+ this.user === undefined ? { server: this.server.name, removed, changed } : { server: this.server.name, user: this.user, removed, changed };
372
747
  try {
373
- (this.options.onChange ?? warn)({ server: this.name, removed, changed });
748
+ (this.server.options.onChange ?? warn)(change);
374
749
  } catch (err) {
375
750
  console.error(err);
376
751
  }
377
752
  }
378
753
 
379
- private async call(tool: string, def: McpToolDef<unknown, unknown>, args: unknown, ctx: McpCallCtx | undefined) {
380
- let input: unknown;
381
- try {
382
- input = def.args.parse(args);
383
- } catch (err) {
384
- throw this.error("args", tool, { cause: err });
385
- }
386
- if (typeof input !== "object" || input === null || Array.isArray(input)) {
387
- throw new McpError("args", `${this.name}/${tool} was not called: its args must be an object`, {
388
- server: this.name,
389
- tool,
390
- });
391
- }
392
-
393
- // maxTotalMs bounds the whole call, connecting included, and is ours to
394
- // keep: the SDK checks its own maxTotalTimeout only when progress comes
395
- const deadline = new AbortController();
396
- const timer = setTimeout(() => deadline.abort(), def.maxTotalMs ?? this.maxTotalMs);
397
- let result: CallToolResult;
398
- try {
399
- let client: Client;
400
- try {
401
- client = await this.until(deadline.signal, tool, this.open());
402
- } catch (err) {
403
- // named again for this call, with what the server said; the
404
- // declared-tool check throws as it is
405
- if (!isMcpError(err) || err.tool === tool) throw err;
406
- throw this.error(err.reason, tool, { cause: err.cause ?? err, text: err.text, code: err.code });
407
- }
408
- try {
409
- result = await client.callTool(
410
- { name: tool, arguments: input as Record<string, unknown> },
411
- {
412
- timeout: def.timeoutMs ?? this.timeoutMs,
413
- // cancels the request, and rejects it as a timeout
414
- signal: deadline.signal,
415
- // asked for whether or not anyone hears it, so progress always
416
- // keeps a long call alive
417
- resetTimeoutOnProgress: true,
418
- onprogress: (p) => {
419
- ctx?.progress?.({
420
- done: p.progress,
421
- ...(p.total === undefined ? {} : { total: p.total }),
422
- ...(p.message === undefined ? {} : { message: p.message }),
423
- });
424
- },
425
- },
426
- );
427
- } catch (err) {
428
- throw this.failure(err, tool, client);
429
- }
430
- } finally {
431
- clearTimeout(timer);
432
- }
433
-
434
- const content = result.content ?? [];
435
- if (result.isError) throw new McpToolError(this.name, tool, content);
436
- if (!def.result) {
437
- return result.structuredContent === undefined ? { content } : { content, structuredContent: result.structuredContent };
438
- }
439
-
440
- let value: unknown;
441
- if (result.structuredContent !== undefined) {
442
- value = result.structuredContent;
443
- } else {
444
- const only = content.length === 1 ? content[0] : undefined;
445
- if (only?.type !== "text") {
446
- throw this.error("result", tool, {
447
- cause: new Error("the result has no structured content, and isn't one text block"),
448
- });
449
- }
450
- try {
451
- value = JSON.parse(only.text);
452
- } catch (err) {
453
- throw this.error("result", tool, { cause: err });
454
- }
754
+ /**
755
+ * An error about the user's credential, as one of ours, or null for any
756
+ * other. A server without `auth` has none: its SDK errors read as in
757
+ * `failure`.
758
+ */
759
+ private authFailure(err: unknown, tool: string | undefined): McpError | null {
760
+ const { server } = this;
761
+ if (err instanceof NeedsAuth) return server.error("auth", tool, { auth: { kind: err.kind } });
762
+ if (err instanceof LookupFailed) return server.error("connection", tool, { cause: err.cause });
763
+ if (!this.credential) return null;
764
+ if (err instanceof InsufficientScopeError) {
765
+ const auth: McpAuthNeed = err.requiredScope ? { kind: "scope", scope: err.requiredScope } : { kind: "scope" };
766
+ return server.error("auth", tool, { cause: err, text: err.message, auth });
455
767
  }
456
- try {
457
- return def.result.parse(value);
458
- } catch (err) {
459
- throw this.error("result", tool, { cause: err });
768
+ // a 401 again on the transport's one retry, after onUnauthorized found a token
769
+ if (err instanceof UnauthorizedError || (err instanceof SdkHttpError && err.code === SdkErrorCode.ClientHttpAuthentication)) {
770
+ return server.error("auth", tool, { cause: err, text: err.message, auth: { kind: "reconnect" } });
460
771
  }
461
- }
462
-
463
- /** What `promise` resolves to, or a timeout for `tool` if `signal` aborts first. */
464
- private until<T>(signal: AbortSignal, tool: string, promise: Promise<T>): Promise<T> {
465
- return new Promise<T>((resolve, reject) => {
466
- const stop = () => reject(this.error("timeout", tool));
467
- signal.addEventListener("abort", stop, { once: true });
468
- promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", stop));
469
- });
772
+ return null;
470
773
  }
471
774
 
472
775
  /** An SDK error, as one of ours. */
473
- private failure(err: unknown, tool: string | undefined, client: Client): McpError {
776
+ failure(err: unknown, tool: string | undefined, client: Client): McpError {
777
+ const { server } = this;
474
778
  if (isMcpError(err)) return err;
475
- if (this.closed) return this.error("closed", tool, { cause: err });
779
+ if (this.closed) return server.error("closed", tool, { cause: err });
780
+ const auth = this.authFailure(err, tool);
781
+ if (auth) return auth;
476
782
  if (err instanceof ProtocolError) {
477
783
  // The SDK's own check of a call's result throws one too: the server's
478
784
  // result is at fault there, not the call. Only a call has a result.
479
785
  const checked = tool !== undefined && OUTPUT_CHECKS.some(([code, words]) => err.code === code && words.test(err.message));
480
- return this.error(checked ? "result" : "protocol", tool, { cause: err, text: err.message, code: err.code });
786
+ return server.error(checked ? "result" : "protocol", tool, { cause: err, text: err.message, code: err.code });
481
787
  }
482
788
  if (err instanceof SdkError) {
483
789
  switch (err.code) {
484
790
  case SdkErrorCode.RequestTimeout:
485
- return this.error("timeout", tool, { cause: err });
791
+ return server.error("timeout", tool, { cause: err });
486
792
  case SdkErrorCode.NotConnected:
487
793
  case SdkErrorCode.ConnectionClosed:
488
794
  case SdkErrorCode.SendFailed:
489
795
  case SdkErrorCode.ClientHttpFailedToOpenStream:
490
- return this.error("connection", tool, { cause: err });
796
+ return server.error("connection", tool, { cause: err });
491
797
  case SdkErrorCode.InvalidResult:
492
798
  // only a call has a result; a listing the SDK can't read is the server's fault
493
- if (tool !== undefined) return this.error("result", tool, { cause: err });
494
- return this.error("protocol", tool, { cause: err, text: err.message });
799
+ if (tool !== undefined) return server.error("result", tool, { cause: err });
800
+ return server.error("protocol", tool, { cause: err, text: err.message });
495
801
  default:
496
- return this.error("protocol", tool, { cause: err, text: err.message });
802
+ return server.error("protocol", tool, { cause: err, text: err.message });
497
803
  }
498
804
  }
499
805
  // the SDK throws a plain Error for a call on a connection that has gone
500
- return this.error(this.dropped.has(client) ? "connection" : "protocol", tool, { cause: err });
806
+ return server.error(this.dropped.has(client) ? "connection" : "protocol", tool, { cause: err });
501
807
  }
808
+ }
502
809
 
503
- private error(reason: McpErrorReason, tool: string | undefined, detail: { cause?: unknown; text?: string; code?: number } = {}) {
504
- const who = tool === undefined ? this.name : `${this.name}/${tool}`;
505
- return new McpError(reason, MESSAGES[reason](who), { server: this.name, tool, ...detail });
506
- }
810
+ /**
811
+ * What `promise` resolves to, unless one of `stops` aborts first: then it
812
+ * rejects with what that stop's `error()` makes, the first in the list if
813
+ * more than one has. `promise` is left to settle on its own, and holds
814
+ * nothing of this wait: every listener goes as soon as it ends, however it
815
+ * ends, so one that never settles leaves none on a long-lived signal.
816
+ */
817
+ function unless<T>(promise: Promise<T>, stops: ReadonlyArray<readonly [AbortSignal, () => unknown]>): Promise<T> {
818
+ return new Promise<T>((resolve, reject) => {
819
+ const offs: Array<() => void> = [];
820
+ const end =
821
+ <V>(settle: (value: V) => void) =>
822
+ (value: V) => {
823
+ for (const off of offs) off();
824
+ settle(value);
825
+ };
826
+ const fulfil = end(resolve);
827
+ const fail = end(reject);
828
+ for (const [signal, error] of stops) {
829
+ const stop = () => fail(error());
830
+ signal.addEventListener("abort", stop, { once: true });
831
+ offs.push(() => signal.removeEventListener("abort", stop));
832
+ }
833
+ promise.then(fulfil, fail);
834
+ // an abort that came before is never heard as an event
835
+ const done = stops.find(([signal]) => signal.aborted);
836
+ if (done) fail(done[1]());
837
+ });
507
838
  }
508
839
 
840
+ const nonEmpty = (value: unknown): string | undefined => (typeof value === "string" && value !== "" ? value : undefined);
841
+
509
842
  function checkMs(label: string, ms: number): number {
510
843
  if (typeof ms !== "number" || !Number.isInteger(ms) || ms <= 0 || ms > MAX_TIMER_MS) {
511
844
  throw new RangeError(`${label} must be a whole number of milliseconds, from 1 to ${MAX_TIMER_MS} (got ${String(ms)})`);
@@ -531,5 +864,6 @@ function warn(change: McpToolChange): void {
531
864
  ...change.removed.map((name) => `tool "${name}" was removed`),
532
865
  ...change.changed.map((name) => `tool "${name}" changed its schema`),
533
866
  ];
534
- console.warn(`@haikit/mcp: ${change.server}: ${parts.join("; ")}`);
867
+ const whose = change.user === undefined ? "" : ` (for user ${change.user})`;
868
+ console.warn(`@haikit/mcp: ${change.server}${whose}: ${parts.join("; ")}`);
535
869
  }