@wrongstack/mcp 1.0.19 → 1.0.26
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/dist/authorization-callback-server.d.ts +24 -0
- package/dist/authorization-discovery.d.ts +96 -0
- package/dist/authorization-manager.d.ts +46 -1
- package/dist/authorization-registration.d.ts +39 -0
- package/dist/authorization.d.ts +6 -61
- package/dist/client-capabilities.d.ts +15 -0
- package/dist/client-http-connection.d.ts +22 -0
- package/dist/client-process.d.ts +6 -0
- package/dist/client-types.d.ts +70 -0
- package/dist/client.d.ts +17 -47
- package/dist/config-env.d.ts +18 -0
- package/dist/constants.d.ts +26 -5
- package/dist/contracts.d.ts +21 -0
- package/dist/elicitation-form.d.ts +26 -0
- package/dist/elicitation.d.ts +151 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +2176 -805
- package/dist/manage.d.ts +1 -0
- package/dist/manifest-cache.d.ts +1 -0
- package/dist/protocol.d.ts +10 -0
- package/dist/registry-authorization.d.ts +3 -2
- package/dist/registry-connect-loop.d.ts +1 -0
- package/dist/registry-slots.d.ts +6 -0
- package/dist/registry-types.d.ts +6 -0
- package/dist/registry.d.ts +12 -1
- package/dist/server.d.ts +6 -3
- package/dist/structured-result.d.ts +7 -0
- package/dist/token-store.d.ts +5 -1
- package/dist/tool-schema.d.ts +14 -1
- package/dist/transport-base.d.ts +21 -4
- package/dist/transport-streamable.d.ts +14 -1
- package/dist/wrap-tool.d.ts +4 -2
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// src/authorization.ts
|
|
2
2
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
// src/authorization-discovery.ts
|
|
3
5
|
import * as dns from "node:dns/promises";
|
|
4
6
|
import * as http from "node:http";
|
|
5
7
|
import * as https from "node:https";
|
|
@@ -29,22 +31,6 @@ function canonicalMcpResource(rawUrl) {
|
|
|
29
31
|
if (url.pathname === "/" && !url.search) return url.origin;
|
|
30
32
|
return url.toString();
|
|
31
33
|
}
|
|
32
|
-
function authorizationHeaderForToken(token, expectedResource, now = Date.now()) {
|
|
33
|
-
if (canonicalMcpResource(token.resource) !== expectedResource) {
|
|
34
|
-
throw new Error("MCP access token resource does not match the target server");
|
|
35
|
-
}
|
|
36
|
-
if (token.expiresAt !== void 0 && token.expiresAt <= now) {
|
|
37
|
-
throw new Error("MCP access token is expired");
|
|
38
|
-
}
|
|
39
|
-
const tokenType = token.tokenType ?? "Bearer";
|
|
40
|
-
if (tokenType.toLowerCase() !== "bearer") {
|
|
41
|
-
throw new Error(`Unsupported MCP OAuth token type "${tokenType}"`);
|
|
42
|
-
}
|
|
43
|
-
if (!token.accessToken || token.accessToken.length > 16384 || /[\r\n]/.test(token.accessToken)) {
|
|
44
|
-
throw new Error("MCP access token is empty, oversized, or contains invalid characters");
|
|
45
|
-
}
|
|
46
|
-
return `Bearer ${token.accessToken}`;
|
|
47
|
-
}
|
|
48
34
|
function parseMcpBearerChallenge(header, resource) {
|
|
49
35
|
const challenge = {
|
|
50
36
|
status: 401,
|
|
@@ -154,36 +140,6 @@ function parseAuthorizationServerMetadata(value, expectedIssuer) {
|
|
|
154
140
|
scopesSupported: optionalStringArray(metadata["scopes_supported"], "scopes_supported", 128)
|
|
155
141
|
};
|
|
156
142
|
}
|
|
157
|
-
function validateMcpAuthorizationServerMetadata(value) {
|
|
158
|
-
const metadata = record(value, "stored authorization server metadata");
|
|
159
|
-
const registration = optionalString(metadata["registrationEndpoint"], "registrationEndpoint");
|
|
160
|
-
const issuer = secureOAuthUrl(requiredString(metadata["issuer"], "issuer"), "issuer").toString();
|
|
161
|
-
const authorizationEndpoint = secureOAuthUrl(
|
|
162
|
-
requiredString(metadata["authorizationEndpoint"], "authorizationEndpoint"),
|
|
163
|
-
"authorization endpoint"
|
|
164
|
-
).toString();
|
|
165
|
-
const tokenEndpoint = secureOAuthUrl(
|
|
166
|
-
requiredString(metadata["tokenEndpoint"], "tokenEndpoint"),
|
|
167
|
-
"token endpoint"
|
|
168
|
-
).toString();
|
|
169
|
-
const issuerParameterSupported = optionalBoolean(
|
|
170
|
-
metadata["authorizationResponseIssuerParameterSupported"],
|
|
171
|
-
"authorizationResponseIssuerParameterSupported"
|
|
172
|
-
);
|
|
173
|
-
if (issuerParameterSupported !== true && (new URL(authorizationEndpoint).origin !== new URL(issuer).origin || new URL(tokenEndpoint).origin !== new URL(issuer).origin)) {
|
|
174
|
-
throw new Error(
|
|
175
|
-
"MCP authorization server with cross-origin endpoints must support the authorization response issuer parameter"
|
|
176
|
-
);
|
|
177
|
-
}
|
|
178
|
-
return {
|
|
179
|
-
issuer,
|
|
180
|
-
authorizationEndpoint,
|
|
181
|
-
tokenEndpoint,
|
|
182
|
-
registrationEndpoint: registration ? secureOAuthUrl(registration, "registration endpoint").toString() : void 0,
|
|
183
|
-
authorizationResponseIssuerParameterSupported: issuerParameterSupported,
|
|
184
|
-
scopesSupported: optionalStringArray(metadata["scopesSupported"], "scopesSupported", 128)
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
143
|
async function discoverMcpAuthorization(resource, options = {}) {
|
|
188
144
|
const canonicalResource = canonicalMcpResource(resource);
|
|
189
145
|
const resourceUrl = new URL(canonicalResource);
|
|
@@ -219,124 +175,6 @@ async function discoverMcpAuthorization(resource, options = {}) {
|
|
|
219
175
|
authorizationServer: authorizationDiscovery.value
|
|
220
176
|
};
|
|
221
177
|
}
|
|
222
|
-
function createMcpAuthorizationRequest(options) {
|
|
223
|
-
const resource = canonicalMcpResource(options.resource);
|
|
224
|
-
const clientId = boundedCredential(options.clientId, "client id");
|
|
225
|
-
const redirectUri = validateRedirectUri(options.redirectUri);
|
|
226
|
-
const scopes = validateScopes(options.scopes ?? []);
|
|
227
|
-
const codeVerifier = base64Url(randomBytes(32));
|
|
228
|
-
const codeChallenge = base64Url(createHash("sha256").update(codeVerifier).digest());
|
|
229
|
-
const state = base64Url(randomBytes(32));
|
|
230
|
-
const authorizationUrl = secureOAuthUrl(
|
|
231
|
-
options.authorizationServer.authorizationEndpoint,
|
|
232
|
-
"authorization endpoint"
|
|
233
|
-
);
|
|
234
|
-
authorizationUrl.searchParams.set("response_type", "code");
|
|
235
|
-
authorizationUrl.searchParams.set("client_id", clientId);
|
|
236
|
-
authorizationUrl.searchParams.set("redirect_uri", redirectUri);
|
|
237
|
-
authorizationUrl.searchParams.set("state", state);
|
|
238
|
-
authorizationUrl.searchParams.set("code_challenge", codeChallenge);
|
|
239
|
-
authorizationUrl.searchParams.set("code_challenge_method", "S256");
|
|
240
|
-
authorizationUrl.searchParams.set("resource", resource);
|
|
241
|
-
if (scopes.length > 0) authorizationUrl.searchParams.set("scope", scopes.join(" "));
|
|
242
|
-
return {
|
|
243
|
-
authorizationUrl: authorizationUrl.toString(),
|
|
244
|
-
state,
|
|
245
|
-
codeVerifier,
|
|
246
|
-
redirectUri,
|
|
247
|
-
clientId,
|
|
248
|
-
resource,
|
|
249
|
-
issuer: options.authorizationServer.issuer,
|
|
250
|
-
requireIssuerParameter: options.authorizationServer.authorizationResponseIssuerParameterSupported === true
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
|
-
function parseMcpAuthorizationCallback(callbackUrl, session) {
|
|
254
|
-
let callback;
|
|
255
|
-
try {
|
|
256
|
-
callback = new URL(callbackUrl);
|
|
257
|
-
} catch {
|
|
258
|
-
throw new Error("MCP OAuth callback must be an absolute URL");
|
|
259
|
-
}
|
|
260
|
-
const expected = new URL(validateRedirectUri(session.redirectUri));
|
|
261
|
-
if (callback.protocol !== expected.protocol || callback.hostname !== expected.hostname || callback.port !== expected.port || callback.pathname !== expected.pathname) {
|
|
262
|
-
throw new Error("MCP OAuth callback redirect URI does not match the authorization session");
|
|
263
|
-
}
|
|
264
|
-
const returnedStates = callback.searchParams.getAll("state");
|
|
265
|
-
if (returnedStates.length !== 1) {
|
|
266
|
-
throw new Error("MCP OAuth callback must contain exactly one state parameter");
|
|
267
|
-
}
|
|
268
|
-
const returnedState = returnedStates[0];
|
|
269
|
-
if (!constantTimeEqual(returnedState, session.state)) {
|
|
270
|
-
throw new Error("MCP OAuth callback state mismatch");
|
|
271
|
-
}
|
|
272
|
-
const returnedIssuers = callback.searchParams.getAll("iss");
|
|
273
|
-
if (session.requireIssuerParameter && returnedIssuers.length !== 1) {
|
|
274
|
-
throw new Error("MCP OAuth callback must contain exactly one issuer parameter");
|
|
275
|
-
}
|
|
276
|
-
if (returnedIssuers.length > 0 && returnedIssuers.some((issuer) => issuer !== session.issuer)) {
|
|
277
|
-
throw new Error("MCP OAuth callback issuer mismatch");
|
|
278
|
-
}
|
|
279
|
-
const oauthErrors = callback.searchParams.getAll("error");
|
|
280
|
-
const codes = callback.searchParams.getAll("code");
|
|
281
|
-
if (oauthErrors.length > 1 || codes.length > 1 || oauthErrors.length === 1 && codes.length > 0) {
|
|
282
|
-
throw new Error("MCP OAuth callback contains ambiguous response parameters");
|
|
283
|
-
}
|
|
284
|
-
if (oauthErrors.length === 1) {
|
|
285
|
-
throw new Error(`MCP OAuth authorization failed: ${boundedErrorCode(oauthErrors[0])}`);
|
|
286
|
-
}
|
|
287
|
-
if (codes.length !== 1) {
|
|
288
|
-
throw new Error("MCP OAuth callback must contain exactly one authorization code");
|
|
289
|
-
}
|
|
290
|
-
return boundedCredential(codes[0], "authorization code");
|
|
291
|
-
}
|
|
292
|
-
async function exchangeMcpAuthorizationCode(options) {
|
|
293
|
-
const resource = canonicalMcpResource(options.resource);
|
|
294
|
-
const body = new URLSearchParams({
|
|
295
|
-
grant_type: "authorization_code",
|
|
296
|
-
code: boundedCredential(options.code, "authorization code"),
|
|
297
|
-
client_id: boundedCredential(options.clientId, "client id"),
|
|
298
|
-
redirect_uri: validateRedirectUri(options.redirectUri),
|
|
299
|
-
code_verifier: validateCodeVerifier(options.codeVerifier),
|
|
300
|
-
resource
|
|
301
|
-
}).toString();
|
|
302
|
-
const response = await requestPinnedJson(options.authorizationServer.tokenEndpoint, {
|
|
303
|
-
method: "POST",
|
|
304
|
-
body,
|
|
305
|
-
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
306
|
-
signal: options.signal,
|
|
307
|
-
timeoutMs: options.timeoutMs,
|
|
308
|
-
maxResponseBytes: options.maxResponseBytes,
|
|
309
|
-
lookup: options.lookup,
|
|
310
|
-
allowedLoopbackHostname: loopbackHostnameForResource(resource),
|
|
311
|
-
label: "token endpoint"
|
|
312
|
-
});
|
|
313
|
-
if (response === void 0) throw new Error("MCP OAuth token endpoint returned no response");
|
|
314
|
-
return parseTokenResponse(response, resource);
|
|
315
|
-
}
|
|
316
|
-
async function refreshMcpAccessToken(options) {
|
|
317
|
-
const resource = canonicalMcpResource(options.resource);
|
|
318
|
-
const previousRefreshToken = boundedCredential(options.refreshToken, "refresh token");
|
|
319
|
-
const body = new URLSearchParams({
|
|
320
|
-
grant_type: "refresh_token",
|
|
321
|
-
refresh_token: previousRefreshToken,
|
|
322
|
-
client_id: boundedCredential(options.clientId, "client id"),
|
|
323
|
-
resource
|
|
324
|
-
}).toString();
|
|
325
|
-
const response = await requestPinnedJson(options.authorizationServer.tokenEndpoint, {
|
|
326
|
-
method: "POST",
|
|
327
|
-
body,
|
|
328
|
-
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
329
|
-
signal: options.signal,
|
|
330
|
-
timeoutMs: options.timeoutMs,
|
|
331
|
-
maxResponseBytes: options.maxResponseBytes,
|
|
332
|
-
lookup: options.lookup,
|
|
333
|
-
allowedLoopbackHostname: loopbackHostnameForResource(resource),
|
|
334
|
-
label: "token endpoint"
|
|
335
|
-
});
|
|
336
|
-
if (response === void 0) throw new Error("MCP OAuth token endpoint returned no response");
|
|
337
|
-
const parsed = parseTokenResponse(response, resource);
|
|
338
|
-
return { ...parsed, refreshToken: parsed.refreshToken ?? previousRefreshToken };
|
|
339
|
-
}
|
|
340
178
|
function challengeParameter(parameters, name) {
|
|
341
179
|
const pattern = new RegExp(
|
|
342
180
|
`(?:^|,)\\s*${name}\\s*=\\s*(?:"((?:\\\\.|[^"\\\\])*)"|([^,\\s]+))`,
|
|
@@ -356,9 +194,9 @@ function validateMetadataUrl(value) {
|
|
|
356
194
|
return void 0;
|
|
357
195
|
}
|
|
358
196
|
}
|
|
359
|
-
function record(value,
|
|
197
|
+
function record(value, label2) {
|
|
360
198
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
361
|
-
throw new Error(`MCP ${
|
|
199
|
+
throw new Error(`MCP ${label2} must be an object`);
|
|
362
200
|
}
|
|
363
201
|
return value;
|
|
364
202
|
}
|
|
@@ -387,23 +225,23 @@ function boundedStringArray(value, field, maxItems) {
|
|
|
387
225
|
function optionalStringArray(value, field, maxItems) {
|
|
388
226
|
return value === void 0 ? [] : boundedStringArray(value, field, maxItems);
|
|
389
227
|
}
|
|
390
|
-
function secureOAuthUrl(value,
|
|
228
|
+
function secureOAuthUrl(value, label2) {
|
|
391
229
|
let url;
|
|
392
230
|
try {
|
|
393
231
|
url = new URL(value);
|
|
394
232
|
} catch {
|
|
395
|
-
throw new Error(`MCP ${
|
|
233
|
+
throw new Error(`MCP ${label2} must be an absolute URL`);
|
|
396
234
|
}
|
|
397
235
|
if (url.protocol !== "https:" && !isLoopbackHttp(url)) {
|
|
398
|
-
throw new Error(`MCP ${
|
|
236
|
+
throw new Error(`MCP ${label2} must use HTTPS (except loopback development)`);
|
|
399
237
|
}
|
|
400
238
|
if (url.username || url.password || url.search || url.hash) {
|
|
401
|
-
throw new Error(`MCP ${
|
|
239
|
+
throw new Error(`MCP ${label2} must not contain credentials, query, or fragment components`);
|
|
402
240
|
}
|
|
403
241
|
if (url.pathname === "/") return new URL(url.origin);
|
|
404
242
|
return url;
|
|
405
243
|
}
|
|
406
|
-
async function discoverFirst(candidates, fetchJson, signal, parse,
|
|
244
|
+
async function discoverFirst(candidates, fetchJson, signal, parse, label2) {
|
|
407
245
|
const failures = [];
|
|
408
246
|
for (const candidate of candidates) {
|
|
409
247
|
signal?.throwIfAborted();
|
|
@@ -419,11 +257,11 @@ async function discoverFirst(candidates, fetchJson, signal, parse, label) {
|
|
|
419
257
|
failures.push(`${candidate}: ${error instanceof Error ? error.message : String(error)}`);
|
|
420
258
|
}
|
|
421
259
|
}
|
|
422
|
-
throw new Error(`MCP ${
|
|
260
|
+
throw new Error(`MCP ${label2} discovery failed (${failures.join("; ")})`);
|
|
423
261
|
}
|
|
424
262
|
async function requestPinnedJson(rawUrl, options) {
|
|
425
|
-
const
|
|
426
|
-
const url = secureOAuthUrl(rawUrl, `${
|
|
263
|
+
const label2 = options.label ?? "discovery";
|
|
264
|
+
const url = secureOAuthUrl(rawUrl, `${label2} URL`);
|
|
427
265
|
const target = await resolvePinnedAddress(url, options);
|
|
428
266
|
const timeoutMs = options.timeoutMs ?? 1e4;
|
|
429
267
|
const maxBytes = options.maxResponseBytes ?? 64 * 1024;
|
|
@@ -467,12 +305,12 @@ async function requestPinnedJson(rawUrl, options) {
|
|
|
467
305
|
}
|
|
468
306
|
if (status >= 300 && status < 400) {
|
|
469
307
|
response.resume();
|
|
470
|
-
finish(new Error(`MCP OAuth ${
|
|
308
|
+
finish(new Error(`MCP OAuth ${label2} redirects are not allowed`));
|
|
471
309
|
return;
|
|
472
310
|
}
|
|
473
311
|
if (status < 200 || status >= 300) {
|
|
474
312
|
response.resume();
|
|
475
|
-
finish(new MCPOAuthHttpError(`MCP OAuth ${
|
|
313
|
+
finish(new MCPOAuthHttpError(`MCP OAuth ${label2} HTTP ${status}`, status));
|
|
476
314
|
return;
|
|
477
315
|
}
|
|
478
316
|
const contentType = response.headers["content-type"] ?? "";
|
|
@@ -516,21 +354,21 @@ async function requestPinnedJson(rawUrl, options) {
|
|
|
516
354
|
});
|
|
517
355
|
}
|
|
518
356
|
async function resolvePinnedAddress(url, options) {
|
|
519
|
-
const
|
|
520
|
-
const literalFamily = net.isIP(
|
|
357
|
+
const hostname2 = unbracket(url.hostname).toLowerCase();
|
|
358
|
+
const literalFamily = net.isIP(hostname2);
|
|
521
359
|
if (literalFamily === 4 || literalFamily === 6) {
|
|
522
360
|
assertDiscoveryAddressAllowed(
|
|
523
|
-
|
|
361
|
+
hostname2,
|
|
524
362
|
literalFamily,
|
|
525
|
-
|
|
363
|
+
hostname2,
|
|
526
364
|
options.allowedLoopbackHostname
|
|
527
365
|
);
|
|
528
|
-
return { address:
|
|
366
|
+
return { address: hostname2, family: literalFamily };
|
|
529
367
|
}
|
|
530
368
|
const lookup3 = options.lookup ?? ((host) => dns.lookup(host, { all: true }));
|
|
531
|
-
const records = await lookup3(
|
|
369
|
+
const records = await lookup3(hostname2);
|
|
532
370
|
if (records.length === 0)
|
|
533
|
-
throw new Error(`MCP OAuth discovery DNS returned no addresses for ${
|
|
371
|
+
throw new Error(`MCP OAuth discovery DNS returned no addresses for ${hostname2}`);
|
|
534
372
|
for (const record3 of records) {
|
|
535
373
|
if (record3.family !== 4 && record3.family !== 6) {
|
|
536
374
|
throw new Error("MCP OAuth discovery DNS returned an unsupported address family");
|
|
@@ -538,114 +376,485 @@ async function resolvePinnedAddress(url, options) {
|
|
|
538
376
|
assertDiscoveryAddressAllowed(
|
|
539
377
|
record3.address,
|
|
540
378
|
record3.family,
|
|
541
|
-
|
|
379
|
+
hostname2,
|
|
542
380
|
options.allowedLoopbackHostname
|
|
543
381
|
);
|
|
544
382
|
}
|
|
545
383
|
const selected = records[0];
|
|
546
384
|
return { address: selected.address, family: selected.family };
|
|
547
385
|
}
|
|
548
|
-
function assertDiscoveryAddressAllowed(address, family,
|
|
386
|
+
function assertDiscoveryAddressAllowed(address, family, hostname2, allowedLoopbackHostname) {
|
|
549
387
|
const isPrivate = family === 4 ? isPrivateIPv4(address) : isPrivateIPv6(address);
|
|
550
388
|
if (!isPrivate) return;
|
|
551
389
|
const loopback = family === 4 ? address.startsWith("127.") : address === "::1";
|
|
552
|
-
if (loopback &&
|
|
390
|
+
if (loopback && hostname2 === allowedLoopbackHostname) return;
|
|
553
391
|
throw new Error(`MCP OAuth discovery blocked private address ${address}`);
|
|
554
392
|
}
|
|
555
|
-
function unbracket(
|
|
556
|
-
return
|
|
557
|
-
}
|
|
558
|
-
function validateRedirectUri(value) {
|
|
559
|
-
let url;
|
|
560
|
-
try {
|
|
561
|
-
url = new URL(value);
|
|
562
|
-
} catch {
|
|
563
|
-
throw new Error("MCP OAuth redirect URI must be an absolute URL");
|
|
564
|
-
}
|
|
565
|
-
if (url.protocol !== "https:" && !isLoopbackHttp(url)) {
|
|
566
|
-
throw new Error("MCP OAuth redirect URI must use HTTPS or loopback HTTP");
|
|
567
|
-
}
|
|
568
|
-
if (url.username || url.password || url.search || url.hash) {
|
|
569
|
-
throw new Error("MCP OAuth redirect URI must not contain credentials, query, or fragment");
|
|
570
|
-
}
|
|
571
|
-
return url.toString();
|
|
393
|
+
function unbracket(hostname2) {
|
|
394
|
+
return hostname2.startsWith("[") && hostname2.endsWith("]") ? hostname2.slice(1, -1) : hostname2;
|
|
572
395
|
}
|
|
573
|
-
function
|
|
574
|
-
if (
|
|
575
|
-
|
|
576
|
-
if (!scope || scope.length > 256 || /\s/.test(scope)) {
|
|
577
|
-
throw new Error("MCP OAuth scopes must be bounded non-empty tokens");
|
|
578
|
-
}
|
|
579
|
-
return scope;
|
|
580
|
-
});
|
|
581
|
-
return [...new Set(normalized)];
|
|
396
|
+
function isLoopbackHttp(url) {
|
|
397
|
+
if (url.protocol !== "http:") return false;
|
|
398
|
+
return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
|
|
582
399
|
}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
400
|
+
|
|
401
|
+
// src/authorization.ts
|
|
402
|
+
function authorizationHeaderForToken(token, expectedResource, now = Date.now()) {
|
|
403
|
+
if (canonicalMcpResource(token.resource) !== expectedResource) {
|
|
404
|
+
throw new Error("MCP access token resource does not match the target server");
|
|
586
405
|
}
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
function boundedCredential(value, label) {
|
|
590
|
-
if (!value || value.length > 16384 || /[\r\n]/.test(value)) {
|
|
591
|
-
throw new Error(`MCP OAuth ${label} is empty, oversized, or invalid`);
|
|
406
|
+
if (token.expiresAt !== void 0 && token.expiresAt <= now) {
|
|
407
|
+
throw new Error("MCP access token is expired");
|
|
592
408
|
}
|
|
593
|
-
|
|
594
|
-
}
|
|
595
|
-
function boundedErrorCode(value) {
|
|
596
|
-
return /^[A-Za-z0-9._-]{1,128}$/.test(value) ? value : "invalid_error";
|
|
597
|
-
}
|
|
598
|
-
function base64Url(value) {
|
|
599
|
-
return Buffer.from(value).toString("base64url");
|
|
600
|
-
}
|
|
601
|
-
function constantTimeEqual(left, right) {
|
|
602
|
-
const leftHash = createHash("sha256").update(left).digest();
|
|
603
|
-
const rightHash = createHash("sha256").update(right).digest();
|
|
604
|
-
return timingSafeEqual(leftHash, rightHash);
|
|
605
|
-
}
|
|
606
|
-
function parseTokenResponse(value, resource) {
|
|
607
|
-
const response = record(value, "token response");
|
|
608
|
-
const accessToken = boundedCredential(
|
|
609
|
-
requiredString(response["access_token"], "access_token"),
|
|
610
|
-
"access token"
|
|
611
|
-
);
|
|
612
|
-
const tokenType = optionalString(response["token_type"], "token_type") ?? "Bearer";
|
|
409
|
+
const tokenType = token.tokenType ?? "Bearer";
|
|
613
410
|
if (tokenType.toLowerCase() !== "bearer") {
|
|
614
411
|
throw new Error(`Unsupported MCP OAuth token type "${tokenType}"`);
|
|
615
412
|
}
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
if (expiresIn !== void 0) {
|
|
619
|
-
if (typeof expiresIn !== "number" || !Number.isFinite(expiresIn) || expiresIn <= 0 || expiresIn > 31536e3) {
|
|
620
|
-
throw new Error("MCP OAuth expires_in must be between 1 second and 1 year");
|
|
621
|
-
}
|
|
622
|
-
expiresAt = Date.now() + Math.floor(expiresIn * 1e3);
|
|
413
|
+
if (!token.accessToken || token.accessToken.length > 16384 || /[\r\n]/.test(token.accessToken)) {
|
|
414
|
+
throw new Error("MCP access token is empty, oversized, or contains invalid characters");
|
|
623
415
|
}
|
|
624
|
-
|
|
625
|
-
const scope = optionalString(response["scope"], "scope");
|
|
626
|
-
const token = {
|
|
627
|
-
accessToken,
|
|
628
|
-
tokenType: "Bearer",
|
|
629
|
-
resource,
|
|
630
|
-
scopes: scope ? validateScopes(scope.split(/\s+/).filter(Boolean)) : [],
|
|
631
|
-
...expiresAt !== void 0 ? { expiresAt } : {},
|
|
632
|
-
...refresh ? { refreshToken: boundedCredential(refresh, "refresh token") } : {}
|
|
633
|
-
};
|
|
634
|
-
authorizationHeaderForToken(token, resource);
|
|
635
|
-
return token;
|
|
636
|
-
}
|
|
637
|
-
function loopbackHostnameForResource(resource) {
|
|
638
|
-
const url = new URL(resource);
|
|
639
|
-
return isLoopbackHttp(url) ? unbracket(url.hostname).toLowerCase() : void 0;
|
|
640
|
-
}
|
|
641
|
-
function isLoopbackHttp(url) {
|
|
642
|
-
if (url.protocol !== "http:") return false;
|
|
643
|
-
return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
|
|
416
|
+
return `Bearer ${token.accessToken}`;
|
|
644
417
|
}
|
|
645
|
-
|
|
646
|
-
|
|
418
|
+
function validateMcpAuthorizationServerMetadata(value) {
|
|
419
|
+
const metadata = record(value, "stored authorization server metadata");
|
|
420
|
+
const registration = optionalString(metadata["registrationEndpoint"], "registrationEndpoint");
|
|
421
|
+
const issuer = secureOAuthUrl(requiredString(metadata["issuer"], "issuer"), "issuer").toString();
|
|
422
|
+
const authorizationEndpoint = secureOAuthUrl(
|
|
423
|
+
requiredString(metadata["authorizationEndpoint"], "authorizationEndpoint"),
|
|
424
|
+
"authorization endpoint"
|
|
425
|
+
).toString();
|
|
426
|
+
const tokenEndpoint = secureOAuthUrl(
|
|
427
|
+
requiredString(metadata["tokenEndpoint"], "tokenEndpoint"),
|
|
428
|
+
"token endpoint"
|
|
429
|
+
).toString();
|
|
430
|
+
const issuerParameterSupported = optionalBoolean(
|
|
431
|
+
metadata["authorizationResponseIssuerParameterSupported"],
|
|
432
|
+
"authorizationResponseIssuerParameterSupported"
|
|
433
|
+
);
|
|
434
|
+
if (issuerParameterSupported !== true && (new URL(authorizationEndpoint).origin !== new URL(issuer).origin || new URL(tokenEndpoint).origin !== new URL(issuer).origin)) {
|
|
435
|
+
throw new Error(
|
|
436
|
+
"MCP authorization server with cross-origin endpoints must support the authorization response issuer parameter"
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
return {
|
|
440
|
+
issuer,
|
|
441
|
+
authorizationEndpoint,
|
|
442
|
+
tokenEndpoint,
|
|
443
|
+
registrationEndpoint: registration ? secureOAuthUrl(registration, "registration endpoint").toString() : void 0,
|
|
444
|
+
authorizationResponseIssuerParameterSupported: issuerParameterSupported,
|
|
445
|
+
scopesSupported: optionalStringArray(metadata["scopesSupported"], "scopesSupported", 128)
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
function createMcpAuthorizationRequest(options) {
|
|
449
|
+
const resource = canonicalMcpResource(options.resource);
|
|
450
|
+
const clientId = boundedCredential(options.clientId, "client id");
|
|
451
|
+
const redirectUri = validateRedirectUri(options.redirectUri);
|
|
452
|
+
const scopes = validateScopes(options.scopes ?? []);
|
|
453
|
+
const codeVerifier = base64Url(randomBytes(32));
|
|
454
|
+
const codeChallenge = base64Url(createHash("sha256").update(codeVerifier).digest());
|
|
455
|
+
const state = base64Url(randomBytes(32));
|
|
456
|
+
const authorizationUrl = secureOAuthUrl(
|
|
457
|
+
options.authorizationServer.authorizationEndpoint,
|
|
458
|
+
"authorization endpoint"
|
|
459
|
+
);
|
|
460
|
+
authorizationUrl.searchParams.set("response_type", "code");
|
|
461
|
+
authorizationUrl.searchParams.set("client_id", clientId);
|
|
462
|
+
authorizationUrl.searchParams.set("redirect_uri", redirectUri);
|
|
463
|
+
authorizationUrl.searchParams.set("state", state);
|
|
464
|
+
authorizationUrl.searchParams.set("code_challenge", codeChallenge);
|
|
465
|
+
authorizationUrl.searchParams.set("code_challenge_method", "S256");
|
|
466
|
+
authorizationUrl.searchParams.set("resource", resource);
|
|
467
|
+
if (scopes.length > 0) authorizationUrl.searchParams.set("scope", scopes.join(" "));
|
|
468
|
+
return {
|
|
469
|
+
authorizationUrl: authorizationUrl.toString(),
|
|
470
|
+
state,
|
|
471
|
+
codeVerifier,
|
|
472
|
+
redirectUri,
|
|
473
|
+
clientId,
|
|
474
|
+
resource,
|
|
475
|
+
issuer: options.authorizationServer.issuer,
|
|
476
|
+
requireIssuerParameter: options.authorizationServer.authorizationResponseIssuerParameterSupported === true
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
function parseMcpAuthorizationCallback(callbackUrl, session) {
|
|
480
|
+
let callback;
|
|
481
|
+
try {
|
|
482
|
+
callback = new URL(callbackUrl);
|
|
483
|
+
} catch {
|
|
484
|
+
throw new Error("MCP OAuth callback must be an absolute URL");
|
|
485
|
+
}
|
|
486
|
+
const expected = new URL(validateRedirectUri(session.redirectUri));
|
|
487
|
+
if (callback.protocol !== expected.protocol || callback.hostname !== expected.hostname || callback.port !== expected.port || callback.pathname !== expected.pathname) {
|
|
488
|
+
throw new Error("MCP OAuth callback redirect URI does not match the authorization session");
|
|
489
|
+
}
|
|
490
|
+
const returnedStates = callback.searchParams.getAll("state");
|
|
491
|
+
if (returnedStates.length !== 1) {
|
|
492
|
+
throw new Error("MCP OAuth callback must contain exactly one state parameter");
|
|
493
|
+
}
|
|
494
|
+
const returnedState = returnedStates[0];
|
|
495
|
+
if (!constantTimeEqual(returnedState, session.state)) {
|
|
496
|
+
throw new Error("MCP OAuth callback state mismatch");
|
|
497
|
+
}
|
|
498
|
+
const returnedIssuers = callback.searchParams.getAll("iss");
|
|
499
|
+
if (session.requireIssuerParameter && returnedIssuers.length !== 1) {
|
|
500
|
+
throw new Error("MCP OAuth callback must contain exactly one issuer parameter");
|
|
501
|
+
}
|
|
502
|
+
if (returnedIssuers.length > 0 && returnedIssuers.some((issuer) => issuer !== session.issuer)) {
|
|
503
|
+
throw new Error("MCP OAuth callback issuer mismatch");
|
|
504
|
+
}
|
|
505
|
+
const oauthErrors = callback.searchParams.getAll("error");
|
|
506
|
+
const codes = callback.searchParams.getAll("code");
|
|
507
|
+
if (oauthErrors.length > 1 || codes.length > 1 || oauthErrors.length === 1 && codes.length > 0) {
|
|
508
|
+
throw new Error("MCP OAuth callback contains ambiguous response parameters");
|
|
509
|
+
}
|
|
510
|
+
if (oauthErrors.length === 1) {
|
|
511
|
+
throw new Error(`MCP OAuth authorization failed: ${boundedErrorCode(oauthErrors[0])}`);
|
|
512
|
+
}
|
|
513
|
+
if (codes.length !== 1) {
|
|
514
|
+
throw new Error("MCP OAuth callback must contain exactly one authorization code");
|
|
515
|
+
}
|
|
516
|
+
return boundedCredential(codes[0], "authorization code");
|
|
517
|
+
}
|
|
518
|
+
async function exchangeMcpAuthorizationCode(options) {
|
|
519
|
+
const resource = canonicalMcpResource(options.resource);
|
|
520
|
+
const body = new URLSearchParams({
|
|
521
|
+
grant_type: "authorization_code",
|
|
522
|
+
code: boundedCredential(options.code, "authorization code"),
|
|
523
|
+
client_id: boundedCredential(options.clientId, "client id"),
|
|
524
|
+
redirect_uri: validateRedirectUri(options.redirectUri),
|
|
525
|
+
code_verifier: validateCodeVerifier(options.codeVerifier),
|
|
526
|
+
resource,
|
|
527
|
+
...options.clientSecret ? { client_secret: boundedCredential(options.clientSecret, "client secret") } : {}
|
|
528
|
+
}).toString();
|
|
529
|
+
const response = await requestPinnedJson(options.authorizationServer.tokenEndpoint, {
|
|
530
|
+
method: "POST",
|
|
531
|
+
body,
|
|
532
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
533
|
+
signal: options.signal,
|
|
534
|
+
timeoutMs: options.timeoutMs,
|
|
535
|
+
maxResponseBytes: options.maxResponseBytes,
|
|
536
|
+
lookup: options.lookup,
|
|
537
|
+
allowedLoopbackHostname: loopbackHostnameForResource(resource),
|
|
538
|
+
label: "token endpoint"
|
|
539
|
+
});
|
|
540
|
+
if (response === void 0) throw new Error("MCP OAuth token endpoint returned no response");
|
|
541
|
+
return parseTokenResponse(response, resource);
|
|
542
|
+
}
|
|
543
|
+
async function refreshMcpAccessToken(options) {
|
|
544
|
+
const resource = canonicalMcpResource(options.resource);
|
|
545
|
+
const previousRefreshToken = boundedCredential(options.refreshToken, "refresh token");
|
|
546
|
+
const body = new URLSearchParams({
|
|
547
|
+
grant_type: "refresh_token",
|
|
548
|
+
refresh_token: previousRefreshToken,
|
|
549
|
+
client_id: boundedCredential(options.clientId, "client id"),
|
|
550
|
+
resource,
|
|
551
|
+
...options.clientSecret ? { client_secret: boundedCredential(options.clientSecret, "client secret") } : {}
|
|
552
|
+
}).toString();
|
|
553
|
+
const response = await requestPinnedJson(options.authorizationServer.tokenEndpoint, {
|
|
554
|
+
method: "POST",
|
|
555
|
+
body,
|
|
556
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
557
|
+
signal: options.signal,
|
|
558
|
+
timeoutMs: options.timeoutMs,
|
|
559
|
+
maxResponseBytes: options.maxResponseBytes,
|
|
560
|
+
lookup: options.lookup,
|
|
561
|
+
allowedLoopbackHostname: loopbackHostnameForResource(resource),
|
|
562
|
+
label: "token endpoint"
|
|
563
|
+
});
|
|
564
|
+
if (response === void 0) throw new Error("MCP OAuth token endpoint returned no response");
|
|
565
|
+
const parsed = parseTokenResponse(response, resource);
|
|
566
|
+
return { ...parsed, refreshToken: parsed.refreshToken ?? previousRefreshToken };
|
|
567
|
+
}
|
|
568
|
+
function validateRedirectUri(value) {
|
|
569
|
+
let url;
|
|
570
|
+
try {
|
|
571
|
+
url = new URL(value);
|
|
572
|
+
} catch {
|
|
573
|
+
throw new Error("MCP OAuth redirect URI must be an absolute URL");
|
|
574
|
+
}
|
|
575
|
+
if (url.protocol !== "https:" && !isLoopbackHttp(url)) {
|
|
576
|
+
throw new Error("MCP OAuth redirect URI must use HTTPS or loopback HTTP");
|
|
577
|
+
}
|
|
578
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
579
|
+
throw new Error("MCP OAuth redirect URI must not contain credentials, query, or fragment");
|
|
580
|
+
}
|
|
581
|
+
return url.toString();
|
|
582
|
+
}
|
|
583
|
+
function validateScopes(scopes) {
|
|
584
|
+
if (scopes.length > 128) throw new Error("MCP OAuth scope list exceeds 128 entries");
|
|
585
|
+
const normalized = scopes.map((scope) => {
|
|
586
|
+
if (!scope || scope.length > 256 || /\s/.test(scope)) {
|
|
587
|
+
throw new Error("MCP OAuth scopes must be bounded non-empty tokens");
|
|
588
|
+
}
|
|
589
|
+
return scope;
|
|
590
|
+
});
|
|
591
|
+
return [...new Set(normalized)];
|
|
592
|
+
}
|
|
593
|
+
function validateCodeVerifier(value) {
|
|
594
|
+
if (value.length < 43 || value.length > 128 || !/^[A-Za-z0-9._~-]+$/.test(value)) {
|
|
595
|
+
throw new Error("MCP OAuth PKCE code verifier is invalid");
|
|
596
|
+
}
|
|
597
|
+
return value;
|
|
598
|
+
}
|
|
599
|
+
function boundedCredential(value, label2) {
|
|
600
|
+
if (!value || value.length > 16384 || /[\r\n]/.test(value)) {
|
|
601
|
+
throw new Error(`MCP OAuth ${label2} is empty, oversized, or invalid`);
|
|
602
|
+
}
|
|
603
|
+
return value;
|
|
604
|
+
}
|
|
605
|
+
function boundedErrorCode(value) {
|
|
606
|
+
return /^[A-Za-z0-9._-]{1,128}$/.test(value) ? value : "invalid_error";
|
|
607
|
+
}
|
|
608
|
+
function base64Url(value) {
|
|
609
|
+
return Buffer.from(value).toString("base64url");
|
|
610
|
+
}
|
|
611
|
+
function constantTimeEqual(left, right) {
|
|
612
|
+
const leftHash = createHash("sha256").update(left).digest();
|
|
613
|
+
const rightHash = createHash("sha256").update(right).digest();
|
|
614
|
+
return timingSafeEqual(leftHash, rightHash);
|
|
615
|
+
}
|
|
616
|
+
function parseTokenResponse(value, resource) {
|
|
617
|
+
const response = record(value, "token response");
|
|
618
|
+
const accessToken = boundedCredential(
|
|
619
|
+
requiredString(response["access_token"], "access_token"),
|
|
620
|
+
"access token"
|
|
621
|
+
);
|
|
622
|
+
const tokenType = optionalString(response["token_type"], "token_type") ?? "Bearer";
|
|
623
|
+
if (tokenType.toLowerCase() !== "bearer") {
|
|
624
|
+
throw new Error(`Unsupported MCP OAuth token type "${tokenType}"`);
|
|
625
|
+
}
|
|
626
|
+
const expiresIn = response["expires_in"];
|
|
627
|
+
let expiresAt;
|
|
628
|
+
if (expiresIn !== void 0) {
|
|
629
|
+
if (typeof expiresIn !== "number" || !Number.isFinite(expiresIn) || expiresIn <= 0 || expiresIn > 31536e3) {
|
|
630
|
+
throw new Error("MCP OAuth expires_in must be between 1 second and 1 year");
|
|
631
|
+
}
|
|
632
|
+
expiresAt = Date.now() + Math.floor(expiresIn * 1e3);
|
|
633
|
+
}
|
|
634
|
+
const refresh = optionalString(response["refresh_token"], "refresh_token");
|
|
635
|
+
const scope = optionalString(response["scope"], "scope");
|
|
636
|
+
const token = {
|
|
637
|
+
accessToken,
|
|
638
|
+
tokenType: "Bearer",
|
|
639
|
+
resource,
|
|
640
|
+
scopes: scope ? validateScopes(scope.split(/\s+/).filter(Boolean)) : [],
|
|
641
|
+
...expiresAt !== void 0 ? { expiresAt } : {},
|
|
642
|
+
...refresh ? { refreshToken: boundedCredential(refresh, "refresh token") } : {}
|
|
643
|
+
};
|
|
644
|
+
authorizationHeaderForToken(token, resource);
|
|
645
|
+
return token;
|
|
646
|
+
}
|
|
647
|
+
function loopbackHostnameForResource(resource) {
|
|
648
|
+
const url = new URL(resource);
|
|
649
|
+
return isLoopbackHttp(url) ? unbracket(url.hostname).toLowerCase() : void 0;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// src/authorization-callback-server.ts
|
|
653
|
+
import * as http2 from "node:http";
|
|
654
|
+
var DEFAULT_PATH = "/callback";
|
|
655
|
+
var DEFAULT_TIMEOUT_MS = 5 * 6e4;
|
|
656
|
+
var MAX_REQUESTS = 16;
|
|
657
|
+
async function startMcpOAuthCallbackServer(options = {}) {
|
|
658
|
+
const path2 = normalizePath(options.path ?? DEFAULT_PATH);
|
|
659
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
660
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
661
|
+
throw new Error("MCP OAuth callback timeout must be a positive finite number");
|
|
662
|
+
}
|
|
663
|
+
options.signal?.throwIfAborted();
|
|
664
|
+
let settle;
|
|
665
|
+
let fail;
|
|
666
|
+
let finished = false;
|
|
667
|
+
let requests = 0;
|
|
668
|
+
const callback = new Promise((resolve, reject) => {
|
|
669
|
+
settle = resolve;
|
|
670
|
+
fail = reject;
|
|
671
|
+
});
|
|
672
|
+
callback.catch(() => void 0);
|
|
673
|
+
const server = http2.createServer((req, res) => {
|
|
674
|
+
if (finished || ++requests > MAX_REQUESTS) {
|
|
675
|
+
res.writeHead(429).end();
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
const requestUrl = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
679
|
+
if (req.method !== "GET" || requestUrl.pathname !== path2) {
|
|
680
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("Not found");
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
finished = true;
|
|
684
|
+
res.writeHead(200, {
|
|
685
|
+
"content-type": "text/html; charset=utf-8",
|
|
686
|
+
// The URL carries a one-shot authorization code; keep it out of caches
|
|
687
|
+
// and out of the next site the user visits.
|
|
688
|
+
"cache-control": "no-store",
|
|
689
|
+
"referrer-policy": "no-referrer"
|
|
690
|
+
}).end(SUCCESS_PAGE);
|
|
691
|
+
const address2 = server.address();
|
|
692
|
+
const port = address2?.port ?? 0;
|
|
693
|
+
settle?.(`http://127.0.0.1:${port}${req.url ?? path2}`);
|
|
694
|
+
});
|
|
695
|
+
server.on("error", (error) => {
|
|
696
|
+
finished = true;
|
|
697
|
+
fail?.(error);
|
|
698
|
+
});
|
|
699
|
+
await new Promise((resolve, reject) => {
|
|
700
|
+
server.once("error", reject);
|
|
701
|
+
server.listen(options.port ?? 0, "127.0.0.1", () => {
|
|
702
|
+
server.removeListener("error", reject);
|
|
703
|
+
resolve();
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
const address = server.address();
|
|
707
|
+
if (!address) {
|
|
708
|
+
server.close();
|
|
709
|
+
throw new Error("MCP OAuth callback listener failed to bind a loopback port");
|
|
710
|
+
}
|
|
711
|
+
const redirectUri = `http://127.0.0.1:${address.port}${path2}`;
|
|
712
|
+
const timer = setTimeout(() => {
|
|
713
|
+
finished = true;
|
|
714
|
+
fail?.(new Error(`MCP OAuth callback was not received within ${timeoutMs}ms`));
|
|
715
|
+
close();
|
|
716
|
+
}, timeoutMs);
|
|
717
|
+
timer.unref?.();
|
|
718
|
+
const onAbort = () => {
|
|
719
|
+
finished = true;
|
|
720
|
+
fail?.(
|
|
721
|
+
options.signal?.reason instanceof Error ? options.signal.reason : new Error("MCP OAuth callback wait was aborted")
|
|
722
|
+
);
|
|
723
|
+
close();
|
|
724
|
+
};
|
|
725
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
726
|
+
function close() {
|
|
727
|
+
clearTimeout(timer);
|
|
728
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
729
|
+
server.closeAllConnections?.();
|
|
730
|
+
server.close(() => void 0);
|
|
731
|
+
}
|
|
732
|
+
return {
|
|
733
|
+
redirectUri,
|
|
734
|
+
waitForCallback: () => callback.finally(close),
|
|
735
|
+
close
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
function normalizePath(value) {
|
|
739
|
+
if (!value.startsWith("/") || value.length > 256 || /[?#\s]/.test(value)) {
|
|
740
|
+
throw new Error("MCP OAuth callback path must be a bounded absolute path without query");
|
|
741
|
+
}
|
|
742
|
+
return value;
|
|
743
|
+
}
|
|
744
|
+
var SUCCESS_PAGE = `<!doctype html>
|
|
745
|
+
<html lang="en"><head><meta charset="utf-8"><title>Authorized</title></head>
|
|
746
|
+
<body style="font-family:system-ui,sans-serif;padding:3rem;text-align:center">
|
|
747
|
+
<h1>Authorization complete</h1>
|
|
748
|
+
<p>You can close this tab and return to WrongStack.</p>
|
|
749
|
+
</body></html>
|
|
750
|
+
`;
|
|
751
|
+
|
|
752
|
+
// src/authorization-registration.ts
|
|
753
|
+
var DEFAULT_CLIENT_NAME = "WrongStack";
|
|
754
|
+
var MAX_REDIRECT_URIS = 8;
|
|
755
|
+
async function registerMcpOAuthClient(options) {
|
|
756
|
+
const endpoint = options.authorizationServer.registrationEndpoint;
|
|
757
|
+
if (!endpoint) {
|
|
758
|
+
throw new Error(
|
|
759
|
+
"MCP authorization server does not support dynamic client registration; pass an explicit client id"
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
const redirectUris = validateRedirectUris(options.redirectUris);
|
|
763
|
+
const scopes = options.scopes?.filter(Boolean) ?? [];
|
|
764
|
+
const body = JSON.stringify({
|
|
765
|
+
client_name: boundedClientName(options.clientName ?? DEFAULT_CLIENT_NAME),
|
|
766
|
+
redirect_uris: redirectUris,
|
|
767
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
768
|
+
response_types: ["code"],
|
|
769
|
+
// A CLI is a public client: it cannot hold a secret that the user could not
|
|
770
|
+
// also read. Asking for `none` keeps the PKCE-only exchange we implement.
|
|
771
|
+
token_endpoint_auth_method: "none",
|
|
772
|
+
application_type: "native",
|
|
773
|
+
...options.clientUri ? { client_uri: options.clientUri } : {},
|
|
774
|
+
...scopes.length > 0 ? { scope: scopes.join(" ") } : {}
|
|
775
|
+
});
|
|
776
|
+
const request3 = options.requestJson ?? requestPinnedJson;
|
|
777
|
+
const response = await request3(endpoint, {
|
|
778
|
+
method: "POST",
|
|
779
|
+
body,
|
|
780
|
+
headers: { "content-type": "application/json" },
|
|
781
|
+
signal: options.signal,
|
|
782
|
+
timeoutMs: options.timeoutMs,
|
|
783
|
+
maxResponseBytes: options.maxResponseBytes,
|
|
784
|
+
lookup: options.lookup,
|
|
785
|
+
allowedLoopbackHostname: loopbackHostnameForResource2(options.resource),
|
|
786
|
+
label: "client registration endpoint"
|
|
787
|
+
});
|
|
788
|
+
if (response === void 0) {
|
|
789
|
+
throw new Error("MCP OAuth client registration endpoint returned no response");
|
|
790
|
+
}
|
|
791
|
+
return parseRegistrationResponse(response, options.authorizationServer.issuer);
|
|
792
|
+
}
|
|
793
|
+
function parseRegistrationResponse(value, issuer) {
|
|
794
|
+
const metadata = record(value, "client registration response");
|
|
795
|
+
const clientId = requiredString(metadata["client_id"], "client_id");
|
|
796
|
+
if (/[\r\n]/.test(clientId)) {
|
|
797
|
+
throw new Error("MCP OAuth client registration returned an invalid client_id");
|
|
798
|
+
}
|
|
799
|
+
const clientSecret = optionalString(metadata["client_secret"], "client_secret");
|
|
800
|
+
if (clientSecret !== void 0 && /[\r\n]/.test(clientSecret)) {
|
|
801
|
+
throw new Error("MCP OAuth client registration returned an invalid client_secret");
|
|
802
|
+
}
|
|
803
|
+
const expiresAt = metadata["client_secret_expires_at"];
|
|
804
|
+
if (clientSecret !== void 0 && typeof expiresAt === "number" && expiresAt !== 0) {
|
|
805
|
+
if (!Number.isFinite(expiresAt) || expiresAt * 1e3 <= Date.now()) {
|
|
806
|
+
throw new Error("MCP OAuth client registration returned an already-expired client secret");
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return {
|
|
810
|
+
clientId,
|
|
811
|
+
...clientSecret !== void 0 ? { clientSecret } : {},
|
|
812
|
+
issuer,
|
|
813
|
+
registeredAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
function validateRedirectUris(values) {
|
|
817
|
+
if (values.length === 0 || values.length > MAX_REDIRECT_URIS) {
|
|
818
|
+
throw new Error(
|
|
819
|
+
`MCP OAuth client registration requires between 1 and ${MAX_REDIRECT_URIS} redirect URIs`
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
return values.map((value) => {
|
|
823
|
+
let url;
|
|
824
|
+
try {
|
|
825
|
+
url = new URL(value);
|
|
826
|
+
} catch {
|
|
827
|
+
throw new Error("MCP OAuth redirect URI must be an absolute URL");
|
|
828
|
+
}
|
|
829
|
+
if (url.protocol !== "https:" && !isLoopbackHttp(url)) {
|
|
830
|
+
throw new Error("MCP OAuth redirect URI must use HTTPS or loopback HTTP");
|
|
831
|
+
}
|
|
832
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
833
|
+
throw new Error("MCP OAuth redirect URI must not contain credentials, query, or fragment");
|
|
834
|
+
}
|
|
835
|
+
return url.toString();
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
function boundedClientName(value) {
|
|
839
|
+
if (!value || value.length > 128 || /[\r\n]/.test(value)) {
|
|
840
|
+
throw new Error("MCP OAuth client name must be a bounded single-line string");
|
|
841
|
+
}
|
|
842
|
+
return value;
|
|
843
|
+
}
|
|
844
|
+
function loopbackHostnameForResource2(resource) {
|
|
845
|
+
if (!resource) return void 0;
|
|
846
|
+
try {
|
|
847
|
+
const url = new URL(resource);
|
|
848
|
+
return isLoopbackHttp(url) ? unbracket(url.hostname).toLowerCase() : void 0;
|
|
849
|
+
} catch {
|
|
850
|
+
return void 0;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// src/authorization-manager.ts
|
|
647
855
|
var DEFAULT_PENDING_TTL_MS = 10 * 6e4;
|
|
648
856
|
var MAX_PENDING_AUTHORIZATIONS = 32;
|
|
857
|
+
var MAX_CACHED_REGISTRATIONS = 32;
|
|
649
858
|
var MCPAuthorizationManager = class {
|
|
650
859
|
constructor(options) {
|
|
651
860
|
this.options = options;
|
|
@@ -655,14 +864,18 @@ var MCPAuthorizationManager = class {
|
|
|
655
864
|
}
|
|
656
865
|
this.discover = options.discover ?? discoverMcpAuthorization;
|
|
657
866
|
this.exchange = options.exchange ?? exchangeMcpAuthorizationCode;
|
|
867
|
+
this.register = options.register ?? registerMcpOAuthClient;
|
|
658
868
|
this.now = options.now ?? Date.now;
|
|
659
869
|
}
|
|
660
870
|
options;
|
|
661
871
|
pending = /* @__PURE__ */ new Map();
|
|
662
872
|
starting = /* @__PURE__ */ new Map();
|
|
873
|
+
/** Registrations reused across servers that share one authorization server. */
|
|
874
|
+
registrations = /* @__PURE__ */ new Map();
|
|
663
875
|
pendingTtlMs;
|
|
664
876
|
discover;
|
|
665
877
|
exchange;
|
|
878
|
+
register;
|
|
666
879
|
now;
|
|
667
880
|
async begin(input) {
|
|
668
881
|
const resource = canonicalMcpResource(input.resource);
|
|
@@ -689,23 +902,42 @@ var MCPAuthorizationManager = class {
|
|
|
689
902
|
resource
|
|
690
903
|
).scopes;
|
|
691
904
|
const scopes = input.scopes ? [...input.scopes] : challengeScopes;
|
|
905
|
+
const identity = await this.resolveClientIdentity({
|
|
906
|
+
serverName: input.serverName,
|
|
907
|
+
resource,
|
|
908
|
+
discovery,
|
|
909
|
+
redirectUri: input.redirectUri,
|
|
910
|
+
explicitClientId: input.clientId,
|
|
911
|
+
scopes,
|
|
912
|
+
signal: input.signal
|
|
913
|
+
});
|
|
914
|
+
if (this.starting.get(key) !== attempt) {
|
|
915
|
+
throw new Error("MCP authorization discovery was cancelled");
|
|
916
|
+
}
|
|
692
917
|
const session = createMcpAuthorizationRequest({
|
|
693
918
|
authorizationServer: discovery.authorizationServer,
|
|
694
|
-
clientId:
|
|
919
|
+
clientId: identity.clientId,
|
|
695
920
|
redirectUri: input.redirectUri,
|
|
696
921
|
resource,
|
|
697
922
|
scopes
|
|
698
923
|
});
|
|
699
924
|
const normalizedScopes = new URL(session.authorizationUrl).searchParams.get("scope")?.split(" ").filter(Boolean) ?? [];
|
|
700
925
|
const expiresAt = this.now() + this.pendingTtlMs;
|
|
701
|
-
this.pending.set(key, {
|
|
926
|
+
this.pending.set(key, {
|
|
927
|
+
session,
|
|
928
|
+
discovery,
|
|
929
|
+
scopes: normalizedScopes,
|
|
930
|
+
expiresAt,
|
|
931
|
+
clientSecret: identity.clientSecret
|
|
932
|
+
});
|
|
702
933
|
return {
|
|
703
934
|
serverName: boundedServerName(input.serverName),
|
|
704
935
|
resource,
|
|
705
936
|
authorizationUrl: session.authorizationUrl,
|
|
706
937
|
redirectUri: session.redirectUri,
|
|
707
938
|
scopes: [...normalizedScopes],
|
|
708
|
-
expiresAt
|
|
939
|
+
expiresAt,
|
|
940
|
+
clientIdSource: identity.source
|
|
709
941
|
};
|
|
710
942
|
} finally {
|
|
711
943
|
if (this.starting.get(key) === attempt) this.starting.delete(key);
|
|
@@ -725,6 +957,7 @@ var MCPAuthorizationManager = class {
|
|
|
725
957
|
const tokenSet = await this.exchange({
|
|
726
958
|
authorizationServer: pending.discovery.authorizationServer,
|
|
727
959
|
clientId: pending.session.clientId,
|
|
960
|
+
clientSecret: pending.clientSecret,
|
|
728
961
|
redirectUri: pending.session.redirectUri,
|
|
729
962
|
resource,
|
|
730
963
|
code,
|
|
@@ -735,6 +968,7 @@ var MCPAuthorizationManager = class {
|
|
|
735
968
|
serverName,
|
|
736
969
|
resource,
|
|
737
970
|
clientId: pending.session.clientId,
|
|
971
|
+
...pending.clientSecret ? { clientSecret: pending.clientSecret } : {},
|
|
738
972
|
authorizationServer: pending.discovery.authorizationServer,
|
|
739
973
|
tokenSet,
|
|
740
974
|
updatedAt: new Date(this.now()).toISOString()
|
|
@@ -743,6 +977,87 @@ var MCPAuthorizationManager = class {
|
|
|
743
977
|
this.emit("authorized", stored);
|
|
744
978
|
return statusFromStored(stored, this.now());
|
|
745
979
|
}
|
|
980
|
+
/**
|
|
981
|
+
* Bind a loopback receiver and start the flow, returning as soon as the URL
|
|
982
|
+
* exists so a surface can show it without blocking. Completion is awaited
|
|
983
|
+
* separately — a slash command returns its message immediately and reports
|
|
984
|
+
* the outcome through `onStateChange`. The two-step `begin`/`complete` pair
|
|
985
|
+
* stays for surfaces that cannot host a listener.
|
|
986
|
+
*/
|
|
987
|
+
async beginLogin(input) {
|
|
988
|
+
const callbackOptions = {
|
|
989
|
+
port: input.port,
|
|
990
|
+
timeoutMs: input.timeoutMs,
|
|
991
|
+
signal: input.signal
|
|
992
|
+
};
|
|
993
|
+
const listener = await startMcpOAuthCallbackServer(callbackOptions);
|
|
994
|
+
let started;
|
|
995
|
+
try {
|
|
996
|
+
started = await this.begin({
|
|
997
|
+
serverName: input.serverName,
|
|
998
|
+
resource: input.resource,
|
|
999
|
+
clientId: input.clientId,
|
|
1000
|
+
redirectUri: listener.redirectUri,
|
|
1001
|
+
scopes: input.scopes,
|
|
1002
|
+
challengeHeader: input.challengeHeader,
|
|
1003
|
+
signal: input.signal
|
|
1004
|
+
});
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
listener.close();
|
|
1007
|
+
throw error;
|
|
1008
|
+
}
|
|
1009
|
+
const completion = listener.waitForCallback().then(
|
|
1010
|
+
(callbackUrl) => this.complete({
|
|
1011
|
+
serverName: input.serverName,
|
|
1012
|
+
resource: input.resource,
|
|
1013
|
+
callbackUrl,
|
|
1014
|
+
signal: input.signal
|
|
1015
|
+
})
|
|
1016
|
+
).finally(() => listener.close());
|
|
1017
|
+
return { started, completion, cancel: () => listener.close() };
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Identity precedence: an explicit id, then the one already stored for this
|
|
1021
|
+
* server (so re-authorizing does not orphan a registration), then the
|
|
1022
|
+
* per-issuer cache, then a fresh dynamic registration.
|
|
1023
|
+
*/
|
|
1024
|
+
async resolveClientIdentity(input) {
|
|
1025
|
+
if (input.explicitClientId) {
|
|
1026
|
+
return { clientId: input.explicitClientId, source: "explicit" };
|
|
1027
|
+
}
|
|
1028
|
+
const issuer = normalizeIssuer(input.discovery.authorizationServer.issuer);
|
|
1029
|
+
const stored = await this.options.store.load(boundedServerName(input.serverName), input.resource).catch(() => void 0);
|
|
1030
|
+
if (stored && normalizeIssuer(stored.authorizationServer.issuer) === issuer) {
|
|
1031
|
+
return {
|
|
1032
|
+
clientId: stored.clientId,
|
|
1033
|
+
clientSecret: stored.clientSecret,
|
|
1034
|
+
source: "stored"
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
const registrationKey = `${issuer}\0${input.redirectUri}`;
|
|
1038
|
+
const cached = this.registrations.get(registrationKey);
|
|
1039
|
+
if (cached) {
|
|
1040
|
+
return { clientId: cached.clientId, clientSecret: cached.clientSecret, source: "registered" };
|
|
1041
|
+
}
|
|
1042
|
+
const registration = await this.register({
|
|
1043
|
+
authorizationServer: input.discovery.authorizationServer,
|
|
1044
|
+
redirectUris: [input.redirectUri],
|
|
1045
|
+
clientName: this.options.clientName,
|
|
1046
|
+
scopes: input.scopes,
|
|
1047
|
+
resource: input.resource,
|
|
1048
|
+
signal: input.signal
|
|
1049
|
+
});
|
|
1050
|
+
if (this.registrations.size >= MAX_CACHED_REGISTRATIONS) {
|
|
1051
|
+
const oldest = this.registrations.keys().next();
|
|
1052
|
+
if (!oldest.done) this.registrations.delete(oldest.value);
|
|
1053
|
+
}
|
|
1054
|
+
this.registrations.set(registrationKey, registration);
|
|
1055
|
+
return {
|
|
1056
|
+
clientId: registration.clientId,
|
|
1057
|
+
clientSecret: registration.clientSecret,
|
|
1058
|
+
source: "registered"
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
746
1061
|
async status(serverName, resource) {
|
|
747
1062
|
const normalizedName = boundedServerName(serverName);
|
|
748
1063
|
const normalizedResource = canonicalMcpResource(resource);
|
|
@@ -816,6 +1131,9 @@ function statusFromStored(value, now) {
|
|
|
816
1131
|
canRefresh: !!value.tokenSet.refreshToken
|
|
817
1132
|
};
|
|
818
1133
|
}
|
|
1134
|
+
function normalizeIssuer(value) {
|
|
1135
|
+
return secureOAuthUrl(value, "authorization server issuer").toString();
|
|
1136
|
+
}
|
|
819
1137
|
function authorizationKey(serverName, resource) {
|
|
820
1138
|
return `${boundedServerName(serverName)}\0${resource}`;
|
|
821
1139
|
}
|
|
@@ -831,53 +1149,19 @@ import { spawn as spawn2 } from "node:child_process";
|
|
|
831
1149
|
import { StringDecoder } from "node:string_decoder";
|
|
832
1150
|
import { buildChildEnv as buildChildEnv2, buildWin32CmdShimInvocation, toErrorMessage } from "@wrongstack/core/utils";
|
|
833
1151
|
|
|
834
|
-
// src/client-process.ts
|
|
835
|
-
import { spawn } from "node:child_process";
|
|
836
|
-
import { buildChildEnv } from "@wrongstack/core/utils";
|
|
837
|
-
function forceKillTree(child) {
|
|
838
|
-
if (child.pid === void 0) {
|
|
839
|
-
try {
|
|
840
|
-
child.kill("SIGKILL");
|
|
841
|
-
} catch {
|
|
842
|
-
}
|
|
843
|
-
return;
|
|
844
|
-
}
|
|
845
|
-
if (process.platform === "win32") {
|
|
846
|
-
const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
|
847
|
-
stdio: "ignore",
|
|
848
|
-
// H-8 convention (spawn-convention test): taskkill needs PATH only —
|
|
849
|
-
// do not hand it the credential-bearing parent environment.
|
|
850
|
-
env: buildChildEnv(),
|
|
851
|
-
windowsHide: true
|
|
852
|
-
});
|
|
853
|
-
killer.once("error", () => {
|
|
854
|
-
try {
|
|
855
|
-
child.kill("SIGKILL");
|
|
856
|
-
} catch {
|
|
857
|
-
}
|
|
858
|
-
});
|
|
859
|
-
killer.unref();
|
|
860
|
-
return;
|
|
861
|
-
}
|
|
862
|
-
try {
|
|
863
|
-
child.kill("SIGKILL");
|
|
864
|
-
} catch {
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
|
|
868
1152
|
// src/client-protocol-helpers.ts
|
|
869
1153
|
var MAX_PROTOCOL_INPUT_CHARS = 8192;
|
|
870
|
-
function validateProtocolString(value,
|
|
1154
|
+
function validateProtocolString(value, label2, allowEmpty = false) {
|
|
871
1155
|
if (typeof value !== "string" || !allowEmpty && value.length === 0) {
|
|
872
|
-
throw new Error(`MCP ${
|
|
1156
|
+
throw new Error(`MCP ${label2} must be ${allowEmpty ? "a string" : "a non-empty string"}`);
|
|
873
1157
|
}
|
|
874
1158
|
if (value.length > MAX_PROTOCOL_INPUT_CHARS) {
|
|
875
|
-
throw new Error(`MCP ${
|
|
1159
|
+
throw new Error(`MCP ${label2} exceeds ${MAX_PROTOCOL_INPUT_CHARS} characters`);
|
|
876
1160
|
}
|
|
877
1161
|
}
|
|
878
|
-
function pageParams(cursor,
|
|
1162
|
+
function pageParams(cursor, label2) {
|
|
879
1163
|
if (cursor === void 0) return {};
|
|
880
|
-
validateProtocolString(cursor,
|
|
1164
|
+
validateProtocolString(cursor, label2);
|
|
881
1165
|
return { cursor };
|
|
882
1166
|
}
|
|
883
1167
|
function parseEmptyResult(value) {
|
|
@@ -886,81 +1170,30 @@ function parseEmptyResult(value) {
|
|
|
886
1170
|
}
|
|
887
1171
|
}
|
|
888
1172
|
|
|
889
|
-
// src/constants.ts
|
|
890
|
-
var SUPPORTED_PROTOCOL_VERSIONS = Object.freeze(["2024-11-05"]);
|
|
891
|
-
function negotiateProtocolVersion(requested) {
|
|
892
|
-
if (typeof requested === "string" && SUPPORTED_PROTOCOL_VERSIONS.includes(requested)) {
|
|
893
|
-
return requested;
|
|
894
|
-
}
|
|
895
|
-
return MCP_CONSTANTS.PROTOCOL_VERSION;
|
|
896
|
-
}
|
|
897
|
-
var MCP_CONSTANTS = Object.freeze({
|
|
898
|
-
/** MCP protocol version advertised during handshake: the newest we implement. */
|
|
899
|
-
PROTOCOL_VERSION: SUPPORTED_PROTOCOL_VERSIONS[0],
|
|
900
|
-
/** Identity announced to MCP servers during `initialize`. */
|
|
901
|
-
CLIENT_INFO: Object.freeze({
|
|
902
|
-
name: "wrongstack",
|
|
903
|
-
version: "0.1.10"
|
|
904
|
-
}),
|
|
905
|
-
/** Reconnection behaviour when a transport disconnects. */
|
|
906
|
-
RECONNECT: Object.freeze({
|
|
907
|
-
/** Max full reconnect cycles before the slot is marked `failed`. */
|
|
908
|
-
MAX_CYCLES: 5,
|
|
909
|
-
/** Base delay between cycles (exponential backoff applied on top). */
|
|
910
|
-
BASE_DELAY_MS: 1e3,
|
|
911
|
-
/** Jitter factor applied to the backoff (0 = no jitter, 1 = full). */
|
|
912
|
-
JITTER_FACTOR: 0.2,
|
|
913
|
-
/** Max connection attempts within a single cycle. */
|
|
914
|
-
MAX_ATTEMPTS: 3,
|
|
915
|
-
/** Base multiplier for the exponential backoff formula (`delay = BASE * multiplier^attempt`). */
|
|
916
|
-
BACKOFF_MULTIPLIER: 2
|
|
917
|
-
}),
|
|
918
|
-
/** Timing for graceful / forced disconnect. */
|
|
919
|
-
DISCONNECT: Object.freeze({
|
|
920
|
-
/** Ms to wait for in-flight requests to complete before force-closing. */
|
|
921
|
-
GRACEFUL_MS: 800,
|
|
922
|
-
/** Ms after which the force disconnect is triggered. */
|
|
923
|
-
FORCE_TIMEOUT_MS: 1200
|
|
924
|
-
}),
|
|
925
|
-
/** Lazy-connect idle lifecycle. */
|
|
926
|
-
IDLE: Object.freeze({
|
|
927
|
-
/** Default ms a lazy server stays connected with no tool calls before auto-sleep. */
|
|
928
|
-
DEFAULT_TIMEOUT_MS: 3e5,
|
|
929
|
-
/** How often the idle sweep runs (kept well below the timeout). */
|
|
930
|
-
SWEEP_INTERVAL_MS: 3e4
|
|
931
|
-
}),
|
|
932
|
-
/** JSON-RPC response timeout for outstanding requests. */
|
|
933
|
-
RESPONSE_TIMEOUT_MS: 500,
|
|
934
|
-
/** Max buffer size for the SSE reader. */
|
|
935
|
-
SSE_READER_MAX_BUFFER: 256 * 1024,
|
|
936
|
-
/** Max characters logged from a request body. */
|
|
937
|
-
REQUEST_LOG_CAP: 1024
|
|
938
|
-
});
|
|
939
|
-
|
|
940
1173
|
// src/protocol.ts
|
|
941
|
-
function record2(value,
|
|
1174
|
+
function record2(value, label2) {
|
|
942
1175
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
943
|
-
throw new Error(`Malformed MCP ${
|
|
1176
|
+
throw new Error(`Malformed MCP ${label2}: expected object`);
|
|
944
1177
|
}
|
|
945
1178
|
return value;
|
|
946
1179
|
}
|
|
947
|
-
function requiredString2(value,
|
|
1180
|
+
function requiredString2(value, label2) {
|
|
948
1181
|
if (typeof value !== "string" || value.length === 0) {
|
|
949
|
-
throw new Error(`Malformed MCP ${
|
|
1182
|
+
throw new Error(`Malformed MCP ${label2}: expected non-empty string`);
|
|
950
1183
|
}
|
|
951
1184
|
return value;
|
|
952
1185
|
}
|
|
953
|
-
function optionalString2(value,
|
|
1186
|
+
function optionalString2(value, label2) {
|
|
954
1187
|
if (value === void 0) return void 0;
|
|
955
|
-
if (typeof value !== "string") throw new Error(`Malformed MCP ${
|
|
1188
|
+
if (typeof value !== "string") throw new Error(`Malformed MCP ${label2}: expected string`);
|
|
956
1189
|
return value;
|
|
957
1190
|
}
|
|
958
|
-
function optionalRecord(value,
|
|
1191
|
+
function optionalRecord(value, label2) {
|
|
959
1192
|
if (value === void 0) return void 0;
|
|
960
|
-
return record2(value,
|
|
1193
|
+
return record2(value, label2);
|
|
961
1194
|
}
|
|
962
|
-
function optionalCursor(value,
|
|
963
|
-
return optionalString2(value, `${
|
|
1195
|
+
function optionalCursor(value, label2) {
|
|
1196
|
+
return optionalString2(value, `${label2}.nextCursor`);
|
|
964
1197
|
}
|
|
965
1198
|
function parseServerMetadata(value) {
|
|
966
1199
|
const input = record2(value, "initialize result");
|
|
@@ -1122,79 +1355,162 @@ function parseGetPromptResult(value) {
|
|
|
1122
1355
|
if (!Array.isArray(input["messages"])) {
|
|
1123
1356
|
throw new Error("Malformed MCP prompts/get result: messages must be an array");
|
|
1124
1357
|
}
|
|
1125
|
-
return {
|
|
1126
|
-
description: optionalString2(input["description"], "prompts/get.description"),
|
|
1127
|
-
messages: input["messages"].map((value2, index) => {
|
|
1128
|
-
const message = record2(value2, `prompts/get.messages[${index}]`);
|
|
1129
|
-
const role = message["role"];
|
|
1130
|
-
if (role !== "user" && role !== "assistant") {
|
|
1131
|
-
throw new Error(`Malformed MCP prompts/get.messages[${index}].role`);
|
|
1132
|
-
}
|
|
1133
|
-
if (message["content"] === void 0) {
|
|
1134
|
-
throw new Error(`Malformed MCP prompts/get.messages[${index}].content`);
|
|
1135
|
-
}
|
|
1136
|
-
return { role, content: message["content"] };
|
|
1137
|
-
})
|
|
1138
|
-
};
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1358
|
+
return {
|
|
1359
|
+
description: optionalString2(input["description"], "prompts/get.description"),
|
|
1360
|
+
messages: input["messages"].map((value2, index) => {
|
|
1361
|
+
const message = record2(value2, `prompts/get.messages[${index}]`);
|
|
1362
|
+
const role = message["role"];
|
|
1363
|
+
if (role !== "user" && role !== "assistant") {
|
|
1364
|
+
throw new Error(`Malformed MCP prompts/get.messages[${index}].role`);
|
|
1365
|
+
}
|
|
1366
|
+
if (message["content"] === void 0) {
|
|
1367
|
+
throw new Error(`Malformed MCP prompts/get.messages[${index}].content`);
|
|
1368
|
+
}
|
|
1369
|
+
return { role, content: message["content"] };
|
|
1370
|
+
})
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
function resourceUpdatedUri(params) {
|
|
1374
|
+
if (!params || typeof params !== "object" || Array.isArray(params)) return void 0;
|
|
1375
|
+
const uri = params.uri;
|
|
1376
|
+
if (typeof uri !== "string" || uri.length === 0 || uri.length > 4096) return void 0;
|
|
1377
|
+
if (/[\r\n]/.test(uri)) return void 0;
|
|
1378
|
+
return uri;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// src/client-capabilities.ts
|
|
1382
|
+
var MCPCapabilityClient = class {
|
|
1383
|
+
constructor(requestCapability, requireResourceSubscriptions) {
|
|
1384
|
+
this.requestCapability = requestCapability;
|
|
1385
|
+
this.requireResourceSubscriptions = requireResourceSubscriptions;
|
|
1386
|
+
}
|
|
1387
|
+
requestCapability;
|
|
1388
|
+
requireResourceSubscriptions;
|
|
1389
|
+
async listResources(opts = {}) {
|
|
1390
|
+
const params = pageParams(opts.cursor, "resources/list cursor");
|
|
1391
|
+
return this.requestCapability(
|
|
1392
|
+
"resources",
|
|
1393
|
+
"resources/list",
|
|
1394
|
+
params,
|
|
1395
|
+
parseListResourcesResult,
|
|
1396
|
+
opts
|
|
1397
|
+
);
|
|
1398
|
+
}
|
|
1399
|
+
async listResourceTemplates(opts = {}) {
|
|
1400
|
+
const params = pageParams(opts.cursor, "resources/templates/list cursor");
|
|
1401
|
+
return this.requestCapability(
|
|
1402
|
+
"resources",
|
|
1403
|
+
"resources/templates/list",
|
|
1404
|
+
params,
|
|
1405
|
+
parseListResourceTemplatesResult,
|
|
1406
|
+
opts
|
|
1407
|
+
);
|
|
1408
|
+
}
|
|
1409
|
+
async readResource(uri, opts = {}) {
|
|
1410
|
+
validateProtocolString(uri, "resource URI");
|
|
1411
|
+
return this.requestCapability(
|
|
1412
|
+
"resources",
|
|
1413
|
+
"resources/read",
|
|
1414
|
+
{ uri },
|
|
1415
|
+
parseReadResourceResult,
|
|
1416
|
+
opts
|
|
1417
|
+
);
|
|
1418
|
+
}
|
|
1419
|
+
async subscribeResource(uri, opts = {}) {
|
|
1420
|
+
validateProtocolString(uri, "resource URI");
|
|
1421
|
+
this.requireResourceSubscriptions("resources/subscribe");
|
|
1422
|
+
await this.requestCapability(
|
|
1423
|
+
"resources",
|
|
1424
|
+
"resources/subscribe",
|
|
1425
|
+
{ uri },
|
|
1426
|
+
parseEmptyResult,
|
|
1427
|
+
opts
|
|
1428
|
+
);
|
|
1429
|
+
}
|
|
1430
|
+
async unsubscribeResource(uri, opts = {}) {
|
|
1431
|
+
validateProtocolString(uri, "resource URI");
|
|
1432
|
+
this.requireResourceSubscriptions("resources/unsubscribe");
|
|
1433
|
+
await this.requestCapability(
|
|
1434
|
+
"resources",
|
|
1435
|
+
"resources/unsubscribe",
|
|
1436
|
+
{ uri },
|
|
1437
|
+
parseEmptyResult,
|
|
1438
|
+
opts
|
|
1439
|
+
);
|
|
1440
|
+
}
|
|
1441
|
+
async listPrompts(opts = {}) {
|
|
1442
|
+
const params = pageParams(opts.cursor, "prompts/list cursor");
|
|
1443
|
+
return this.requestCapability("prompts", "prompts/list", params, parseListPromptsResult, opts);
|
|
1444
|
+
}
|
|
1445
|
+
async getPrompt(name, args, opts = {}) {
|
|
1446
|
+
validateProtocolString(name, "prompt name");
|
|
1447
|
+
if (args && Object.keys(args).length > 64) {
|
|
1448
|
+
throw new Error("MCP prompt arguments exceed the limit of 64");
|
|
1155
1449
|
}
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1450
|
+
for (const [key, value] of Object.entries(args ?? {})) {
|
|
1451
|
+
validateProtocolString(key, "prompt argument name");
|
|
1452
|
+
validateProtocolString(value, `prompt argument "${key}"`, true);
|
|
1159
1453
|
}
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1454
|
+
return this.requestCapability(
|
|
1455
|
+
"prompts",
|
|
1456
|
+
"prompts/get",
|
|
1457
|
+
args === void 0 ? { name } : { name, arguments: args },
|
|
1458
|
+
parseGetPromptResult,
|
|
1459
|
+
opts
|
|
1460
|
+
);
|
|
1167
1461
|
}
|
|
1168
|
-
|
|
1462
|
+
};
|
|
1463
|
+
|
|
1464
|
+
// src/client-process.ts
|
|
1465
|
+
import { spawn } from "node:child_process";
|
|
1466
|
+
import { buildChildEnv } from "@wrongstack/core/utils";
|
|
1467
|
+
function resolveHttpBearerHeaders(options, env = process.env) {
|
|
1468
|
+
const envName = options.bearerTokenEnv?.trim();
|
|
1469
|
+
if (!envName) return options.headers;
|
|
1470
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(envName)) {
|
|
1471
|
+
throw new Error(`MCP bearerTokenEnv "${envName}" is not a valid environment variable name`);
|
|
1472
|
+
}
|
|
1473
|
+
const token = env[envName];
|
|
1474
|
+
if (!token) throw new Error(`MCP "${options.name}" requires environment variable ${envName}`);
|
|
1475
|
+
if (token.length > 16384 || /[\r\n]/.test(token)) {
|
|
1476
|
+
throw new Error(`MCP "${options.name}" bearer token is oversized or contains newlines`);
|
|
1477
|
+
}
|
|
1478
|
+
const resolved = { ...options.headers };
|
|
1479
|
+
for (const key of Object.keys(resolved)) {
|
|
1480
|
+
if (key.toLowerCase() === "authorization") delete resolved[key];
|
|
1481
|
+
}
|
|
1482
|
+
resolved.Authorization = `Bearer ${token}`;
|
|
1483
|
+
return resolved;
|
|
1169
1484
|
}
|
|
1170
|
-
function
|
|
1171
|
-
if (
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
const t = raw;
|
|
1176
|
-
if (typeof t.name !== "string") continue;
|
|
1177
|
-
const name = t.name.trim();
|
|
1178
|
-
if (name.length === 0) continue;
|
|
1179
|
-
const inputSchema = t.inputSchema && typeof t.inputSchema === "object" && !Array.isArray(t.inputSchema) ? t.inputSchema : { type: "object", properties: {} };
|
|
1180
|
-
if (!t.inputSchema || typeof t.inputSchema !== "object" || Array.isArray(t.inputSchema)) {
|
|
1181
|
-
console.warn(
|
|
1182
|
-
JSON.stringify({
|
|
1183
|
-
level: "warn",
|
|
1184
|
-
event: "mcp.tool_schema_invalid",
|
|
1185
|
-
tool: name,
|
|
1186
|
-
message: "no/invalid inputSchema \u2014 defaulting to empty object",
|
|
1187
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1188
|
-
})
|
|
1189
|
-
);
|
|
1485
|
+
function forceKillTree(child) {
|
|
1486
|
+
if (child.pid === void 0) {
|
|
1487
|
+
try {
|
|
1488
|
+
child.kill("SIGKILL");
|
|
1489
|
+
} catch {
|
|
1190
1490
|
}
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
if (process.platform === "win32") {
|
|
1494
|
+
const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
|
1495
|
+
stdio: "ignore",
|
|
1496
|
+
// H-8 convention (spawn-convention test): taskkill needs PATH only —
|
|
1497
|
+
// do not hand it the credential-bearing parent environment.
|
|
1498
|
+
env: buildChildEnv(),
|
|
1499
|
+
windowsHide: true
|
|
1500
|
+
});
|
|
1501
|
+
killer.once("error", () => {
|
|
1502
|
+
try {
|
|
1503
|
+
child.kill("SIGKILL");
|
|
1504
|
+
} catch {
|
|
1505
|
+
}
|
|
1195
1506
|
});
|
|
1507
|
+
killer.unref();
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
try {
|
|
1511
|
+
child.kill("SIGKILL");
|
|
1512
|
+
} catch {
|
|
1196
1513
|
}
|
|
1197
|
-
return tools;
|
|
1198
1514
|
}
|
|
1199
1515
|
|
|
1200
1516
|
// src/sse-reader.ts
|
|
@@ -1319,18 +1635,391 @@ var SSEReader = class {
|
|
|
1319
1635
|
}
|
|
1320
1636
|
}
|
|
1321
1637
|
}
|
|
1322
|
-
reset() {
|
|
1323
|
-
this.buffer = "";
|
|
1324
|
-
this.dataLines = [];
|
|
1325
|
-
this.eventName = "";
|
|
1326
|
-
this.listeners = [];
|
|
1327
|
-
this.endpointListeners = [];
|
|
1328
|
-
}
|
|
1329
|
-
};
|
|
1330
|
-
|
|
1331
|
-
// src/transport-base.ts
|
|
1332
|
-
import * as https2 from "node:https";
|
|
1333
|
-
import { ConfigError as ConfigError2 } from "@wrongstack/core/types";
|
|
1638
|
+
reset() {
|
|
1639
|
+
this.buffer = "";
|
|
1640
|
+
this.dataLines = [];
|
|
1641
|
+
this.eventName = "";
|
|
1642
|
+
this.listeners = [];
|
|
1643
|
+
this.endpointListeners = [];
|
|
1644
|
+
}
|
|
1645
|
+
};
|
|
1646
|
+
|
|
1647
|
+
// src/transport-base.ts
|
|
1648
|
+
import * as https2 from "node:https";
|
|
1649
|
+
import { ConfigError as ConfigError2 } from "@wrongstack/core/types";
|
|
1650
|
+
|
|
1651
|
+
// src/elicitation.ts
|
|
1652
|
+
var ELICITATION_MAX_WAIT_MS = 10 * 6e4;
|
|
1653
|
+
var MAX_FIELDS = 32;
|
|
1654
|
+
var MAX_MESSAGE_CHARS = 4e3;
|
|
1655
|
+
var MAX_OPTIONS = 100;
|
|
1656
|
+
function isRecord(v) {
|
|
1657
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1658
|
+
}
|
|
1659
|
+
function optionalString3(v) {
|
|
1660
|
+
return typeof v === "string" && v.trim() ? v : void 0;
|
|
1661
|
+
}
|
|
1662
|
+
function optionalNumber(v) {
|
|
1663
|
+
return typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
1664
|
+
}
|
|
1665
|
+
function optionalCount(v) {
|
|
1666
|
+
return typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : void 0;
|
|
1667
|
+
}
|
|
1668
|
+
function parseOptions(schema) {
|
|
1669
|
+
const values = schema["enum"];
|
|
1670
|
+
if (Array.isArray(values)) {
|
|
1671
|
+
if (!values.every((v) => typeof v === "string")) return void 0;
|
|
1672
|
+
const names = Array.isArray(schema["enumNames"]) ? schema["enumNames"] : [];
|
|
1673
|
+
return values.map((value, i) => ({
|
|
1674
|
+
value,
|
|
1675
|
+
label: typeof names[i] === "string" && names[i] ? names[i] : value
|
|
1676
|
+
}));
|
|
1677
|
+
}
|
|
1678
|
+
const titled = Array.isArray(schema["oneOf"]) ? schema["oneOf"] : Array.isArray(schema["anyOf"]) ? schema["anyOf"] : void 0;
|
|
1679
|
+
if (!titled) return void 0;
|
|
1680
|
+
const options = [];
|
|
1681
|
+
for (const entry of titled) {
|
|
1682
|
+
if (!isRecord(entry) || typeof entry["const"] !== "string") return void 0;
|
|
1683
|
+
options.push({
|
|
1684
|
+
value: entry["const"],
|
|
1685
|
+
label: optionalString3(entry["title"]) ?? entry["const"]
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
return options;
|
|
1689
|
+
}
|
|
1690
|
+
function parseField(name, schema, required) {
|
|
1691
|
+
if (!isRecord(schema)) return { ok: false, error: `property "${name}" is not a schema object` };
|
|
1692
|
+
const base = {
|
|
1693
|
+
name,
|
|
1694
|
+
title: optionalString3(schema["title"]),
|
|
1695
|
+
description: optionalString3(schema["description"]),
|
|
1696
|
+
required
|
|
1697
|
+
};
|
|
1698
|
+
const type = schema["type"];
|
|
1699
|
+
if (type === "array") {
|
|
1700
|
+
const items = schema["items"];
|
|
1701
|
+
const options = isRecord(items) ? parseOptions(items) : void 0;
|
|
1702
|
+
if (!options || options.length === 0 || options.length > MAX_OPTIONS) {
|
|
1703
|
+
return { ok: false, error: `property "${name}": arrays must be a list of enum values` };
|
|
1704
|
+
}
|
|
1705
|
+
const defaults = Array.isArray(schema["default"]) ? schema["default"].filter((v) => typeof v === "string") : void 0;
|
|
1706
|
+
return {
|
|
1707
|
+
ok: true,
|
|
1708
|
+
value: {
|
|
1709
|
+
...base,
|
|
1710
|
+
kind: "multi-enum",
|
|
1711
|
+
options,
|
|
1712
|
+
minItems: optionalCount(schema["minItems"]),
|
|
1713
|
+
maxItems: optionalCount(schema["maxItems"]),
|
|
1714
|
+
default: defaults
|
|
1715
|
+
}
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
if (type === "string") {
|
|
1719
|
+
const options = parseOptions(schema);
|
|
1720
|
+
if (options) {
|
|
1721
|
+
if (options.length === 0 || options.length > MAX_OPTIONS) {
|
|
1722
|
+
return { ok: false, error: `property "${name}": enum needs 1-${MAX_OPTIONS} values` };
|
|
1723
|
+
}
|
|
1724
|
+
return {
|
|
1725
|
+
ok: true,
|
|
1726
|
+
value: { ...base, kind: "enum", options, default: optionalString3(schema["default"]) }
|
|
1727
|
+
};
|
|
1728
|
+
}
|
|
1729
|
+
return {
|
|
1730
|
+
ok: true,
|
|
1731
|
+
value: {
|
|
1732
|
+
...base,
|
|
1733
|
+
kind: "string",
|
|
1734
|
+
minLength: optionalCount(schema["minLength"]),
|
|
1735
|
+
maxLength: optionalCount(schema["maxLength"]),
|
|
1736
|
+
format: optionalString3(schema["format"]),
|
|
1737
|
+
default: typeof schema["default"] === "string" ? schema["default"] : void 0
|
|
1738
|
+
}
|
|
1739
|
+
};
|
|
1740
|
+
}
|
|
1741
|
+
if (type === "number" || type === "integer") {
|
|
1742
|
+
return {
|
|
1743
|
+
ok: true,
|
|
1744
|
+
value: {
|
|
1745
|
+
...base,
|
|
1746
|
+
kind: type,
|
|
1747
|
+
minimum: optionalNumber(schema["minimum"]),
|
|
1748
|
+
maximum: optionalNumber(schema["maximum"]),
|
|
1749
|
+
default: optionalNumber(schema["default"])
|
|
1750
|
+
}
|
|
1751
|
+
};
|
|
1752
|
+
}
|
|
1753
|
+
if (type === "boolean") {
|
|
1754
|
+
return {
|
|
1755
|
+
ok: true,
|
|
1756
|
+
value: {
|
|
1757
|
+
...base,
|
|
1758
|
+
kind: "boolean",
|
|
1759
|
+
default: typeof schema["default"] === "boolean" ? schema["default"] : void 0
|
|
1760
|
+
}
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
1763
|
+
return {
|
|
1764
|
+
ok: false,
|
|
1765
|
+
error: `property "${name}" has unsupported type ${JSON.stringify(type)} \u2014 only flat primitive fields can be elicited`
|
|
1766
|
+
};
|
|
1767
|
+
}
|
|
1768
|
+
function parseUrlElicitation(params) {
|
|
1769
|
+
if (!isRecord(params)) return { ok: false, error: "params must be an object" };
|
|
1770
|
+
const message = params["message"];
|
|
1771
|
+
if (typeof message !== "string" || !message.trim()) {
|
|
1772
|
+
return { ok: false, error: "message is required" };
|
|
1773
|
+
}
|
|
1774
|
+
const elicitationId = params["elicitationId"];
|
|
1775
|
+
if (typeof elicitationId !== "string" || !elicitationId) {
|
|
1776
|
+
return { ok: false, error: "elicitationId is required in URL mode" };
|
|
1777
|
+
}
|
|
1778
|
+
const raw = params["url"];
|
|
1779
|
+
let url;
|
|
1780
|
+
try {
|
|
1781
|
+
url = new URL(typeof raw === "string" ? raw : "");
|
|
1782
|
+
} catch {
|
|
1783
|
+
return { ok: false, error: "url must be a valid URL" };
|
|
1784
|
+
}
|
|
1785
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
1786
|
+
return { ok: false, error: `url must be http(s), not ${url.protocol}` };
|
|
1787
|
+
}
|
|
1788
|
+
return {
|
|
1789
|
+
ok: true,
|
|
1790
|
+
value: {
|
|
1791
|
+
mode: "url",
|
|
1792
|
+
message: message.slice(0, MAX_MESSAGE_CHARS),
|
|
1793
|
+
url: url.href,
|
|
1794
|
+
elicitationId
|
|
1795
|
+
}
|
|
1796
|
+
};
|
|
1797
|
+
}
|
|
1798
|
+
function parseElicitationParams(params) {
|
|
1799
|
+
if (!isRecord(params)) return { ok: false, error: "params must be an object" };
|
|
1800
|
+
const message = params["message"];
|
|
1801
|
+
if (typeof message !== "string" || !message.trim()) {
|
|
1802
|
+
return { ok: false, error: "message is required" };
|
|
1803
|
+
}
|
|
1804
|
+
if (params["mode"] === "url") return parseUrlElicitation(params);
|
|
1805
|
+
if (params["mode"] !== void 0 && params["mode"] !== "form") {
|
|
1806
|
+
return { ok: false, error: `unsupported mode ${JSON.stringify(params["mode"])}` };
|
|
1807
|
+
}
|
|
1808
|
+
const schema = params["requestedSchema"];
|
|
1809
|
+
if (!isRecord(schema) || schema["type"] !== "object" || !isRecord(schema["properties"])) {
|
|
1810
|
+
return { ok: false, error: "requestedSchema must be an object schema with properties" };
|
|
1811
|
+
}
|
|
1812
|
+
const required = new Set(
|
|
1813
|
+
Array.isArray(schema["required"]) ? schema["required"].filter((v) => typeof v === "string") : []
|
|
1814
|
+
);
|
|
1815
|
+
const entries = Object.entries(schema["properties"]);
|
|
1816
|
+
if (entries.length > MAX_FIELDS) {
|
|
1817
|
+
return { ok: false, error: `requestedSchema has more than ${MAX_FIELDS} properties` };
|
|
1818
|
+
}
|
|
1819
|
+
const fields = [];
|
|
1820
|
+
for (const [name, property] of entries) {
|
|
1821
|
+
const parsed = parseField(name, property, required.has(name));
|
|
1822
|
+
if (!parsed.ok) return parsed;
|
|
1823
|
+
fields.push(parsed.value);
|
|
1824
|
+
}
|
|
1825
|
+
return { ok: true, value: { message: message.slice(0, MAX_MESSAGE_CHARS), fields } };
|
|
1826
|
+
}
|
|
1827
|
+
function label(field) {
|
|
1828
|
+
return field.title ?? field.name;
|
|
1829
|
+
}
|
|
1830
|
+
function checkValue(field, raw) {
|
|
1831
|
+
const name = label(field);
|
|
1832
|
+
switch (field.kind) {
|
|
1833
|
+
case "string": {
|
|
1834
|
+
if (typeof raw !== "string") return { ok: false, error: `${name} must be text` };
|
|
1835
|
+
if (field.minLength !== void 0 && raw.length < field.minLength) {
|
|
1836
|
+
return { ok: false, error: `${name} needs at least ${field.minLength} characters` };
|
|
1837
|
+
}
|
|
1838
|
+
if (field.maxLength !== void 0 && raw.length > field.maxLength) {
|
|
1839
|
+
return { ok: false, error: `${name} allows at most ${field.maxLength} characters` };
|
|
1840
|
+
}
|
|
1841
|
+
return { ok: true, value: raw };
|
|
1842
|
+
}
|
|
1843
|
+
case "number":
|
|
1844
|
+
case "integer": {
|
|
1845
|
+
const n = typeof raw === "string" && raw.trim() ? Number(raw.trim()) : raw;
|
|
1846
|
+
if (typeof n !== "number" || !Number.isFinite(n)) {
|
|
1847
|
+
return { ok: false, error: `${name} must be a number` };
|
|
1848
|
+
}
|
|
1849
|
+
if (field.kind === "integer" && !Number.isInteger(n)) {
|
|
1850
|
+
return { ok: false, error: `${name} must be a whole number` };
|
|
1851
|
+
}
|
|
1852
|
+
if (field.minimum !== void 0 && n < field.minimum) {
|
|
1853
|
+
return { ok: false, error: `${name} must be at least ${field.minimum}` };
|
|
1854
|
+
}
|
|
1855
|
+
if (field.maximum !== void 0 && n > field.maximum) {
|
|
1856
|
+
return { ok: false, error: `${name} must be at most ${field.maximum}` };
|
|
1857
|
+
}
|
|
1858
|
+
return { ok: true, value: n };
|
|
1859
|
+
}
|
|
1860
|
+
case "boolean":
|
|
1861
|
+
return typeof raw === "boolean" ? { ok: true, value: raw } : { ok: false, error: `${name} must be yes or no` };
|
|
1862
|
+
case "enum":
|
|
1863
|
+
return typeof raw === "string" && field.options.some((o) => o.value === raw) ? { ok: true, value: raw } : { ok: false, error: `${name} must be one of the offered choices` };
|
|
1864
|
+
case "multi-enum": {
|
|
1865
|
+
if (!Array.isArray(raw) || !raw.every((v) => field.options.some((o) => o.value === v))) {
|
|
1866
|
+
return { ok: false, error: `${name} must be picked from the offered choices` };
|
|
1867
|
+
}
|
|
1868
|
+
const picked = [...new Set(raw)];
|
|
1869
|
+
if (field.minItems !== void 0 && picked.length < field.minItems) {
|
|
1870
|
+
return { ok: false, error: `${name} needs at least ${field.minItems} choices` };
|
|
1871
|
+
}
|
|
1872
|
+
if (field.maxItems !== void 0 && picked.length > field.maxItems) {
|
|
1873
|
+
return { ok: false, error: `${name} allows at most ${field.maxItems} choices` };
|
|
1874
|
+
}
|
|
1875
|
+
return { ok: true, value: picked };
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
function validateElicitationContent(fields, content) {
|
|
1880
|
+
const out = {};
|
|
1881
|
+
for (const field of fields) {
|
|
1882
|
+
const raw = content[field.name];
|
|
1883
|
+
if (Array.isArray(raw) && raw.length === 0 && field.kind === "multi-enum" && field.minItems === 0) {
|
|
1884
|
+
out[field.name] = [];
|
|
1885
|
+
continue;
|
|
1886
|
+
}
|
|
1887
|
+
if (raw === void 0 || raw === null || raw === "" || Array.isArray(raw) && raw.length === 0) {
|
|
1888
|
+
if (field.required) return { ok: false, error: `${label(field)} is required` };
|
|
1889
|
+
continue;
|
|
1890
|
+
}
|
|
1891
|
+
const checked = checkValue(field, raw);
|
|
1892
|
+
if (!checked.ok) return checked;
|
|
1893
|
+
out[field.name] = checked.value;
|
|
1894
|
+
}
|
|
1895
|
+
return { ok: true, value: out };
|
|
1896
|
+
}
|
|
1897
|
+
var ServerRequestResponder = class {
|
|
1898
|
+
constructor(elicitation) {
|
|
1899
|
+
this.elicitation = elicitation;
|
|
1900
|
+
}
|
|
1901
|
+
elicitation;
|
|
1902
|
+
pending = /* @__PURE__ */ new Map();
|
|
1903
|
+
/** Client capabilities to declare in `initialize` — only what can be answered. */
|
|
1904
|
+
capabilities() {
|
|
1905
|
+
return this.elicitation ? { elicitation: { form: {}, url: {} } } : {};
|
|
1906
|
+
}
|
|
1907
|
+
/** True while an elicitation waits on the user; request timeouts hold meanwhile. */
|
|
1908
|
+
get awaitingUser() {
|
|
1909
|
+
return this.pending.size > 0;
|
|
1910
|
+
}
|
|
1911
|
+
async answer(request3) {
|
|
1912
|
+
const { id, method } = request3;
|
|
1913
|
+
const error = (code, message) => ({
|
|
1914
|
+
jsonrpc: "2.0",
|
|
1915
|
+
id,
|
|
1916
|
+
error: { code, message }
|
|
1917
|
+
});
|
|
1918
|
+
if (method === "ping") return { jsonrpc: "2.0", id, result: {} };
|
|
1919
|
+
if (method === "sampling/createMessage") {
|
|
1920
|
+
return error(-32601, "Client sampling is disabled by policy");
|
|
1921
|
+
}
|
|
1922
|
+
if (method !== "elicitation/create" || !this.elicitation) {
|
|
1923
|
+
return error(-32601, `Method not found: ${method}`);
|
|
1924
|
+
}
|
|
1925
|
+
const form = parseElicitationParams(request3.params);
|
|
1926
|
+
if (!form.ok) return error(-32602, `Invalid elicitation request: ${form.error}`);
|
|
1927
|
+
if (this.pending.size > 0) {
|
|
1928
|
+
return error(-32603, "Another elicitation from this server is still waiting on the user");
|
|
1929
|
+
}
|
|
1930
|
+
const controller = new AbortController();
|
|
1931
|
+
this.pending.set(id, controller);
|
|
1932
|
+
const deadline = setTimeout(
|
|
1933
|
+
() => controller.abort(new Error("elicitation timed out")),
|
|
1934
|
+
ELICITATION_MAX_WAIT_MS
|
|
1935
|
+
);
|
|
1936
|
+
deadline.unref?.();
|
|
1937
|
+
try {
|
|
1938
|
+
const prompt = form.value;
|
|
1939
|
+
const result = await this.elicitation({ ...prompt, signal: controller.signal });
|
|
1940
|
+
if (controller.signal.aborted) return { jsonrpc: "2.0", id, result: { action: "cancel" } };
|
|
1941
|
+
if (result.action !== "accept")
|
|
1942
|
+
return { jsonrpc: "2.0", id, result: { action: result.action } };
|
|
1943
|
+
if (prompt.mode === "url") return { jsonrpc: "2.0", id, result: { action: "accept" } };
|
|
1944
|
+
const content = validateElicitationContent(prompt.fields, result.content);
|
|
1945
|
+
if (!content.ok) return error(-32603, `Elicitation answer rejected: ${content.error}`);
|
|
1946
|
+
return { jsonrpc: "2.0", id, result: { action: "accept", content: content.value } };
|
|
1947
|
+
} catch {
|
|
1948
|
+
return { jsonrpc: "2.0", id, result: { action: "cancel" } };
|
|
1949
|
+
} finally {
|
|
1950
|
+
clearTimeout(deadline);
|
|
1951
|
+
this.pending.delete(id);
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
/**
|
|
1955
|
+
* Pages a tool call's `-32042` (URL elicitation required) error names, put
|
|
1956
|
+
* to the user one at a time. Returns what the user chose for each; nothing
|
|
1957
|
+
* is opened without their consent, and without a handler every one is
|
|
1958
|
+
* `cancel`.
|
|
1959
|
+
*/
|
|
1960
|
+
async presentUrl(elicitations, signal) {
|
|
1961
|
+
const outcomes = [];
|
|
1962
|
+
for (const elicitation of elicitations) {
|
|
1963
|
+
let action = "cancel";
|
|
1964
|
+
if (this.elicitation && !signal.aborted) {
|
|
1965
|
+
try {
|
|
1966
|
+
action = (await this.elicitation({ ...elicitation, signal })).action;
|
|
1967
|
+
} catch {
|
|
1968
|
+
action = "cancel";
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
outcomes.push({ elicitation, action });
|
|
1972
|
+
}
|
|
1973
|
+
return outcomes;
|
|
1974
|
+
}
|
|
1975
|
+
/** `notifications/cancelled` from the server: stop waiting for that answer. */
|
|
1976
|
+
cancel(params) {
|
|
1977
|
+
if (!isRecord(params)) return;
|
|
1978
|
+
const requestId = params["requestId"];
|
|
1979
|
+
if (typeof requestId !== "number" && typeof requestId !== "string") return;
|
|
1980
|
+
this.pending.get(requestId)?.abort(new Error("cancelled by server"));
|
|
1981
|
+
}
|
|
1982
|
+
/** The connection is gone: abandon every open form. */
|
|
1983
|
+
dispose() {
|
|
1984
|
+
for (const controller of this.pending.values())
|
|
1985
|
+
controller.abort(new Error("connection closed"));
|
|
1986
|
+
this.pending.clear();
|
|
1987
|
+
}
|
|
1988
|
+
};
|
|
1989
|
+
|
|
1990
|
+
// src/read-body.ts
|
|
1991
|
+
import { ToolError as ToolError2 } from "@wrongstack/core/types";
|
|
1992
|
+
var MAX_MCP_HTTP_BODY_BYTES = 16 * 1024 * 1024;
|
|
1993
|
+
async function readBodyCapped(res, maxBytes = MAX_MCP_HTTP_BODY_BYTES) {
|
|
1994
|
+
const body = res.body;
|
|
1995
|
+
if (!body || typeof body.getReader !== "function") {
|
|
1996
|
+
return await res.text();
|
|
1997
|
+
}
|
|
1998
|
+
const reader = body.getReader();
|
|
1999
|
+
const chunks = [];
|
|
2000
|
+
let total = 0;
|
|
2001
|
+
try {
|
|
2002
|
+
for (; ; ) {
|
|
2003
|
+
const { done, value } = await reader.read();
|
|
2004
|
+
if (done) break;
|
|
2005
|
+
if (!value) continue;
|
|
2006
|
+
total += value.byteLength;
|
|
2007
|
+
if (total > maxBytes) {
|
|
2008
|
+
await reader.cancel().catch(() => void 0);
|
|
2009
|
+
throw new ToolError2({
|
|
2010
|
+
message: `MCP response body exceeded ${maxBytes} bytes \u2014 refusing to buffer`,
|
|
2011
|
+
code: "TOOL_EXECUTION_FAILED",
|
|
2012
|
+
toolName: "mcp_transport",
|
|
2013
|
+
context: { maxBytes, received: total }
|
|
2014
|
+
});
|
|
2015
|
+
}
|
|
2016
|
+
chunks.push(value);
|
|
2017
|
+
}
|
|
2018
|
+
} finally {
|
|
2019
|
+
reader.releaseLock?.();
|
|
2020
|
+
}
|
|
2021
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
2022
|
+
}
|
|
1334
2023
|
|
|
1335
2024
|
// src/transport-security.ts
|
|
1336
2025
|
import * as dns2 from "node:dns/promises";
|
|
@@ -1358,36 +2047,26 @@ function validateTransportUrl(rawUrl) {
|
|
|
1358
2047
|
context: { field: "url", rawUrl, protocol: url.protocol }
|
|
1359
2048
|
});
|
|
1360
2049
|
}
|
|
1361
|
-
const
|
|
1362
|
-
const host =
|
|
2050
|
+
const hostname2 = url.hostname;
|
|
2051
|
+
const host = hostname2.startsWith("[") && hostname2.endsWith("]") ? hostname2.slice(1, -1) : hostname2;
|
|
1363
2052
|
const ipVersion = net2.isIP(host);
|
|
1364
|
-
if (ipVersion
|
|
1365
|
-
const
|
|
1366
|
-
if (
|
|
2053
|
+
if (ipVersion !== 0) {
|
|
2054
|
+
const classification = classifyTransportAddress(host, ipVersion);
|
|
2055
|
+
if (classification === "blocked") {
|
|
1367
2056
|
throw new ConfigError({
|
|
1368
|
-
message: `MCP transport: blocked link-local/IMDS address "${
|
|
2057
|
+
message: `MCP transport: blocked link-local/IMDS address "${hostname2}" \u2014 likely not a valid MCP server`,
|
|
1369
2058
|
code: "CONFIG_INVALID",
|
|
1370
|
-
context: { field: "url", rawUrl, hostname }
|
|
1371
|
-
});
|
|
1372
|
-
}
|
|
1373
|
-
} else if (ipVersion === 6) {
|
|
1374
|
-
const lower = host.toLowerCase();
|
|
1375
|
-
const linkLocal = /^fe[89ab]/.test(lower);
|
|
1376
|
-
if (linkLocal || lower === "fd00:ec2::254") {
|
|
1377
|
-
throw new ConfigError({
|
|
1378
|
-
message: `MCP transport: blocked link-local/IMDS address "${hostname}" \u2014 likely not a valid MCP server`,
|
|
1379
|
-
code: "CONFIG_INVALID",
|
|
1380
|
-
context: { field: "url", rawUrl, hostname }
|
|
2059
|
+
context: { field: "url", rawUrl, hostname: hostname2 }
|
|
1381
2060
|
});
|
|
1382
2061
|
}
|
|
1383
2062
|
}
|
|
1384
2063
|
if (url.protocol === "http:") {
|
|
1385
|
-
const isLoopback =
|
|
2064
|
+
const isLoopback = host === "localhost" || ipVersion !== 0 && classifyTransportAddress(host, ipVersion) === "loopback";
|
|
1386
2065
|
if (!isLoopback) {
|
|
1387
2066
|
throw new ConfigError({
|
|
1388
|
-
message: `MCP transport: http:// is only allowed for loopback addresses; use https:// for "${
|
|
2067
|
+
message: `MCP transport: http:// is only allowed for loopback addresses; use https:// for "${hostname2}"`,
|
|
1389
2068
|
code: "CONFIG_INVALID",
|
|
1390
|
-
context: { field: "url", rawUrl, hostname, protocol: url.protocol }
|
|
2069
|
+
context: { field: "url", rawUrl, hostname: hostname2, protocol: url.protocol }
|
|
1391
2070
|
});
|
|
1392
2071
|
}
|
|
1393
2072
|
}
|
|
@@ -1398,6 +2077,11 @@ if (ALLOW_MCP_PRIVATE_NETWORKS && !process.env["CI"]) {
|
|
|
1398
2077
|
"[WrongStack] WARNING: WRONGSTACK_MCP_ALLOW_PRIVATE=1 is active \u2014\n MCP HTTP transports may dial private/LAN addresses (10.x, 192.168.x,\n 172.16-31.x, ULA) when a server config sets allowPrivateNetworks. Link-local/IMDS targets stay blocked."
|
|
1399
2078
|
);
|
|
1400
2079
|
}
|
|
2080
|
+
function isImdsIpv6Literal(v6) {
|
|
2081
|
+
const groups = expandIPv6(v6);
|
|
2082
|
+
if (groups === null) return false;
|
|
2083
|
+
return groups[0] === 64768 && groups[1] === 3778 && groups[2] === 0 && groups[3] === 0 && groups[4] === 0 && groups[5] === 0 && groups[6] === 0 && groups[7] === 596;
|
|
2084
|
+
}
|
|
1401
2085
|
function classifyTransportAddress(address, family) {
|
|
1402
2086
|
if (family === 4) {
|
|
1403
2087
|
const v4 = address.toLowerCase();
|
|
@@ -1408,7 +2092,7 @@ function classifyTransportAddress(address, family) {
|
|
|
1408
2092
|
if (family === 6) {
|
|
1409
2093
|
const v6 = address.toLowerCase();
|
|
1410
2094
|
if (v6 === "::1") return "loopback";
|
|
1411
|
-
if (/^fe[89ab]/.test(v6) || v6
|
|
2095
|
+
if (/^fe[89ab]/.test(v6) || isImdsIpv6Literal(v6)) return "blocked";
|
|
1412
2096
|
if (v6.startsWith("::ffff:")) {
|
|
1413
2097
|
const tail = v6.slice("::ffff:".length);
|
|
1414
2098
|
if (tail.includes(".")) return classifyTransportAddress(tail, 4);
|
|
@@ -1434,30 +2118,30 @@ function classifyTransportAddress(address, family) {
|
|
|
1434
2118
|
}
|
|
1435
2119
|
return "blocked";
|
|
1436
2120
|
}
|
|
1437
|
-
function assertTransportAddressAllowed(address, family,
|
|
2121
|
+
function assertTransportAddressAllowed(address, family, hostname2, allowPrivateNetworks) {
|
|
1438
2122
|
const classification = classifyTransportAddress(address, family);
|
|
1439
2123
|
if (classification === "blocked") {
|
|
1440
2124
|
throw new ConfigError({
|
|
1441
|
-
message: `MCP transport: resolved address "${address}" for "${
|
|
2125
|
+
message: `MCP transport: resolved address "${address}" for "${hostname2}" is link-local/IMDS \u2014 never a valid MCP target`,
|
|
1442
2126
|
code: "CONFIG_INVALID",
|
|
1443
|
-
context: { hostname, address }
|
|
2127
|
+
context: { hostname: hostname2, address }
|
|
1444
2128
|
});
|
|
1445
2129
|
}
|
|
1446
2130
|
if (classification === "private" && !allowPrivateNetworks) {
|
|
1447
2131
|
throw new ConfigError({
|
|
1448
|
-
message: `MCP transport: "${
|
|
2132
|
+
message: `MCP transport: "${hostname2}" resolved to private address ${address}. Private/LAN targets are blocked by default; if this server really runs on your private network, set allowPrivateNetworks: true on its config (or WRONGSTACK_MCP_ALLOW_PRIVATE=1 globally).`,
|
|
1449
2133
|
code: "CONFIG_INVALID",
|
|
1450
|
-
context: { hostname, address }
|
|
2134
|
+
context: { hostname: hostname2, address }
|
|
1451
2135
|
});
|
|
1452
2136
|
}
|
|
1453
2137
|
}
|
|
1454
2138
|
function transportPinnedLookup(options) {
|
|
1455
2139
|
const lookup3 = options.lookup ?? (async (host) => dns2.lookup(host, { all: true }));
|
|
1456
|
-
return (
|
|
1457
|
-
lookup3(
|
|
2140
|
+
return (hostname2, connectOptions, callback) => {
|
|
2141
|
+
lookup3(hostname2).then((records) => {
|
|
1458
2142
|
if (records.length === 0) {
|
|
1459
2143
|
callback(
|
|
1460
|
-
Object.assign(new Error(`MCP transport: no addresses for "${
|
|
2144
|
+
Object.assign(new Error(`MCP transport: no addresses for "${hostname2}"`), {
|
|
1461
2145
|
code: "ENOTFOUND"
|
|
1462
2146
|
})
|
|
1463
2147
|
);
|
|
@@ -1467,7 +2151,7 @@ function transportPinnedLookup(options) {
|
|
|
1467
2151
|
assertTransportAddressAllowed(
|
|
1468
2152
|
record3.address,
|
|
1469
2153
|
record3.family,
|
|
1470
|
-
|
|
2154
|
+
hostname2,
|
|
1471
2155
|
options.allowPrivateNetworks
|
|
1472
2156
|
);
|
|
1473
2157
|
}
|
|
@@ -1515,7 +2199,7 @@ function makeAbortError(method) {
|
|
|
1515
2199
|
function nextJsonRpcId(id) {
|
|
1516
2200
|
return id >= Number.MAX_SAFE_INTEGER ? 1 : id + 1;
|
|
1517
2201
|
}
|
|
1518
|
-
function createTimeoutSignal(parent, timeoutMs) {
|
|
2202
|
+
function createTimeoutSignal(parent, timeoutMs, hold) {
|
|
1519
2203
|
const ctrl = new AbortController();
|
|
1520
2204
|
const onAbort = () => ctrl.abort(parent?.reason);
|
|
1521
2205
|
if (parent?.aborted) {
|
|
@@ -1523,10 +2207,15 @@ function createTimeoutSignal(parent, timeoutMs) {
|
|
|
1523
2207
|
} else {
|
|
1524
2208
|
parent?.addEventListener("abort", onAbort, { once: true });
|
|
1525
2209
|
}
|
|
1526
|
-
const
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
2210
|
+
const onTimeout = () => {
|
|
2211
|
+
if (hold?.()) {
|
|
2212
|
+
timer = setTimeout(onTimeout, timeoutMs);
|
|
2213
|
+
timer.unref?.();
|
|
2214
|
+
return;
|
|
2215
|
+
}
|
|
2216
|
+
ctrl.abort(new Error(`MCP HTTP request timed out after ${timeoutMs}ms`));
|
|
2217
|
+
};
|
|
2218
|
+
let timer = setTimeout(onTimeout, timeoutMs);
|
|
1530
2219
|
timer.unref?.();
|
|
1531
2220
|
return {
|
|
1532
2221
|
signal: ctrl.signal,
|
|
@@ -1557,9 +2246,12 @@ var BaseHTTPTransport = class {
|
|
|
1557
2246
|
disconnectHandlers = [];
|
|
1558
2247
|
toolsChangedListeners = /* @__PURE__ */ new Set();
|
|
1559
2248
|
resourcesChangedListeners = /* @__PURE__ */ new Set();
|
|
2249
|
+
resourceUpdatedListeners = /* @__PURE__ */ new Set();
|
|
1560
2250
|
promptsChangedListeners = /* @__PURE__ */ new Set();
|
|
1561
2251
|
protocolVersion;
|
|
2252
|
+
serverRequests;
|
|
1562
2253
|
constructor(opts, transportName) {
|
|
2254
|
+
this.serverRequests = opts.serverRequests ?? new ServerRequestResponder();
|
|
1563
2255
|
validateTransportUrl(opts.url);
|
|
1564
2256
|
this.name = opts.name;
|
|
1565
2257
|
this.url = opts.url;
|
|
@@ -1683,6 +2375,10 @@ var BaseHTTPTransport = class {
|
|
|
1683
2375
|
this.promptsChangedListeners.add(cb);
|
|
1684
2376
|
return () => this.promptsChangedListeners.delete(cb);
|
|
1685
2377
|
}
|
|
2378
|
+
onResourceUpdated(cb) {
|
|
2379
|
+
this.resourceUpdatedListeners.add(cb);
|
|
2380
|
+
return () => this.resourceUpdatedListeners.delete(cb);
|
|
2381
|
+
}
|
|
1686
2382
|
/**
|
|
1687
2383
|
* Fire all disconnect handlers. Subclasses call this when the connection
|
|
1688
2384
|
* drops so the registry can schedule reconnects.
|
|
@@ -1711,6 +2407,14 @@ var BaseHTTPTransport = class {
|
|
|
1711
2407
|
}
|
|
1712
2408
|
}
|
|
1713
2409
|
}
|
|
2410
|
+
notifyResourceUpdated(uri) {
|
|
2411
|
+
for (const cb of this.resourceUpdatedListeners) {
|
|
2412
|
+
try {
|
|
2413
|
+
cb(uri);
|
|
2414
|
+
} catch {
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
1714
2418
|
async dispatcherFetch() {
|
|
1715
2419
|
if (globalThis.fetch !== nativeGlobalFetch) return globalThis.fetch;
|
|
1716
2420
|
const undici = await ensureUndici();
|
|
@@ -1762,10 +2466,40 @@ var BaseHTTPTransport = class {
|
|
|
1762
2466
|
fetchOpts.dispatcher = this.tlsAgent;
|
|
1763
2467
|
}
|
|
1764
2468
|
}
|
|
2469
|
+
/**
|
|
2470
|
+
* Request timeout for a call. It holds while the server waits on the user's
|
|
2471
|
+
* elicitation answer — that wait is the user typing, not the server stalling.
|
|
2472
|
+
*/
|
|
2473
|
+
requestTimeoutSignal(parent, timeoutMs) {
|
|
2474
|
+
return createTimeoutSignal(parent, timeoutMs, () => this.serverRequests.awaitingUser);
|
|
2475
|
+
}
|
|
2476
|
+
/**
|
|
2477
|
+
* Answer a server→client request. Over HTTP the reply is POSTed back like
|
|
2478
|
+
* any client message; the server acknowledges it with 202. A failed reply is
|
|
2479
|
+
* not retried — the server times its request out on its side.
|
|
2480
|
+
*/
|
|
2481
|
+
async replyToServer(request3, url, headers) {
|
|
2482
|
+
const response = await this.serverRequests.answer(request3);
|
|
2483
|
+
const timeoutSignal = createTimeoutSignal(this.abortController?.signal, this.requestTimeout);
|
|
2484
|
+
const init = {
|
|
2485
|
+
method: "POST",
|
|
2486
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
2487
|
+
body: JSON.stringify(response),
|
|
2488
|
+
signal: timeoutSignal.signal
|
|
2489
|
+
};
|
|
2490
|
+
this.applyTlsAgent(init);
|
|
2491
|
+
try {
|
|
2492
|
+
const res = await this.fetchWithAuthorization(url, init, timeoutSignal.signal);
|
|
2493
|
+
await readBodyCapped(res).catch(() => void 0);
|
|
2494
|
+
} catch {
|
|
2495
|
+
} finally {
|
|
2496
|
+
timeoutSignal.dispose();
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
1765
2499
|
};
|
|
1766
2500
|
|
|
1767
2501
|
// src/transport-jsonrpc.ts
|
|
1768
|
-
import { ToolError as
|
|
2502
|
+
import { ToolError as ToolError3 } from "@wrongstack/core/types";
|
|
1769
2503
|
function encodeJsonRpcMessage(id, method, params) {
|
|
1770
2504
|
return JSON.stringify(
|
|
1771
2505
|
method.startsWith("notifications/") ? { jsonrpc: "2.0", method, params } : { jsonrpc: "2.0", id, method, params }
|
|
@@ -1839,7 +2573,7 @@ function extractJsonRpcResults(text) {
|
|
|
1839
2573
|
}
|
|
1840
2574
|
function assertMatchingJsonRpcResult(data, expectedId, method) {
|
|
1841
2575
|
if (!isJsonRpcResult(data)) {
|
|
1842
|
-
throw new
|
|
2576
|
+
throw new ToolError3({
|
|
1843
2577
|
message: "Invalid JSON-RPC response: not a JSON-RPC 2.0 envelope",
|
|
1844
2578
|
code: "TOOL_EXECUTION_FAILED",
|
|
1845
2579
|
toolName: "mcp_transport_jsonrpc",
|
|
@@ -1847,7 +2581,7 @@ function assertMatchingJsonRpcResult(data, expectedId, method) {
|
|
|
1847
2581
|
});
|
|
1848
2582
|
}
|
|
1849
2583
|
if (data.id !== expectedId) {
|
|
1850
|
-
throw new
|
|
2584
|
+
throw new ToolError3({
|
|
1851
2585
|
message: `Invalid JSON-RPC response: id mismatch for ${method} (expected ${expectedId}, got ${data.id})`,
|
|
1852
2586
|
code: "TOOL_EXECUTION_FAILED",
|
|
1853
2587
|
toolName: "mcp_transport_jsonrpc",
|
|
@@ -1861,38 +2595,147 @@ function assertMatchingJsonRpcResult(data, expectedId, method) {
|
|
|
1861
2595
|
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
1862
2596
|
import { ToolError as ToolError4 } from "@wrongstack/core/types";
|
|
1863
2597
|
|
|
1864
|
-
// src/
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
if (!body || typeof body.getReader !== "function") {
|
|
1870
|
-
return await res.text();
|
|
2598
|
+
// src/constants.ts
|
|
2599
|
+
var SUPPORTED_PROTOCOL_VERSIONS = Object.freeze(["2024-11-05"]);
|
|
2600
|
+
function negotiateProtocolVersion(requested) {
|
|
2601
|
+
if (typeof requested === "string" && SUPPORTED_PROTOCOL_VERSIONS.includes(requested)) {
|
|
2602
|
+
return requested;
|
|
1871
2603
|
}
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
2604
|
+
return MCP_CONSTANTS.PROTOCOL_VERSION;
|
|
2605
|
+
}
|
|
2606
|
+
var MCP_CONSTANTS = Object.freeze({
|
|
2607
|
+
/** MCP protocol version advertised during handshake: the newest we implement. */
|
|
2608
|
+
PROTOCOL_VERSION: SUPPORTED_PROTOCOL_VERSIONS[0],
|
|
2609
|
+
/** Identity announced to MCP servers during `initialize`. */
|
|
2610
|
+
CLIENT_INFO: Object.freeze({
|
|
2611
|
+
name: "wrongstack",
|
|
2612
|
+
version: "0.1.10"
|
|
2613
|
+
}),
|
|
2614
|
+
/** Reconnection behaviour when a transport disconnects. */
|
|
2615
|
+
RECONNECT: Object.freeze({
|
|
2616
|
+
/** Max full reconnect cycles before the slot is marked `failed`. */
|
|
2617
|
+
MAX_CYCLES: 5,
|
|
2618
|
+
/** Base delay between cycles (exponential backoff applied on top). */
|
|
2619
|
+
BASE_DELAY_MS: 1e3,
|
|
2620
|
+
/** Jitter factor applied to the backoff (0 = no jitter, 1 = full). */
|
|
2621
|
+
JITTER_FACTOR: 0.2,
|
|
2622
|
+
/** Max connection attempts within a single cycle. */
|
|
2623
|
+
MAX_ATTEMPTS: 3,
|
|
2624
|
+
/** Base multiplier for the exponential backoff formula (`delay = BASE * multiplier^attempt`). */
|
|
2625
|
+
BACKOFF_MULTIPLIER: 2
|
|
2626
|
+
}),
|
|
2627
|
+
/** Timing for graceful / forced disconnect. */
|
|
2628
|
+
DISCONNECT: Object.freeze({
|
|
2629
|
+
/** Ms to wait for in-flight requests to complete before force-closing. */
|
|
2630
|
+
GRACEFUL_MS: 800,
|
|
2631
|
+
/** Ms after which the force disconnect is triggered. */
|
|
2632
|
+
FORCE_TIMEOUT_MS: 1200
|
|
2633
|
+
}),
|
|
2634
|
+
/** Lazy-connect idle lifecycle. */
|
|
2635
|
+
IDLE: Object.freeze({
|
|
2636
|
+
/** Default ms a lazy server stays connected with no tool calls before auto-sleep. */
|
|
2637
|
+
DEFAULT_TIMEOUT_MS: 3e5,
|
|
2638
|
+
/** How often the idle sweep runs (kept well below the timeout). */
|
|
2639
|
+
SWEEP_INTERVAL_MS: 3e4
|
|
2640
|
+
}),
|
|
2641
|
+
/** JSON-RPC response timeout for outstanding requests. */
|
|
2642
|
+
RESPONSE_TIMEOUT_MS: 500,
|
|
2643
|
+
/** Max buffer size for the SSE reader. */
|
|
2644
|
+
SSE_READER_MAX_BUFFER: 256 * 1024,
|
|
2645
|
+
/** Max characters logged from a request body. */
|
|
2646
|
+
REQUEST_LOG_CAP: 1024
|
|
2647
|
+
});
|
|
2648
|
+
|
|
2649
|
+
// src/tool-schema.ts
|
|
2650
|
+
var URL_ELICITATION_REQUIRED = -32042;
|
|
2651
|
+
function isPlainObject(value) {
|
|
2652
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2653
|
+
}
|
|
2654
|
+
function toToolCallResult(response) {
|
|
2655
|
+
if (response.error) {
|
|
2656
|
+
const pages = urlElicitationsOf(response.error);
|
|
2657
|
+
return {
|
|
2658
|
+
content: response.error.message,
|
|
2659
|
+
isError: true,
|
|
2660
|
+
...pages.length > 0 ? { urlElicitations: pages } : {}
|
|
2661
|
+
};
|
|
2662
|
+
}
|
|
2663
|
+
const result = isPlainObject(response.result) ? response.result : {};
|
|
2664
|
+
const structured = result["structuredContent"];
|
|
2665
|
+
return {
|
|
2666
|
+
content: result["content"] ?? "",
|
|
2667
|
+
isError: Boolean(result["isError"]),
|
|
2668
|
+
...isPlainObject(structured) ? { structuredContent: structured } : {}
|
|
2669
|
+
};
|
|
2670
|
+
}
|
|
2671
|
+
function urlElicitationsOf(error) {
|
|
2672
|
+
if (error.code !== URL_ELICITATION_REQUIRED || !isPlainObject(error.data)) return [];
|
|
2673
|
+
const listed = error.data["elicitations"];
|
|
2674
|
+
if (!Array.isArray(listed)) return [];
|
|
2675
|
+
const pages = [];
|
|
2676
|
+
for (const entry of listed) {
|
|
2677
|
+
const parsed = parseUrlElicitation(entry);
|
|
2678
|
+
if (parsed.ok) pages.push(parsed.value);
|
|
2679
|
+
}
|
|
2680
|
+
return pages;
|
|
2681
|
+
}
|
|
2682
|
+
var MAX_TOOL_PAGES = 100;
|
|
2683
|
+
var MAX_TOOLS = 1e4;
|
|
2684
|
+
async function listAllTools(requestPage) {
|
|
2685
|
+
const tools = [];
|
|
2686
|
+
const seenCursors = /* @__PURE__ */ new Set();
|
|
2687
|
+
let cursor;
|
|
2688
|
+
for (let page = 0; page < MAX_TOOL_PAGES; page++) {
|
|
2689
|
+
let response;
|
|
2690
|
+
try {
|
|
2691
|
+
response = await requestPage(cursor ? { cursor } : {});
|
|
2692
|
+
} catch (err) {
|
|
2693
|
+
if (page === 0) throw err;
|
|
2694
|
+
break;
|
|
1891
2695
|
}
|
|
1892
|
-
|
|
1893
|
-
|
|
2696
|
+
if (response.error) {
|
|
2697
|
+
if (page === 0) return null;
|
|
2698
|
+
break;
|
|
2699
|
+
}
|
|
2700
|
+
const result = response.result;
|
|
2701
|
+
tools.push(...normalizeMCPTools(result?.tools));
|
|
2702
|
+
const next = result?.nextCursor;
|
|
2703
|
+
if (typeof next !== "string" || next.length === 0) break;
|
|
2704
|
+
if (seenCursors.has(next) || tools.length >= MAX_TOOLS) break;
|
|
2705
|
+
seenCursors.add(next);
|
|
2706
|
+
cursor = next;
|
|
1894
2707
|
}
|
|
1895
|
-
return
|
|
2708
|
+
return tools.slice(0, MAX_TOOLS);
|
|
2709
|
+
}
|
|
2710
|
+
function normalizeMCPTools(value) {
|
|
2711
|
+
if (!Array.isArray(value)) return [];
|
|
2712
|
+
const tools = [];
|
|
2713
|
+
for (const raw of value) {
|
|
2714
|
+
if (!raw || typeof raw !== "object") continue;
|
|
2715
|
+
const t = raw;
|
|
2716
|
+
if (typeof t.name !== "string") continue;
|
|
2717
|
+
const name = t.name.trim();
|
|
2718
|
+
if (name.length === 0) continue;
|
|
2719
|
+
const inputSchema = t.inputSchema && typeof t.inputSchema === "object" && !Array.isArray(t.inputSchema) ? t.inputSchema : { type: "object", properties: {} };
|
|
2720
|
+
if (!t.inputSchema || typeof t.inputSchema !== "object" || Array.isArray(t.inputSchema)) {
|
|
2721
|
+
console.warn(
|
|
2722
|
+
JSON.stringify({
|
|
2723
|
+
level: "warn",
|
|
2724
|
+
event: "mcp.tool_schema_invalid",
|
|
2725
|
+
tool: name,
|
|
2726
|
+
message: "no/invalid inputSchema \u2014 defaulting to empty object",
|
|
2727
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2728
|
+
})
|
|
2729
|
+
);
|
|
2730
|
+
}
|
|
2731
|
+
tools.push({
|
|
2732
|
+
name,
|
|
2733
|
+
...typeof t.description === "string" ? { description: t.description } : {},
|
|
2734
|
+
inputSchema,
|
|
2735
|
+
...isPlainObject(t.outputSchema) ? { outputSchema: t.outputSchema } : {}
|
|
2736
|
+
});
|
|
2737
|
+
}
|
|
2738
|
+
return tools;
|
|
1896
2739
|
}
|
|
1897
2740
|
|
|
1898
2741
|
// src/transport-sse.ts
|
|
@@ -1978,13 +2821,27 @@ var SSETransport = class extends BaseHTTPTransport {
|
|
|
1978
2821
|
});
|
|
1979
2822
|
sseReader.onMessage((msg) => {
|
|
1980
2823
|
this.streamSignal?.();
|
|
1981
|
-
|
|
1982
|
-
|
|
2824
|
+
const id = msg.id;
|
|
2825
|
+
if (msg.method && (typeof id === "number" || typeof id === "string")) {
|
|
2826
|
+
void this.replyToServer(
|
|
2827
|
+
{ id, method: msg.method, params: msg.params },
|
|
2828
|
+
this.endpointUrl ?? this.url,
|
|
2829
|
+
this.headers
|
|
2830
|
+
);
|
|
2831
|
+
return;
|
|
2832
|
+
}
|
|
2833
|
+
if (msg.method) {
|
|
2834
|
+
if (msg.method === "notifications/cancelled") {
|
|
2835
|
+
this.serverRequests.cancel(msg.params);
|
|
2836
|
+
} else if (msg.method === "notifications/tools/list_changed") {
|
|
1983
2837
|
void this.handleToolsListChanged();
|
|
1984
2838
|
} else if (msg.method === "notifications/resources/list_changed") {
|
|
1985
2839
|
this.notifyResourcesChanged();
|
|
1986
2840
|
} else if (msg.method === "notifications/prompts/list_changed") {
|
|
1987
2841
|
this.notifyPromptsChanged();
|
|
2842
|
+
} else if (msg.method === "notifications/resources/updated") {
|
|
2843
|
+
const uri = resourceUpdatedUri(msg.params);
|
|
2844
|
+
if (uri) this.notifyResourceUpdated(uri);
|
|
1988
2845
|
}
|
|
1989
2846
|
return;
|
|
1990
2847
|
}
|
|
@@ -2005,9 +2862,9 @@ var SSETransport = class extends BaseHTTPTransport {
|
|
|
2005
2862
|
await this.waitForStream(streamReady, signal);
|
|
2006
2863
|
const initRes = await this.httpPost("initialize", {
|
|
2007
2864
|
protocolVersion: MCP_CONSTANTS.PROTOCOL_VERSION,
|
|
2008
|
-
// Client capabilities
|
|
2865
|
+
// Client capabilities: elicitation when the host can ask its user.
|
|
2009
2866
|
// `tools` is a SERVER capability and never belonged here.
|
|
2010
|
-
capabilities:
|
|
2867
|
+
capabilities: this.serverRequests.capabilities(),
|
|
2011
2868
|
clientInfo: MCP_CONSTANTS.CLIENT_INFO
|
|
2012
2869
|
});
|
|
2013
2870
|
if (initRes.error) {
|
|
@@ -2135,7 +2992,7 @@ var SSETransport = class extends BaseHTTPTransport {
|
|
|
2135
2992
|
const body = encodeJsonRpcMessage(id, method, params);
|
|
2136
2993
|
const external = opts?.signal;
|
|
2137
2994
|
const parent = external && this.abortController ? AbortSignal.any([this.abortController.signal, external]) : external ?? this.abortController?.signal;
|
|
2138
|
-
const timeoutSignal =
|
|
2995
|
+
const timeoutSignal = this.requestTimeoutSignal(parent, timeoutMs);
|
|
2139
2996
|
const streamed = isNotification ? void 0 : this.awaitStreamResponse(id, timeoutSignal.signal);
|
|
2140
2997
|
const fetchOpts = {
|
|
2141
2998
|
method: "POST",
|
|
@@ -2224,14 +3081,7 @@ var SSETransport = class extends BaseHTTPTransport {
|
|
|
2224
3081
|
});
|
|
2225
3082
|
}
|
|
2226
3083
|
const res = await this.httpPost("tools/call", { name, arguments: input }, opts);
|
|
2227
|
-
|
|
2228
|
-
return { content: res.error.message, isError: true };
|
|
2229
|
-
}
|
|
2230
|
-
const result = res.result;
|
|
2231
|
-
return {
|
|
2232
|
-
content: result?.content ?? "",
|
|
2233
|
-
isError: Boolean(result?.isError)
|
|
2234
|
-
};
|
|
3084
|
+
return toToolCallResult(res);
|
|
2235
3085
|
}
|
|
2236
3086
|
/** Generic JSON-RPC request — used by MCPClient.request() for SSE transports. */
|
|
2237
3087
|
async request(method, params, timeoutMs, opts) {
|
|
@@ -2288,23 +3138,89 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
|
|
|
2288
3138
|
this._nextId = nextJsonRpcId(id);
|
|
2289
3139
|
return id;
|
|
2290
3140
|
}
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
3141
|
+
/**
|
|
3142
|
+
* Read a POST's reply. A `text/event-stream` reply is consumed event by
|
|
3143
|
+
* event: the server may put notifications and its OWN requests (elicitation)
|
|
3144
|
+
* ahead of our response and then wait for our answer to them, so buffering
|
|
3145
|
+
* the whole body first would deadlock the call.
|
|
3146
|
+
*/
|
|
3147
|
+
async readResponse(res, requestId) {
|
|
3148
|
+
const streamed = (res.headers?.get("content-type") ?? "").includes("text/event-stream");
|
|
3149
|
+
const body = res.body;
|
|
3150
|
+
if (!streamed || !body || typeof body.getReader !== "function") {
|
|
3151
|
+
return this.takeEnvelopes(await readBodyCapped(res), requestId);
|
|
3152
|
+
}
|
|
3153
|
+
const reader = body.getReader();
|
|
3154
|
+
const decoder = new TextDecoder();
|
|
3155
|
+
const others = [];
|
|
3156
|
+
let pending = "";
|
|
3157
|
+
let total = 0;
|
|
3158
|
+
try {
|
|
3159
|
+
for (; ; ) {
|
|
3160
|
+
const { done, value } = await reader.read();
|
|
3161
|
+
if (done) break;
|
|
3162
|
+
total += value.byteLength;
|
|
3163
|
+
if (total > MAX_MCP_HTTP_BODY_BYTES) {
|
|
3164
|
+
throw new Error(`MCP response stream exceeded ${MAX_MCP_HTTP_BODY_BYTES} bytes`);
|
|
3165
|
+
}
|
|
3166
|
+
pending += decoder.decode(value, { stream: true });
|
|
3167
|
+
const cut = completeEventsEnd(pending);
|
|
3168
|
+
if (cut === 0) continue;
|
|
3169
|
+
const match = this.takeEnvelopes(pending.slice(0, cut), requestId, others);
|
|
3170
|
+
pending = pending.slice(cut);
|
|
3171
|
+
if (match) {
|
|
3172
|
+
await reader.cancel().catch(() => void 0);
|
|
3173
|
+
return match;
|
|
3174
|
+
}
|
|
3175
|
+
}
|
|
3176
|
+
pending += decoder.decode();
|
|
3177
|
+
return this.takeEnvelopes(pending, requestId, others) ?? others[0];
|
|
3178
|
+
} finally {
|
|
3179
|
+
reader.releaseLock?.();
|
|
3180
|
+
}
|
|
3181
|
+
}
|
|
3182
|
+
/**
|
|
3183
|
+
* Dispatch the notifications and server requests in `text`, and return the
|
|
3184
|
+
* response to `requestId` — falling back to the first other response, which
|
|
3185
|
+
* the caller's id check then rejects.
|
|
3186
|
+
*/
|
|
3187
|
+
takeEnvelopes(text, requestId, others = []) {
|
|
3188
|
+
let match;
|
|
3189
|
+
for (const envelope of extractJsonRpcEnvelopes(text)) {
|
|
3190
|
+
if (isJsonRpcResult(envelope)) {
|
|
3191
|
+
if (envelope.id === requestId) match ??= envelope;
|
|
3192
|
+
else others.push(envelope);
|
|
3193
|
+
} else if (envelope.id !== void 0) {
|
|
3194
|
+
void this.replyToServer(
|
|
3195
|
+
{ id: envelope.id, method: envelope.method, params: envelope.params },
|
|
3196
|
+
this.url,
|
|
3197
|
+
this.sessionHeaders()
|
|
3198
|
+
);
|
|
3199
|
+
} else {
|
|
3200
|
+
this.handleNotification(envelope.method, envelope.params);
|
|
2296
3201
|
}
|
|
2297
3202
|
}
|
|
2298
|
-
|
|
2299
|
-
|
|
3203
|
+
return match ?? others[0];
|
|
3204
|
+
}
|
|
3205
|
+
sessionHeaders() {
|
|
3206
|
+
return {
|
|
3207
|
+
Accept: "application/json, text/event-stream",
|
|
3208
|
+
...this.sessionId ? { "Mcp-Session-Id": this.sessionId } : {},
|
|
3209
|
+
...this.headers
|
|
3210
|
+
};
|
|
2300
3211
|
}
|
|
2301
|
-
handleNotification(method) {
|
|
2302
|
-
if (method === "notifications/
|
|
3212
|
+
handleNotification(method, params) {
|
|
3213
|
+
if (method === "notifications/cancelled") {
|
|
3214
|
+
this.serverRequests.cancel(params);
|
|
3215
|
+
} else if (method === "notifications/resources/list_changed") {
|
|
2303
3216
|
this.notifyResourcesChanged();
|
|
2304
3217
|
} else if (method === "notifications/prompts/list_changed") {
|
|
2305
3218
|
this.notifyPromptsChanged();
|
|
2306
3219
|
} else if (method === "notifications/tools/list_changed") {
|
|
2307
3220
|
void this.refreshTools();
|
|
3221
|
+
} else if (method === "notifications/resources/updated") {
|
|
3222
|
+
const uri = resourceUpdatedUri(params);
|
|
3223
|
+
if (uri) this.notifyResourceUpdated(uri);
|
|
2308
3224
|
}
|
|
2309
3225
|
}
|
|
2310
3226
|
async refreshTools() {
|
|
@@ -2343,8 +3259,9 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
|
|
|
2343
3259
|
method: "initialize",
|
|
2344
3260
|
params: {
|
|
2345
3261
|
protocolVersion: MCP_CONSTANTS.PROTOCOL_VERSION,
|
|
2346
|
-
// Client capabilities
|
|
2347
|
-
|
|
3262
|
+
// Client capabilities: elicitation when the host can ask its user
|
|
3263
|
+
// (`tools` is a server capability and never belongs here).
|
|
3264
|
+
capabilities: this.serverRequests.capabilities(),
|
|
2348
3265
|
clientInfo: MCP_CONSTANTS.CLIENT_INFO
|
|
2349
3266
|
}
|
|
2350
3267
|
}),
|
|
@@ -2390,7 +3307,7 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
|
|
|
2390
3307
|
const body = encodeJsonRpcMessage(id, method, params);
|
|
2391
3308
|
const external = opts?.signal;
|
|
2392
3309
|
const parent = external && this.abortController ? AbortSignal.any([this.abortController.signal, external]) : external ?? this.abortController?.signal;
|
|
2393
|
-
const timeoutSignal =
|
|
3310
|
+
const timeoutSignal = this.requestTimeoutSignal(parent, this.requestTimeout);
|
|
2394
3311
|
const fetchOpts = {
|
|
2395
3312
|
method: "POST",
|
|
2396
3313
|
headers: {
|
|
@@ -2415,7 +3332,7 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
|
|
|
2415
3332
|
await readBodyCapped(res).catch(() => void 0);
|
|
2416
3333
|
return { jsonrpc: "2.0", id };
|
|
2417
3334
|
}
|
|
2418
|
-
const match = this.
|
|
3335
|
+
const match = await this.readResponse(res, id);
|
|
2419
3336
|
if (match) {
|
|
2420
3337
|
return assertMatchingJsonRpcResult(match, id, method);
|
|
2421
3338
|
}
|
|
@@ -2440,7 +3357,7 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
|
|
|
2440
3357
|
const body = encodeJsonRpcMessage(id, method, params);
|
|
2441
3358
|
const external = opts?.signal;
|
|
2442
3359
|
const parent = external && this.abortController ? AbortSignal.any([this.abortController.signal, external]) : external ?? this.abortController?.signal;
|
|
2443
|
-
const timeoutSignal =
|
|
3360
|
+
const timeoutSignal = this.requestTimeoutSignal(parent, timeoutMs ?? this.requestTimeout);
|
|
2444
3361
|
const fetchOpts = {
|
|
2445
3362
|
method: "POST",
|
|
2446
3363
|
headers: {
|
|
@@ -2465,7 +3382,7 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
|
|
|
2465
3382
|
await readBodyCapped(res).catch(() => void 0);
|
|
2466
3383
|
return { jsonrpc: "2.0", id };
|
|
2467
3384
|
}
|
|
2468
|
-
const parsed = this.
|
|
3385
|
+
const parsed = await this.readResponse(res, id);
|
|
2469
3386
|
if (parsed) {
|
|
2470
3387
|
const matched = assertMatchingJsonRpcResult(parsed, id, method);
|
|
2471
3388
|
return {
|
|
@@ -2495,14 +3412,7 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
|
|
|
2495
3412
|
throw new Error(`streamable-http transport not connected (state=${this.state})`);
|
|
2496
3413
|
}
|
|
2497
3414
|
const res = await this.postRaw("tools/call", { name, arguments: input }, opts);
|
|
2498
|
-
|
|
2499
|
-
return { content: res.error.message, isError: true };
|
|
2500
|
-
}
|
|
2501
|
-
const result = res.result;
|
|
2502
|
-
return {
|
|
2503
|
-
content: result?.content ?? "",
|
|
2504
|
-
isError: Boolean(result?.isError)
|
|
2505
|
-
};
|
|
3415
|
+
return toToolCallResult(res);
|
|
2506
3416
|
}
|
|
2507
3417
|
async close() {
|
|
2508
3418
|
this.releasePinnedDispatcher();
|
|
@@ -2527,14 +3437,132 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
|
|
|
2527
3437
|
this.notifyDisconnect();
|
|
2528
3438
|
}
|
|
2529
3439
|
}
|
|
2530
|
-
};
|
|
3440
|
+
};
|
|
3441
|
+
function completeEventsEnd(text) {
|
|
3442
|
+
const lf = text.lastIndexOf("\n\n");
|
|
3443
|
+
const crlf = text.lastIndexOf("\r\n\r\n");
|
|
3444
|
+
return Math.max(lf === -1 ? 0 : lf + 2, crlf === -1 ? 0 : crlf + 4);
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3447
|
+
// src/client-http-connection.ts
|
|
3448
|
+
async function connectSSE(host) {
|
|
3449
|
+
if (!host.opts.url) {
|
|
3450
|
+
host.state = "failed";
|
|
3451
|
+
throw new Error('MCP SSE transport requires "url"');
|
|
3452
|
+
}
|
|
3453
|
+
const httpOpts = {
|
|
3454
|
+
name: host.opts.name,
|
|
3455
|
+
url: host.opts.url,
|
|
3456
|
+
headers: resolveHttpBearerHeaders(host.opts),
|
|
3457
|
+
startupTimeoutMs: host.opts.startupTimeoutMs,
|
|
3458
|
+
requestTimeoutMs: host.opts.requestTimeoutMs,
|
|
3459
|
+
authorizationProvider: host.opts.authorizationProvider,
|
|
3460
|
+
allowPrivateNetworks: host.opts.allowPrivateNetworks,
|
|
3461
|
+
serverRequests: host.serverRequests
|
|
3462
|
+
};
|
|
3463
|
+
host.sseTransport = new SSETransport(httpOpts);
|
|
3464
|
+
host.sseTransport.onDisconnect(() => {
|
|
3465
|
+
host.state = "disconnected";
|
|
3466
|
+
for (const cb of host.disconnectListeners) {
|
|
3467
|
+
try {
|
|
3468
|
+
cb();
|
|
3469
|
+
} catch {
|
|
3470
|
+
}
|
|
3471
|
+
}
|
|
3472
|
+
});
|
|
3473
|
+
host.sseTransport.onToolsChanged((tools) => {
|
|
3474
|
+
host._tools = tools;
|
|
3475
|
+
host._toolsCache = tools;
|
|
3476
|
+
for (const cb of host.toolsChangedListeners) {
|
|
3477
|
+
try {
|
|
3478
|
+
cb(host.opts.name, tools);
|
|
3479
|
+
} catch {
|
|
3480
|
+
}
|
|
3481
|
+
}
|
|
3482
|
+
});
|
|
3483
|
+
host.sseTransport.onResourcesChanged(() => host.emitCapabilityChanged("resources"));
|
|
3484
|
+
host.sseTransport.onResourceUpdated((uri) => host.emitResourceUpdated(uri));
|
|
3485
|
+
host.sseTransport.onPromptsChanged(() => host.emitCapabilityChanged("prompts"));
|
|
3486
|
+
try {
|
|
3487
|
+
await host.sseTransport.connect();
|
|
3488
|
+
} catch (err) {
|
|
3489
|
+
const t = host.sseTransport;
|
|
3490
|
+
host.sseTransport = void 0;
|
|
3491
|
+
await t.close().catch(() => {
|
|
3492
|
+
});
|
|
3493
|
+
host.state = "failed";
|
|
3494
|
+
throw err;
|
|
3495
|
+
}
|
|
3496
|
+
host._tools = host.sseTransport.listTools();
|
|
3497
|
+
host._toolsCache = host._tools;
|
|
3498
|
+
host._serverMetadata = host.sseTransport.getServerMetadata();
|
|
3499
|
+
host.state = "connected";
|
|
3500
|
+
}
|
|
3501
|
+
async function connectStreamableHTTP(host) {
|
|
3502
|
+
if (!host.opts.url) {
|
|
3503
|
+
host.state = "failed";
|
|
3504
|
+
throw new Error('MCP streamable-http transport requires "url"');
|
|
3505
|
+
}
|
|
3506
|
+
const httpOpts = {
|
|
3507
|
+
name: host.opts.name,
|
|
3508
|
+
url: host.opts.url,
|
|
3509
|
+
headers: resolveHttpBearerHeaders(host.opts),
|
|
3510
|
+
startupTimeoutMs: host.opts.startupTimeoutMs,
|
|
3511
|
+
requestTimeoutMs: host.opts.requestTimeoutMs,
|
|
3512
|
+
authorizationProvider: host.opts.authorizationProvider,
|
|
3513
|
+
allowPrivateNetworks: host.opts.allowPrivateNetworks,
|
|
3514
|
+
serverRequests: host.serverRequests
|
|
3515
|
+
};
|
|
3516
|
+
host.httpTransport = new StreamableHTTPTransport(httpOpts);
|
|
3517
|
+
host.httpTransport.onDisconnect(() => {
|
|
3518
|
+
host.state = "disconnected";
|
|
3519
|
+
for (const cb of host.disconnectListeners) {
|
|
3520
|
+
try {
|
|
3521
|
+
cb();
|
|
3522
|
+
} catch {
|
|
3523
|
+
}
|
|
3524
|
+
}
|
|
3525
|
+
});
|
|
3526
|
+
host.httpTransport.onToolsChanged((tools) => {
|
|
3527
|
+
host._tools = tools;
|
|
3528
|
+
host._toolsCache = tools;
|
|
3529
|
+
for (const cb of host.toolsChangedListeners) {
|
|
3530
|
+
try {
|
|
3531
|
+
cb(host.opts.name, tools);
|
|
3532
|
+
} catch {
|
|
3533
|
+
}
|
|
3534
|
+
}
|
|
3535
|
+
});
|
|
3536
|
+
host.httpTransport.onResourcesChanged(() => host.emitCapabilityChanged("resources"));
|
|
3537
|
+
host.httpTransport.onResourceUpdated((uri) => host.emitResourceUpdated(uri));
|
|
3538
|
+
host.httpTransport.onPromptsChanged(() => host.emitCapabilityChanged("prompts"));
|
|
3539
|
+
try {
|
|
3540
|
+
await host.httpTransport.connect();
|
|
3541
|
+
} catch (err) {
|
|
3542
|
+
const t = host.httpTransport;
|
|
3543
|
+
host.httpTransport = void 0;
|
|
3544
|
+
await t.close().catch(() => {
|
|
3545
|
+
});
|
|
3546
|
+
host.state = "failed";
|
|
3547
|
+
throw err;
|
|
3548
|
+
}
|
|
3549
|
+
host._tools = host.httpTransport.listTools();
|
|
3550
|
+
host._toolsCache = host._tools;
|
|
3551
|
+
host._serverMetadata = host.httpTransport.getServerMetadata();
|
|
3552
|
+
host.state = "connected";
|
|
3553
|
+
}
|
|
2531
3554
|
|
|
2532
3555
|
// src/client.ts
|
|
2533
3556
|
var MCPClient = class _MCPClient {
|
|
2534
3557
|
constructor(opts) {
|
|
2535
3558
|
this.opts = opts;
|
|
3559
|
+
this.serverRequests = new ServerRequestResponder(opts.elicitation);
|
|
2536
3560
|
}
|
|
2537
3561
|
opts;
|
|
3562
|
+
capabilityClient = new MCPCapabilityClient(
|
|
3563
|
+
this.requestCapability.bind(this),
|
|
3564
|
+
this.requireResourceSubscriptions.bind(this)
|
|
3565
|
+
);
|
|
2538
3566
|
/**
|
|
2539
3567
|
* Maximum bytes the rx buffer may accumulate before the connection is
|
|
2540
3568
|
* forcefully closed. A well-behaved JSON-RPC server emits newline-delimited
|
|
@@ -2581,8 +3609,11 @@ var MCPClient = class _MCPClient {
|
|
|
2581
3609
|
toolsChangedListeners = /* @__PURE__ */ new Set();
|
|
2582
3610
|
resourcesChangedListeners = /* @__PURE__ */ new Set();
|
|
2583
3611
|
promptsChangedListeners = /* @__PURE__ */ new Set();
|
|
3612
|
+
resourceUpdatedListeners = /* @__PURE__ */ new Set();
|
|
2584
3613
|
/** Notified when an HTTP transport (SSE or streamable-http) disconnects. */
|
|
2585
3614
|
disconnectListeners = /* @__PURE__ */ new Set();
|
|
3615
|
+
/** Answers server→client requests (ping, elicitation) on every transport. */
|
|
3616
|
+
serverRequests;
|
|
2586
3617
|
getState() {
|
|
2587
3618
|
return this.state;
|
|
2588
3619
|
}
|
|
@@ -2724,9 +3755,9 @@ var MCPClient = class _MCPClient {
|
|
|
2724
3755
|
"initialize",
|
|
2725
3756
|
{
|
|
2726
3757
|
protocolVersion: MCP_CONSTANTS.PROTOCOL_VERSION,
|
|
2727
|
-
// Client capabilities
|
|
3758
|
+
// Client capabilities: elicitation when the host can ask its user.
|
|
2728
3759
|
// `tools` is a SERVER capability and never belonged here.
|
|
2729
|
-
capabilities:
|
|
3760
|
+
capabilities: this.serverRequests.capabilities(),
|
|
2730
3761
|
clientInfo: MCP_CONSTANTS.CLIENT_INFO
|
|
2731
3762
|
},
|
|
2732
3763
|
this.opts.startupTimeoutMs ?? 1e4
|
|
@@ -2753,106 +3784,10 @@ var MCPClient = class _MCPClient {
|
|
|
2753
3784
|
this.state = "connected";
|
|
2754
3785
|
}
|
|
2755
3786
|
async connectSSE() {
|
|
2756
|
-
|
|
2757
|
-
this.state = "failed";
|
|
2758
|
-
throw new Error('MCP SSE transport requires "url"');
|
|
2759
|
-
}
|
|
2760
|
-
const httpOpts = {
|
|
2761
|
-
name: this.opts.name,
|
|
2762
|
-
url: this.opts.url,
|
|
2763
|
-
headers: this.opts.headers,
|
|
2764
|
-
startupTimeoutMs: this.opts.startupTimeoutMs,
|
|
2765
|
-
requestTimeoutMs: this.opts.requestTimeoutMs,
|
|
2766
|
-
authorizationProvider: this.opts.authorizationProvider,
|
|
2767
|
-
allowPrivateNetworks: this.opts.allowPrivateNetworks
|
|
2768
|
-
};
|
|
2769
|
-
this.sseTransport = new SSETransport(httpOpts);
|
|
2770
|
-
this.sseTransport.onDisconnect(() => {
|
|
2771
|
-
this.state = "disconnected";
|
|
2772
|
-
for (const cb of this.disconnectListeners) {
|
|
2773
|
-
try {
|
|
2774
|
-
cb();
|
|
2775
|
-
} catch {
|
|
2776
|
-
}
|
|
2777
|
-
}
|
|
2778
|
-
});
|
|
2779
|
-
this.sseTransport.onToolsChanged((tools) => {
|
|
2780
|
-
this._tools = tools;
|
|
2781
|
-
this._toolsCache = tools;
|
|
2782
|
-
for (const cb of this.toolsChangedListeners) {
|
|
2783
|
-
try {
|
|
2784
|
-
cb(this.opts.name, tools);
|
|
2785
|
-
} catch {
|
|
2786
|
-
}
|
|
2787
|
-
}
|
|
2788
|
-
});
|
|
2789
|
-
this.sseTransport.onResourcesChanged(() => this.emitCapabilityChanged("resources"));
|
|
2790
|
-
this.sseTransport.onPromptsChanged(() => this.emitCapabilityChanged("prompts"));
|
|
2791
|
-
try {
|
|
2792
|
-
await this.sseTransport.connect();
|
|
2793
|
-
} catch (err) {
|
|
2794
|
-
const t = this.sseTransport;
|
|
2795
|
-
this.sseTransport = void 0;
|
|
2796
|
-
await t.close().catch(() => {
|
|
2797
|
-
});
|
|
2798
|
-
this.state = "failed";
|
|
2799
|
-
throw err;
|
|
2800
|
-
}
|
|
2801
|
-
this._tools = this.sseTransport.listTools();
|
|
2802
|
-
this._toolsCache = this._tools;
|
|
2803
|
-
this._serverMetadata = this.sseTransport.getServerMetadata();
|
|
2804
|
-
this.state = "connected";
|
|
3787
|
+
return connectSSE(this.clientHttpConnectionHost());
|
|
2805
3788
|
}
|
|
2806
3789
|
async connectStreamableHTTP() {
|
|
2807
|
-
|
|
2808
|
-
this.state = "failed";
|
|
2809
|
-
throw new Error('MCP streamable-http transport requires "url"');
|
|
2810
|
-
}
|
|
2811
|
-
const httpOpts = {
|
|
2812
|
-
name: this.opts.name,
|
|
2813
|
-
url: this.opts.url,
|
|
2814
|
-
headers: this.opts.headers,
|
|
2815
|
-
startupTimeoutMs: this.opts.startupTimeoutMs,
|
|
2816
|
-
requestTimeoutMs: this.opts.requestTimeoutMs,
|
|
2817
|
-
authorizationProvider: this.opts.authorizationProvider,
|
|
2818
|
-
allowPrivateNetworks: this.opts.allowPrivateNetworks
|
|
2819
|
-
};
|
|
2820
|
-
this.httpTransport = new StreamableHTTPTransport(httpOpts);
|
|
2821
|
-
this.httpTransport.onDisconnect(() => {
|
|
2822
|
-
this.state = "disconnected";
|
|
2823
|
-
for (const cb of this.disconnectListeners) {
|
|
2824
|
-
try {
|
|
2825
|
-
cb();
|
|
2826
|
-
} catch {
|
|
2827
|
-
}
|
|
2828
|
-
}
|
|
2829
|
-
});
|
|
2830
|
-
this.httpTransport.onToolsChanged((tools) => {
|
|
2831
|
-
this._tools = tools;
|
|
2832
|
-
this._toolsCache = tools;
|
|
2833
|
-
for (const cb of this.toolsChangedListeners) {
|
|
2834
|
-
try {
|
|
2835
|
-
cb(this.opts.name, tools);
|
|
2836
|
-
} catch {
|
|
2837
|
-
}
|
|
2838
|
-
}
|
|
2839
|
-
});
|
|
2840
|
-
this.httpTransport.onResourcesChanged(() => this.emitCapabilityChanged("resources"));
|
|
2841
|
-
this.httpTransport.onPromptsChanged(() => this.emitCapabilityChanged("prompts"));
|
|
2842
|
-
try {
|
|
2843
|
-
await this.httpTransport.connect();
|
|
2844
|
-
} catch (err) {
|
|
2845
|
-
const t = this.httpTransport;
|
|
2846
|
-
this.httpTransport = void 0;
|
|
2847
|
-
await t.close().catch(() => {
|
|
2848
|
-
});
|
|
2849
|
-
this.state = "failed";
|
|
2850
|
-
throw err;
|
|
2851
|
-
}
|
|
2852
|
-
this._tools = this.httpTransport.listTools();
|
|
2853
|
-
this._toolsCache = this._tools;
|
|
2854
|
-
this._serverMetadata = this.httpTransport.getServerMetadata();
|
|
2855
|
-
this.state = "connected";
|
|
3790
|
+
return connectStreamableHTTP(this.clientHttpConnectionHost());
|
|
2856
3791
|
}
|
|
2857
3792
|
async callTool(name, input, opts) {
|
|
2858
3793
|
if (this.state !== "connected") {
|
|
@@ -2865,87 +3800,35 @@ var MCPClient = class _MCPClient {
|
|
|
2865
3800
|
return this.httpTransport.callTool(name, input, opts);
|
|
2866
3801
|
}
|
|
2867
3802
|
const res = await this.request("tools/call", { name, arguments: input }, void 0, opts);
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
3803
|
+
return toToolCallResult(res);
|
|
3804
|
+
}
|
|
3805
|
+
/**
|
|
3806
|
+
* Put the pages of a `-32042` tool-call error to the user (consent first,
|
|
3807
|
+
* nothing opened without it), on the run whose call got the error.
|
|
3808
|
+
*/
|
|
3809
|
+
presentUrlElicitations(elicitations, signal) {
|
|
3810
|
+
return this.serverRequests.presentUrl(elicitations, signal);
|
|
2876
3811
|
}
|
|
2877
3812
|
async listResources(opts = {}) {
|
|
2878
|
-
|
|
2879
|
-
return this.requestCapability(
|
|
2880
|
-
"resources",
|
|
2881
|
-
"resources/list",
|
|
2882
|
-
params,
|
|
2883
|
-
parseListResourcesResult,
|
|
2884
|
-
opts
|
|
2885
|
-
);
|
|
3813
|
+
return this.capabilityClient.listResources(opts);
|
|
2886
3814
|
}
|
|
2887
3815
|
async listResourceTemplates(opts = {}) {
|
|
2888
|
-
|
|
2889
|
-
return this.requestCapability(
|
|
2890
|
-
"resources",
|
|
2891
|
-
"resources/templates/list",
|
|
2892
|
-
params,
|
|
2893
|
-
parseListResourceTemplatesResult,
|
|
2894
|
-
opts
|
|
2895
|
-
);
|
|
3816
|
+
return this.capabilityClient.listResourceTemplates(opts);
|
|
2896
3817
|
}
|
|
2897
3818
|
async readResource(uri, opts = {}) {
|
|
2898
|
-
|
|
2899
|
-
return this.requestCapability(
|
|
2900
|
-
"resources",
|
|
2901
|
-
"resources/read",
|
|
2902
|
-
{ uri },
|
|
2903
|
-
parseReadResourceResult,
|
|
2904
|
-
opts
|
|
2905
|
-
);
|
|
3819
|
+
return this.capabilityClient.readResource(uri, opts);
|
|
2906
3820
|
}
|
|
2907
3821
|
async subscribeResource(uri, opts = {}) {
|
|
2908
|
-
|
|
2909
|
-
this.requireResourceSubscriptions("resources/subscribe");
|
|
2910
|
-
await this.requestCapability(
|
|
2911
|
-
"resources",
|
|
2912
|
-
"resources/subscribe",
|
|
2913
|
-
{ uri },
|
|
2914
|
-
parseEmptyResult,
|
|
2915
|
-
opts
|
|
2916
|
-
);
|
|
3822
|
+
return this.capabilityClient.subscribeResource(uri, opts);
|
|
2917
3823
|
}
|
|
2918
3824
|
async unsubscribeResource(uri, opts = {}) {
|
|
2919
|
-
|
|
2920
|
-
this.requireResourceSubscriptions("resources/unsubscribe");
|
|
2921
|
-
await this.requestCapability(
|
|
2922
|
-
"resources",
|
|
2923
|
-
"resources/unsubscribe",
|
|
2924
|
-
{ uri },
|
|
2925
|
-
parseEmptyResult,
|
|
2926
|
-
opts
|
|
2927
|
-
);
|
|
3825
|
+
return this.capabilityClient.unsubscribeResource(uri, opts);
|
|
2928
3826
|
}
|
|
2929
3827
|
async listPrompts(opts = {}) {
|
|
2930
|
-
|
|
2931
|
-
return this.requestCapability("prompts", "prompts/list", params, parseListPromptsResult, opts);
|
|
3828
|
+
return this.capabilityClient.listPrompts(opts);
|
|
2932
3829
|
}
|
|
2933
3830
|
async getPrompt(name, args, opts = {}) {
|
|
2934
|
-
|
|
2935
|
-
if (args && Object.keys(args).length > 64) {
|
|
2936
|
-
throw new Error("MCP prompt arguments exceed the limit of 64");
|
|
2937
|
-
}
|
|
2938
|
-
for (const [key, value] of Object.entries(args ?? {})) {
|
|
2939
|
-
validateProtocolString(key, "prompt argument name");
|
|
2940
|
-
validateProtocolString(value, `prompt argument "${key}"`, true);
|
|
2941
|
-
}
|
|
2942
|
-
return this.requestCapability(
|
|
2943
|
-
"prompts",
|
|
2944
|
-
"prompts/get",
|
|
2945
|
-
args === void 0 ? { name } : { name, arguments: args },
|
|
2946
|
-
parseGetPromptResult,
|
|
2947
|
-
opts
|
|
2948
|
-
);
|
|
3831
|
+
return this.capabilityClient.getPrompt(name, args, opts);
|
|
2949
3832
|
}
|
|
2950
3833
|
async close() {
|
|
2951
3834
|
if (this.closePromise) return this.closePromise;
|
|
@@ -2998,6 +3881,7 @@ var MCPClient = class _MCPClient {
|
|
|
2998
3881
|
this.child = void 0;
|
|
2999
3882
|
}
|
|
3000
3883
|
this.failPending(`MCP "${this.opts.name}" closed`);
|
|
3884
|
+
this.serverRequests.dispose();
|
|
3001
3885
|
await Promise.allSettled([this.sseTransport?.close(), this.httpTransport?.close()]);
|
|
3002
3886
|
this.state = "disconnected";
|
|
3003
3887
|
}
|
|
@@ -3031,26 +3915,31 @@ var MCPClient = class _MCPClient {
|
|
|
3031
3915
|
const detach = () => {
|
|
3032
3916
|
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
3033
3917
|
};
|
|
3034
|
-
const
|
|
3918
|
+
const onTimeout = () => {
|
|
3919
|
+
if (this.serverRequests.awaitingUser) {
|
|
3920
|
+
entry.timer = setTimeout(onTimeout, timeoutMs);
|
|
3921
|
+
return;
|
|
3922
|
+
}
|
|
3035
3923
|
this.pending.delete(id);
|
|
3036
3924
|
detach();
|
|
3037
3925
|
reject(
|
|
3038
3926
|
new Error(`MCP "${this.opts.name}" request "${method}" timed out after ${timeoutMs}ms`)
|
|
3039
3927
|
);
|
|
3040
|
-
}
|
|
3041
|
-
|
|
3928
|
+
};
|
|
3929
|
+
const entry = {
|
|
3042
3930
|
resolve: (res) => {
|
|
3043
|
-
clearTimeout(timer);
|
|
3931
|
+
clearTimeout(entry.timer);
|
|
3044
3932
|
detach();
|
|
3045
3933
|
resolve(res);
|
|
3046
3934
|
},
|
|
3047
3935
|
reject: (err) => {
|
|
3048
|
-
clearTimeout(timer);
|
|
3936
|
+
clearTimeout(entry.timer);
|
|
3049
3937
|
detach();
|
|
3050
3938
|
reject(err);
|
|
3051
3939
|
},
|
|
3052
|
-
timer
|
|
3053
|
-
}
|
|
3940
|
+
timer: setTimeout(onTimeout, timeoutMs)
|
|
3941
|
+
};
|
|
3942
|
+
this.pending.set(id, entry);
|
|
3054
3943
|
const stdin = this.child?.stdin;
|
|
3055
3944
|
if (!stdin || stdin.destroyed) {
|
|
3056
3945
|
const pending = this.pending.get(id);
|
|
@@ -3212,7 +4101,7 @@ var MCPClient = class _MCPClient {
|
|
|
3212
4101
|
if (typeof envelope["method"] === "string") {
|
|
3213
4102
|
const id = envelope["id"];
|
|
3214
4103
|
if (typeof id === "number" || typeof id === "string") {
|
|
3215
|
-
this.handleServerRequest({
|
|
4104
|
+
void this.handleServerRequest({
|
|
3216
4105
|
jsonrpc: "2.0",
|
|
3217
4106
|
id,
|
|
3218
4107
|
method: envelope["method"],
|
|
@@ -3221,12 +4110,17 @@ var MCPClient = class _MCPClient {
|
|
|
3221
4110
|
return;
|
|
3222
4111
|
}
|
|
3223
4112
|
if (Object.hasOwn(envelope, "id")) return;
|
|
3224
|
-
if (envelope["method"] === "notifications/
|
|
4113
|
+
if (envelope["method"] === "notifications/cancelled") {
|
|
4114
|
+
this.serverRequests.cancel(envelope["params"]);
|
|
4115
|
+
} else if (envelope["method"] === "notifications/tools/list_changed") {
|
|
3225
4116
|
void this.handleToolsListChanged();
|
|
3226
4117
|
} else if (envelope["method"] === "notifications/resources/list_changed") {
|
|
3227
4118
|
this.emitCapabilityChanged("resources");
|
|
3228
4119
|
} else if (envelope["method"] === "notifications/prompts/list_changed") {
|
|
3229
4120
|
this.emitCapabilityChanged("prompts");
|
|
4121
|
+
} else if (envelope["method"] === "notifications/resources/updated") {
|
|
4122
|
+
const uri = resourceUpdatedUri(envelope["params"]);
|
|
4123
|
+
if (uri) this.emitResourceUpdated(uri);
|
|
3230
4124
|
}
|
|
3231
4125
|
return;
|
|
3232
4126
|
}
|
|
@@ -3238,15 +4132,8 @@ var MCPClient = class _MCPClient {
|
|
|
3238
4132
|
entry?.resolve(response);
|
|
3239
4133
|
}
|
|
3240
4134
|
}
|
|
3241
|
-
handleServerRequest(request3) {
|
|
3242
|
-
const response =
|
|
3243
|
-
jsonrpc: "2.0",
|
|
3244
|
-
id: request3.id,
|
|
3245
|
-
error: {
|
|
3246
|
-
code: -32601,
|
|
3247
|
-
message: request3.method === "sampling/createMessage" ? "Client sampling is disabled by policy" : `Method not found: ${request3.method}`
|
|
3248
|
-
}
|
|
3249
|
-
};
|
|
4135
|
+
async handleServerRequest(request3) {
|
|
4136
|
+
const response = await this.serverRequests.answer(request3);
|
|
3250
4137
|
try {
|
|
3251
4138
|
this.child?.stdin?.write(`${JSON.stringify(response)}
|
|
3252
4139
|
`);
|
|
@@ -3286,12 +4173,26 @@ var MCPClient = class _MCPClient {
|
|
|
3286
4173
|
removeResourcesChangedListener(listener) {
|
|
3287
4174
|
this.resourcesChangedListeners.delete(listener);
|
|
3288
4175
|
}
|
|
4176
|
+
addResourceUpdatedListener(listener) {
|
|
4177
|
+
this.resourceUpdatedListeners.add(listener);
|
|
4178
|
+
}
|
|
4179
|
+
removeResourceUpdatedListener(listener) {
|
|
4180
|
+
this.resourceUpdatedListeners.delete(listener);
|
|
4181
|
+
}
|
|
3289
4182
|
addPromptsChangedListener(listener) {
|
|
3290
4183
|
this.promptsChangedListeners.add(listener);
|
|
3291
4184
|
}
|
|
3292
4185
|
removePromptsChangedListener(listener) {
|
|
3293
4186
|
this.promptsChangedListeners.delete(listener);
|
|
3294
4187
|
}
|
|
4188
|
+
emitResourceUpdated(uri) {
|
|
4189
|
+
for (const listener of this.resourceUpdatedListeners) {
|
|
4190
|
+
try {
|
|
4191
|
+
listener(this.opts.name, uri);
|
|
4192
|
+
} catch {
|
|
4193
|
+
}
|
|
4194
|
+
}
|
|
4195
|
+
}
|
|
3295
4196
|
emitCapabilityChanged(capability) {
|
|
3296
4197
|
const listeners = capability === "resources" ? this.resourcesChangedListeners : this.promptsChangedListeners;
|
|
3297
4198
|
for (const listener of listeners) {
|
|
@@ -3301,8 +4202,99 @@ var MCPClient = class _MCPClient {
|
|
|
3301
4202
|
}
|
|
3302
4203
|
}
|
|
3303
4204
|
}
|
|
4205
|
+
clientHttpConnectionHost() {
|
|
4206
|
+
const self = this;
|
|
4207
|
+
return {
|
|
4208
|
+
get opts() {
|
|
4209
|
+
return self.opts;
|
|
4210
|
+
},
|
|
4211
|
+
get state() {
|
|
4212
|
+
return self.state;
|
|
4213
|
+
},
|
|
4214
|
+
set state(value) {
|
|
4215
|
+
self.state = value;
|
|
4216
|
+
},
|
|
4217
|
+
get sseTransport() {
|
|
4218
|
+
return self.sseTransport;
|
|
4219
|
+
},
|
|
4220
|
+
set sseTransport(value) {
|
|
4221
|
+
self.sseTransport = value;
|
|
4222
|
+
},
|
|
4223
|
+
disconnectListeners: this.disconnectListeners,
|
|
4224
|
+
get _tools() {
|
|
4225
|
+
return self._tools;
|
|
4226
|
+
},
|
|
4227
|
+
set _tools(value) {
|
|
4228
|
+
self._tools = value;
|
|
4229
|
+
},
|
|
4230
|
+
get _toolsCache() {
|
|
4231
|
+
return self._toolsCache;
|
|
4232
|
+
},
|
|
4233
|
+
set _toolsCache(value) {
|
|
4234
|
+
self._toolsCache = value;
|
|
4235
|
+
},
|
|
4236
|
+
toolsChangedListeners: this.toolsChangedListeners,
|
|
4237
|
+
emitCapabilityChanged: (...args) => this.emitCapabilityChanged(...args),
|
|
4238
|
+
emitResourceUpdated: (uri) => this.emitResourceUpdated(uri),
|
|
4239
|
+
get _serverMetadata() {
|
|
4240
|
+
return self._serverMetadata;
|
|
4241
|
+
},
|
|
4242
|
+
set _serverMetadata(value) {
|
|
4243
|
+
self._serverMetadata = value;
|
|
4244
|
+
},
|
|
4245
|
+
get httpTransport() {
|
|
4246
|
+
return self.httpTransport;
|
|
4247
|
+
},
|
|
4248
|
+
set httpTransport(value) {
|
|
4249
|
+
self.httpTransport = value;
|
|
4250
|
+
},
|
|
4251
|
+
serverRequests: this.serverRequests
|
|
4252
|
+
};
|
|
4253
|
+
}
|
|
3304
4254
|
};
|
|
3305
4255
|
|
|
4256
|
+
// src/config-env.ts
|
|
4257
|
+
var PLACEHOLDER = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
|
|
4258
|
+
function mcpEnvPlaceholders(cfg) {
|
|
4259
|
+
const names = /* @__PURE__ */ new Set();
|
|
4260
|
+
const scan = (value) => {
|
|
4261
|
+
if (!value) return;
|
|
4262
|
+
for (const match of value.matchAll(PLACEHOLDER)) if (match[1]) names.add(match[1]);
|
|
4263
|
+
};
|
|
4264
|
+
scan(cfg.command);
|
|
4265
|
+
scan(cfg.url);
|
|
4266
|
+
for (const arg of cfg.args ?? []) scan(arg);
|
|
4267
|
+
for (const value of Object.values(cfg.env ?? {})) scan(value);
|
|
4268
|
+
for (const value of Object.values(cfg.headers ?? {})) scan(value);
|
|
4269
|
+
return [...names].sort();
|
|
4270
|
+
}
|
|
4271
|
+
function expandMcpEnvPlaceholders(cfg, env = process.env) {
|
|
4272
|
+
const missing = /* @__PURE__ */ new Set();
|
|
4273
|
+
const expand = (value) => value.replace(PLACEHOLDER, (_match, name, fallback) => {
|
|
4274
|
+
const resolved = env[name];
|
|
4275
|
+
if (resolved !== void 0 && resolved !== "") return resolved;
|
|
4276
|
+
if (fallback !== void 0) return fallback;
|
|
4277
|
+
missing.add(name);
|
|
4278
|
+
return "";
|
|
4279
|
+
});
|
|
4280
|
+
const mapValues = (record3) => record3 ? Object.fromEntries(Object.entries(record3).map(([key, value]) => [key, expand(value)])) : void 0;
|
|
4281
|
+
const expanded = {
|
|
4282
|
+
command: cfg.command === void 0 ? void 0 : expand(cfg.command),
|
|
4283
|
+
args: cfg.args?.map(expand),
|
|
4284
|
+
env: mapValues(cfg.env),
|
|
4285
|
+
url: cfg.url === void 0 ? void 0 : expand(cfg.url),
|
|
4286
|
+
headers: mapValues(cfg.headers)
|
|
4287
|
+
};
|
|
4288
|
+
if (missing.size > 0) {
|
|
4289
|
+
throw new Error(
|
|
4290
|
+
`MCP server "${cfg.name}" needs environment variable${missing.size === 1 ? "" : "s"} ${[
|
|
4291
|
+
...missing
|
|
4292
|
+
].join(", ")} (referenced as \${\u2026} in its config)`
|
|
4293
|
+
);
|
|
4294
|
+
}
|
|
4295
|
+
return expanded;
|
|
4296
|
+
}
|
|
4297
|
+
|
|
3306
4298
|
// src/content-selection.ts
|
|
3307
4299
|
var DEFAULT_MCP_INSERTION_MAX_BYTES = 256 * 1024;
|
|
3308
4300
|
var DEFAULT_MCP_RESOURCE_SCHEMES = [
|
|
@@ -3423,21 +4415,247 @@ function base64DecodedBytes(blob) {
|
|
|
3423
4415
|
function utf8Bytes(value) {
|
|
3424
4416
|
return new TextEncoder().encode(value).byteLength;
|
|
3425
4417
|
}
|
|
3426
|
-
function requireIdentity(value,
|
|
4418
|
+
function requireIdentity(value, label2) {
|
|
3427
4419
|
if (value.length === 0 || value.length > 256) {
|
|
3428
|
-
throw new Error(`MCP insertion ${
|
|
4420
|
+
throw new Error(`MCP insertion ${label2} must contain 1\u2013256 characters`);
|
|
3429
4421
|
}
|
|
3430
4422
|
}
|
|
3431
4423
|
|
|
4424
|
+
// src/elicitation-form.ts
|
|
4425
|
+
import { randomUUID } from "node:crypto";
|
|
4426
|
+
import { hostname } from "node:os";
|
|
4427
|
+
var MAX_ATTEMPTS = 3;
|
|
4428
|
+
var CONFIRM_QUESTION = "confirm";
|
|
4429
|
+
var URL_QUESTION = "url";
|
|
4430
|
+
function questionId(index) {
|
|
4431
|
+
return `f${index}`;
|
|
4432
|
+
}
|
|
4433
|
+
function optionId(index) {
|
|
4434
|
+
return `o${index}`;
|
|
4435
|
+
}
|
|
4436
|
+
function bounds(min, max, unit = "") {
|
|
4437
|
+
if (min !== void 0 && max !== void 0) return `Between ${min} and ${max}${unit}.`;
|
|
4438
|
+
if (min !== void 0) return `At least ${min}${unit}.`;
|
|
4439
|
+
if (max !== void 0) return `At most ${max}${unit}.`;
|
|
4440
|
+
return void 0;
|
|
4441
|
+
}
|
|
4442
|
+
function describe(field) {
|
|
4443
|
+
const notes = [field.description];
|
|
4444
|
+
if (field.kind === "string") {
|
|
4445
|
+
notes.push(field.format ? `Format: ${field.format}.` : void 0);
|
|
4446
|
+
notes.push(bounds(field.minLength, field.maxLength, " characters"));
|
|
4447
|
+
} else if (field.kind === "number" || field.kind === "integer") {
|
|
4448
|
+
notes.push(field.kind === "integer" ? "A whole number." : "A number.");
|
|
4449
|
+
notes.push(bounds(field.minimum, field.maximum));
|
|
4450
|
+
} else if (field.kind === "multi-enum") {
|
|
4451
|
+
notes.push(bounds(field.minItems, field.maxItems, " choices"));
|
|
4452
|
+
}
|
|
4453
|
+
const text = notes.filter(Boolean).join(" ");
|
|
4454
|
+
return text || void 0;
|
|
4455
|
+
}
|
|
4456
|
+
function toQuestion(field, index) {
|
|
4457
|
+
const base = {
|
|
4458
|
+
id: questionId(index),
|
|
4459
|
+
prompt: field.title ?? field.name,
|
|
4460
|
+
description: describe(field),
|
|
4461
|
+
required: field.required
|
|
4462
|
+
};
|
|
4463
|
+
switch (field.kind) {
|
|
4464
|
+
case "string":
|
|
4465
|
+
return { ...base, kind: "text", recommendedText: field.default, placeholder: field.format };
|
|
4466
|
+
case "number":
|
|
4467
|
+
case "integer":
|
|
4468
|
+
return {
|
|
4469
|
+
...base,
|
|
4470
|
+
kind: "text",
|
|
4471
|
+
recommendedText: field.default === void 0 ? void 0 : String(field.default)
|
|
4472
|
+
};
|
|
4473
|
+
case "boolean":
|
|
4474
|
+
return {
|
|
4475
|
+
...base,
|
|
4476
|
+
kind: "single_select",
|
|
4477
|
+
options: [
|
|
4478
|
+
{ id: "yes", label: "Yes" },
|
|
4479
|
+
{ id: "no", label: "No" }
|
|
4480
|
+
],
|
|
4481
|
+
recommendedOptionIds: field.default === void 0 ? void 0 : [field.default ? "yes" : "no"]
|
|
4482
|
+
};
|
|
4483
|
+
case "enum":
|
|
4484
|
+
case "multi-enum": {
|
|
4485
|
+
const defaults = field.kind === "enum" ? [field.default] : field.default ?? [];
|
|
4486
|
+
const recommended = field.options.map((option, i) => defaults.includes(option.value) ? optionId(i) : void 0).filter((id) => id !== void 0);
|
|
4487
|
+
return {
|
|
4488
|
+
...base,
|
|
4489
|
+
kind: field.kind === "enum" ? "single_select" : "multi_select",
|
|
4490
|
+
options: field.options.map((option, i) => ({ id: optionId(i), label: option.label })),
|
|
4491
|
+
recommendedOptionIds: recommended.length > 0 ? recommended : void 0
|
|
4492
|
+
};
|
|
4493
|
+
}
|
|
4494
|
+
}
|
|
4495
|
+
}
|
|
4496
|
+
function pageWarnings(url) {
|
|
4497
|
+
const warnings = [];
|
|
4498
|
+
if (url.hostname.split(".").some((label2) => label2.startsWith("xn--"))) {
|
|
4499
|
+
warnings.push(
|
|
4500
|
+
"The address uses international characters (punycode); it may imitate another site."
|
|
4501
|
+
);
|
|
4502
|
+
}
|
|
4503
|
+
const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
4504
|
+
if (url.protocol === "http:" && !local) {
|
|
4505
|
+
warnings.push("The page is not served over HTTPS.");
|
|
4506
|
+
}
|
|
4507
|
+
if (url.username || url.password) warnings.push("The address carries a user name or password.");
|
|
4508
|
+
return warnings;
|
|
4509
|
+
}
|
|
4510
|
+
function toPageRequest(request3, canOpen) {
|
|
4511
|
+
const url = new URL(request3.url);
|
|
4512
|
+
const description = [
|
|
4513
|
+
request3.message,
|
|
4514
|
+
`Page: ${request3.url}`,
|
|
4515
|
+
`Site: ${url.host}`,
|
|
4516
|
+
...pageWarnings(url).map((warning) => `Warning: ${warning}`),
|
|
4517
|
+
"What you do on that page goes to the server directly, not through WrongStack."
|
|
4518
|
+
].join("\n\n");
|
|
4519
|
+
return {
|
|
4520
|
+
id: `mcp-elicitation-${randomUUID()}`,
|
|
4521
|
+
title: `MCP server "${request3.server}" asks you to open ${url.host}`,
|
|
4522
|
+
description,
|
|
4523
|
+
submitLabel: "Continue",
|
|
4524
|
+
tabs: [
|
|
4525
|
+
{
|
|
4526
|
+
id: "page",
|
|
4527
|
+
label: request3.server,
|
|
4528
|
+
questions: [
|
|
4529
|
+
{
|
|
4530
|
+
id: URL_QUESTION,
|
|
4531
|
+
prompt: `Open ${url.host}?`,
|
|
4532
|
+
kind: "single_select",
|
|
4533
|
+
required: true,
|
|
4534
|
+
options: [
|
|
4535
|
+
// Named: with `wstack remote` this process runs on another machine.
|
|
4536
|
+
...canOpen ? [{ id: "open", label: `Open it in the browser on ${hostname()}` }] : [],
|
|
4537
|
+
{ id: "self", label: "I will open it myself" },
|
|
4538
|
+
{ id: "decline", label: "Decline" }
|
|
4539
|
+
]
|
|
4540
|
+
}
|
|
4541
|
+
]
|
|
4542
|
+
}
|
|
4543
|
+
]
|
|
4544
|
+
};
|
|
4545
|
+
}
|
|
4546
|
+
function toUserInputRequest(request3, problem) {
|
|
4547
|
+
const questions = request3.fields.length > 0 ? request3.fields.map(toQuestion) : [
|
|
4548
|
+
{
|
|
4549
|
+
id: CONFIRM_QUESTION,
|
|
4550
|
+
prompt: "Go ahead?",
|
|
4551
|
+
kind: "single_select",
|
|
4552
|
+
required: true,
|
|
4553
|
+
options: [
|
|
4554
|
+
{ id: "yes", label: "Yes" },
|
|
4555
|
+
{ id: "no", label: "No" }
|
|
4556
|
+
]
|
|
4557
|
+
}
|
|
4558
|
+
];
|
|
4559
|
+
const description = [
|
|
4560
|
+
request3.message,
|
|
4561
|
+
`Only share what you are comfortable sending to "${request3.server}".`,
|
|
4562
|
+
problem ? `The last answer was not accepted: ${problem}.` : void 0
|
|
4563
|
+
].filter(Boolean).join("\n\n");
|
|
4564
|
+
return {
|
|
4565
|
+
id: `mcp-elicitation-${randomUUID()}`,
|
|
4566
|
+
title: `MCP server "${request3.server}" is asking for input`,
|
|
4567
|
+
description,
|
|
4568
|
+
submitLabel: `Send to ${request3.server}`,
|
|
4569
|
+
tabs: [{ id: "form", label: request3.server, questions }]
|
|
4570
|
+
};
|
|
4571
|
+
}
|
|
4572
|
+
function answerValue(field, answer) {
|
|
4573
|
+
if (!answer || answer.delegated) return void 0;
|
|
4574
|
+
switch (field.kind) {
|
|
4575
|
+
case "string":
|
|
4576
|
+
return answer.text?.trim() ? answer.text : void 0;
|
|
4577
|
+
case "number":
|
|
4578
|
+
case "integer":
|
|
4579
|
+
return answer.text?.trim() || void 0;
|
|
4580
|
+
case "boolean": {
|
|
4581
|
+
const picked = answer.selectedOptionIds[0];
|
|
4582
|
+
return picked === "yes" ? true : picked === "no" ? false : void 0;
|
|
4583
|
+
}
|
|
4584
|
+
case "enum":
|
|
4585
|
+
case "multi-enum": {
|
|
4586
|
+
const values = answer.selectedOptionIds.map((id) => field.options[Number(id.slice(1))]?.value).filter((v) => v !== void 0);
|
|
4587
|
+
if (field.kind === "enum") return values[0];
|
|
4588
|
+
return values;
|
|
4589
|
+
}
|
|
4590
|
+
}
|
|
4591
|
+
}
|
|
4592
|
+
async function elicitViaUserInput(request3, fallback, options = {}) {
|
|
4593
|
+
const target = request3.requester ?? fallback;
|
|
4594
|
+
if (!target) return { action: "cancel" };
|
|
4595
|
+
if (request3.mode === "url") {
|
|
4596
|
+
if (request3.signal.aborted) return { action: "cancel" };
|
|
4597
|
+
const response = await target.requestUserInput(
|
|
4598
|
+
toPageRequest(request3, options.openUrl !== void 0),
|
|
4599
|
+
request3.signal
|
|
4600
|
+
);
|
|
4601
|
+
if (!response || request3.signal.aborted) return { action: "cancel" };
|
|
4602
|
+
if (response.status !== "submitted") return { action: "decline" };
|
|
4603
|
+
const choice = response.answers.find((a) => a.questionId === URL_QUESTION)?.selectedOptionIds[0];
|
|
4604
|
+
if (choice === "open" && options.openUrl) {
|
|
4605
|
+
options.openUrl(request3.url);
|
|
4606
|
+
return { action: "accept", content: {} };
|
|
4607
|
+
}
|
|
4608
|
+
if (choice === "self") return { action: "accept", content: {} };
|
|
4609
|
+
return { action: "decline" };
|
|
4610
|
+
}
|
|
4611
|
+
const form = request3;
|
|
4612
|
+
let problem;
|
|
4613
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
4614
|
+
if (request3.signal.aborted) return { action: "cancel" };
|
|
4615
|
+
const response = await target.requestUserInput(
|
|
4616
|
+
toUserInputRequest(form, problem),
|
|
4617
|
+
request3.signal
|
|
4618
|
+
);
|
|
4619
|
+
if (!response || request3.signal.aborted) return { action: "cancel" };
|
|
4620
|
+
if (response.status !== "submitted") return { action: "decline" };
|
|
4621
|
+
const byId = new Map(response.answers.map((answer) => [answer.questionId, answer]));
|
|
4622
|
+
if (form.fields.length === 0) {
|
|
4623
|
+
return byId.get(CONFIRM_QUESTION)?.selectedOptionIds[0] === "yes" ? { action: "accept", content: {} } : { action: "decline" };
|
|
4624
|
+
}
|
|
4625
|
+
const content = {};
|
|
4626
|
+
form.fields.forEach((field, i) => {
|
|
4627
|
+
content[field.name] = answerValue(field, byId.get(questionId(i)));
|
|
4628
|
+
});
|
|
4629
|
+
const checked = validateElicitationContent(form.fields, content);
|
|
4630
|
+
if (checked.ok) return { action: "accept", content: checked.value };
|
|
4631
|
+
problem = checked.error;
|
|
4632
|
+
}
|
|
4633
|
+
return { action: "cancel" };
|
|
4634
|
+
}
|
|
4635
|
+
|
|
3432
4636
|
// src/manage.ts
|
|
3433
4637
|
import { randomBytes as randomBytes3 } from "node:crypto";
|
|
3434
4638
|
import * as fs from "node:fs/promises";
|
|
3435
4639
|
async function readConfig(path2) {
|
|
4640
|
+
let raw;
|
|
4641
|
+
try {
|
|
4642
|
+
raw = await fs.readFile(path2, "utf8");
|
|
4643
|
+
} catch (error) {
|
|
4644
|
+
if (error.code === "ENOENT") return {};
|
|
4645
|
+
throw error;
|
|
4646
|
+
}
|
|
4647
|
+
let parsed;
|
|
3436
4648
|
try {
|
|
3437
|
-
|
|
4649
|
+
parsed = JSON.parse(raw);
|
|
3438
4650
|
} catch {
|
|
3439
|
-
|
|
4651
|
+
throw new Error(
|
|
4652
|
+
`Profile config "${path2}" is not valid JSON \u2014 fix or remove it before changing MCP servers`
|
|
4653
|
+
);
|
|
4654
|
+
}
|
|
4655
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
4656
|
+
throw new Error(`Profile config "${path2}" must contain a JSON object`);
|
|
3440
4657
|
}
|
|
4658
|
+
return parsed;
|
|
3441
4659
|
}
|
|
3442
4660
|
async function writeConfig(path2, cfg) {
|
|
3443
4661
|
const raw = JSON.stringify(cfg, null, 2);
|
|
@@ -3509,6 +4727,8 @@ function buildConfig(input, base) {
|
|
|
3509
4727
|
if (url !== void 0) cfg.url = url;
|
|
3510
4728
|
const headers = input.headers ?? base?.headers;
|
|
3511
4729
|
if (headers !== void 0) cfg.headers = headers;
|
|
4730
|
+
const bearerTokenEnv = input.bearerTokenEnv ?? base?.bearerTokenEnv;
|
|
4731
|
+
if (bearerTokenEnv !== void 0) cfg.bearerTokenEnv = bearerTokenEnv;
|
|
3512
4732
|
const allowedTools = input.allowedTools ?? base?.allowedTools;
|
|
3513
4733
|
if (allowedTools !== void 0) cfg.allowedTools = allowedTools;
|
|
3514
4734
|
const permission = input.permission ?? base?.permission;
|
|
@@ -3649,11 +4869,11 @@ async function disableMcp(name, deps) {
|
|
|
3649
4869
|
}
|
|
3650
4870
|
const cfg = servers[name];
|
|
3651
4871
|
if (!cfg) return { ok: false, message: `Server "${name}" is not in config.` };
|
|
3652
|
-
await safeStop(name, deps);
|
|
3653
4872
|
cfg.enabled = false;
|
|
3654
|
-
trackDisabled(deps.registry, { ...cfg, name });
|
|
3655
4873
|
servers[name] = cfg;
|
|
3656
4874
|
await persist(deps.configPath, full, servers);
|
|
4875
|
+
trackDisabled(deps.registry, { ...cfg, name });
|
|
4876
|
+
await safeStop(name, deps);
|
|
3657
4877
|
return {
|
|
3658
4878
|
ok: true,
|
|
3659
4879
|
message: `Server "${name}" disabled`,
|
|
@@ -3753,6 +4973,7 @@ function manifestConfigHash(cfg) {
|
|
|
3753
4973
|
url: cfg.url ?? null,
|
|
3754
4974
|
env: sortedEntries(cfg.env),
|
|
3755
4975
|
headers: sortedEntries(cfg.headers),
|
|
4976
|
+
bearerTokenEnv: cfg.bearerTokenEnv ?? null,
|
|
3756
4977
|
passthroughEnv: passthroughEntries(cfg.passthroughEnv)
|
|
3757
4978
|
});
|
|
3758
4979
|
return createHash2("sha256").update(basis).digest("hex").slice(0, 16);
|
|
@@ -3963,6 +5184,13 @@ function requireHttpServerConfig(servers, disabledServers, name) {
|
|
|
3963
5184
|
}
|
|
3964
5185
|
return cfg;
|
|
3965
5186
|
}
|
|
5187
|
+
async function loginRegistryAuthorization(manager, cfg, name, input) {
|
|
5188
|
+
return requireAuthorizationManager(manager).beginLogin({
|
|
5189
|
+
serverName: name,
|
|
5190
|
+
resource: cfg.url,
|
|
5191
|
+
...input
|
|
5192
|
+
});
|
|
5193
|
+
}
|
|
3966
5194
|
async function beginRegistryAuthorization(manager, cfg, name, input) {
|
|
3967
5195
|
return requireAuthorizationManager(manager).begin({
|
|
3968
5196
|
serverName: name,
|
|
@@ -4020,6 +5248,56 @@ import { expectDefined } from "@wrongstack/core/utils";
|
|
|
4020
5248
|
// src/wrap-tool.ts
|
|
4021
5249
|
import { ToolCapabilities } from "@wrongstack/core/security";
|
|
4022
5250
|
import { mcpQualifiedToolName } from "@wrongstack/core/utils";
|
|
5251
|
+
|
|
5252
|
+
// src/structured-result.ts
|
|
5253
|
+
import { validateAgainstSchema } from "@wrongstack/core/utils";
|
|
5254
|
+
function canonical(value) {
|
|
5255
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
5256
|
+
if (value && typeof value === "object") {
|
|
5257
|
+
const entries = Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`);
|
|
5258
|
+
return `{${entries.join(",")}}`;
|
|
5259
|
+
}
|
|
5260
|
+
return JSON.stringify(value) ?? "null";
|
|
5261
|
+
}
|
|
5262
|
+
function textBlocks(content) {
|
|
5263
|
+
if (typeof content === "string") return [content];
|
|
5264
|
+
if (!Array.isArray(content)) return [];
|
|
5265
|
+
return content.flatMap(
|
|
5266
|
+
(block) => block && typeof block === "object" && block.type === "text" ? [String(block.text ?? "")] : []
|
|
5267
|
+
);
|
|
5268
|
+
}
|
|
5269
|
+
function alreadyInText(content, structured) {
|
|
5270
|
+
const wanted = canonical(structured);
|
|
5271
|
+
return textBlocks(content).some((text) => {
|
|
5272
|
+
const trimmed = text.trim();
|
|
5273
|
+
if (!trimmed.startsWith("{")) return false;
|
|
5274
|
+
try {
|
|
5275
|
+
return canonical(JSON.parse(trimmed)) === wanted;
|
|
5276
|
+
} catch {
|
|
5277
|
+
return false;
|
|
5278
|
+
}
|
|
5279
|
+
});
|
|
5280
|
+
}
|
|
5281
|
+
function renderStructuredResult(rendered, content, structured, outputSchema) {
|
|
5282
|
+
if (!structured) return rendered;
|
|
5283
|
+
const parts = [rendered];
|
|
5284
|
+
if (!alreadyInText(content, structured)) {
|
|
5285
|
+
parts.push(`Structured result:
|
|
5286
|
+
${JSON.stringify(structured, null, 2)}`);
|
|
5287
|
+
}
|
|
5288
|
+
if (outputSchema) {
|
|
5289
|
+
const check = validateAgainstSchema(structured, outputSchema);
|
|
5290
|
+
if (!check.ok) {
|
|
5291
|
+
const problems = check.errors.slice(0, 3).map((e) => `${e.path || "(root)"}: ${e.message}`).join("; ");
|
|
5292
|
+
parts.push(
|
|
5293
|
+
`[The structured result does not match the tool's declared output schema \u2014 ${problems}]`
|
|
5294
|
+
);
|
|
5295
|
+
}
|
|
5296
|
+
}
|
|
5297
|
+
return parts.filter((part) => part.length > 0).join("\n\n");
|
|
5298
|
+
}
|
|
5299
|
+
|
|
5300
|
+
// src/wrap-tool.ts
|
|
4023
5301
|
var MUTATING_RE = /create|update|delete|write|send|set|put|post|patch|remove|rename|move/i;
|
|
4024
5302
|
function isMutatingTool(mcpTool) {
|
|
4025
5303
|
if (MUTATING_RE.test(mcpTool.name)) return true;
|
|
@@ -4046,31 +5324,56 @@ function wrapMCPTool(serverName, mcpTool, client, permission = "confirm", observ
|
|
|
4046
5324
|
inputSchema: mcpTool.inputSchema ?? { type: "object", properties: {} },
|
|
4047
5325
|
async execute(input, ctx, opts) {
|
|
4048
5326
|
const startedAt = Date.now();
|
|
4049
|
-
observer?.onStart();
|
|
5327
|
+
observer?.onStart(ctx);
|
|
4050
5328
|
let ok = false;
|
|
4051
5329
|
try {
|
|
4052
5330
|
const live = typeof client === "function" ? await client() : client;
|
|
4053
5331
|
const signal = opts?.signal ?? ctx?.signal;
|
|
4054
5332
|
const res = await live.callTool(mcpTool.name, input, signal ? { signal } : void 0);
|
|
5333
|
+
if (res.urlElicitations && res.urlElicitations.length > 0) {
|
|
5334
|
+
const outcomes = await live.presentUrlElicitations(
|
|
5335
|
+
res.urlElicitations,
|
|
5336
|
+
signal ?? new AbortController().signal
|
|
5337
|
+
);
|
|
5338
|
+
throw new Error(describeUrlOutcomes(serverName, qualifiedName, outcomes));
|
|
5339
|
+
}
|
|
4055
5340
|
if (res.isError) {
|
|
4056
5341
|
const errText = stringify(res.content);
|
|
4057
5342
|
throw new Error(errText || `MCP tool "${qualifiedName}" failed`);
|
|
4058
5343
|
}
|
|
4059
5344
|
ok = true;
|
|
4060
|
-
return
|
|
5345
|
+
return renderStructuredResult(
|
|
5346
|
+
stringify(res.content),
|
|
5347
|
+
res.content,
|
|
5348
|
+
res.structuredContent,
|
|
5349
|
+
mcpTool.outputSchema
|
|
5350
|
+
);
|
|
4061
5351
|
} finally {
|
|
4062
|
-
observer?.onFinish({ durationMs: Date.now() - startedAt, ok });
|
|
5352
|
+
observer?.onFinish({ durationMs: Date.now() - startedAt, ok }, ctx);
|
|
4063
5353
|
}
|
|
4064
5354
|
}
|
|
4065
5355
|
};
|
|
4066
5356
|
}
|
|
5357
|
+
function describeUrlOutcomes(serverName, toolName, outcomes) {
|
|
5358
|
+
const lines = outcomes.map(({ elicitation, action }) => {
|
|
5359
|
+
const site = new URL(elicitation.url).host;
|
|
5360
|
+
const choice = action === "accept" ? "the user agreed to open it" : action === "decline" ? "the user declined" : "no answer";
|
|
5361
|
+
return `- ${site}: ${elicitation.message} (${choice})`;
|
|
5362
|
+
});
|
|
5363
|
+
const agreed = outcomes.length > 0 && outcomes.every((o) => o.action === "accept");
|
|
5364
|
+
return [
|
|
5365
|
+
`MCP server "${serverName}" needs the user to finish a step in the browser before "${toolName}" can run:`,
|
|
5366
|
+
...lines,
|
|
5367
|
+
agreed ? "Ask the user to tell you when they have finished, then call the tool again." : "The user did not agree to every page, so do not call the tool again unless they ask."
|
|
5368
|
+
].join("\n");
|
|
5369
|
+
}
|
|
4067
5370
|
function renderContentBlock(item) {
|
|
4068
5371
|
const type = item["type"];
|
|
4069
5372
|
if (type === "text") return typeof item["text"] === "string" ? item["text"] : "";
|
|
4070
5373
|
if (type === "image" || type === "audio") {
|
|
4071
5374
|
const mime = typeof item["mimeType"] === "string" ? item["mimeType"] : "unknown type";
|
|
4072
5375
|
const data = typeof item["data"] === "string" ? item["data"] : "";
|
|
4073
|
-
const kb = Math.max(1, Math.round(data.length * 3 / 4 / 1024));
|
|
5376
|
+
const kb = data.length === 0 ? 0 : Math.max(1, Math.round(data.length * 3 / 4 / 1024));
|
|
4074
5377
|
return `[${type} content: ${mime}, ~${kb} KB \u2014 binary payload not inlined]`;
|
|
4075
5378
|
}
|
|
4076
5379
|
if (type === "resource" && item["resource"] && typeof item["resource"] === "object") {
|
|
@@ -4087,6 +5390,9 @@ ${resource["text"]}`;
|
|
|
4087
5390
|
if (type === "resource_link" && typeof item["uri"] === "string") {
|
|
4088
5391
|
return `[resource link: ${item["uri"]}]`;
|
|
4089
5392
|
}
|
|
5393
|
+
if ("text" in item) {
|
|
5394
|
+
return String(item["text"] ?? "");
|
|
5395
|
+
}
|
|
4090
5396
|
return JSON.stringify(item);
|
|
4091
5397
|
}
|
|
4092
5398
|
function stringify(c) {
|
|
@@ -4100,10 +5406,7 @@ function stringify(c) {
|
|
|
4100
5406
|
}).join("\n");
|
|
4101
5407
|
}
|
|
4102
5408
|
if (c && typeof c === "object") {
|
|
4103
|
-
|
|
4104
|
-
return String(c.text);
|
|
4105
|
-
}
|
|
4106
|
-
return JSON.stringify(c);
|
|
5409
|
+
return renderContentBlock(c);
|
|
4107
5410
|
}
|
|
4108
5411
|
return String(c ?? "");
|
|
4109
5412
|
}
|
|
@@ -4121,7 +5424,8 @@ function applySlotTools(ctx, slot, tools, client) {
|
|
|
4121
5424
|
const clientArg = slot.lazy ? () => ctx.ensureConnected(slot.cfg.name) : expectDefined(client);
|
|
4122
5425
|
const wrapped = filtered.map(
|
|
4123
5426
|
(t) => wrapMCPTool(slot.cfg.name, t, clientArg, slot.cfg.permission ?? "confirm", {
|
|
4124
|
-
onStart: () => {
|
|
5427
|
+
onStart: (caller) => {
|
|
5428
|
+
if (caller) slot.activeCallers = [...slot.activeCallers ?? [], caller];
|
|
4125
5429
|
slot.operations.inFlightCalls++;
|
|
4126
5430
|
slot.operations.peakInFlightCalls = Math.max(
|
|
4127
5431
|
slot.operations.peakInFlightCalls,
|
|
@@ -4129,7 +5433,9 @@ function applySlotTools(ctx, slot, tools, client) {
|
|
|
4129
5433
|
);
|
|
4130
5434
|
ctx.recordOperation(slot, "call", "started", void 0, void 0, false);
|
|
4131
5435
|
},
|
|
4132
|
-
onFinish: ({ durationMs, ok }) => {
|
|
5436
|
+
onFinish: ({ durationMs, ok }, caller) => {
|
|
5437
|
+
const at = caller ? slot.activeCallers?.lastIndexOf(caller) ?? -1 : -1;
|
|
5438
|
+
if (at >= 0) slot.activeCallers?.splice(at, 1);
|
|
4133
5439
|
slot.operations.inFlightCalls = Math.max(0, slot.operations.inFlightCalls - 1);
|
|
4134
5440
|
slot.lastUsed = Date.now();
|
|
4135
5441
|
pushBounded(slot.operations.callSamples, durationMs, MCP_OPERATION_LIMITS.LATENCY_SAMPLES);
|
|
@@ -4229,9 +5535,9 @@ async function persistSlotCapabilityManifest(cacheDir, slot) {
|
|
|
4229
5535
|
if (slot.manifestWrite === pending) slot.manifestWrite = void 0;
|
|
4230
5536
|
}
|
|
4231
5537
|
async function attemptConnectSlot(ctx, slot) {
|
|
4232
|
-
const
|
|
5538
|
+
const MAX_ATTEMPTS2 = MCP_CONSTANTS.RECONNECT.MAX_ATTEMPTS;
|
|
4233
5539
|
let attempt = 0;
|
|
4234
|
-
while (attempt <
|
|
5540
|
+
while (attempt < MAX_ATTEMPTS2) {
|
|
4235
5541
|
if (ctx.servers.get(slot.cfg.name) !== slot) {
|
|
4236
5542
|
return;
|
|
4237
5543
|
}
|
|
@@ -4245,17 +5551,17 @@ async function attemptConnectSlot(ctx, slot) {
|
|
|
4245
5551
|
client = new MCPClient({
|
|
4246
5552
|
name: slot.cfg.name,
|
|
4247
5553
|
transport: slot.cfg.transport,
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
headers: slot.cfg.headers,
|
|
5554
|
+
// `${VAR}` placeholders resolve here, per connect — never into the
|
|
5555
|
+
// stored config (see config-env.ts).
|
|
5556
|
+
...expandMcpEnvPlaceholders(slot.cfg),
|
|
5557
|
+
bearerTokenEnv: slot.cfg.bearerTokenEnv,
|
|
4253
5558
|
startupTimeoutMs: slot.cfg.startupTimeoutMs,
|
|
4254
5559
|
requestTimeoutMs: slot.cfg.requestTimeoutMs,
|
|
4255
5560
|
cwd: ctx.cwd,
|
|
4256
5561
|
allowPrivateNetworks: slot.cfg.allowPrivateNetworks,
|
|
4257
5562
|
passthroughEnv: slot.cfg.passthroughEnv,
|
|
4258
|
-
authorizationProvider: ctx.authorizationProviderFactory?.(slot.cfg)
|
|
5563
|
+
authorizationProvider: ctx.authorizationProviderFactory?.(slot.cfg),
|
|
5564
|
+
elicitation: slotElicitation(ctx, slot)
|
|
4259
5565
|
});
|
|
4260
5566
|
if (slot.cfg.transport === "stdio") {
|
|
4261
5567
|
client.addExitListener(ctx.onChildExit);
|
|
@@ -4328,9 +5634,9 @@ async function attemptConnectSlot(ctx, slot) {
|
|
|
4328
5634
|
await client.close().catch(() => {
|
|
4329
5635
|
});
|
|
4330
5636
|
}
|
|
4331
|
-
if (attempt >=
|
|
5637
|
+
if (attempt >= MAX_ATTEMPTS2) {
|
|
4332
5638
|
ctx.log.error(
|
|
4333
|
-
`MCP server "${slot.cfg.name}" connect exhausted after ${
|
|
5639
|
+
`MCP server "${slot.cfg.name}" connect exhausted after ${MAX_ATTEMPTS2} attempts`,
|
|
4334
5640
|
err
|
|
4335
5641
|
);
|
|
4336
5642
|
slot.state = "failed";
|
|
@@ -4354,6 +5660,11 @@ async function attemptConnectSlot(ctx, slot) {
|
|
|
4354
5660
|
}
|
|
4355
5661
|
}
|
|
4356
5662
|
}
|
|
5663
|
+
function slotElicitation(ctx, slot) {
|
|
5664
|
+
const handler = ctx.elicitationHandler;
|
|
5665
|
+
if (!handler) return void 0;
|
|
5666
|
+
return (form) => handler({ ...form, server: slot.cfg.name, requester: slot.activeCallers?.at(-1) });
|
|
5667
|
+
}
|
|
4357
5668
|
|
|
4358
5669
|
// src/registry-disconnect.ts
|
|
4359
5670
|
function resetDisconnectedSlotTools(slot, toolRegistry) {
|
|
@@ -4461,8 +5772,10 @@ async function sleepIdleSlot(ctx, slot) {
|
|
|
4461
5772
|
clearTimeout(slot.reconnectTimer);
|
|
4462
5773
|
slot.reconnectTimer = void 0;
|
|
4463
5774
|
}
|
|
4464
|
-
|
|
4465
|
-
|
|
5775
|
+
slot.state = "dormant";
|
|
5776
|
+
const client = slot.client;
|
|
5777
|
+
slot.client = void 0;
|
|
5778
|
+
if (client) {
|
|
4466
5779
|
client.removeExitListener?.(ctx.onChildExit);
|
|
4467
5780
|
if (slot.onDisconnect) client.removeDisconnectListener?.(slot.onDisconnect);
|
|
4468
5781
|
client.removeToolsChangedListener?.(ctx.onToolsChanged);
|
|
@@ -4472,10 +5785,9 @@ async function sleepIdleSlot(ctx, slot) {
|
|
|
4472
5785
|
} catch (err) {
|
|
4473
5786
|
ctx.log.warn(`MCP server "${slot.cfg.name}" error during idle sleep close`, err);
|
|
4474
5787
|
}
|
|
4475
|
-
slot.client = void 0;
|
|
4476
5788
|
}
|
|
5789
|
+
if (slot.client || slot.state !== "dormant") return;
|
|
4477
5790
|
slot.onDisconnect = void 0;
|
|
4478
|
-
slot.state = "dormant";
|
|
4479
5791
|
slot.operations.sleepCount++;
|
|
4480
5792
|
ctx.recordOperation(slot, "sleep", "idle-timeout");
|
|
4481
5793
|
ctx.log.info(`MCP server "${slot.cfg.name}" idle \u2014 sleeping (tools stay registered)`);
|
|
@@ -4590,7 +5902,10 @@ var MCPRegistry = class _MCPRegistry {
|
|
|
4590
5902
|
idleTimeoutMs;
|
|
4591
5903
|
authorizationProviderFactory;
|
|
4592
5904
|
authorizationManager;
|
|
5905
|
+
elicitationHandler;
|
|
4593
5906
|
operationListeners = /* @__PURE__ */ new Set();
|
|
5907
|
+
/** Starts still in flight, for {@link whenStarted}. */
|
|
5908
|
+
startups = /* @__PURE__ */ new Set();
|
|
4594
5909
|
/** Single shared idle sweep timer (started lazily; unref'd; cleared on stopAll). */
|
|
4595
5910
|
idleTimer;
|
|
4596
5911
|
constructor(opts) {
|
|
@@ -4603,12 +5918,17 @@ var MCPRegistry = class _MCPRegistry {
|
|
|
4603
5918
|
this.idleTimeoutMs = opts.idleTimeoutMs ?? MCP_CONSTANTS.IDLE.DEFAULT_TIMEOUT_MS;
|
|
4604
5919
|
this.authorizationProviderFactory = opts.authorizationProviderFactory;
|
|
4605
5920
|
this.authorizationManager = opts.authorizationManager;
|
|
5921
|
+
this.elicitationHandler = opts.elicitationHandler;
|
|
4606
5922
|
}
|
|
4607
5923
|
requireSlot(name) {
|
|
4608
5924
|
const slot = this.servers.get(name);
|
|
4609
5925
|
if (!slot) throw new Error(`MCP server "${name}" not registered`);
|
|
4610
5926
|
return slot;
|
|
4611
5927
|
}
|
|
5928
|
+
async loginAuthorization(name, input) {
|
|
5929
|
+
const cfg = this.requireHttpServerConfig(name);
|
|
5930
|
+
return loginRegistryAuthorization(this.authorizationManager, cfg, name, input);
|
|
5931
|
+
}
|
|
4612
5932
|
async beginAuthorization(name, input) {
|
|
4613
5933
|
const cfg = this.requireHttpServerConfig(name);
|
|
4614
5934
|
return beginRegistryAuthorization(this.authorizationManager, cfg, name, input);
|
|
@@ -4659,11 +5979,24 @@ var MCPRegistry = class _MCPRegistry {
|
|
|
4659
5979
|
operations: createMCPServerOperationState()
|
|
4660
5980
|
};
|
|
4661
5981
|
this.servers.set(cfg.name, slot);
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
|
|
5982
|
+
const startup = lazy ? this.startLazy(slot) : this.singleFlightConnect(slot);
|
|
5983
|
+
const settled = startup.then(
|
|
5984
|
+
() => {
|
|
5985
|
+
},
|
|
5986
|
+
() => {
|
|
5987
|
+
}
|
|
5988
|
+
);
|
|
5989
|
+
this.startups.add(settled);
|
|
5990
|
+
void settled.finally(() => this.startups.delete(settled));
|
|
5991
|
+
await startup;
|
|
5992
|
+
}
|
|
5993
|
+
/**
|
|
5994
|
+
* Resolves once every start already in flight has connected or failed. A
|
|
5995
|
+
* host that starts its servers in the background and then runs a single
|
|
5996
|
+
* turn waits here, so the turn's tool list includes theirs.
|
|
5997
|
+
*/
|
|
5998
|
+
async whenStarted() {
|
|
5999
|
+
while (this.startups.size > 0) await Promise.all([...this.startups]);
|
|
4667
6000
|
}
|
|
4668
6001
|
/** Record an intentionally disabled configuration without opening a transport. */
|
|
4669
6002
|
markDisabled(cfg) {
|
|
@@ -4815,10 +6148,13 @@ var MCPRegistry = class _MCPRegistry {
|
|
|
4815
6148
|
slot.reconnectTimer = void 0;
|
|
4816
6149
|
}
|
|
4817
6150
|
slot.state = "disconnected";
|
|
4818
|
-
|
|
6151
|
+
while (slot.client) {
|
|
4819
6152
|
const client = slot.client;
|
|
6153
|
+
slot.client = void 0;
|
|
6154
|
+
const handler = slot.onDisconnect;
|
|
6155
|
+
slot.onDisconnect = void 0;
|
|
4820
6156
|
client.removeExitListener?.(this.onChildExit);
|
|
4821
|
-
if (
|
|
6157
|
+
if (handler) client.removeDisconnectListener?.(handler);
|
|
4822
6158
|
client.removeToolsChangedListener?.(this.onToolsChanged);
|
|
4823
6159
|
this.removeCatalogListeners(client);
|
|
4824
6160
|
try {
|
|
@@ -4826,9 +6162,8 @@ var MCPRegistry = class _MCPRegistry {
|
|
|
4826
6162
|
} catch (err) {
|
|
4827
6163
|
this.log.warn(`MCP server "${name}" error during stop close`, err);
|
|
4828
6164
|
}
|
|
4829
|
-
slot.
|
|
6165
|
+
slot.state = "disconnected";
|
|
4830
6166
|
}
|
|
4831
|
-
slot.onDisconnect = void 0;
|
|
4832
6167
|
slot.connecting = void 0;
|
|
4833
6168
|
resetDisconnectedSlotTools(slot, this.toolRegistry);
|
|
4834
6169
|
slot.registeredLazy = false;
|
|
@@ -5004,6 +6339,7 @@ var MCPRegistry = class _MCPRegistry {
|
|
|
5004
6339
|
cacheDir: this.cacheDir,
|
|
5005
6340
|
cwd: this.cwd,
|
|
5006
6341
|
authorizationProviderFactory: this.authorizationProviderFactory,
|
|
6342
|
+
elicitationHandler: this.elicitationHandler,
|
|
5007
6343
|
operationListeners: this.operationListeners,
|
|
5008
6344
|
ensureConnected: (name) => this.ensureConnected(name),
|
|
5009
6345
|
recordOperation: (slot, kind, reason, failureKind, durationMs, retain) => this.recordOperation(slot, kind, reason, failureKind, durationMs, retain),
|
|
@@ -5129,6 +6465,10 @@ var MCPRegistry = class _MCPRegistry {
|
|
|
5129
6465
|
void this.persistCapabilityManifest(slot);
|
|
5130
6466
|
this.log.info(`MCP server "${name}" resource catalog invalidated`);
|
|
5131
6467
|
};
|
|
6468
|
+
onResourceUpdated = (name, uri) => {
|
|
6469
|
+
if (!this.servers.has(name)) return;
|
|
6470
|
+
this.events.emit("mcp.resource.updated", { name, uri });
|
|
6471
|
+
};
|
|
5132
6472
|
onPromptsChanged = (name) => {
|
|
5133
6473
|
const slot = this.servers.get(name);
|
|
5134
6474
|
if (!slot) return;
|
|
@@ -5139,10 +6479,12 @@ var MCPRegistry = class _MCPRegistry {
|
|
|
5139
6479
|
addCatalogListeners(client) {
|
|
5140
6480
|
client.addResourcesChangedListener(this.onResourcesChanged);
|
|
5141
6481
|
client.addPromptsChangedListener(this.onPromptsChanged);
|
|
6482
|
+
client.addResourceUpdatedListener?.(this.onResourceUpdated);
|
|
5142
6483
|
}
|
|
5143
6484
|
removeCatalogListeners(client) {
|
|
5144
6485
|
client.removeResourcesChangedListener?.(this.onResourcesChanged);
|
|
5145
6486
|
client.removePromptsChangedListener?.(this.onPromptsChanged);
|
|
6487
|
+
client.removeResourceUpdatedListener?.(this.onResourceUpdated);
|
|
5146
6488
|
}
|
|
5147
6489
|
onChildExit = (name, code, _signal) => {
|
|
5148
6490
|
const slot = this.servers.get(name);
|
|
@@ -5227,8 +6569,8 @@ var MCPRegistry = class _MCPRegistry {
|
|
|
5227
6569
|
|
|
5228
6570
|
// src/server.ts
|
|
5229
6571
|
import { timingSafeEqual as timingSafeEqual2 } from "node:crypto";
|
|
5230
|
-
import { createServer } from "node:http";
|
|
5231
|
-
import { expectDefined as expectDefined3, toErrorMessage as toErrorMessage2, validateAgainstSchema } from "@wrongstack/core/utils";
|
|
6572
|
+
import { createServer as createServer2 } from "node:http";
|
|
6573
|
+
import { expectDefined as expectDefined3, toErrorMessage as toErrorMessage2, validateAgainstSchema as validateAgainstSchema2 } from "@wrongstack/core/utils";
|
|
5232
6574
|
var PARSE_ERROR = -32700;
|
|
5233
6575
|
var INVALID_REQUEST = -32600;
|
|
5234
6576
|
var METHOD_NOT_FOUND = -32601;
|
|
@@ -5240,6 +6582,7 @@ var MCPServer = class {
|
|
|
5240
6582
|
logger;
|
|
5241
6583
|
resources;
|
|
5242
6584
|
prompts;
|
|
6585
|
+
inFlightRequests = /* @__PURE__ */ new Map();
|
|
5243
6586
|
constructor(opts) {
|
|
5244
6587
|
this.host = opts.host;
|
|
5245
6588
|
this.serverInfo = opts.serverInfo ?? {
|
|
@@ -5264,32 +6607,44 @@ var MCPServer = class {
|
|
|
5264
6607
|
} catch {
|
|
5265
6608
|
return this.encodeError(null, PARSE_ERROR, "Parse error");
|
|
5266
6609
|
}
|
|
5267
|
-
|
|
6610
|
+
const validId = msg?.id === void 0 || msg.id === null || typeof msg.id === "string" || typeof msg.id === "number";
|
|
6611
|
+
if (typeof msg !== "object" || msg === null || msg.jsonrpc !== "2.0" || typeof msg.method !== "string" || !validId) {
|
|
5268
6612
|
const id = msg && typeof msg === "object" ? msg.id ?? null : null;
|
|
5269
6613
|
return this.encodeError(id ?? null, INVALID_REQUEST, "Invalid Request");
|
|
5270
6614
|
}
|
|
5271
6615
|
const isNotification = msg.id === void 0 || msg.id === null;
|
|
5272
6616
|
if (isNotification) {
|
|
6617
|
+
if (msg.method === "notifications/cancelled") {
|
|
6618
|
+
const input = paramsRecord(msg.params);
|
|
6619
|
+
const requestId2 = input["requestId"];
|
|
6620
|
+
if (typeof requestId2 === "number" || typeof requestId2 === "string") {
|
|
6621
|
+
const reason = typeof input["reason"] === "string" && input["reason"].trim().length > 0 ? input["reason"].trim().slice(0, 500) : "MCP request cancelled by client";
|
|
6622
|
+
this.inFlightRequests.get(requestId2)?.abort(new Error(reason));
|
|
6623
|
+
}
|
|
6624
|
+
}
|
|
5273
6625
|
return null;
|
|
5274
6626
|
}
|
|
6627
|
+
const requestId = expectDefined3(msg.id);
|
|
6628
|
+
const controller = new AbortController();
|
|
6629
|
+
this.inFlightRequests.set(requestId, controller);
|
|
5275
6630
|
try {
|
|
5276
|
-
const result = await this.dispatch(msg.method, msg.params);
|
|
6631
|
+
const result = await this.dispatch(msg.method, msg.params, controller.signal);
|
|
5277
6632
|
if (result === METHOD_NOT_FOUND_SENTINEL) {
|
|
5278
|
-
return this.encodeError(
|
|
5279
|
-
expectDefined3(msg.id),
|
|
5280
|
-
METHOD_NOT_FOUND,
|
|
5281
|
-
`Method not found: ${msg.method}`
|
|
5282
|
-
);
|
|
6633
|
+
return this.encodeError(requestId, METHOD_NOT_FOUND, `Method not found: ${msg.method}`);
|
|
5283
6634
|
}
|
|
5284
6635
|
return JSON.stringify({ jsonrpc: "2.0", id: msg.id, result });
|
|
5285
6636
|
} catch (err) {
|
|
5286
6637
|
const message = toErrorMessage2(err);
|
|
5287
6638
|
this.logger?.warn?.(`MCP server: method "${msg.method}" threw: ${message}`);
|
|
5288
6639
|
const code = err instanceof InvalidToolArgumentsError ? INVALID_PARAMS : INTERNAL_ERROR;
|
|
5289
|
-
return this.encodeError(
|
|
6640
|
+
return this.encodeError(requestId, code, message);
|
|
6641
|
+
} finally {
|
|
6642
|
+
if (this.inFlightRequests.get(requestId) === controller) {
|
|
6643
|
+
this.inFlightRequests.delete(requestId);
|
|
6644
|
+
}
|
|
5290
6645
|
}
|
|
5291
6646
|
}
|
|
5292
|
-
async dispatch(method, params) {
|
|
6647
|
+
async dispatch(method, params, signal) {
|
|
5293
6648
|
switch (method) {
|
|
5294
6649
|
case "initialize":
|
|
5295
6650
|
return {
|
|
@@ -5316,7 +6671,7 @@ var MCPServer = class {
|
|
|
5316
6671
|
}
|
|
5317
6672
|
const args = p.arguments && typeof p.arguments === "object" && !Array.isArray(p.arguments) ? p.arguments : {};
|
|
5318
6673
|
await this.assertArgumentsMatchSchema(p.name, args);
|
|
5319
|
-
const res = await this.host.callTool(p.name, args);
|
|
6674
|
+
const res = await this.host.callTool(p.name, args, { signal });
|
|
5320
6675
|
return { content: toContentBlocks(res.content), isError: res.isError };
|
|
5321
6676
|
}
|
|
5322
6677
|
case "resources/list": {
|
|
@@ -5408,7 +6763,7 @@ var MCPServer = class {
|
|
|
5408
6763
|
}
|
|
5409
6764
|
const schema = tools.find((tool) => tool.name === name)?.inputSchema;
|
|
5410
6765
|
if (!schema || typeof schema !== "object") return;
|
|
5411
|
-
const result =
|
|
6766
|
+
const result = validateAgainstSchema2(
|
|
5412
6767
|
args,
|
|
5413
6768
|
schema
|
|
5414
6769
|
);
|
|
@@ -5453,14 +6808,14 @@ function requiredParamString(params, field, method) {
|
|
|
5453
6808
|
}
|
|
5454
6809
|
return value;
|
|
5455
6810
|
}
|
|
5456
|
-
function stringRecord(value,
|
|
6811
|
+
function stringRecord(value, label2) {
|
|
5457
6812
|
if (value === void 0) return {};
|
|
5458
6813
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
5459
|
-
throw new Error(`${
|
|
6814
|
+
throw new Error(`${label2} must be an object`);
|
|
5460
6815
|
}
|
|
5461
6816
|
const result = {};
|
|
5462
6817
|
for (const [key, item] of Object.entries(value)) {
|
|
5463
|
-
if (typeof item !== "string") throw new Error(`${
|
|
6818
|
+
if (typeof item !== "string") throw new Error(`${label2}.${key} must be a string`);
|
|
5464
6819
|
result[key] = item;
|
|
5465
6820
|
}
|
|
5466
6821
|
return result;
|
|
@@ -5473,16 +6828,22 @@ function renderPromptTemplate(template, args) {
|
|
|
5473
6828
|
});
|
|
5474
6829
|
}
|
|
5475
6830
|
var METHOD_NOT_FOUND_SENTINEL = /* @__PURE__ */ Symbol("method-not-found");
|
|
6831
|
+
function isMCPContentBlock(c) {
|
|
6832
|
+
if (!c || typeof c !== "object") return false;
|
|
6833
|
+
const type = c["type"];
|
|
6834
|
+
return type === "text" || type === "image" || type === "audio" || type === "resource" || type === "resource_link";
|
|
6835
|
+
}
|
|
5476
6836
|
function toContentBlocks(content) {
|
|
5477
6837
|
if (typeof content === "string") return [{ type: "text", text: content }];
|
|
5478
6838
|
if (Array.isArray(content)) {
|
|
5479
|
-
const allBlocks = content.every(
|
|
5480
|
-
(c) => c && typeof c === "object" && c.type === "text"
|
|
5481
|
-
);
|
|
6839
|
+
const allBlocks = content.every(isMCPContentBlock);
|
|
5482
6840
|
if (allBlocks) return content;
|
|
5483
6841
|
return [{ type: "text", text: content.map((c) => stringifyItem(c)).join("\n") }];
|
|
5484
6842
|
}
|
|
5485
6843
|
if (content === void 0 || content === null) return [{ type: "text", text: "" }];
|
|
6844
|
+
if (isMCPContentBlock(content)) {
|
|
6845
|
+
return [content];
|
|
6846
|
+
}
|
|
5486
6847
|
return [{ type: "text", text: stringifyItem(content) }];
|
|
5487
6848
|
}
|
|
5488
6849
|
function stringifyItem(c) {
|
|
@@ -5636,7 +6997,7 @@ function serveHttp(server, opts = {}) {
|
|
|
5636
6997
|
)
|
|
5637
6998
|
);
|
|
5638
6999
|
}
|
|
5639
|
-
const httpServer =
|
|
7000
|
+
const httpServer = createServer2((req, res) => {
|
|
5640
7001
|
void handleHttpRequest(server, req, res, token, log, host);
|
|
5641
7002
|
});
|
|
5642
7003
|
return new Promise((resolve, reject) => {
|
|
@@ -5712,8 +7073,8 @@ async function handleHttpRequest(server, req, res, token, log, boundHost) {
|
|
|
5712
7073
|
});
|
|
5713
7074
|
});
|
|
5714
7075
|
}
|
|
5715
|
-
function isLoopbackHostname(
|
|
5716
|
-
const bare =
|
|
7076
|
+
function isLoopbackHostname(hostname2) {
|
|
7077
|
+
const bare = hostname2.toLowerCase().replace(/^\[(.*)\]$/, "$1");
|
|
5717
7078
|
return bare === "localhost" || bare === "::1" || bare === "127.0.0.1" || bare.startsWith("127.");
|
|
5718
7079
|
}
|
|
5719
7080
|
function originIsAcceptable(req, boundHost) {
|
|
@@ -5840,13 +7201,15 @@ var MCPVaultTokenStore = class {
|
|
|
5840
7201
|
encryptEntry(value) {
|
|
5841
7202
|
const accessToken = this.vault.encrypt(value.tokenSet.accessToken);
|
|
5842
7203
|
const refreshToken = value.tokenSet.refreshToken ? this.vault.encrypt(value.tokenSet.refreshToken) : void 0;
|
|
5843
|
-
|
|
7204
|
+
const clientSecret = value.clientSecret ? this.vault.encrypt(value.clientSecret) : void 0;
|
|
7205
|
+
if (!this.vault.isEncrypted(accessToken) || refreshToken && !this.vault.isEncrypted(refreshToken) || clientSecret && !this.vault.isEncrypted(clientSecret)) {
|
|
5844
7206
|
throw new Error("MCP token store requires an encrypting SecretVault");
|
|
5845
7207
|
}
|
|
5846
7208
|
return {
|
|
5847
7209
|
serverName: value.serverName,
|
|
5848
7210
|
resource: value.resource,
|
|
5849
7211
|
clientId: value.clientId,
|
|
7212
|
+
clientSecret,
|
|
5850
7213
|
authorizationServer: value.authorizationServer,
|
|
5851
7214
|
accessToken,
|
|
5852
7215
|
refreshToken,
|
|
@@ -5857,13 +7220,14 @@ var MCPVaultTokenStore = class {
|
|
|
5857
7220
|
};
|
|
5858
7221
|
}
|
|
5859
7222
|
decryptEntry(entry) {
|
|
5860
|
-
if (!this.vault.isEncrypted(entry.accessToken) || entry.refreshToken !== void 0 && !this.vault.isEncrypted(entry.refreshToken)) {
|
|
7223
|
+
if (!this.vault.isEncrypted(entry.accessToken) || entry.refreshToken !== void 0 && !this.vault.isEncrypted(entry.refreshToken) || entry.clientSecret !== void 0 && !this.vault.isEncrypted(entry.clientSecret)) {
|
|
5861
7224
|
throw new Error("MCP token store contains an unencrypted token");
|
|
5862
7225
|
}
|
|
5863
7226
|
const value = {
|
|
5864
7227
|
serverName: entry.serverName,
|
|
5865
7228
|
resource: entry.resource,
|
|
5866
7229
|
clientId: entry.clientId,
|
|
7230
|
+
...entry.clientSecret !== void 0 ? { clientSecret: this.vault.decrypt(entry.clientSecret) } : {},
|
|
5867
7231
|
authorizationServer: entry.authorizationServer,
|
|
5868
7232
|
tokenSet: {
|
|
5869
7233
|
accessToken: this.vault.decrypt(entry.accessToken),
|
|
@@ -5934,6 +7298,7 @@ var MCPRefreshingAuthorizationProvider = class {
|
|
|
5934
7298
|
tokenSet = await refreshMcpAccessToken({
|
|
5935
7299
|
authorizationServer: state.authorizationServer,
|
|
5936
7300
|
clientId: state.clientId,
|
|
7301
|
+
clientSecret: state.clientSecret,
|
|
5937
7302
|
resource: state.resource,
|
|
5938
7303
|
refreshToken
|
|
5939
7304
|
});
|
|
@@ -6007,7 +7372,7 @@ function emptyFile() {
|
|
|
6007
7372
|
return { version: TOKEN_STORE_VERSION, updatedAt: (/* @__PURE__ */ new Date(0)).toISOString(), entries: [] };
|
|
6008
7373
|
}
|
|
6009
7374
|
function validateStoreFile(value) {
|
|
6010
|
-
if (!
|
|
7375
|
+
if (!isRecord2(value) || value["version"] !== TOKEN_STORE_VERSION || !Array.isArray(value["entries"])) {
|
|
6011
7376
|
throw new Error("MCP token store has an unsupported or malformed structure");
|
|
6012
7377
|
}
|
|
6013
7378
|
if (value["entries"].length > MAX_ENTRIES)
|
|
@@ -6019,7 +7384,7 @@ function validateStoreFile(value) {
|
|
|
6019
7384
|
};
|
|
6020
7385
|
}
|
|
6021
7386
|
function validateEncryptedEntry(value) {
|
|
6022
|
-
if (!
|
|
7387
|
+
if (!isRecord2(value)) throw new Error("MCP token store entry must be an object");
|
|
6023
7388
|
const resource = canonicalMcpResource(boundedString(value["resource"], "resource", 4096));
|
|
6024
7389
|
const authorizationServer = validateMcpAuthorizationServerMetadata(value["authorizationServer"]);
|
|
6025
7390
|
const scopes = stringArray(value["scopes"], "scopes", 128);
|
|
@@ -6031,6 +7396,7 @@ function validateEncryptedEntry(value) {
|
|
|
6031
7396
|
serverName: boundedString(value["serverName"], "serverName", 256),
|
|
6032
7397
|
resource,
|
|
6033
7398
|
clientId: boundedString(value["clientId"], "clientId", 4096),
|
|
7399
|
+
clientSecret: value["clientSecret"] === void 0 ? void 0 : boundedString(value["clientSecret"], "clientSecret", 32768),
|
|
6034
7400
|
authorizationServer,
|
|
6035
7401
|
accessToken: boundedString(value["accessToken"], "accessToken", 32768),
|
|
6036
7402
|
refreshToken: value["refreshToken"] === void 0 ? void 0 : boundedString(value["refreshToken"], "refreshToken", 32768),
|
|
@@ -6057,12 +7423,13 @@ function normalizeStoredAuthorization(value) {
|
|
|
6057
7423
|
serverName,
|
|
6058
7424
|
resource,
|
|
6059
7425
|
clientId: boundedString(value.clientId, "clientId", 4096),
|
|
7426
|
+
...value.clientSecret ? { clientSecret: boundedString(value.clientSecret, "clientSecret", 4096) } : {},
|
|
6060
7427
|
authorizationServer,
|
|
6061
7428
|
tokenSet,
|
|
6062
7429
|
updatedAt: boundedString(value.updatedAt, "updatedAt", 128)
|
|
6063
7430
|
};
|
|
6064
7431
|
}
|
|
6065
|
-
function
|
|
7432
|
+
function isRecord2(value) {
|
|
6066
7433
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6067
7434
|
}
|
|
6068
7435
|
function boundedString(value, field, maxLength) {
|
|
@@ -6102,10 +7469,12 @@ export {
|
|
|
6102
7469
|
disableMcp,
|
|
6103
7470
|
discoverMcp,
|
|
6104
7471
|
discoverMcpAuthorization,
|
|
7472
|
+
elicitViaUserInput,
|
|
6105
7473
|
enableMcp,
|
|
6106
7474
|
exchangeMcpAuthorizationCode,
|
|
6107
7475
|
listMcp,
|
|
6108
7476
|
manifestConfigHash,
|
|
7477
|
+
mcpEnvPlaceholders,
|
|
6109
7478
|
negotiateProtocolVersion,
|
|
6110
7479
|
parseAuthorizationServerMetadata,
|
|
6111
7480
|
parseGetPromptResult,
|
|
@@ -6123,10 +7492,12 @@ export {
|
|
|
6123
7492
|
readCapabilityManifest,
|
|
6124
7493
|
readManifest,
|
|
6125
7494
|
refreshMcpAccessToken,
|
|
7495
|
+
registerMcpOAuthClient,
|
|
6126
7496
|
removeMcp,
|
|
6127
7497
|
restartMcp,
|
|
6128
7498
|
serveHttp,
|
|
6129
7499
|
serveStdio,
|
|
7500
|
+
startMcpOAuthCallbackServer,
|
|
6130
7501
|
toContentBlocks,
|
|
6131
7502
|
updateMcp,
|
|
6132
7503
|
validateMcpAuthorizationServerMetadata,
|