@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/README.md +20 -0
- package/dist/auth.d.ts +80 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +119 -0
- package/dist/auth.js.map +1 -0
- package/dist/errors.d.ts +17 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +3 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +42 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +456 -146
- package/dist/index.js.map +1 -1
- package/dist/transports.d.ts +13 -3
- package/dist/transports.d.ts.map +1 -1
- package/dist/transports.js +11 -3
- package/dist/transports.js.map +1 -1
- package/package.json +2 -2
- package/src/auth.ts +160 -0
- package/src/errors.ts +19 -2
- package/src/index.ts +493 -159
- package/src/transports.ts +27 -4
package/src/index.ts
CHANGED
|
@@ -18,22 +18,29 @@ import { createRequire } from "node:module";
|
|
|
18
18
|
import type { Progress, Schema } from "@haikit/core";
|
|
19
19
|
import {
|
|
20
20
|
Client,
|
|
21
|
+
InsufficientScopeError,
|
|
21
22
|
ProtocolError,
|
|
22
23
|
ProtocolErrorCode,
|
|
23
24
|
SdkError,
|
|
24
25
|
SdkErrorCode,
|
|
26
|
+
SdkHttpError,
|
|
27
|
+
UnauthorizedError,
|
|
28
|
+
type AuthProvider,
|
|
25
29
|
type CallToolResult,
|
|
26
30
|
type ContentBlock,
|
|
27
31
|
type Tool as ServerTool,
|
|
28
32
|
type Transport,
|
|
29
33
|
type VersionNegotiationMode,
|
|
30
34
|
} from "@modelcontextprotocol/client";
|
|
31
|
-
import {
|
|
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";
|
|
32
38
|
|
|
33
|
-
export {
|
|
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";
|
|
34
41
|
export { textOf, type TextOptions } from "./text.js";
|
|
35
42
|
export { http, stdio, type HttpOptions } from "./transports.js";
|
|
36
|
-
export type { ContentBlock, Transport } from "@modelcontextprotocol/client";
|
|
43
|
+
export type { AuthProvider, ContentBlock, Transport } from "@modelcontextprotocol/client";
|
|
37
44
|
|
|
38
45
|
const { version: VERSION } = createRequire(import.meta.url)("../package.json") as { version: string };
|
|
39
46
|
|
|
@@ -42,6 +49,8 @@ const TIMEOUT_MS = 30_000;
|
|
|
42
49
|
const MAX_TOTAL_MS = 90_000;
|
|
43
50
|
/** Past the longest timer Node keeps, it fires after about a millisecond. */
|
|
44
51
|
const MAX_TIMER_MS = 2_147_483_647;
|
|
52
|
+
const MAX_CONNECTIONS = 100;
|
|
53
|
+
const IDLE_MS = 300_000;
|
|
45
54
|
/**
|
|
46
55
|
* The SDK's own checks of a tool's result against the output schema the
|
|
47
56
|
* server declared, as of @modelcontextprotocol/client 2.4: `callTool` throws
|
|
@@ -59,13 +68,28 @@ export interface McpServerOptions {
|
|
|
59
68
|
name: string;
|
|
60
69
|
/**
|
|
61
70
|
* Makes a connection: called by the first `connect()` or call, and again
|
|
62
|
-
* for each reconnect. `http(url)` and `stdio({ command })` make one.
|
|
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.
|
|
63
74
|
*/
|
|
64
|
-
transport: () => Transport | Promise<Transport>;
|
|
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;
|
|
65
89
|
/**
|
|
66
90
|
* How long the server may go without answering a call or reporting progress
|
|
67
|
-
* on it, in milliseconds. Also bounds making the transport, connecting
|
|
68
|
-
* listing tools. Default 30,000.
|
|
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.
|
|
69
93
|
*/
|
|
70
94
|
timeoutMs?: number;
|
|
71
95
|
/**
|
|
@@ -86,6 +110,8 @@ export interface McpServerOptions {
|
|
|
86
110
|
|
|
87
111
|
export interface McpToolChange {
|
|
88
112
|
server: string;
|
|
113
|
+
/** With `auth`: the user whose connection saw the change. Each user's tools are compared apart. */
|
|
114
|
+
user?: string;
|
|
89
115
|
/** Declared tools the server no longer has. */
|
|
90
116
|
removed: string[];
|
|
91
117
|
/** Declared tools whose input or output schema, or task support, changed. */
|
|
@@ -121,10 +147,16 @@ export interface McpResult {
|
|
|
121
147
|
*/
|
|
122
148
|
export interface McpCallCtx {
|
|
123
149
|
/**
|
|
124
|
-
* The conversation the call is for.
|
|
125
|
-
*
|
|
150
|
+
* The conversation the call is for. A server with `auth` calls as its
|
|
151
|
+
* owner, from `userOf`.
|
|
126
152
|
*/
|
|
127
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;
|
|
128
160
|
/** Hears the server's progress notifications, as `ToolCtx.progress` does. */
|
|
129
161
|
progress?(progress: Progress): void;
|
|
130
162
|
}
|
|
@@ -153,48 +185,75 @@ export interface McpConnection {
|
|
|
153
185
|
* Connects, and checks every declared tool: it throws if the server has no
|
|
154
186
|
* tool of that name, or one that can only run as an MCP task. Call it at
|
|
155
187
|
* startup, so a mistake stops the app there; the first call connects
|
|
156
|
-
* otherwise, and fails with the same error.
|
|
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.
|
|
157
195
|
*/
|
|
158
|
-
|
|
196
|
+
forget(userId: string): Promise<void>;
|
|
159
197
|
/** Closes the connection, and stops a `stdio` server. Final: a later call fails with reason `closed`. */
|
|
160
198
|
close(): Promise<void>;
|
|
161
199
|
}
|
|
162
200
|
|
|
163
201
|
/** The connection to an MCP server. Nothing connects until `connect()` or the first call. */
|
|
164
202
|
export function mcpServer(options: McpServerOptions): McpConnection {
|
|
165
|
-
return new
|
|
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";
|
|
166
213
|
}
|
|
167
214
|
|
|
168
|
-
const
|
|
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> = {
|
|
169
222
|
args: (who) => `${who} was not called: its args do not match the schema`,
|
|
170
223
|
result: (who) => `${who} returned a result that does not match the schema`,
|
|
171
224
|
timeout: (who) => `${who} did not answer in time`,
|
|
172
225
|
connection: (who) => `${who} could not be reached`,
|
|
173
226
|
protocol: (who) => `${who} was refused by the server`,
|
|
174
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`,
|
|
175
235
|
};
|
|
176
236
|
|
|
177
|
-
class
|
|
237
|
+
class Server implements McpConnection {
|
|
178
238
|
readonly name: string;
|
|
179
|
-
|
|
180
|
-
|
|
239
|
+
readonly options: McpServerOptions;
|
|
240
|
+
readonly timeoutMs: number;
|
|
181
241
|
private readonly maxTotalMs: number;
|
|
182
|
-
/**
|
|
183
|
-
|
|
184
|
-
private
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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>();
|
|
189
250
|
private closed = false;
|
|
251
|
+
/** Aborted by `close()`: a call waiting on anything stops with reason `closed`. */
|
|
252
|
+
private readonly shutdown = new AbortController();
|
|
190
253
|
/** The first `close()`, which the others wait for. */
|
|
191
254
|
private closing: Promise<void> | null = null;
|
|
192
|
-
/** Clients whose connection has gone. */
|
|
193
|
-
private readonly dropped = new WeakSet<Client>();
|
|
194
|
-
/** The server's tools at the last listing, by name. Null until a first connect passes the check. */
|
|
195
|
-
private listed: Map<string, ServerTool> | null = null;
|
|
196
255
|
/** The tools `tool()` declared: the only ones that can be called. */
|
|
197
|
-
|
|
256
|
+
readonly declared = new Set<string>();
|
|
198
257
|
|
|
199
258
|
constructor(options: McpServerOptions) {
|
|
200
259
|
if (typeof options.name !== "string" || options.name === "") {
|
|
@@ -207,6 +266,36 @@ class Connection implements McpConnection {
|
|
|
207
266
|
this.options = options;
|
|
208
267
|
this.timeoutMs = checkMs("timeoutMs", options.timeoutMs ?? TIMEOUT_MS);
|
|
209
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;
|
|
210
299
|
}
|
|
211
300
|
|
|
212
301
|
tool(name: string, def: McpToolDef<any, any>): McpTool<any, any> {
|
|
@@ -219,10 +308,9 @@ class Connection implements McpConnection {
|
|
|
219
308
|
}
|
|
220
309
|
if (def.timeoutMs !== undefined) checkMs("timeoutMs", def.timeoutMs);
|
|
221
310
|
if (def.maxTotalMs !== undefined) checkMs("maxTotalMs", def.maxTotalMs);
|
|
222
|
-
if
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
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);
|
|
226
314
|
this.declared.add(name);
|
|
227
315
|
return Object.freeze({
|
|
228
316
|
server: this.name,
|
|
@@ -231,8 +319,31 @@ class Connection implements McpConnection {
|
|
|
231
319
|
});
|
|
232
320
|
}
|
|
233
321
|
|
|
234
|
-
async connect(): Promise<void> {
|
|
235
|
-
|
|
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();
|
|
236
347
|
}
|
|
237
348
|
|
|
238
349
|
close(): Promise<void> {
|
|
@@ -243,17 +354,252 @@ class Connection implements McpConnection {
|
|
|
243
354
|
|
|
244
355
|
private async shut(): Promise<void> {
|
|
245
356
|
this.closed = true;
|
|
246
|
-
|
|
247
|
-
this.
|
|
248
|
-
|
|
249
|
-
this.
|
|
250
|
-
await
|
|
251
|
-
|
|
252
|
-
|
|
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
|
+
]);
|
|
253
517
|
}
|
|
254
518
|
|
|
255
|
-
|
|
256
|
-
|
|
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));
|
|
257
603
|
if (this.client) return Promise.resolve(this.client);
|
|
258
604
|
// one connection at a time: calls that arrive meanwhile share it
|
|
259
605
|
this.dialing ??= this.dial().finally(() => {
|
|
@@ -263,15 +609,39 @@ class Connection implements McpConnection {
|
|
|
263
609
|
return this.dialing;
|
|
264
610
|
}
|
|
265
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
|
+
|
|
266
634
|
/**
|
|
267
635
|
* What `transport()` makes, within `timeoutMs` and unless `close()` comes
|
|
268
636
|
* first. A transport made after that is closed when it comes.
|
|
269
637
|
*/
|
|
270
638
|
private async made(): Promise<Transport> {
|
|
271
|
-
const
|
|
639
|
+
const { options, timeoutMs } = this.server;
|
|
640
|
+
const authProvider = this.credential?.provider;
|
|
641
|
+
const making = Promise.resolve().then(() => (authProvider ? options.transport(authProvider) : options.transport()));
|
|
272
642
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
273
643
|
const cut = new Promise<never>((_, reject) => {
|
|
274
|
-
timer = setTimeout(() => reject(new Error(`no transport within ${
|
|
644
|
+
timer = setTimeout(() => reject(new Error(`no transport within ${timeoutMs} ms`)), timeoutMs);
|
|
275
645
|
this.abandon = () => reject(new Error("closed while the transport was being made"));
|
|
276
646
|
});
|
|
277
647
|
try {
|
|
@@ -286,6 +656,7 @@ class Connection implements McpConnection {
|
|
|
286
656
|
}
|
|
287
657
|
|
|
288
658
|
private async dial(): Promise<Client> {
|
|
659
|
+
const { server } = this;
|
|
289
660
|
let client: Client | undefined;
|
|
290
661
|
try {
|
|
291
662
|
const transport = await this.made();
|
|
@@ -301,9 +672,9 @@ class Connection implements McpConnection {
|
|
|
301
672
|
// this client for input; one that does gets the call refused,
|
|
302
673
|
// rather than a reply nobody wrote.
|
|
303
674
|
inputRequired: { autoFulfill: false },
|
|
304
|
-
...(
|
|
675
|
+
...(server.options.versionNegotiation === undefined
|
|
305
676
|
? {}
|
|
306
|
-
: { versionNegotiation: { mode:
|
|
677
|
+
: { versionNegotiation: { mode: server.options.versionNegotiation } }),
|
|
307
678
|
listChanged: {
|
|
308
679
|
tools: {
|
|
309
680
|
onChanged: (error, tools) => {
|
|
@@ -319,15 +690,16 @@ class Connection implements McpConnection {
|
|
|
319
690
|
this.dropped.add(self);
|
|
320
691
|
if (this.client === self) this.client = null;
|
|
321
692
|
};
|
|
322
|
-
await self.connect(transport, { timeout:
|
|
693
|
+
await self.connect(transport, { timeout: server.timeoutMs });
|
|
323
694
|
} catch (err) {
|
|
324
695
|
await client?.close().catch(() => {});
|
|
325
|
-
|
|
696
|
+
if (this.closed) throw server.error("closed", undefined, { cause: err });
|
|
697
|
+
throw this.authFailure(err, undefined) ?? server.error("connection", undefined, { cause: err });
|
|
326
698
|
}
|
|
327
699
|
|
|
328
700
|
let tools: ServerTool[];
|
|
329
701
|
try {
|
|
330
|
-
({ tools } = await client.listTools(undefined, { timeout:
|
|
702
|
+
({ tools } = await client.listTools(undefined, { timeout: server.timeoutMs }));
|
|
331
703
|
} catch (err) {
|
|
332
704
|
await client.close().catch(() => {});
|
|
333
705
|
throw this.failure(err, undefined, client);
|
|
@@ -335,21 +707,22 @@ class Connection implements McpConnection {
|
|
|
335
707
|
|
|
336
708
|
if (this.listed === null) {
|
|
337
709
|
const listing = new Map(tools.map((t) => [t.name, t]));
|
|
338
|
-
const problems = [...
|
|
710
|
+
const problems = [...server.declared].flatMap((name) => problemWith(name, listing) ?? []);
|
|
339
711
|
if (problems.length) {
|
|
340
712
|
await client.close().catch(() => {});
|
|
341
|
-
throw new Error(`${
|
|
713
|
+
throw new Error(`${server.name}: ${problems.join("; ")}`);
|
|
342
714
|
}
|
|
343
715
|
this.listed = listing;
|
|
716
|
+
for (const name of server.declared) this.checked.add(name);
|
|
344
717
|
} else {
|
|
345
718
|
this.compare(tools);
|
|
346
719
|
}
|
|
347
720
|
|
|
348
721
|
if (this.closed) {
|
|
349
722
|
await client.close().catch(() => {});
|
|
350
|
-
throw
|
|
723
|
+
throw server.error("closed", undefined);
|
|
351
724
|
}
|
|
352
|
-
if (this.dropped.has(client)) throw
|
|
725
|
+
if (this.dropped.has(client)) throw server.error("connection", undefined);
|
|
353
726
|
this.client = client;
|
|
354
727
|
return client;
|
|
355
728
|
}
|
|
@@ -361,7 +734,7 @@ class Connection implements McpConnection {
|
|
|
361
734
|
this.listed = next;
|
|
362
735
|
const removed: string[] = [];
|
|
363
736
|
const changed: string[] = [];
|
|
364
|
-
for (const name of this.declared) {
|
|
737
|
+
for (const name of this.server.declared) {
|
|
365
738
|
const was = before.get(name);
|
|
366
739
|
const now = next.get(name);
|
|
367
740
|
if (!was) continue;
|
|
@@ -369,143 +742,103 @@ class Connection implements McpConnection {
|
|
|
369
742
|
else if (shape(was) !== shape(now)) changed.push(name);
|
|
370
743
|
}
|
|
371
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 };
|
|
372
747
|
try {
|
|
373
|
-
(this.options.onChange ?? warn)(
|
|
748
|
+
(this.server.options.onChange ?? warn)(change);
|
|
374
749
|
} catch (err) {
|
|
375
750
|
console.error(err);
|
|
376
751
|
}
|
|
377
752
|
}
|
|
378
753
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
}
|
|
386
|
-
if (
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
// maxTotalMs bounds the whole call, connecting included, and is ours to
|
|
394
|
-
// keep: the SDK checks its own maxTotalTimeout only when progress comes
|
|
395
|
-
const deadline = new AbortController();
|
|
396
|
-
const timer = setTimeout(() => deadline.abort(), def.maxTotalMs ?? this.maxTotalMs);
|
|
397
|
-
let result: CallToolResult;
|
|
398
|
-
try {
|
|
399
|
-
let client: Client;
|
|
400
|
-
try {
|
|
401
|
-
client = await this.until(deadline.signal, tool, this.open());
|
|
402
|
-
} catch (err) {
|
|
403
|
-
// named again for this call, with what the server said; the
|
|
404
|
-
// declared-tool check throws as it is
|
|
405
|
-
if (!isMcpError(err) || err.tool === tool) throw err;
|
|
406
|
-
throw this.error(err.reason, tool, { cause: err.cause ?? err, text: err.text, code: err.code });
|
|
407
|
-
}
|
|
408
|
-
try {
|
|
409
|
-
result = await client.callTool(
|
|
410
|
-
{ name: tool, arguments: input as Record<string, unknown> },
|
|
411
|
-
{
|
|
412
|
-
timeout: def.timeoutMs ?? this.timeoutMs,
|
|
413
|
-
// cancels the request, and rejects it as a timeout
|
|
414
|
-
signal: deadline.signal,
|
|
415
|
-
// asked for whether or not anyone hears it, so progress always
|
|
416
|
-
// keeps a long call alive
|
|
417
|
-
resetTimeoutOnProgress: true,
|
|
418
|
-
onprogress: (p) => {
|
|
419
|
-
ctx?.progress?.({
|
|
420
|
-
done: p.progress,
|
|
421
|
-
...(p.total === undefined ? {} : { total: p.total }),
|
|
422
|
-
...(p.message === undefined ? {} : { message: p.message }),
|
|
423
|
-
});
|
|
424
|
-
},
|
|
425
|
-
},
|
|
426
|
-
);
|
|
427
|
-
} catch (err) {
|
|
428
|
-
throw this.failure(err, tool, client);
|
|
429
|
-
}
|
|
430
|
-
} finally {
|
|
431
|
-
clearTimeout(timer);
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
const content = result.content ?? [];
|
|
435
|
-
if (result.isError) throw new McpToolError(this.name, tool, content);
|
|
436
|
-
if (!def.result) {
|
|
437
|
-
return result.structuredContent === undefined ? { content } : { content, structuredContent: result.structuredContent };
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
let value: unknown;
|
|
441
|
-
if (result.structuredContent !== undefined) {
|
|
442
|
-
value = result.structuredContent;
|
|
443
|
-
} else {
|
|
444
|
-
const only = content.length === 1 ? content[0] : undefined;
|
|
445
|
-
if (only?.type !== "text") {
|
|
446
|
-
throw this.error("result", tool, {
|
|
447
|
-
cause: new Error("the result has no structured content, and isn't one text block"),
|
|
448
|
-
});
|
|
449
|
-
}
|
|
450
|
-
try {
|
|
451
|
-
value = JSON.parse(only.text);
|
|
452
|
-
} catch (err) {
|
|
453
|
-
throw this.error("result", tool, { cause: err });
|
|
454
|
-
}
|
|
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 });
|
|
455
767
|
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
throw this.error("result", tool, { cause: err });
|
|
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" } });
|
|
460
771
|
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
/** What `promise` resolves to, or a timeout for `tool` if `signal` aborts first. */
|
|
464
|
-
private until<T>(signal: AbortSignal, tool: string, promise: Promise<T>): Promise<T> {
|
|
465
|
-
return new Promise<T>((resolve, reject) => {
|
|
466
|
-
const stop = () => reject(this.error("timeout", tool));
|
|
467
|
-
signal.addEventListener("abort", stop, { once: true });
|
|
468
|
-
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", stop));
|
|
469
|
-
});
|
|
772
|
+
return null;
|
|
470
773
|
}
|
|
471
774
|
|
|
472
775
|
/** An SDK error, as one of ours. */
|
|
473
|
-
|
|
776
|
+
failure(err: unknown, tool: string | undefined, client: Client): McpError {
|
|
777
|
+
const { server } = this;
|
|
474
778
|
if (isMcpError(err)) return err;
|
|
475
|
-
if (this.closed) return
|
|
779
|
+
if (this.closed) return server.error("closed", tool, { cause: err });
|
|
780
|
+
const auth = this.authFailure(err, tool);
|
|
781
|
+
if (auth) return auth;
|
|
476
782
|
if (err instanceof ProtocolError) {
|
|
477
783
|
// The SDK's own check of a call's result throws one too: the server's
|
|
478
784
|
// result is at fault there, not the call. Only a call has a result.
|
|
479
785
|
const checked = tool !== undefined && OUTPUT_CHECKS.some(([code, words]) => err.code === code && words.test(err.message));
|
|
480
|
-
return
|
|
786
|
+
return server.error(checked ? "result" : "protocol", tool, { cause: err, text: err.message, code: err.code });
|
|
481
787
|
}
|
|
482
788
|
if (err instanceof SdkError) {
|
|
483
789
|
switch (err.code) {
|
|
484
790
|
case SdkErrorCode.RequestTimeout:
|
|
485
|
-
return
|
|
791
|
+
return server.error("timeout", tool, { cause: err });
|
|
486
792
|
case SdkErrorCode.NotConnected:
|
|
487
793
|
case SdkErrorCode.ConnectionClosed:
|
|
488
794
|
case SdkErrorCode.SendFailed:
|
|
489
795
|
case SdkErrorCode.ClientHttpFailedToOpenStream:
|
|
490
|
-
return
|
|
796
|
+
return server.error("connection", tool, { cause: err });
|
|
491
797
|
case SdkErrorCode.InvalidResult:
|
|
492
798
|
// only a call has a result; a listing the SDK can't read is the server's fault
|
|
493
|
-
if (tool !== undefined) return
|
|
494
|
-
return
|
|
799
|
+
if (tool !== undefined) return server.error("result", tool, { cause: err });
|
|
800
|
+
return server.error("protocol", tool, { cause: err, text: err.message });
|
|
495
801
|
default:
|
|
496
|
-
return
|
|
802
|
+
return server.error("protocol", tool, { cause: err, text: err.message });
|
|
497
803
|
}
|
|
498
804
|
}
|
|
499
805
|
// the SDK throws a plain Error for a call on a connection that has gone
|
|
500
|
-
return
|
|
806
|
+
return server.error(this.dropped.has(client) ? "connection" : "protocol", tool, { cause: err });
|
|
501
807
|
}
|
|
808
|
+
}
|
|
502
809
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
+
});
|
|
507
838
|
}
|
|
508
839
|
|
|
840
|
+
const nonEmpty = (value: unknown): string | undefined => (typeof value === "string" && value !== "" ? value : undefined);
|
|
841
|
+
|
|
509
842
|
function checkMs(label: string, ms: number): number {
|
|
510
843
|
if (typeof ms !== "number" || !Number.isInteger(ms) || ms <= 0 || ms > MAX_TIMER_MS) {
|
|
511
844
|
throw new RangeError(`${label} must be a whole number of milliseconds, from 1 to ${MAX_TIMER_MS} (got ${String(ms)})`);
|
|
@@ -531,5 +864,6 @@ function warn(change: McpToolChange): void {
|
|
|
531
864
|
...change.removed.map((name) => `tool "${name}" was removed`),
|
|
532
865
|
...change.changed.map((name) => `tool "${name}" changed its schema`),
|
|
533
866
|
];
|
|
534
|
-
|
|
867
|
+
const whose = change.user === undefined ? "" : ` (for user ${change.user})`;
|
|
868
|
+
console.warn(`@haikit/mcp: ${change.server}${whose}: ${parts.join("; ")}`);
|
|
535
869
|
}
|