@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/dist/index.js CHANGED
@@ -14,10 +14,15 @@
14
14
  * Imports only @haikit/core, like the model adapters.
15
15
  */
16
16
  import { createRequire } from "node:module";
17
- import { Client, ProtocolError, ProtocolErrorCode, SdkError, SdkErrorCode, } from "@modelcontextprotocol/client";
17
+ import { Client, InsufficientScopeError, ProtocolError, ProtocolErrorCode, SdkError, SdkErrorCode, SdkHttpError, UnauthorizedError, } from "@modelcontextprotocol/client";
18
+ import { configOf } from "./auth.js";
19
+ import { LookupFailed, Mismatch, NeedsAuth } from "./credential.js";
18
20
  import { McpError, McpToolError, isMcpError } from "./errors.js";
21
+ import { madeByHttp } from "./transports.js";
22
+ export { createMcpAuth, } from "./auth.js";
19
23
  export { McpError, McpToolError, isMcpError, isMcpToolError } from "./errors.js";
20
24
  export { textOf } from "./text.js";
25
+ export { memoryCredentialStore } from "./store.js";
21
26
  export { http, stdio } from "./transports.js";
22
27
  const { version: VERSION } = createRequire(import.meta.url)("../package.json");
23
28
  const TIMEOUT_MS = 30_000;
@@ -25,6 +30,8 @@ const TIMEOUT_MS = 30_000;
25
30
  const MAX_TOTAL_MS = 90_000;
26
31
  /** Past the longest timer Node keeps, it fires after about a millisecond. */
27
32
  const MAX_TIMER_MS = 2_147_483_647;
33
+ const MAX_CONNECTIONS = 100;
34
+ const IDLE_MS = 300_000;
28
35
  /**
29
36
  * The SDK's own checks of a tool's result against the output schema the
30
37
  * server declared, as of @modelcontextprotocol/client 2.4: `callTool` throws
@@ -38,8 +45,13 @@ const OUTPUT_CHECKS = [
38
45
  ];
39
46
  /** The connection to an MCP server. Nothing connects until `connect()` or the first call. */
40
47
  export function mcpServer(options) {
41
- return new Connection(options);
48
+ return new Server(options);
42
49
  }
50
+ const NO_USER = {
51
+ none: "there is no user to call it as",
52
+ lookup: "the user it runs for could not be looked up",
53
+ other: "its userId is not the conversation's owner",
54
+ };
43
55
  const MESSAGES = {
44
56
  args: (who) => `${who} was not called: its args do not match the schema`,
45
57
  result: (who) => `${who} returned a result that does not match the schema`,
@@ -47,26 +59,33 @@ const MESSAGES = {
47
59
  connection: (who) => `${who} could not be reached`,
48
60
  protocol: (who) => `${who} was refused by the server`,
49
61
  closed: (who) => `${who}: the connection was closed`,
62
+ user: (who, { user = "none" }) => `${who} was not called: ${NO_USER[user]}`,
63
+ auth: (who, { auth }) => auth?.kind === "connect"
64
+ ? `${who}: the user has not connected an account`
65
+ : auth?.kind === "scope"
66
+ ? `${who}: the user's account needs more access`
67
+ : `${who}: the server refused the user's credential`,
50
68
  };
51
- class Connection {
69
+ class Server {
52
70
  name;
53
71
  options;
54
72
  timeoutMs;
55
73
  maxTotalMs;
56
- /** The connected client, or null between connections. */
57
- client = null;
58
- dialing = null;
59
- /** While a connection is being made: its client, once there is one, so `close()` can stop it. */
60
- connecting = null;
61
- /** While `transport()` is pending: gives up on it, for `close()`. */
62
- abandon = null;
74
+ /** With `auth`: whose calls are made as whom. */
75
+ auth;
76
+ /** With `auth`: this server's place on its `McpAuth`, which makes each user's credential. */
77
+ binding;
78
+ maxConnections;
79
+ idleMs;
80
+ /** Without `auth`: the one connection every call shares. */
81
+ shared;
82
+ /** With `auth`: each user's connection, the least recently used first. */
83
+ links = new Map();
63
84
  closed = false;
85
+ /** Aborted by `close()`: a call waiting on anything stops with reason `closed`. */
86
+ shutdown = new AbortController();
64
87
  /** The first `close()`, which the others wait for. */
65
88
  closing = null;
66
- /** Clients whose connection has gone. */
67
- dropped = new WeakSet();
68
- /** The server's tools at the last listing, by name. Null until a first connect passes the check. */
69
- listed = null;
70
89
  /** The tools `tool()` declared: the only ones that can be called. */
71
90
  declared = new Set();
72
91
  constructor(options) {
@@ -80,6 +99,38 @@ class Connection {
80
99
  this.options = options;
81
100
  this.timeoutMs = checkMs("timeoutMs", options.timeoutMs ?? TIMEOUT_MS);
82
101
  this.maxTotalMs = checkMs("maxTotalMs", options.maxTotalMs ?? MAX_TOTAL_MS);
102
+ if (options.auth === undefined) {
103
+ if (options.maxConnections !== undefined || options.idleMs !== undefined) {
104
+ throw new TypeError("maxConnections and idleMs are for a server with auth: without it, every call shares one connection");
105
+ }
106
+ this.auth = null;
107
+ this.maxConnections = 1;
108
+ this.idleMs = IDLE_MS;
109
+ this.shared = new Link(this, undefined, null);
110
+ this.binding = null;
111
+ return;
112
+ }
113
+ const auth = configOf(options.auth);
114
+ if (!auth)
115
+ throw new TypeError("auth must come from createMcpAuth(), such as mcpAuth.bearer(token)");
116
+ const made = madeByHttp.get(options.transport);
117
+ if (!made) {
118
+ throw new TypeError("auth needs a transport made by http(url): stdio() and other transports can't carry each user's credential");
119
+ }
120
+ if (made.authProvider)
121
+ throw new TypeError("auth gives the transport each user's credential: http() can't be given an authProvider as well");
122
+ if (made.authorization)
123
+ throw new TypeError("auth sets each user's Authorization header: http() can't send one of its own as well");
124
+ const max = options.maxConnections ?? MAX_CONNECTIONS;
125
+ if (typeof max !== "number" || !Number.isSafeInteger(max) || max < 1) {
126
+ throw new RangeError(`maxConnections must be a whole number, 1 or more (got ${String(max)})`);
127
+ }
128
+ this.idleMs = checkMs("idleMs", options.idleMs ?? IDLE_MS);
129
+ this.maxConnections = max;
130
+ // last, so a server refused above leaves its name free
131
+ this.binding = auth.attach({ name: this.name, timeoutMs: this.timeoutMs, url: made.url, fetch: made.fetch, forget: (user) => this.forget(user) });
132
+ this.auth = auth;
133
+ this.shared = null;
83
134
  }
84
135
  tool(name, def) {
85
136
  if (typeof name !== "string" || name === "")
@@ -94,11 +145,9 @@ class Connection {
94
145
  checkMs("timeoutMs", def.timeoutMs);
95
146
  if (def.maxTotalMs !== undefined)
96
147
  checkMs("maxTotalMs", def.maxTotalMs);
97
- if (this.listed) {
98
- const problem = problemWith(name, this.listed);
99
- if (problem)
100
- throw new Error(`${this.name}: ${problem}`);
101
- }
148
+ // checked against the latest tools, even if declared before; each user's
149
+ // connection checks a tool declared late on its first call to it
150
+ this.shared?.verify(name, true);
102
151
  this.declared.add(name);
103
152
  return Object.freeze({
104
153
  server: this.name,
@@ -106,8 +155,39 @@ class Connection {
106
155
  call: (args, ctx) => this.call(name, def, args, ctx),
107
156
  });
108
157
  }
109
- async connect() {
110
- await this.open();
158
+ async connect(userId) {
159
+ if (this.shared) {
160
+ if (userId !== undefined)
161
+ throw new TypeError("connect() takes a user only for a server with auth");
162
+ await this.shared.open();
163
+ return;
164
+ }
165
+ if (typeof userId !== "string" || userId === "") {
166
+ throw new TypeError("connect() needs a userId for a server with auth: each user's connection is checked as it's made");
167
+ }
168
+ if (this.closed)
169
+ throw this.error("closed", undefined);
170
+ const link = this.take(userId);
171
+ try {
172
+ await link.ready();
173
+ }
174
+ catch (err) {
175
+ throw this.linked(err, undefined);
176
+ }
177
+ finally {
178
+ link.release();
179
+ }
180
+ }
181
+ async forget(userId) {
182
+ if (!this.auth)
183
+ throw new TypeError("forget() is for a server with auth: without it, every call shares one connection");
184
+ if (typeof userId !== "string" || userId === "")
185
+ throw new TypeError("forget() needs a userId");
186
+ const link = this.links.get(userId);
187
+ if (!link)
188
+ return;
189
+ this.links.delete(userId);
190
+ await link.close();
111
191
  }
112
192
  close() {
113
193
  // every close() resolves once the first has closed everything
@@ -116,17 +196,311 @@ class Connection {
116
196
  }
117
197
  async shut() {
118
198
  this.closed = true;
199
+ this.shutdown.abort();
200
+ this.binding?.detach();
201
+ const links = this.shared ? [this.shared] : [...this.links.values()];
202
+ this.links.clear();
203
+ await Promise.all(links.map((link) => link.close()));
204
+ }
205
+ /** The user's connection, made if need be, marked most recently used, and held for a call. */
206
+ take(user) {
207
+ let link = this.links.get(user);
208
+ if (link) {
209
+ this.links.delete(user);
210
+ }
211
+ else {
212
+ this.trim(this.maxConnections - 1);
213
+ link = new Link(this, user, this.binding.credential(user));
214
+ }
215
+ this.links.set(user, link);
216
+ link.hold();
217
+ return link;
218
+ }
219
+ /** Closes the least recently used connections with no call in flight, down to `limit`. */
220
+ trim(limit = this.maxConnections) {
221
+ for (const [user, link] of this.links) {
222
+ if (this.links.size <= limit)
223
+ return;
224
+ // when every link is busy, the pool grows past the cap until one is free
225
+ if (link.busy === 0)
226
+ this.drop(user, link);
227
+ }
228
+ }
229
+ /** Closes a user's connection, unless another has replaced it. */
230
+ drop(user, link) {
231
+ if (this.links.get(user) !== link)
232
+ return;
233
+ this.links.delete(user);
234
+ link.close().catch(() => { });
235
+ }
236
+ /**
237
+ * Who a call on a server with `auth` runs as: the conversation's owner, or
238
+ * the `userId` of a call made outside any conversation. Never anyone else.
239
+ */
240
+ async userFor(ctx, tool) {
241
+ const conversationId = nonEmpty(ctx?.conversationId);
242
+ const given = nonEmpty(ctx?.userId);
243
+ if (conversationId === undefined) {
244
+ if (given === undefined)
245
+ throw this.error("user", tool);
246
+ return given;
247
+ }
248
+ let owner;
249
+ try {
250
+ owner = await this.auth.userOf(conversationId);
251
+ }
252
+ catch (err) {
253
+ throw this.error("user", tool, { cause: err, user: "lookup" });
254
+ }
255
+ if (typeof owner !== "string" || owner === "")
256
+ throw this.error("user", tool);
257
+ if (given !== undefined && given !== owner)
258
+ throw this.error("user", tool, { user: "other" });
259
+ return owner;
260
+ }
261
+ async call(tool, def, args, ctx) {
262
+ let input;
263
+ try {
264
+ input = def.args.parse(args);
265
+ }
266
+ catch (err) {
267
+ throw this.error("args", tool, { cause: err });
268
+ }
269
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
270
+ throw new McpError("args", `${this.name}/${tool} was not called: its args must be an object`, {
271
+ server: this.name,
272
+ tool,
273
+ });
274
+ }
275
+ // maxTotalMs bounds the whole call, connecting included, and is ours to
276
+ // keep: the SDK checks its own maxTotalTimeout only when progress comes
277
+ const deadline = new AbortController();
278
+ const timer = setTimeout(() => deadline.abort(), def.maxTotalMs ?? this.maxTotalMs);
279
+ let link;
280
+ let result;
281
+ try {
282
+ if (this.shared) {
283
+ link = this.shared;
284
+ }
285
+ else {
286
+ if (this.closed)
287
+ throw this.error("closed", tool);
288
+ const user = await this.until(deadline.signal, tool, this.userFor(ctx, tool));
289
+ if (this.closed)
290
+ throw this.error("closed", tool);
291
+ link = this.take(user);
292
+ }
293
+ let client;
294
+ try {
295
+ client = await this.until(deadline.signal, tool, link.ready());
296
+ }
297
+ catch (err) {
298
+ // named again for this call, with what the server said; the
299
+ // declared-tool check throws as it is
300
+ if (!isMcpError(err) || err.tool === tool)
301
+ throw err;
302
+ throw this.error(err.reason, tool, { cause: err.cause ?? err, text: err.text, code: err.code, auth: err.auth });
303
+ }
304
+ link.verify(tool);
305
+ try {
306
+ result = await client.callTool({ name: tool, arguments: input }, {
307
+ timeout: def.timeoutMs ?? this.timeoutMs,
308
+ // cancels the request, and rejects it as a timeout
309
+ signal: deadline.signal,
310
+ // asked for whether or not anyone hears it, so progress always
311
+ // keeps a long call alive
312
+ resetTimeoutOnProgress: true,
313
+ onprogress: (p) => {
314
+ ctx?.progress?.({
315
+ done: p.progress,
316
+ ...(p.total === undefined ? {} : { total: p.total }),
317
+ ...(p.message === undefined ? {} : { message: p.message }),
318
+ });
319
+ },
320
+ });
321
+ }
322
+ catch (err) {
323
+ throw link.failure(err, tool, client);
324
+ }
325
+ }
326
+ catch (err) {
327
+ throw this.linked(err, nonEmpty(ctx?.conversationId));
328
+ }
329
+ finally {
330
+ clearTimeout(timer);
331
+ if (link !== this.shared)
332
+ link?.release();
333
+ }
334
+ const content = result.content ?? [];
335
+ if (result.isError)
336
+ throw new McpToolError(this.name, tool, content);
337
+ if (!def.result) {
338
+ return result.structuredContent === undefined ? { content } : { content, structuredContent: result.structuredContent };
339
+ }
340
+ let value;
341
+ if (result.structuredContent !== undefined) {
342
+ value = result.structuredContent;
343
+ }
344
+ else {
345
+ const only = content.length === 1 ? content[0] : undefined;
346
+ if (only?.type !== "text") {
347
+ throw this.error("result", tool, {
348
+ cause: new Error("the result has no structured content, and isn't one text block"),
349
+ });
350
+ }
351
+ try {
352
+ value = JSON.parse(only.text);
353
+ }
354
+ catch (err) {
355
+ throw this.error("result", tool, { cause: err });
356
+ }
357
+ }
358
+ try {
359
+ return def.result.parse(value);
360
+ }
361
+ catch (err) {
362
+ throw this.error("result", tool, { cause: err });
363
+ }
364
+ }
365
+ /** What `promise` resolves to, unless `close()` comes first (`closed`), or `deadline` aborts (`timeout`). */
366
+ until(deadline, tool, promise) {
367
+ return unless(promise, [
368
+ [this.shutdown.signal, () => this.error("closed", tool)],
369
+ [deadline, () => this.error("timeout", tool)],
370
+ ]);
371
+ }
372
+ /**
373
+ * An `auth` error of an `oauth()` server, with the connect route's path for
374
+ * the browser: for the call's conversation, and the scope the server asked
375
+ * for. Built from configuration, never from what the server said but that
376
+ * scope. Any other error, as it is.
377
+ */
378
+ linked(err, conversationId) {
379
+ const connect = this.binding?.connect;
380
+ if (!connect || !isMcpError(err) || err.reason !== "auth" || !err.auth)
381
+ return err;
382
+ const auth = { ...err.auth, connect: connect(conversationId, err.auth.kind === "scope" ? err.auth.scope : undefined) };
383
+ return new McpError("auth", err.message, { server: err.server, tool: err.tool, text: err.text, code: err.code, cause: err.cause, auth });
384
+ }
385
+ error(reason, tool, detail = {}) {
386
+ const who = tool === undefined ? this.name : `${this.name}/${tool}`;
387
+ const { user, ...kept } = detail;
388
+ return new McpError(reason, MESSAGES[reason](who, detail), { server: this.name, tool, ...kept });
389
+ }
390
+ }
391
+ /**
392
+ * One SDK client over one transport, for one identity: the server's own, or
393
+ * one user's. It connects when first needed, and again after it drops.
394
+ */
395
+ class Link {
396
+ server;
397
+ user;
398
+ credential;
399
+ /** The connected client, or null between connections. */
400
+ client = null;
401
+ dialing = null;
402
+ /** While a connection is being made: its client, once there is one, so `close()` can stop it. */
403
+ connecting = null;
404
+ /** While `transport()` is pending: gives up on it, for `close()`. */
405
+ abandon = null;
406
+ closed = false;
407
+ /** Aborted by `close()`: stops a wait for the credential, which no transport is there to stop. */
408
+ ending = new AbortController();
409
+ /** The first `close()`, which the others wait for. */
410
+ closing = null;
411
+ /** Clients whose connection has gone. */
412
+ dropped = new WeakSet();
413
+ /** The server's tools at the last listing, by name. Null until a first connect passes the check. */
414
+ listed = null;
415
+ /** The declared tools checked against this connection's tools. */
416
+ checked = new Set();
417
+ /** The credential's check before a call, which calls at once share. */
418
+ checking = null;
419
+ /** Calls and `connect()`s using it now: a busy link is never closed for room or for idling. */
420
+ busy = 0;
421
+ idle = null;
422
+ constructor(server,
423
+ /** The user it calls as; undefined for the server's own identity. */
424
+ user, credential) {
425
+ this.server = server;
426
+ this.user = user;
427
+ this.credential = credential;
428
+ }
429
+ hold() {
430
+ this.busy++;
431
+ if (this.idle)
432
+ clearTimeout(this.idle);
433
+ this.idle = null;
434
+ }
435
+ release() {
436
+ if (--this.busy > 0 || this.user === undefined || this.closed)
437
+ return;
438
+ const user = this.user;
439
+ this.idle = setTimeout(() => this.server.drop(user, this), this.server.idleMs);
440
+ this.idle.unref?.();
441
+ // a pool grown past its cap shrinks as soon as a connection is free
442
+ this.server.trim();
443
+ }
444
+ /** Connected, with a credential if it needs one. */
445
+ async ready() {
446
+ if (this.credential && !this.closed) {
447
+ const credential = this.credential;
448
+ this.checking ??= this.check(credential).finally(() => {
449
+ this.checking = null;
450
+ });
451
+ try {
452
+ await unless(this.checking, [[this.ending.signal, () => this.server.error("closed", undefined)]]);
453
+ }
454
+ catch (err) {
455
+ // closed, whatever the lookup did meanwhile
456
+ if (this.closed)
457
+ throw isMcpError(err) ? err : this.server.error("closed", undefined, { cause: err });
458
+ throw this.authFailure(err, undefined) ?? err;
459
+ }
460
+ }
461
+ return this.open();
462
+ }
463
+ /**
464
+ * The credential, before a call. A connection made with one that's gone,
465
+ * or that the user has since replaced by connecting again, is hung up, so
466
+ * nothing more is sent with it, and the next is made with what's held now.
467
+ */
468
+ async check(credential) {
469
+ let changed;
470
+ try {
471
+ changed = await credential.ensure();
472
+ }
473
+ catch (err) {
474
+ if (err instanceof NeedsAuth)
475
+ await this.hangUp();
476
+ throw err;
477
+ }
478
+ if (changed)
479
+ await this.hangUp();
480
+ }
481
+ /** Ends the connection, as when it drops, but not the link: the next call connects again. */
482
+ async hangUp() {
483
+ await this.dialing?.catch(() => { });
119
484
  const client = this.client;
120
485
  this.client = null;
121
- // a connection still being made is stopped where it is, and sees `closed`
122
- this.abandon?.();
123
- await this.connecting?.close().catch(() => { });
124
- await this.dialing?.catch(() => { });
125
- await client?.close();
486
+ await client?.close().catch(() => { });
487
+ }
488
+ /**
489
+ * A tool declared after this connection's first check is checked on its
490
+ * first call, and throws as `connect` would. `again` checks it against the
491
+ * latest tools even if it was checked before.
492
+ */
493
+ verify(tool, again = false) {
494
+ if (!this.listed || (!again && this.checked.has(tool)))
495
+ return;
496
+ const problem = problemWith(tool, this.listed);
497
+ if (problem)
498
+ throw new Error(`${this.server.name}: ${problem}`);
499
+ this.checked.add(tool);
126
500
  }
127
501
  open() {
128
502
  if (this.closed)
129
- return Promise.reject(this.error("closed", undefined));
503
+ return Promise.reject(this.server.error("closed", undefined));
130
504
  if (this.client)
131
505
  return Promise.resolve(this.client);
132
506
  // one connection at a time: calls that arrive meanwhile share it
@@ -136,15 +510,40 @@ class Connection {
136
510
  });
137
511
  return this.dialing;
138
512
  }
513
+ close() {
514
+ // every close() resolves once the first has closed everything
515
+ this.closing ??= this.shut();
516
+ return this.closing;
517
+ }
518
+ async shut() {
519
+ this.closed = true;
520
+ this.ending.abort();
521
+ // a lookup that never answers would keep its timer, and the process, going
522
+ this.credential?.close();
523
+ if (this.idle)
524
+ clearTimeout(this.idle);
525
+ this.idle = null;
526
+ const client = this.client;
527
+ this.client = null;
528
+ // a connection still being made is stopped where it is, and sees `closed`
529
+ this.abandon?.();
530
+ await this.connecting?.close().catch(() => { });
531
+ await this.dialing?.catch(() => { });
532
+ await client?.close();
533
+ }
139
534
  /**
140
535
  * What `transport()` makes, within `timeoutMs` and unless `close()` comes
141
536
  * first. A transport made after that is closed when it comes.
142
537
  */
143
538
  async made() {
144
- const making = Promise.resolve().then(() => this.options.transport());
539
+ const { options, timeoutMs } = this.server;
540
+ const authProvider = this.credential?.provider;
541
+ // An OAuthClientProvider, from sdk(), goes to http()'s transport as it
542
+ // is, which adapts it: only http() is ever given one.
543
+ const making = Promise.resolve().then(() => (authProvider ? options.transport(authProvider) : options.transport()));
145
544
  let timer;
146
545
  const cut = new Promise((_, reject) => {
147
- timer = setTimeout(() => reject(new Error(`no transport within ${this.timeoutMs} ms`)), this.timeoutMs);
546
+ timer = setTimeout(() => reject(new Error(`no transport within ${timeoutMs} ms`)), timeoutMs);
148
547
  this.abandon = () => reject(new Error("closed while the transport was being made"));
149
548
  });
150
549
  try {
@@ -160,6 +559,7 @@ class Connection {
160
559
  }
161
560
  }
162
561
  async dial() {
562
+ const { server } = this;
163
563
  let client;
164
564
  try {
165
565
  const transport = await this.made();
@@ -173,9 +573,9 @@ class Connection {
173
573
  // this client for input; one that does gets the call refused,
174
574
  // rather than a reply nobody wrote.
175
575
  inputRequired: { autoFulfill: false },
176
- ...(this.options.versionNegotiation === undefined
576
+ ...(server.options.versionNegotiation === undefined
177
577
  ? {}
178
- : { versionNegotiation: { mode: this.options.versionNegotiation } }),
578
+ : { versionNegotiation: { mode: server.options.versionNegotiation } }),
179
579
  listChanged: {
180
580
  tools: {
181
581
  onChanged: (error, tools) => {
@@ -192,15 +592,17 @@ class Connection {
192
592
  if (this.client === self)
193
593
  this.client = null;
194
594
  };
195
- await self.connect(transport, { timeout: this.timeoutMs });
595
+ await self.connect(transport, { timeout: server.timeoutMs });
196
596
  }
197
597
  catch (err) {
198
598
  await client?.close().catch(() => { });
199
- throw this.error(this.closed ? "closed" : "connection", undefined, { cause: err });
599
+ if (this.closed)
600
+ throw server.error("closed", undefined, { cause: err });
601
+ throw this.authFailure(err, undefined) ?? server.error("connection", undefined, { cause: err });
200
602
  }
201
603
  let tools;
202
604
  try {
203
- ({ tools } = await client.listTools(undefined, { timeout: this.timeoutMs }));
605
+ ({ tools } = await client.listTools(undefined, { timeout: server.timeoutMs }));
204
606
  }
205
607
  catch (err) {
206
608
  await client.close().catch(() => { });
@@ -208,22 +610,24 @@ class Connection {
208
610
  }
209
611
  if (this.listed === null) {
210
612
  const listing = new Map(tools.map((t) => [t.name, t]));
211
- const problems = [...this.declared].flatMap((name) => problemWith(name, listing) ?? []);
613
+ const problems = [...server.declared].flatMap((name) => problemWith(name, listing) ?? []);
212
614
  if (problems.length) {
213
615
  await client.close().catch(() => { });
214
- throw new Error(`${this.name}: ${problems.join("; ")}`);
616
+ throw new Error(`${server.name}: ${problems.join("; ")}`);
215
617
  }
216
618
  this.listed = listing;
619
+ for (const name of server.declared)
620
+ this.checked.add(name);
217
621
  }
218
622
  else {
219
623
  this.compare(tools);
220
624
  }
221
625
  if (this.closed) {
222
626
  await client.close().catch(() => { });
223
- throw this.error("closed", undefined);
627
+ throw server.error("closed", undefined);
224
628
  }
225
629
  if (this.dropped.has(client))
226
- throw this.error("connection", undefined);
630
+ throw server.error("connection", undefined);
227
631
  this.client = client;
228
632
  return client;
229
633
  }
@@ -234,7 +638,7 @@ class Connection {
234
638
  this.listed = next;
235
639
  const removed = [];
236
640
  const changed = [];
237
- for (const name of this.declared) {
641
+ for (const name of this.server.declared) {
238
642
  const was = before.get(name);
239
643
  const now = next.get(name);
240
644
  if (!was)
@@ -246,145 +650,107 @@ class Connection {
246
650
  }
247
651
  if (!removed.length && !changed.length)
248
652
  return;
653
+ const change = this.user === undefined ? { server: this.server.name, removed, changed } : { server: this.server.name, user: this.user, removed, changed };
249
654
  try {
250
- (this.options.onChange ?? warn)({ server: this.name, removed, changed });
655
+ (this.server.options.onChange ?? warn)(change);
251
656
  }
252
657
  catch (err) {
253
658
  console.error(err);
254
659
  }
255
660
  }
256
- async call(tool, def, args, ctx) {
257
- let input;
258
- try {
259
- input = def.args.parse(args);
260
- }
261
- catch (err) {
262
- throw this.error("args", tool, { cause: err });
263
- }
264
- if (typeof input !== "object" || input === null || Array.isArray(input)) {
265
- throw new McpError("args", `${this.name}/${tool} was not called: its args must be an object`, {
266
- server: this.name,
267
- tool,
268
- });
269
- }
270
- // maxTotalMs bounds the whole call, connecting included, and is ours to
271
- // keep: the SDK checks its own maxTotalTimeout only when progress comes
272
- const deadline = new AbortController();
273
- const timer = setTimeout(() => deadline.abort(), def.maxTotalMs ?? this.maxTotalMs);
274
- let result;
275
- try {
276
- let client;
277
- try {
278
- client = await this.until(deadline.signal, tool, this.open());
279
- }
280
- catch (err) {
281
- // named again for this call, with what the server said; the
282
- // declared-tool check throws as it is
283
- if (!isMcpError(err) || err.tool === tool)
284
- throw err;
285
- throw this.error(err.reason, tool, { cause: err.cause ?? err, text: err.text, code: err.code });
286
- }
287
- try {
288
- result = await client.callTool({ name: tool, arguments: input }, {
289
- timeout: def.timeoutMs ?? this.timeoutMs,
290
- // cancels the request, and rejects it as a timeout
291
- signal: deadline.signal,
292
- // asked for whether or not anyone hears it, so progress always
293
- // keeps a long call alive
294
- resetTimeoutOnProgress: true,
295
- onprogress: (p) => {
296
- ctx?.progress?.({
297
- done: p.progress,
298
- ...(p.total === undefined ? {} : { total: p.total }),
299
- ...(p.message === undefined ? {} : { message: p.message }),
300
- });
301
- },
302
- });
303
- }
304
- catch (err) {
305
- throw this.failure(err, tool, client);
306
- }
307
- }
308
- finally {
309
- clearTimeout(timer);
310
- }
311
- const content = result.content ?? [];
312
- if (result.isError)
313
- throw new McpToolError(this.name, tool, content);
314
- if (!def.result) {
315
- return result.structuredContent === undefined ? { content } : { content, structuredContent: result.structuredContent };
316
- }
317
- let value;
318
- if (result.structuredContent !== undefined) {
319
- value = result.structuredContent;
320
- }
321
- else {
322
- const only = content.length === 1 ? content[0] : undefined;
323
- if (only?.type !== "text") {
324
- throw this.error("result", tool, {
325
- cause: new Error("the result has no structured content, and isn't one text block"),
326
- });
327
- }
328
- try {
329
- value = JSON.parse(only.text);
330
- }
331
- catch (err) {
332
- throw this.error("result", tool, { cause: err });
333
- }
661
+ /**
662
+ * An error about the user's credential, as one of ours, or null for any
663
+ * other. A server without `auth` has none: its SDK errors read as in
664
+ * `failure`.
665
+ */
666
+ authFailure(err, tool) {
667
+ const { server } = this;
668
+ if (err instanceof NeedsAuth)
669
+ return server.error("auth", tool, { auth: { kind: err.kind } });
670
+ if (err instanceof LookupFailed)
671
+ return server.error("connection", tool, { cause: err.cause });
672
+ if (err instanceof Mismatch)
673
+ return server.error("protocol", tool, { cause: err });
674
+ if (!this.credential)
675
+ return null;
676
+ if (err instanceof InsufficientScopeError) {
677
+ const auth = err.requiredScope ? { kind: "scope", scope: err.requiredScope } : { kind: "scope" };
678
+ return server.error("auth", tool, { cause: err, text: err.message, auth });
334
679
  }
335
- try {
336
- return def.result.parse(value);
680
+ // a 401 again on the transport's one retry, after onUnauthorized found a token
681
+ if (err instanceof UnauthorizedError || (err instanceof SdkHttpError && err.code === SdkErrorCode.ClientHttpAuthentication)) {
682
+ return server.error("auth", tool, { cause: err, text: err.message, auth: { kind: "reconnect" } });
337
683
  }
338
- catch (err) {
339
- throw this.error("result", tool, { cause: err });
340
- }
341
- }
342
- /** What `promise` resolves to, or a timeout for `tool` if `signal` aborts first. */
343
- until(signal, tool, promise) {
344
- return new Promise((resolve, reject) => {
345
- const stop = () => reject(this.error("timeout", tool));
346
- signal.addEventListener("abort", stop, { once: true });
347
- promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", stop));
348
- });
684
+ return null;
349
685
  }
350
686
  /** An SDK error, as one of ours. */
351
687
  failure(err, tool, client) {
688
+ const { server } = this;
352
689
  if (isMcpError(err))
353
690
  return err;
354
691
  if (this.closed)
355
- return this.error("closed", tool, { cause: err });
692
+ return server.error("closed", tool, { cause: err });
693
+ const auth = this.authFailure(err, tool);
694
+ if (auth)
695
+ return auth;
356
696
  if (err instanceof ProtocolError) {
357
697
  // The SDK's own check of a call's result throws one too: the server's
358
698
  // result is at fault there, not the call. Only a call has a result.
359
699
  const checked = tool !== undefined && OUTPUT_CHECKS.some(([code, words]) => err.code === code && words.test(err.message));
360
- return this.error(checked ? "result" : "protocol", tool, { cause: err, text: err.message, code: err.code });
700
+ return server.error(checked ? "result" : "protocol", tool, { cause: err, text: err.message, code: err.code });
361
701
  }
362
702
  if (err instanceof SdkError) {
363
703
  switch (err.code) {
364
704
  case SdkErrorCode.RequestTimeout:
365
- return this.error("timeout", tool, { cause: err });
705
+ return server.error("timeout", tool, { cause: err });
366
706
  case SdkErrorCode.NotConnected:
367
707
  case SdkErrorCode.ConnectionClosed:
368
708
  case SdkErrorCode.SendFailed:
369
709
  case SdkErrorCode.ClientHttpFailedToOpenStream:
370
- return this.error("connection", tool, { cause: err });
710
+ return server.error("connection", tool, { cause: err });
371
711
  case SdkErrorCode.InvalidResult:
372
712
  // only a call has a result; a listing the SDK can't read is the server's fault
373
713
  if (tool !== undefined)
374
- return this.error("result", tool, { cause: err });
375
- return this.error("protocol", tool, { cause: err, text: err.message });
714
+ return server.error("result", tool, { cause: err });
715
+ return server.error("protocol", tool, { cause: err, text: err.message });
376
716
  default:
377
- return this.error("protocol", tool, { cause: err, text: err.message });
717
+ return server.error("protocol", tool, { cause: err, text: err.message });
378
718
  }
379
719
  }
380
720
  // the SDK throws a plain Error for a call on a connection that has gone
381
- return this.error(this.dropped.has(client) ? "connection" : "protocol", tool, { cause: err });
382
- }
383
- error(reason, tool, detail = {}) {
384
- const who = tool === undefined ? this.name : `${this.name}/${tool}`;
385
- return new McpError(reason, MESSAGES[reason](who), { server: this.name, tool, ...detail });
721
+ return server.error(this.dropped.has(client) ? "connection" : "protocol", tool, { cause: err });
386
722
  }
387
723
  }
724
+ /**
725
+ * What `promise` resolves to, unless one of `stops` aborts first: then it
726
+ * rejects with what that stop's `error()` makes, the first in the list if
727
+ * more than one has. `promise` is left to settle on its own, and holds
728
+ * nothing of this wait: every listener goes as soon as it ends, however it
729
+ * ends, so one that never settles leaves none on a long-lived signal.
730
+ */
731
+ function unless(promise, stops) {
732
+ return new Promise((resolve, reject) => {
733
+ const offs = [];
734
+ const end = (settle) => (value) => {
735
+ for (const off of offs)
736
+ off();
737
+ settle(value);
738
+ };
739
+ const fulfil = end(resolve);
740
+ const fail = end(reject);
741
+ for (const [signal, error] of stops) {
742
+ const stop = () => fail(error());
743
+ signal.addEventListener("abort", stop, { once: true });
744
+ offs.push(() => signal.removeEventListener("abort", stop));
745
+ }
746
+ promise.then(fulfil, fail);
747
+ // an abort that came before is never heard as an event
748
+ const done = stops.find(([signal]) => signal.aborted);
749
+ if (done)
750
+ fail(done[1]());
751
+ });
752
+ }
753
+ const nonEmpty = (value) => (typeof value === "string" && value !== "" ? value : undefined);
388
754
  function checkMs(label, ms) {
389
755
  if (typeof ms !== "number" || !Number.isInteger(ms) || ms <= 0 || ms > MAX_TIMER_MS) {
390
756
  throw new RangeError(`${label} must be a whole number of milliseconds, from 1 to ${MAX_TIMER_MS} (got ${String(ms)})`);
@@ -407,6 +773,7 @@ function warn(change) {
407
773
  ...change.removed.map((name) => `tool "${name}" was removed`),
408
774
  ...change.changed.map((name) => `tool "${name}" changed its schema`),
409
775
  ];
410
- console.warn(`@haikit/mcp: ${change.server}: ${parts.join("; ")}`);
776
+ const whose = change.user === undefined ? "" : ` (for user ${change.user})`;
777
+ console.warn(`@haikit/mcp: ${change.server}${whose}: ${parts.join("; ")}`);
411
778
  }
412
779
  //# sourceMappingURL=index.js.map