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