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