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