@haikit/mcp 0.0.0-stage → 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 ADDED
@@ -0,0 +1,722 @@
1
+ /**
2
+ * @haikit/mcp — call MCP servers' tools from inside your own tools.
3
+ *
4
+ * The model never sees an MCP tool. An app declares the server tools it calls,
5
+ * typed by schemas it owns, and calls them from a HaiKIT tool's `run` or an
6
+ * action handler, the way it would call a database: that code decides what the
7
+ * model hears of a result and what the browser shows. So nothing a third-party
8
+ * server says — its tools' names and descriptions, its instructions, its
9
+ * results — reaches the model unless app code puts it there.
10
+ *
11
+ * There is no way to make a HaiKIT `Tool` from an MCP tool here, on purpose:
12
+ * an `McpTool` has no `run`, so `createHai({ tools })` won't take one.
13
+ *
14
+ * Imports only @haikit/core, like the model adapters.
15
+ */
16
+ import { createRequire } from "node:module";
17
+ import { Client, InsufficientScopeError, ProtocolError, ProtocolErrorCode, SdkError, SdkErrorCode, SdkHttpError, UnauthorizedError, } from "@modelcontextprotocol/client";
18
+ import { BearerCredential, LookupFailed, NeedsAuth, configOf } from "./auth.js";
19
+ import { McpError, McpToolError, isMcpError } from "./errors.js";
20
+ import { madeByHttp } from "./transports.js";
21
+ export { createMcpAuth } from "./auth.js";
22
+ export { McpError, McpToolError, isMcpError, isMcpToolError } from "./errors.js";
23
+ export { textOf } from "./text.js";
24
+ export { http, stdio } from "./transports.js";
25
+ const { version: VERSION } = createRequire(import.meta.url)("../package.json");
26
+ const TIMEOUT_MS = 30_000;
27
+ /** Under the 120 s turn lease the stores default to, which isn't renewed while a tool runs. */
28
+ const MAX_TOTAL_MS = 90_000;
29
+ /** Past the longest timer Node keeps, it fires after about a millisecond. */
30
+ const MAX_TIMER_MS = 2_147_483_647;
31
+ const MAX_CONNECTIONS = 100;
32
+ const IDLE_MS = 300_000;
33
+ /**
34
+ * The SDK's own checks of a tool's result against the output schema the
35
+ * server declared, as of @modelcontextprotocol/client 2.4: `callTool` throws
36
+ * these, with these codes and words, once the server has answered. A
37
+ * server's own error reads as one only if it has the same code and words.
38
+ */
39
+ const OUTPUT_CHECKS = [
40
+ [ProtocolErrorCode.InvalidRequest, /^Tool .+ has an output schema but did not return structured content$/],
41
+ [ProtocolErrorCode.InvalidParams, /^Structured content does not match the tool's output schema: /],
42
+ [ProtocolErrorCode.InvalidParams, /^Failed to validate structured content: /],
43
+ ];
44
+ /** The connection to an MCP server. Nothing connects until `connect()` or the first call. */
45
+ export function mcpServer(options) {
46
+ return new Server(options);
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
+ };
53
+ const MESSAGES = {
54
+ args: (who) => `${who} was not called: its args do not match the schema`,
55
+ result: (who) => `${who} returned a result that does not match the schema`,
56
+ timeout: (who) => `${who} did not answer in time`,
57
+ connection: (who) => `${who} could not be reached`,
58
+ protocol: (who) => `${who} was refused by the server`,
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`,
66
+ };
67
+ class Server {
68
+ name;
69
+ options;
70
+ timeoutMs;
71
+ maxTotalMs;
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();
80
+ closed = false;
81
+ /** Aborted by `close()`: a call waiting on anything stops with reason `closed`. */
82
+ shutdown = new AbortController();
83
+ /** The first `close()`, which the others wait for. */
84
+ closing = null;
85
+ /** The tools `tool()` declared: the only ones that can be called. */
86
+ declared = new Set();
87
+ constructor(options) {
88
+ if (typeof options.name !== "string" || options.name === "") {
89
+ throw new TypeError("name must be a non-empty string");
90
+ }
91
+ if (typeof options.transport !== "function") {
92
+ throw new TypeError("transport must be a function that makes a Transport, such as http(url) or stdio({ command })");
93
+ }
94
+ this.name = options.name;
95
+ this.options = options;
96
+ this.timeoutMs = checkMs("timeoutMs", options.timeoutMs ?? TIMEOUT_MS);
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;
131
+ }
132
+ tool(name, def) {
133
+ if (typeof name !== "string" || name === "")
134
+ throw new TypeError("a tool's name must be a non-empty string");
135
+ if (typeof def?.args?.parse !== "function") {
136
+ throw new TypeError(`tool "${name}" needs an args schema: anything with a parse method`);
137
+ }
138
+ if (def.result !== undefined && typeof def.result.parse !== "function") {
139
+ throw new TypeError(`tool "${name}" has a result schema with no parse method`);
140
+ }
141
+ if (def.timeoutMs !== undefined)
142
+ checkMs("timeoutMs", def.timeoutMs);
143
+ if (def.maxTotalMs !== undefined)
144
+ checkMs("maxTotalMs", def.maxTotalMs);
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);
148
+ this.declared.add(name);
149
+ return Object.freeze({
150
+ server: this.name,
151
+ name,
152
+ call: (args, ctx) => this.call(name, def, args, ctx),
153
+ });
154
+ }
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();
185
+ }
186
+ close() {
187
+ // every close() resolves once the first has closed everything
188
+ this.closing ??= this.shut();
189
+ return this.closing;
190
+ }
191
+ async shut() {
192
+ this.closed = true;
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);
447
+ }
448
+ open() {
449
+ if (this.closed)
450
+ return Promise.reject(this.server.error("closed", undefined));
451
+ if (this.client)
452
+ return Promise.resolve(this.client);
453
+ // one connection at a time: calls that arrive meanwhile share it
454
+ this.dialing ??= this.dial().finally(() => {
455
+ this.dialing = null;
456
+ this.connecting = null;
457
+ });
458
+ return this.dialing;
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
+ }
481
+ /**
482
+ * What `transport()` makes, within `timeoutMs` and unless `close()` comes
483
+ * first. A transport made after that is closed when it comes.
484
+ */
485
+ async made() {
486
+ const { options, timeoutMs } = this.server;
487
+ const authProvider = this.credential?.provider;
488
+ const making = Promise.resolve().then(() => (authProvider ? options.transport(authProvider) : options.transport()));
489
+ let timer;
490
+ const cut = new Promise((_, reject) => {
491
+ timer = setTimeout(() => reject(new Error(`no transport within ${timeoutMs} ms`)), timeoutMs);
492
+ this.abandon = () => reject(new Error("closed while the transport was being made"));
493
+ });
494
+ try {
495
+ return await Promise.race([making, cut]);
496
+ }
497
+ catch (err) {
498
+ making.then((transport) => transport.close()).catch(() => { });
499
+ throw err;
500
+ }
501
+ finally {
502
+ clearTimeout(timer);
503
+ this.abandon = null;
504
+ }
505
+ }
506
+ async dial() {
507
+ const { server } = this;
508
+ let client;
509
+ try {
510
+ const transport = await this.made();
511
+ // close() may have come as it was made, with nothing yet to stop
512
+ if (this.closed) {
513
+ await transport.close().catch(() => { });
514
+ throw new Error("closed as the transport was made");
515
+ }
516
+ const self = new Client({ name: "haikit", version: VERSION }, {
517
+ // No capabilities are advertised, so a server has no reason to ask
518
+ // this client for input; one that does gets the call refused,
519
+ // rather than a reply nobody wrote.
520
+ inputRequired: { autoFulfill: false },
521
+ ...(server.options.versionNegotiation === undefined
522
+ ? {}
523
+ : { versionNegotiation: { mode: server.options.versionNegotiation } }),
524
+ listChanged: {
525
+ tools: {
526
+ onChanged: (error, tools) => {
527
+ if (!error && tools && this.client === self)
528
+ this.compare(tools);
529
+ },
530
+ },
531
+ },
532
+ });
533
+ client = self;
534
+ this.connecting = self;
535
+ self.onclose = () => {
536
+ this.dropped.add(self);
537
+ if (this.client === self)
538
+ this.client = null;
539
+ };
540
+ await self.connect(transport, { timeout: server.timeoutMs });
541
+ }
542
+ catch (err) {
543
+ await client?.close().catch(() => { });
544
+ if (this.closed)
545
+ throw server.error("closed", undefined, { cause: err });
546
+ throw this.authFailure(err, undefined) ?? server.error("connection", undefined, { cause: err });
547
+ }
548
+ let tools;
549
+ try {
550
+ ({ tools } = await client.listTools(undefined, { timeout: server.timeoutMs }));
551
+ }
552
+ catch (err) {
553
+ await client.close().catch(() => { });
554
+ throw this.failure(err, undefined, client);
555
+ }
556
+ if (this.listed === null) {
557
+ const listing = new Map(tools.map((t) => [t.name, t]));
558
+ const problems = [...server.declared].flatMap((name) => problemWith(name, listing) ?? []);
559
+ if (problems.length) {
560
+ await client.close().catch(() => { });
561
+ throw new Error(`${server.name}: ${problems.join("; ")}`);
562
+ }
563
+ this.listed = listing;
564
+ for (const name of server.declared)
565
+ this.checked.add(name);
566
+ }
567
+ else {
568
+ this.compare(tools);
569
+ }
570
+ if (this.closed) {
571
+ await client.close().catch(() => { });
572
+ throw server.error("closed", undefined);
573
+ }
574
+ if (this.dropped.has(client))
575
+ throw server.error("connection", undefined);
576
+ this.client = client;
577
+ return client;
578
+ }
579
+ /** Report what changed about the declared tools since the last listing, and keep this one. */
580
+ compare(tools) {
581
+ const before = this.listed ?? new Map();
582
+ const next = new Map(tools.map((t) => [t.name, t]));
583
+ this.listed = next;
584
+ const removed = [];
585
+ const changed = [];
586
+ for (const name of this.server.declared) {
587
+ const was = before.get(name);
588
+ const now = next.get(name);
589
+ if (!was)
590
+ continue;
591
+ if (!now)
592
+ removed.push(name);
593
+ else if (shape(was) !== shape(now))
594
+ changed.push(name);
595
+ }
596
+ if (!removed.length && !changed.length)
597
+ return;
598
+ const change = this.user === undefined ? { server: this.server.name, removed, changed } : { server: this.server.name, user: this.user, removed, changed };
599
+ try {
600
+ (this.server.options.onChange ?? warn)(change);
601
+ }
602
+ catch (err) {
603
+ console.error(err);
604
+ }
605
+ }
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 });
622
+ }
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" } });
626
+ }
627
+ return null;
628
+ }
629
+ /** An SDK error, as one of ours. */
630
+ failure(err, tool, client) {
631
+ const { server } = this;
632
+ if (isMcpError(err))
633
+ return err;
634
+ if (this.closed)
635
+ return server.error("closed", tool, { cause: err });
636
+ const auth = this.authFailure(err, tool);
637
+ if (auth)
638
+ return auth;
639
+ if (err instanceof ProtocolError) {
640
+ // The SDK's own check of a call's result throws one too: the server's
641
+ // result is at fault there, not the call. Only a call has a result.
642
+ const checked = tool !== undefined && OUTPUT_CHECKS.some(([code, words]) => err.code === code && words.test(err.message));
643
+ return server.error(checked ? "result" : "protocol", tool, { cause: err, text: err.message, code: err.code });
644
+ }
645
+ if (err instanceof SdkError) {
646
+ switch (err.code) {
647
+ case SdkErrorCode.RequestTimeout:
648
+ return server.error("timeout", tool, { cause: err });
649
+ case SdkErrorCode.NotConnected:
650
+ case SdkErrorCode.ConnectionClosed:
651
+ case SdkErrorCode.SendFailed:
652
+ case SdkErrorCode.ClientHttpFailedToOpenStream:
653
+ return server.error("connection", tool, { cause: err });
654
+ case SdkErrorCode.InvalidResult:
655
+ // only a call has a result; a listing the SDK can't read is the server's fault
656
+ if (tool !== undefined)
657
+ return server.error("result", tool, { cause: err });
658
+ return server.error("protocol", tool, { cause: err, text: err.message });
659
+ default:
660
+ return server.error("protocol", tool, { cause: err, text: err.message });
661
+ }
662
+ }
663
+ // the SDK throws a plain Error for a call on a connection that has gone
664
+ return server.error(this.dropped.has(client) ? "connection" : "protocol", tool, { cause: err });
665
+ }
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);
697
+ function checkMs(label, ms) {
698
+ if (typeof ms !== "number" || !Number.isInteger(ms) || ms <= 0 || ms > MAX_TIMER_MS) {
699
+ throw new RangeError(`${label} must be a whole number of milliseconds, from 1 to ${MAX_TIMER_MS} (got ${String(ms)})`);
700
+ }
701
+ return ms;
702
+ }
703
+ function problemWith(name, listing) {
704
+ const tool = listing.get(name);
705
+ if (!tool)
706
+ return `the server has no tool named "${name}"`;
707
+ if (tool.execution?.taskSupport === "required") {
708
+ return `tool "${name}" can only run as an MCP task, which @haikit/mcp does not support`;
709
+ }
710
+ return null;
711
+ }
712
+ /** What a change to a tool can break: its schemas, and whether it must run as a task. */
713
+ const shape = (tool) => JSON.stringify([tool.inputSchema, tool.outputSchema ?? null, tool.execution?.taskSupport ?? null]);
714
+ function warn(change) {
715
+ const parts = [
716
+ ...change.removed.map((name) => `tool "${name}" was removed`),
717
+ ...change.changed.map((name) => `tool "${name}" changed its schema`),
718
+ ];
719
+ const whose = change.user === undefined ? "" : ` (for user ${change.user})`;
720
+ console.warn(`@haikit/mcp: ${change.server}${whose}: ${parts.join("; ")}`);
721
+ }
722
+ //# sourceMappingURL=index.js.map