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