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