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