@wrongstack/mcp 0.285.0 → 0.287.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/authorization-manager.d.ts +65 -0
- package/dist/authorization-manager.d.ts.map +1 -0
- package/dist/authorization.d.ts +128 -0
- package/dist/authorization.d.ts.map +1 -0
- package/dist/client.d.ts +31 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/content-selection.d.ts +33 -0
- package/dist/content-selection.d.ts.map +1 -0
- package/dist/index.d.ts +13 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3085 -619
- package/dist/index.js.map +4 -4
- package/dist/manage.d.ts +3 -1
- package/dist/manage.d.ts.map +1 -1
- package/dist/manifest-cache.d.ts +15 -0
- package/dist/manifest-cache.d.ts.map +1 -1
- package/dist/operations.d.ts +100 -0
- package/dist/operations.d.ts.map +1 -0
- package/dist/protocol.d.ts +91 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/registry.d.ts +73 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/server.d.ts +25 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/sse-reader.d.ts +17 -0
- package/dist/sse-reader.d.ts.map +1 -0
- package/dist/token-store.d.ts +56 -0
- package/dist/token-store.d.ts.map +1 -0
- package/dist/transport-base.d.ts +90 -0
- package/dist/transport-base.d.ts.map +1 -0
- package/dist/transport-jsonrpc.d.ts +32 -0
- package/dist/transport-jsonrpc.d.ts.map +1 -0
- package/dist/transport-security.d.ts +13 -0
- package/dist/transport-security.d.ts.map +1 -0
- package/dist/transport-sse.d.ts +31 -0
- package/dist/transport-sse.d.ts.map +1 -0
- package/dist/transport-streamable.d.ts +27 -0
- package/dist/transport-streamable.d.ts.map +1 -0
- package/dist/transport.d.ts +7 -144
- package/dist/transport.d.ts.map +1 -1
- package/dist/wrap-tool.d.ts +8 -1
- package/dist/wrap-tool.d.ts.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,3 +1,742 @@
|
|
|
1
|
+
// src/authorization.ts
|
|
2
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import * as dns from "node:dns/promises";
|
|
4
|
+
import * as http from "node:http";
|
|
5
|
+
import * as https from "node:https";
|
|
6
|
+
import * as net from "node:net";
|
|
7
|
+
import { isPrivateIPv4, isPrivateIPv6 } from "@wrongstack/core/utils";
|
|
8
|
+
function canonicalMcpResource(rawUrl) {
|
|
9
|
+
let url;
|
|
10
|
+
try {
|
|
11
|
+
url = new URL(rawUrl);
|
|
12
|
+
} catch {
|
|
13
|
+
throw new Error("MCP authorization resource must be an absolute URL");
|
|
14
|
+
}
|
|
15
|
+
if (url.protocol !== "https:" && !isLoopbackHttp(url)) {
|
|
16
|
+
throw new Error("MCP authorization resource must use HTTPS (except loopback development)");
|
|
17
|
+
}
|
|
18
|
+
if (url.username || url.password || url.hash) {
|
|
19
|
+
throw new Error("MCP authorization resource must not contain credentials or a fragment");
|
|
20
|
+
}
|
|
21
|
+
if (url.pathname === "/" && !url.search) return url.origin;
|
|
22
|
+
return url.toString();
|
|
23
|
+
}
|
|
24
|
+
function authorizationHeaderForToken(token, expectedResource, now = Date.now()) {
|
|
25
|
+
if (canonicalMcpResource(token.resource) !== expectedResource) {
|
|
26
|
+
throw new Error("MCP access token resource does not match the target server");
|
|
27
|
+
}
|
|
28
|
+
if (token.expiresAt !== void 0 && token.expiresAt <= now) {
|
|
29
|
+
throw new Error("MCP access token is expired");
|
|
30
|
+
}
|
|
31
|
+
const tokenType = token.tokenType ?? "Bearer";
|
|
32
|
+
if (tokenType.toLowerCase() !== "bearer") {
|
|
33
|
+
throw new Error(`Unsupported MCP OAuth token type "${tokenType}"`);
|
|
34
|
+
}
|
|
35
|
+
if (!token.accessToken || token.accessToken.length > 16384 || /[\r\n]/.test(token.accessToken)) {
|
|
36
|
+
throw new Error("MCP access token is empty, oversized, or contains invalid characters");
|
|
37
|
+
}
|
|
38
|
+
return `Bearer ${token.accessToken}`;
|
|
39
|
+
}
|
|
40
|
+
function parseMcpBearerChallenge(header, resource) {
|
|
41
|
+
const challenge = {
|
|
42
|
+
status: 401,
|
|
43
|
+
resource,
|
|
44
|
+
scopes: [],
|
|
45
|
+
rawScheme: "Bearer"
|
|
46
|
+
};
|
|
47
|
+
if (!header) return challenge;
|
|
48
|
+
const bearer = /(?:^|,)\s*Bearer(?:\s+|$)/i.exec(header);
|
|
49
|
+
if (!bearer) return challenge;
|
|
50
|
+
const parameters = header.slice((bearer.index ?? 0) + bearer[0].length);
|
|
51
|
+
const resourceMetadata = challengeParameter(parameters, "resource_metadata");
|
|
52
|
+
if (resourceMetadata) {
|
|
53
|
+
const metadataUrl = validateMetadataUrl(resourceMetadata);
|
|
54
|
+
if (metadataUrl) challenge.resourceMetadataUrl = metadataUrl;
|
|
55
|
+
}
|
|
56
|
+
const scope = challengeParameter(parameters, "scope");
|
|
57
|
+
if (scope) {
|
|
58
|
+
challenge.scopes = [...new Set(scope.split(/\s+/).filter(Boolean))].slice(0, 64);
|
|
59
|
+
}
|
|
60
|
+
return challenge;
|
|
61
|
+
}
|
|
62
|
+
function protectedResourceMetadataUrls(resource) {
|
|
63
|
+
const url = new URL(canonicalMcpResource(resource));
|
|
64
|
+
const suffix = url.pathname === "/" ? "" : url.pathname;
|
|
65
|
+
const candidates = [
|
|
66
|
+
new URL(`/.well-known/oauth-protected-resource${suffix}`, url.origin).toString(),
|
|
67
|
+
new URL("/.well-known/oauth-protected-resource", url.origin).toString()
|
|
68
|
+
];
|
|
69
|
+
return [...new Set(candidates)];
|
|
70
|
+
}
|
|
71
|
+
function authorizationServerMetadataUrls(issuer) {
|
|
72
|
+
const url = secureOAuthUrl(issuer, "authorization server issuer");
|
|
73
|
+
const suffix = url.pathname === "/" ? "" : url.pathname;
|
|
74
|
+
const candidates = [
|
|
75
|
+
new URL(`/.well-known/oauth-authorization-server${suffix}`, url.origin).toString(),
|
|
76
|
+
new URL(`/.well-known/openid-configuration${suffix}`, url.origin).toString()
|
|
77
|
+
];
|
|
78
|
+
if (suffix) {
|
|
79
|
+
candidates.push(
|
|
80
|
+
new URL(
|
|
81
|
+
`${suffix.replace(/\/$/, "")}/.well-known/openid-configuration`,
|
|
82
|
+
url.origin
|
|
83
|
+
).toString()
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return candidates;
|
|
87
|
+
}
|
|
88
|
+
function parseProtectedResourceMetadata(value, expectedResource) {
|
|
89
|
+
const metadata = record(value, "protected resource metadata");
|
|
90
|
+
const resource = canonicalMcpResource(requiredString(metadata["resource"], "resource"));
|
|
91
|
+
if (resource !== canonicalMcpResource(expectedResource)) {
|
|
92
|
+
throw new Error("MCP protected resource metadata resource does not match the target server");
|
|
93
|
+
}
|
|
94
|
+
const authorizationServers = boundedStringArray(
|
|
95
|
+
metadata["authorization_servers"],
|
|
96
|
+
"authorization_servers",
|
|
97
|
+
8
|
|
98
|
+
).map((issuer) => secureOAuthUrl(issuer, "authorization server issuer").toString());
|
|
99
|
+
if (authorizationServers.length === 0) {
|
|
100
|
+
throw new Error("MCP protected resource metadata must declare an authorization server");
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
resource,
|
|
104
|
+
authorizationServers,
|
|
105
|
+
scopesSupported: optionalStringArray(metadata["scopes_supported"], "scopes_supported", 128)
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function parseAuthorizationServerMetadata(value, expectedIssuer) {
|
|
109
|
+
const metadata = record(value, "authorization server metadata");
|
|
110
|
+
const issuer = secureOAuthUrl(requiredString(metadata["issuer"], "issuer"), "issuer").toString();
|
|
111
|
+
if (issuer !== secureOAuthUrl(expectedIssuer, "expected issuer").toString()) {
|
|
112
|
+
throw new Error("MCP authorization metadata issuer mismatch");
|
|
113
|
+
}
|
|
114
|
+
const methods = boundedStringArray(
|
|
115
|
+
metadata["code_challenge_methods_supported"],
|
|
116
|
+
"code_challenge_methods_supported",
|
|
117
|
+
16
|
|
118
|
+
);
|
|
119
|
+
if (!methods.includes("S256")) {
|
|
120
|
+
throw new Error("MCP authorization server does not advertise required PKCE S256 support");
|
|
121
|
+
}
|
|
122
|
+
const registration = optionalString(metadata["registration_endpoint"], "registration_endpoint");
|
|
123
|
+
return {
|
|
124
|
+
issuer,
|
|
125
|
+
authorizationEndpoint: secureOAuthUrl(
|
|
126
|
+
requiredString(metadata["authorization_endpoint"], "authorization_endpoint"),
|
|
127
|
+
"authorization endpoint"
|
|
128
|
+
).toString(),
|
|
129
|
+
tokenEndpoint: secureOAuthUrl(
|
|
130
|
+
requiredString(metadata["token_endpoint"], "token_endpoint"),
|
|
131
|
+
"token endpoint"
|
|
132
|
+
).toString(),
|
|
133
|
+
registrationEndpoint: registration ? secureOAuthUrl(registration, "registration endpoint").toString() : void 0,
|
|
134
|
+
scopesSupported: optionalStringArray(metadata["scopes_supported"], "scopes_supported", 128)
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function validateMcpAuthorizationServerMetadata(value) {
|
|
138
|
+
const metadata = record(value, "stored authorization server metadata");
|
|
139
|
+
const registration = optionalString(metadata["registrationEndpoint"], "registrationEndpoint");
|
|
140
|
+
return {
|
|
141
|
+
issuer: secureOAuthUrl(requiredString(metadata["issuer"], "issuer"), "issuer").toString(),
|
|
142
|
+
authorizationEndpoint: secureOAuthUrl(
|
|
143
|
+
requiredString(metadata["authorizationEndpoint"], "authorizationEndpoint"),
|
|
144
|
+
"authorization endpoint"
|
|
145
|
+
).toString(),
|
|
146
|
+
tokenEndpoint: secureOAuthUrl(
|
|
147
|
+
requiredString(metadata["tokenEndpoint"], "tokenEndpoint"),
|
|
148
|
+
"token endpoint"
|
|
149
|
+
).toString(),
|
|
150
|
+
registrationEndpoint: registration ? secureOAuthUrl(registration, "registration endpoint").toString() : void 0,
|
|
151
|
+
scopesSupported: optionalStringArray(metadata["scopesSupported"], "scopesSupported", 128)
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
async function discoverMcpAuthorization(resource, options = {}) {
|
|
155
|
+
const canonicalResource = canonicalMcpResource(resource);
|
|
156
|
+
const resourceUrl = new URL(canonicalResource);
|
|
157
|
+
const allowedLoopbackHostname = isLoopbackHttp(resourceUrl) ? unbracket(resourceUrl.hostname).toLowerCase() : void 0;
|
|
158
|
+
const fetchJson = options.fetchJson ?? ((url, signal) => requestPinnedJson(url, {
|
|
159
|
+
signal,
|
|
160
|
+
timeoutMs: options.timeoutMs,
|
|
161
|
+
maxResponseBytes: options.maxResponseBytes,
|
|
162
|
+
lookup: options.lookup,
|
|
163
|
+
allowedLoopbackHostname
|
|
164
|
+
}));
|
|
165
|
+
const challenge = parseMcpBearerChallenge(options.challengeHeader ?? null, canonicalResource);
|
|
166
|
+
const resourceCandidates = challenge.resourceMetadataUrl ? [challenge.resourceMetadataUrl] : protectedResourceMetadataUrls(canonicalResource);
|
|
167
|
+
const resourceDiscovery = await discoverFirst(
|
|
168
|
+
resourceCandidates,
|
|
169
|
+
fetchJson,
|
|
170
|
+
options.signal,
|
|
171
|
+
(value) => parseProtectedResourceMetadata(value, canonicalResource),
|
|
172
|
+
"protected resource metadata"
|
|
173
|
+
);
|
|
174
|
+
const issuer = resourceDiscovery.value.authorizationServers[0];
|
|
175
|
+
const authorizationDiscovery = await discoverFirst(
|
|
176
|
+
authorizationServerMetadataUrls(issuer),
|
|
177
|
+
fetchJson,
|
|
178
|
+
options.signal,
|
|
179
|
+
(value) => parseAuthorizationServerMetadata(value, issuer),
|
|
180
|
+
"authorization server metadata"
|
|
181
|
+
);
|
|
182
|
+
return {
|
|
183
|
+
resourceMetadataUrl: resourceDiscovery.url,
|
|
184
|
+
authorizationServerMetadataUrl: authorizationDiscovery.url,
|
|
185
|
+
protectedResource: resourceDiscovery.value,
|
|
186
|
+
authorizationServer: authorizationDiscovery.value
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function createMcpAuthorizationRequest(options) {
|
|
190
|
+
const resource = canonicalMcpResource(options.resource);
|
|
191
|
+
const clientId = boundedCredential(options.clientId, "client id");
|
|
192
|
+
const redirectUri = validateRedirectUri(options.redirectUri);
|
|
193
|
+
const scopes = validateScopes(options.scopes ?? []);
|
|
194
|
+
const codeVerifier = base64Url(randomBytes(32));
|
|
195
|
+
const codeChallenge = base64Url(createHash("sha256").update(codeVerifier).digest());
|
|
196
|
+
const state = base64Url(randomBytes(32));
|
|
197
|
+
const authorizationUrl = secureOAuthUrl(
|
|
198
|
+
options.authorizationServer.authorizationEndpoint,
|
|
199
|
+
"authorization endpoint"
|
|
200
|
+
);
|
|
201
|
+
authorizationUrl.searchParams.set("response_type", "code");
|
|
202
|
+
authorizationUrl.searchParams.set("client_id", clientId);
|
|
203
|
+
authorizationUrl.searchParams.set("redirect_uri", redirectUri);
|
|
204
|
+
authorizationUrl.searchParams.set("state", state);
|
|
205
|
+
authorizationUrl.searchParams.set("code_challenge", codeChallenge);
|
|
206
|
+
authorizationUrl.searchParams.set("code_challenge_method", "S256");
|
|
207
|
+
authorizationUrl.searchParams.set("resource", resource);
|
|
208
|
+
if (scopes.length > 0) authorizationUrl.searchParams.set("scope", scopes.join(" "));
|
|
209
|
+
return {
|
|
210
|
+
authorizationUrl: authorizationUrl.toString(),
|
|
211
|
+
state,
|
|
212
|
+
codeVerifier,
|
|
213
|
+
redirectUri,
|
|
214
|
+
clientId,
|
|
215
|
+
resource
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
function parseMcpAuthorizationCallback(callbackUrl, session) {
|
|
219
|
+
let callback;
|
|
220
|
+
try {
|
|
221
|
+
callback = new URL(callbackUrl);
|
|
222
|
+
} catch {
|
|
223
|
+
throw new Error("MCP OAuth callback must be an absolute URL");
|
|
224
|
+
}
|
|
225
|
+
const expected = new URL(validateRedirectUri(session.redirectUri));
|
|
226
|
+
if (callback.protocol !== expected.protocol || callback.hostname !== expected.hostname || callback.port !== expected.port || callback.pathname !== expected.pathname) {
|
|
227
|
+
throw new Error("MCP OAuth callback redirect URI does not match the authorization session");
|
|
228
|
+
}
|
|
229
|
+
const returnedState = callback.searchParams.get("state") ?? "";
|
|
230
|
+
if (!constantTimeEqual(returnedState, session.state)) {
|
|
231
|
+
throw new Error("MCP OAuth callback state mismatch");
|
|
232
|
+
}
|
|
233
|
+
const oauthError = callback.searchParams.get("error");
|
|
234
|
+
if (oauthError)
|
|
235
|
+
throw new Error(`MCP OAuth authorization failed: ${boundedErrorCode(oauthError)}`);
|
|
236
|
+
return boundedCredential(callback.searchParams.get("code") ?? "", "authorization code");
|
|
237
|
+
}
|
|
238
|
+
async function exchangeMcpAuthorizationCode(options) {
|
|
239
|
+
const resource = canonicalMcpResource(options.resource);
|
|
240
|
+
const body = new URLSearchParams({
|
|
241
|
+
grant_type: "authorization_code",
|
|
242
|
+
code: boundedCredential(options.code, "authorization code"),
|
|
243
|
+
client_id: boundedCredential(options.clientId, "client id"),
|
|
244
|
+
redirect_uri: validateRedirectUri(options.redirectUri),
|
|
245
|
+
code_verifier: validateCodeVerifier(options.codeVerifier),
|
|
246
|
+
resource
|
|
247
|
+
}).toString();
|
|
248
|
+
const response = await requestPinnedJson(options.authorizationServer.tokenEndpoint, {
|
|
249
|
+
method: "POST",
|
|
250
|
+
body,
|
|
251
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
252
|
+
signal: options.signal,
|
|
253
|
+
timeoutMs: options.timeoutMs,
|
|
254
|
+
maxResponseBytes: options.maxResponseBytes,
|
|
255
|
+
lookup: options.lookup,
|
|
256
|
+
allowedLoopbackHostname: loopbackHostnameForResource(resource)
|
|
257
|
+
});
|
|
258
|
+
if (response === void 0) throw new Error("MCP OAuth token endpoint returned no response");
|
|
259
|
+
return parseTokenResponse(response, resource);
|
|
260
|
+
}
|
|
261
|
+
async function refreshMcpAccessToken(options) {
|
|
262
|
+
const resource = canonicalMcpResource(options.resource);
|
|
263
|
+
const previousRefreshToken = boundedCredential(options.refreshToken, "refresh token");
|
|
264
|
+
const body = new URLSearchParams({
|
|
265
|
+
grant_type: "refresh_token",
|
|
266
|
+
refresh_token: previousRefreshToken,
|
|
267
|
+
client_id: boundedCredential(options.clientId, "client id"),
|
|
268
|
+
resource
|
|
269
|
+
}).toString();
|
|
270
|
+
const response = await requestPinnedJson(options.authorizationServer.tokenEndpoint, {
|
|
271
|
+
method: "POST",
|
|
272
|
+
body,
|
|
273
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
274
|
+
signal: options.signal,
|
|
275
|
+
timeoutMs: options.timeoutMs,
|
|
276
|
+
maxResponseBytes: options.maxResponseBytes,
|
|
277
|
+
lookup: options.lookup,
|
|
278
|
+
allowedLoopbackHostname: loopbackHostnameForResource(resource)
|
|
279
|
+
});
|
|
280
|
+
if (response === void 0) throw new Error("MCP OAuth token endpoint returned no response");
|
|
281
|
+
const parsed = parseTokenResponse(response, resource);
|
|
282
|
+
return { ...parsed, refreshToken: parsed.refreshToken ?? previousRefreshToken };
|
|
283
|
+
}
|
|
284
|
+
function challengeParameter(parameters, name) {
|
|
285
|
+
const pattern = new RegExp(
|
|
286
|
+
`(?:^|,)\\s*${name}\\s*=\\s*(?:"((?:\\\\.|[^"\\\\])*)"|([^,\\s]+))`,
|
|
287
|
+
"i"
|
|
288
|
+
);
|
|
289
|
+
const match = pattern.exec(parameters);
|
|
290
|
+
const value = match?.[1] ?? match?.[2];
|
|
291
|
+
return value?.replace(/\\(["\\])/g, "$1");
|
|
292
|
+
}
|
|
293
|
+
function validateMetadataUrl(value) {
|
|
294
|
+
try {
|
|
295
|
+
const url = new URL(value);
|
|
296
|
+
if (url.username || url.password || url.hash) return void 0;
|
|
297
|
+
if (url.protocol !== "https:" && !isLoopbackHttp(url)) return void 0;
|
|
298
|
+
return url.toString();
|
|
299
|
+
} catch {
|
|
300
|
+
return void 0;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function record(value, label) {
|
|
304
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
305
|
+
throw new Error(`MCP ${label} must be an object`);
|
|
306
|
+
}
|
|
307
|
+
return value;
|
|
308
|
+
}
|
|
309
|
+
function requiredString(value, field) {
|
|
310
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 4096) {
|
|
311
|
+
throw new Error(`MCP authorization field "${field}" must be a bounded non-empty string`);
|
|
312
|
+
}
|
|
313
|
+
return value;
|
|
314
|
+
}
|
|
315
|
+
function optionalString(value, field) {
|
|
316
|
+
return value === void 0 ? void 0 : requiredString(value, field);
|
|
317
|
+
}
|
|
318
|
+
function boundedStringArray(value, field, maxItems) {
|
|
319
|
+
if (!Array.isArray(value) || value.length > maxItems) {
|
|
320
|
+
throw new Error(`MCP authorization field "${field}" must be an array of at most ${maxItems}`);
|
|
321
|
+
}
|
|
322
|
+
return [...new Set(value.map((entry) => requiredString(entry, field)))];
|
|
323
|
+
}
|
|
324
|
+
function optionalStringArray(value, field, maxItems) {
|
|
325
|
+
return value === void 0 ? [] : boundedStringArray(value, field, maxItems);
|
|
326
|
+
}
|
|
327
|
+
function secureOAuthUrl(value, label) {
|
|
328
|
+
let url;
|
|
329
|
+
try {
|
|
330
|
+
url = new URL(value);
|
|
331
|
+
} catch {
|
|
332
|
+
throw new Error(`MCP ${label} must be an absolute URL`);
|
|
333
|
+
}
|
|
334
|
+
if (url.protocol !== "https:" && !isLoopbackHttp(url)) {
|
|
335
|
+
throw new Error(`MCP ${label} must use HTTPS (except loopback development)`);
|
|
336
|
+
}
|
|
337
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
338
|
+
throw new Error(`MCP ${label} must not contain credentials, query, or fragment components`);
|
|
339
|
+
}
|
|
340
|
+
if (url.pathname === "/") return new URL(url.origin);
|
|
341
|
+
return url;
|
|
342
|
+
}
|
|
343
|
+
async function discoverFirst(candidates, fetchJson, signal, parse, label) {
|
|
344
|
+
const failures = [];
|
|
345
|
+
for (const candidate of candidates) {
|
|
346
|
+
signal?.throwIfAborted();
|
|
347
|
+
try {
|
|
348
|
+
const value = await fetchJson(candidate, signal);
|
|
349
|
+
if (value === void 0) {
|
|
350
|
+
failures.push(`${candidate}: not found`);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
return { url: candidate, value: parse(value) };
|
|
354
|
+
} catch (error) {
|
|
355
|
+
signal?.throwIfAborted();
|
|
356
|
+
failures.push(`${candidate}: ${error instanceof Error ? error.message : String(error)}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
throw new Error(`MCP ${label} discovery failed (${failures.join("; ")})`);
|
|
360
|
+
}
|
|
361
|
+
async function requestPinnedJson(rawUrl, options) {
|
|
362
|
+
const url = secureOAuthUrl(rawUrl, "discovery URL");
|
|
363
|
+
const target = await resolvePinnedAddress(url, options);
|
|
364
|
+
const timeoutMs = options.timeoutMs ?? 1e4;
|
|
365
|
+
const maxBytes = options.maxResponseBytes ?? 64 * 1024;
|
|
366
|
+
options.signal?.throwIfAborted();
|
|
367
|
+
return new Promise((resolve, reject) => {
|
|
368
|
+
let settled = false;
|
|
369
|
+
const finish = (error, value) => {
|
|
370
|
+
if (settled) return;
|
|
371
|
+
settled = true;
|
|
372
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
373
|
+
if (error) reject(error);
|
|
374
|
+
else resolve(value);
|
|
375
|
+
};
|
|
376
|
+
const onAbort = () => {
|
|
377
|
+
request3.destroy(options.signal?.reason instanceof Error ? options.signal.reason : void 0);
|
|
378
|
+
};
|
|
379
|
+
const headers = {
|
|
380
|
+
accept: "application/json",
|
|
381
|
+
host: url.host,
|
|
382
|
+
...options.headers
|
|
383
|
+
};
|
|
384
|
+
if (options.body !== void 0) {
|
|
385
|
+
headers["content-length"] = Buffer.byteLength(options.body);
|
|
386
|
+
}
|
|
387
|
+
const requestOptions = {
|
|
388
|
+
host: target.address,
|
|
389
|
+
family: target.family,
|
|
390
|
+
port: Number(url.port || (url.protocol === "https:" ? 443 : 80)),
|
|
391
|
+
method: options.method ?? "GET",
|
|
392
|
+
path: `${url.pathname}${url.search}`,
|
|
393
|
+
headers,
|
|
394
|
+
...url.protocol === "https:" && net.isIP(unbracket(url.hostname)) === 0 ? { servername: unbracket(url.hostname) } : {}
|
|
395
|
+
};
|
|
396
|
+
const requestFn = url.protocol === "https:" ? https.request : http.request;
|
|
397
|
+
const request3 = requestFn(requestOptions, (response) => {
|
|
398
|
+
const status = response.statusCode ?? 0;
|
|
399
|
+
if (status === 404 || status === 410) {
|
|
400
|
+
response.resume();
|
|
401
|
+
finish(void 0, void 0);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (status >= 300 && status < 400) {
|
|
405
|
+
response.resume();
|
|
406
|
+
finish(new Error("MCP OAuth discovery redirects are not allowed"));
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (status < 200 || status >= 300) {
|
|
410
|
+
response.resume();
|
|
411
|
+
finish(new Error(`MCP OAuth discovery HTTP ${status}`));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const contentType = response.headers["content-type"] ?? "";
|
|
415
|
+
if (!/^(?:application\/json|[^;]+\+json)(?:;|$)/i.test(contentType)) {
|
|
416
|
+
response.resume();
|
|
417
|
+
finish(new Error("MCP OAuth discovery response must be JSON"));
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const declaredLength = Number(response.headers["content-length"] ?? 0);
|
|
421
|
+
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
422
|
+
response.destroy();
|
|
423
|
+
finish(new Error(`MCP OAuth discovery response exceeds ${maxBytes} bytes`));
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const chunks = [];
|
|
427
|
+
let size = 0;
|
|
428
|
+
response.on("data", (chunk) => {
|
|
429
|
+
size += chunk.length;
|
|
430
|
+
if (size > maxBytes) {
|
|
431
|
+
response.destroy();
|
|
432
|
+
finish(new Error(`MCP OAuth discovery response exceeds ${maxBytes} bytes`));
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
chunks.push(chunk);
|
|
436
|
+
});
|
|
437
|
+
response.once("end", () => {
|
|
438
|
+
try {
|
|
439
|
+
finish(void 0, JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
440
|
+
} catch {
|
|
441
|
+
finish(new Error("MCP OAuth discovery response is not valid JSON"));
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
response.once("error", (error) => finish(error));
|
|
445
|
+
});
|
|
446
|
+
request3.setTimeout(timeoutMs, () => {
|
|
447
|
+
request3.destroy(new Error(`MCP OAuth discovery timed out after ${timeoutMs}ms`));
|
|
448
|
+
});
|
|
449
|
+
request3.once("error", (error) => finish(error));
|
|
450
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
451
|
+
request3.end(options.body);
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
async function resolvePinnedAddress(url, options) {
|
|
455
|
+
const hostname = unbracket(url.hostname).toLowerCase();
|
|
456
|
+
const literalFamily = net.isIP(hostname);
|
|
457
|
+
if (literalFamily === 4 || literalFamily === 6) {
|
|
458
|
+
assertDiscoveryAddressAllowed(
|
|
459
|
+
hostname,
|
|
460
|
+
literalFamily,
|
|
461
|
+
hostname,
|
|
462
|
+
options.allowedLoopbackHostname
|
|
463
|
+
);
|
|
464
|
+
return { address: hostname, family: literalFamily };
|
|
465
|
+
}
|
|
466
|
+
const lookup2 = options.lookup ?? ((host) => dns.lookup(host, { all: true }));
|
|
467
|
+
const records = await lookup2(hostname);
|
|
468
|
+
if (records.length === 0)
|
|
469
|
+
throw new Error(`MCP OAuth discovery DNS returned no addresses for ${hostname}`);
|
|
470
|
+
for (const record3 of records) {
|
|
471
|
+
if (record3.family !== 4 && record3.family !== 6) {
|
|
472
|
+
throw new Error("MCP OAuth discovery DNS returned an unsupported address family");
|
|
473
|
+
}
|
|
474
|
+
assertDiscoveryAddressAllowed(
|
|
475
|
+
record3.address,
|
|
476
|
+
record3.family,
|
|
477
|
+
hostname,
|
|
478
|
+
options.allowedLoopbackHostname
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
const selected = records[0];
|
|
482
|
+
return { address: selected.address, family: selected.family };
|
|
483
|
+
}
|
|
484
|
+
function assertDiscoveryAddressAllowed(address, family, hostname, allowedLoopbackHostname) {
|
|
485
|
+
const isPrivate = family === 4 ? isPrivateIPv4(address) : isPrivateIPv6(address);
|
|
486
|
+
if (!isPrivate) return;
|
|
487
|
+
const loopback = family === 4 ? address.startsWith("127.") : address === "::1";
|
|
488
|
+
if (loopback && hostname === allowedLoopbackHostname) return;
|
|
489
|
+
throw new Error(`MCP OAuth discovery blocked private address ${address}`);
|
|
490
|
+
}
|
|
491
|
+
function unbracket(hostname) {
|
|
492
|
+
return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
493
|
+
}
|
|
494
|
+
function validateRedirectUri(value) {
|
|
495
|
+
let url;
|
|
496
|
+
try {
|
|
497
|
+
url = new URL(value);
|
|
498
|
+
} catch {
|
|
499
|
+
throw new Error("MCP OAuth redirect URI must be an absolute URL");
|
|
500
|
+
}
|
|
501
|
+
if (url.protocol !== "https:" && !isLoopbackHttp(url)) {
|
|
502
|
+
throw new Error("MCP OAuth redirect URI must use HTTPS or loopback HTTP");
|
|
503
|
+
}
|
|
504
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
505
|
+
throw new Error("MCP OAuth redirect URI must not contain credentials, query, or fragment");
|
|
506
|
+
}
|
|
507
|
+
return url.toString();
|
|
508
|
+
}
|
|
509
|
+
function validateScopes(scopes) {
|
|
510
|
+
if (scopes.length > 128) throw new Error("MCP OAuth scope list exceeds 128 entries");
|
|
511
|
+
const normalized = scopes.map((scope) => {
|
|
512
|
+
if (!scope || scope.length > 256 || /\s/.test(scope)) {
|
|
513
|
+
throw new Error("MCP OAuth scopes must be bounded non-empty tokens");
|
|
514
|
+
}
|
|
515
|
+
return scope;
|
|
516
|
+
});
|
|
517
|
+
return [...new Set(normalized)];
|
|
518
|
+
}
|
|
519
|
+
function validateCodeVerifier(value) {
|
|
520
|
+
if (value.length < 43 || value.length > 128 || !/^[A-Za-z0-9._~-]+$/.test(value)) {
|
|
521
|
+
throw new Error("MCP OAuth PKCE code verifier is invalid");
|
|
522
|
+
}
|
|
523
|
+
return value;
|
|
524
|
+
}
|
|
525
|
+
function boundedCredential(value, label) {
|
|
526
|
+
if (!value || value.length > 16384 || /[\r\n]/.test(value)) {
|
|
527
|
+
throw new Error(`MCP OAuth ${label} is empty, oversized, or invalid`);
|
|
528
|
+
}
|
|
529
|
+
return value;
|
|
530
|
+
}
|
|
531
|
+
function boundedErrorCode(value) {
|
|
532
|
+
return /^[A-Za-z0-9._-]{1,128}$/.test(value) ? value : "invalid_error";
|
|
533
|
+
}
|
|
534
|
+
function base64Url(value) {
|
|
535
|
+
return Buffer.from(value).toString("base64url");
|
|
536
|
+
}
|
|
537
|
+
function constantTimeEqual(left, right) {
|
|
538
|
+
const leftHash = createHash("sha256").update(left).digest();
|
|
539
|
+
const rightHash = createHash("sha256").update(right).digest();
|
|
540
|
+
return timingSafeEqual(leftHash, rightHash);
|
|
541
|
+
}
|
|
542
|
+
function parseTokenResponse(value, resource) {
|
|
543
|
+
const response = record(value, "token response");
|
|
544
|
+
const accessToken = boundedCredential(
|
|
545
|
+
requiredString(response["access_token"], "access_token"),
|
|
546
|
+
"access token"
|
|
547
|
+
);
|
|
548
|
+
const tokenType = optionalString(response["token_type"], "token_type") ?? "Bearer";
|
|
549
|
+
if (tokenType.toLowerCase() !== "bearer") {
|
|
550
|
+
throw new Error(`Unsupported MCP OAuth token type "${tokenType}"`);
|
|
551
|
+
}
|
|
552
|
+
const expiresIn = response["expires_in"];
|
|
553
|
+
let expiresAt;
|
|
554
|
+
if (expiresIn !== void 0) {
|
|
555
|
+
if (typeof expiresIn !== "number" || !Number.isFinite(expiresIn) || expiresIn <= 0 || expiresIn > 31536e3) {
|
|
556
|
+
throw new Error("MCP OAuth expires_in must be between 1 second and 1 year");
|
|
557
|
+
}
|
|
558
|
+
expiresAt = Date.now() + Math.floor(expiresIn * 1e3);
|
|
559
|
+
}
|
|
560
|
+
const refresh = optionalString(response["refresh_token"], "refresh_token");
|
|
561
|
+
const scope = optionalString(response["scope"], "scope");
|
|
562
|
+
const token = {
|
|
563
|
+
accessToken,
|
|
564
|
+
tokenType: "Bearer",
|
|
565
|
+
resource,
|
|
566
|
+
scopes: scope ? validateScopes(scope.split(/\s+/).filter(Boolean)) : [],
|
|
567
|
+
...expiresAt !== void 0 ? { expiresAt } : {},
|
|
568
|
+
...refresh ? { refreshToken: boundedCredential(refresh, "refresh token") } : {}
|
|
569
|
+
};
|
|
570
|
+
authorizationHeaderForToken(token, resource);
|
|
571
|
+
return token;
|
|
572
|
+
}
|
|
573
|
+
function loopbackHostnameForResource(resource) {
|
|
574
|
+
const url = new URL(resource);
|
|
575
|
+
return isLoopbackHttp(url) ? unbracket(url.hostname).toLowerCase() : void 0;
|
|
576
|
+
}
|
|
577
|
+
function isLoopbackHttp(url) {
|
|
578
|
+
if (url.protocol !== "http:") return false;
|
|
579
|
+
return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// src/authorization-manager.ts
|
|
583
|
+
var DEFAULT_PENDING_TTL_MS = 10 * 6e4;
|
|
584
|
+
var MAX_PENDING_AUTHORIZATIONS = 32;
|
|
585
|
+
var MCPAuthorizationManager = class {
|
|
586
|
+
constructor(options) {
|
|
587
|
+
this.options = options;
|
|
588
|
+
this.pendingTtlMs = options.pendingTtlMs ?? DEFAULT_PENDING_TTL_MS;
|
|
589
|
+
if (!Number.isFinite(this.pendingTtlMs) || this.pendingTtlMs <= 0) {
|
|
590
|
+
throw new Error("MCP authorization pending TTL must be a positive finite number");
|
|
591
|
+
}
|
|
592
|
+
this.discover = options.discover ?? discoverMcpAuthorization;
|
|
593
|
+
this.exchange = options.exchange ?? exchangeMcpAuthorizationCode;
|
|
594
|
+
this.now = options.now ?? Date.now;
|
|
595
|
+
}
|
|
596
|
+
options;
|
|
597
|
+
pending = /* @__PURE__ */ new Map();
|
|
598
|
+
pendingTtlMs;
|
|
599
|
+
discover;
|
|
600
|
+
exchange;
|
|
601
|
+
now;
|
|
602
|
+
async begin(input) {
|
|
603
|
+
const resource = canonicalMcpResource(input.resource);
|
|
604
|
+
const key = authorizationKey(input.serverName, resource);
|
|
605
|
+
this.pruneExpired();
|
|
606
|
+
if (!this.pending.has(key) && this.pending.size >= MAX_PENDING_AUTHORIZATIONS) {
|
|
607
|
+
throw new Error("Too many pending MCP authorization sessions");
|
|
608
|
+
}
|
|
609
|
+
const discovery = await this.discover(resource, {
|
|
610
|
+
challengeHeader: input.challengeHeader,
|
|
611
|
+
signal: input.signal
|
|
612
|
+
});
|
|
613
|
+
const challengeScopes = parseMcpBearerChallenge(input.challengeHeader ?? null, resource).scopes;
|
|
614
|
+
const scopes = input.scopes ? [...input.scopes] : challengeScopes;
|
|
615
|
+
const session = createMcpAuthorizationRequest({
|
|
616
|
+
authorizationServer: discovery.authorizationServer,
|
|
617
|
+
clientId: input.clientId,
|
|
618
|
+
redirectUri: input.redirectUri,
|
|
619
|
+
resource,
|
|
620
|
+
scopes
|
|
621
|
+
});
|
|
622
|
+
const normalizedScopes = new URL(session.authorizationUrl).searchParams.get("scope")?.split(" ").filter(Boolean) ?? [];
|
|
623
|
+
const expiresAt = this.now() + this.pendingTtlMs;
|
|
624
|
+
this.pending.set(key, { session, discovery, scopes: normalizedScopes, expiresAt });
|
|
625
|
+
return {
|
|
626
|
+
serverName: boundedServerName(input.serverName),
|
|
627
|
+
resource,
|
|
628
|
+
authorizationUrl: session.authorizationUrl,
|
|
629
|
+
redirectUri: session.redirectUri,
|
|
630
|
+
scopes: [...normalizedScopes],
|
|
631
|
+
expiresAt
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
async complete(input) {
|
|
635
|
+
const serverName = boundedServerName(input.serverName);
|
|
636
|
+
const resource = canonicalMcpResource(input.resource);
|
|
637
|
+
const key = authorizationKey(serverName, resource);
|
|
638
|
+
this.pruneExpired();
|
|
639
|
+
const pending = this.pending.get(key);
|
|
640
|
+
if (!pending) {
|
|
641
|
+
throw new Error("No live MCP authorization session exists for this server");
|
|
642
|
+
}
|
|
643
|
+
const code = parseMcpAuthorizationCallback(input.callbackUrl, pending.session);
|
|
644
|
+
this.pending.delete(key);
|
|
645
|
+
const tokenSet = await this.exchange({
|
|
646
|
+
authorizationServer: pending.discovery.authorizationServer,
|
|
647
|
+
clientId: pending.session.clientId,
|
|
648
|
+
redirectUri: pending.session.redirectUri,
|
|
649
|
+
resource,
|
|
650
|
+
code,
|
|
651
|
+
codeVerifier: pending.session.codeVerifier,
|
|
652
|
+
signal: input.signal
|
|
653
|
+
});
|
|
654
|
+
const stored = {
|
|
655
|
+
serverName,
|
|
656
|
+
resource,
|
|
657
|
+
clientId: pending.session.clientId,
|
|
658
|
+
authorizationServer: pending.discovery.authorizationServer,
|
|
659
|
+
tokenSet,
|
|
660
|
+
updatedAt: new Date(this.now()).toISOString()
|
|
661
|
+
};
|
|
662
|
+
await this.options.store.save(stored);
|
|
663
|
+
this.emit("authorized", stored);
|
|
664
|
+
return statusFromStored(stored, this.now());
|
|
665
|
+
}
|
|
666
|
+
async status(serverName, resource) {
|
|
667
|
+
const normalizedName = boundedServerName(serverName);
|
|
668
|
+
const normalizedResource = canonicalMcpResource(resource);
|
|
669
|
+
this.pruneExpired();
|
|
670
|
+
const pending = this.pending.get(authorizationKey(normalizedName, normalizedResource));
|
|
671
|
+
if (pending) {
|
|
672
|
+
return {
|
|
673
|
+
serverName: normalizedName,
|
|
674
|
+
resource: normalizedResource,
|
|
675
|
+
state: "pending",
|
|
676
|
+
expiresAt: pending.expiresAt,
|
|
677
|
+
scopes: [...pending.scopes],
|
|
678
|
+
canRefresh: false
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
const stored = await this.options.store.load(normalizedName, normalizedResource);
|
|
682
|
+
return stored ? statusFromStored(stored, this.now()) : {
|
|
683
|
+
serverName: normalizedName,
|
|
684
|
+
resource: normalizedResource,
|
|
685
|
+
state: "not_authorized",
|
|
686
|
+
scopes: [],
|
|
687
|
+
canRefresh: false
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
async disconnect(serverName, resource) {
|
|
691
|
+
const normalizedName = boundedServerName(serverName);
|
|
692
|
+
const normalizedResource = canonicalMcpResource(resource);
|
|
693
|
+
this.pending.delete(authorizationKey(normalizedName, normalizedResource));
|
|
694
|
+
const removed = await this.options.store.remove(normalizedName, normalizedResource);
|
|
695
|
+
if (removed) {
|
|
696
|
+
this.options.onStateChange?.({
|
|
697
|
+
serverName: normalizedName,
|
|
698
|
+
state: "removed",
|
|
699
|
+
resource: normalizedResource
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
return removed;
|
|
703
|
+
}
|
|
704
|
+
pruneExpired() {
|
|
705
|
+
const now = this.now();
|
|
706
|
+
for (const [key, value] of this.pending) {
|
|
707
|
+
if (value.expiresAt <= now) this.pending.delete(key);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
emit(state, value) {
|
|
711
|
+
this.options.onStateChange?.({
|
|
712
|
+
serverName: value.serverName,
|
|
713
|
+
state,
|
|
714
|
+
resource: value.resource,
|
|
715
|
+
expiresAt: value.tokenSet.expiresAt,
|
|
716
|
+
scopes: [...value.tokenSet.scopes ?? []]
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
function statusFromStored(value, now) {
|
|
721
|
+
return {
|
|
722
|
+
serverName: value.serverName,
|
|
723
|
+
resource: value.resource,
|
|
724
|
+
state: value.tokenSet.expiresAt !== void 0 && value.tokenSet.expiresAt <= now ? "expired" : "authorized",
|
|
725
|
+
expiresAt: value.tokenSet.expiresAt,
|
|
726
|
+
scopes: [...value.tokenSet.scopes ?? []],
|
|
727
|
+
canRefresh: !!value.tokenSet.refreshToken
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
function authorizationKey(serverName, resource) {
|
|
731
|
+
return `${boundedServerName(serverName)}\0${resource}`;
|
|
732
|
+
}
|
|
733
|
+
function boundedServerName(value) {
|
|
734
|
+
if (!value || value.length > 256 || /[\r\n\0]/.test(value)) {
|
|
735
|
+
throw new Error("MCP authorization server name is invalid");
|
|
736
|
+
}
|
|
737
|
+
return value;
|
|
738
|
+
}
|
|
739
|
+
|
|
1
740
|
// src/client.ts
|
|
2
741
|
import { spawn } from "node:child_process";
|
|
3
742
|
import { buildChildEnv } from "@wrongstack/core";
|
|
@@ -47,6 +786,207 @@ var MCP_CONSTANTS = Object.freeze({
|
|
|
47
786
|
REQUEST_LOG_CAP: 1024
|
|
48
787
|
});
|
|
49
788
|
|
|
789
|
+
// src/protocol.ts
|
|
790
|
+
function record2(value, label) {
|
|
791
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
792
|
+
throw new Error(`Malformed MCP ${label}: expected object`);
|
|
793
|
+
}
|
|
794
|
+
return value;
|
|
795
|
+
}
|
|
796
|
+
function requiredString2(value, label) {
|
|
797
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
798
|
+
throw new Error(`Malformed MCP ${label}: expected non-empty string`);
|
|
799
|
+
}
|
|
800
|
+
return value;
|
|
801
|
+
}
|
|
802
|
+
function optionalString2(value, label) {
|
|
803
|
+
if (value === void 0) return void 0;
|
|
804
|
+
if (typeof value !== "string") throw new Error(`Malformed MCP ${label}: expected string`);
|
|
805
|
+
return value;
|
|
806
|
+
}
|
|
807
|
+
function optionalRecord(value, label) {
|
|
808
|
+
if (value === void 0) return void 0;
|
|
809
|
+
return record2(value, label);
|
|
810
|
+
}
|
|
811
|
+
function optionalCursor(value, label) {
|
|
812
|
+
return optionalString2(value, `${label}.nextCursor`);
|
|
813
|
+
}
|
|
814
|
+
function parseServerMetadata(value) {
|
|
815
|
+
const input = record2(value, "initialize result");
|
|
816
|
+
const serverInfo = record2(input["serverInfo"], "initialize.serverInfo");
|
|
817
|
+
const capabilities = record2(input["capabilities"], "initialize.capabilities");
|
|
818
|
+
return {
|
|
819
|
+
protocolVersion: requiredString2(input["protocolVersion"], "initialize.protocolVersion"),
|
|
820
|
+
capabilities,
|
|
821
|
+
serverInfo: {
|
|
822
|
+
name: requiredString2(serverInfo["name"], "initialize.serverInfo.name"),
|
|
823
|
+
version: requiredString2(serverInfo["version"], "initialize.serverInfo.version"),
|
|
824
|
+
title: optionalString2(serverInfo["title"], "initialize.serverInfo.title")
|
|
825
|
+
},
|
|
826
|
+
instructions: optionalString2(input["instructions"], "initialize.instructions")
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
function parseResource(value, index) {
|
|
830
|
+
const input = record2(value, `resources/list.resources[${index}]`);
|
|
831
|
+
const size = input["size"];
|
|
832
|
+
if (size !== void 0 && (typeof size !== "number" || !Number.isFinite(size) || size < 0)) {
|
|
833
|
+
throw new Error(`Malformed MCP resources/list.resources[${index}].size`);
|
|
834
|
+
}
|
|
835
|
+
return {
|
|
836
|
+
uri: requiredString2(input["uri"], `resources/list.resources[${index}].uri`),
|
|
837
|
+
name: requiredString2(input["name"], `resources/list.resources[${index}].name`),
|
|
838
|
+
title: optionalString2(input["title"], `resources/list.resources[${index}].title`),
|
|
839
|
+
description: optionalString2(
|
|
840
|
+
input["description"],
|
|
841
|
+
`resources/list.resources[${index}].description`
|
|
842
|
+
),
|
|
843
|
+
mimeType: optionalString2(input["mimeType"], `resources/list.resources[${index}].mimeType`),
|
|
844
|
+
size,
|
|
845
|
+
annotations: optionalRecord(
|
|
846
|
+
input["annotations"],
|
|
847
|
+
`resources/list.resources[${index}].annotations`
|
|
848
|
+
)
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
function parseListResourcesResult(value) {
|
|
852
|
+
const input = record2(value, "resources/list result");
|
|
853
|
+
if (!Array.isArray(input["resources"])) {
|
|
854
|
+
throw new Error("Malformed MCP resources/list result: resources must be an array");
|
|
855
|
+
}
|
|
856
|
+
return {
|
|
857
|
+
resources: input["resources"].map(parseResource),
|
|
858
|
+
nextCursor: optionalCursor(input["nextCursor"], "resources/list")
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
function parseListResourceTemplatesResult(value) {
|
|
862
|
+
const input = record2(value, "resources/templates/list result");
|
|
863
|
+
const templates = input["resourceTemplates"];
|
|
864
|
+
if (!Array.isArray(templates)) {
|
|
865
|
+
throw new Error(
|
|
866
|
+
"Malformed MCP resources/templates/list result: resourceTemplates must be an array"
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
return {
|
|
870
|
+
resourceTemplates: templates.map((value2, index) => {
|
|
871
|
+
const template = record2(value2, `resources/templates/list.resourceTemplates[${index}]`);
|
|
872
|
+
return {
|
|
873
|
+
uriTemplate: requiredString2(
|
|
874
|
+
template["uriTemplate"],
|
|
875
|
+
`resources/templates/list.resourceTemplates[${index}].uriTemplate`
|
|
876
|
+
),
|
|
877
|
+
name: requiredString2(
|
|
878
|
+
template["name"],
|
|
879
|
+
`resources/templates/list.resourceTemplates[${index}].name`
|
|
880
|
+
),
|
|
881
|
+
title: optionalString2(
|
|
882
|
+
template["title"],
|
|
883
|
+
`resources/templates/list.resourceTemplates[${index}].title`
|
|
884
|
+
),
|
|
885
|
+
description: optionalString2(
|
|
886
|
+
template["description"],
|
|
887
|
+
`resources/templates/list.resourceTemplates[${index}].description`
|
|
888
|
+
),
|
|
889
|
+
mimeType: optionalString2(
|
|
890
|
+
template["mimeType"],
|
|
891
|
+
`resources/templates/list.resourceTemplates[${index}].mimeType`
|
|
892
|
+
),
|
|
893
|
+
annotations: optionalRecord(
|
|
894
|
+
template["annotations"],
|
|
895
|
+
`resources/templates/list.resourceTemplates[${index}].annotations`
|
|
896
|
+
)
|
|
897
|
+
};
|
|
898
|
+
}),
|
|
899
|
+
nextCursor: optionalCursor(input["nextCursor"], "resources/templates/list")
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
function parseReadResourceResult(value) {
|
|
903
|
+
const input = record2(value, "resources/read result");
|
|
904
|
+
if (!Array.isArray(input["contents"])) {
|
|
905
|
+
throw new Error("Malformed MCP resources/read result: contents must be an array");
|
|
906
|
+
}
|
|
907
|
+
return {
|
|
908
|
+
contents: input["contents"].map((value2, index) => {
|
|
909
|
+
const content = record2(value2, `resources/read.contents[${index}]`);
|
|
910
|
+
const text = optionalString2(content["text"], `resources/read.contents[${index}].text`);
|
|
911
|
+
const blob = optionalString2(content["blob"], `resources/read.contents[${index}].blob`);
|
|
912
|
+
if (text === void 0 && blob === void 0) {
|
|
913
|
+
throw new Error(`Malformed MCP resources/read.contents[${index}]: expected text or blob`);
|
|
914
|
+
}
|
|
915
|
+
return {
|
|
916
|
+
uri: requiredString2(content["uri"], `resources/read.contents[${index}].uri`),
|
|
917
|
+
mimeType: optionalString2(content["mimeType"], `resources/read.contents[${index}].mimeType`),
|
|
918
|
+
text,
|
|
919
|
+
blob
|
|
920
|
+
};
|
|
921
|
+
})
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
function parsePromptArgument(value, promptIndex, argIndex) {
|
|
925
|
+
const input = record2(value, `prompts/list.prompts[${promptIndex}].arguments[${argIndex}]`);
|
|
926
|
+
const required = input["required"];
|
|
927
|
+
if (required !== void 0 && typeof required !== "boolean") {
|
|
928
|
+
throw new Error(
|
|
929
|
+
`Malformed MCP prompts/list.prompts[${promptIndex}].arguments[${argIndex}].required`
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
return {
|
|
933
|
+
name: requiredString2(
|
|
934
|
+
input["name"],
|
|
935
|
+
`prompts/list.prompts[${promptIndex}].arguments[${argIndex}].name`
|
|
936
|
+
),
|
|
937
|
+
description: optionalString2(
|
|
938
|
+
input["description"],
|
|
939
|
+
`prompts/list.prompts[${promptIndex}].arguments[${argIndex}].description`
|
|
940
|
+
),
|
|
941
|
+
required
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
function parseListPromptsResult(value) {
|
|
945
|
+
const input = record2(value, "prompts/list result");
|
|
946
|
+
if (!Array.isArray(input["prompts"])) {
|
|
947
|
+
throw new Error("Malformed MCP prompts/list result: prompts must be an array");
|
|
948
|
+
}
|
|
949
|
+
return {
|
|
950
|
+
prompts: input["prompts"].map((value2, index) => {
|
|
951
|
+
const prompt = record2(value2, `prompts/list.prompts[${index}]`);
|
|
952
|
+
const args = prompt["arguments"];
|
|
953
|
+
if (args !== void 0 && !Array.isArray(args)) {
|
|
954
|
+
throw new Error(`Malformed MCP prompts/list.prompts[${index}].arguments`);
|
|
955
|
+
}
|
|
956
|
+
return {
|
|
957
|
+
name: requiredString2(prompt["name"], `prompts/list.prompts[${index}].name`),
|
|
958
|
+
title: optionalString2(prompt["title"], `prompts/list.prompts[${index}].title`),
|
|
959
|
+
description: optionalString2(
|
|
960
|
+
prompt["description"],
|
|
961
|
+
`prompts/list.prompts[${index}].description`
|
|
962
|
+
),
|
|
963
|
+
arguments: args?.map((arg, argIndex) => parsePromptArgument(arg, index, argIndex))
|
|
964
|
+
};
|
|
965
|
+
}),
|
|
966
|
+
nextCursor: optionalCursor(input["nextCursor"], "prompts/list")
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
function parseGetPromptResult(value) {
|
|
970
|
+
const input = record2(value, "prompts/get result");
|
|
971
|
+
if (!Array.isArray(input["messages"])) {
|
|
972
|
+
throw new Error("Malformed MCP prompts/get result: messages must be an array");
|
|
973
|
+
}
|
|
974
|
+
return {
|
|
975
|
+
description: optionalString2(input["description"], "prompts/get.description"),
|
|
976
|
+
messages: input["messages"].map((value2, index) => {
|
|
977
|
+
const message = record2(value2, `prompts/get.messages[${index}]`);
|
|
978
|
+
const role = message["role"];
|
|
979
|
+
if (role !== "user" && role !== "assistant") {
|
|
980
|
+
throw new Error(`Malformed MCP prompts/get.messages[${index}].role`);
|
|
981
|
+
}
|
|
982
|
+
if (message["content"] === void 0) {
|
|
983
|
+
throw new Error(`Malformed MCP prompts/get.messages[${index}].content`);
|
|
984
|
+
}
|
|
985
|
+
return { role, content: message["content"] };
|
|
986
|
+
})
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
|
|
50
990
|
// src/tool-schema.ts
|
|
51
991
|
function normalizeMCPTools(value) {
|
|
52
992
|
if (!Array.isArray(value)) return [];
|
|
@@ -74,66 +1014,96 @@ function normalizeMCPTools(value) {
|
|
|
74
1014
|
return tools;
|
|
75
1015
|
}
|
|
76
1016
|
|
|
77
|
-
// src/transport.ts
|
|
78
|
-
import {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
throw new ConfigError({
|
|
91
|
-
message: `MCP transport: invalid URL "${rawUrl}"`,
|
|
92
|
-
code: "CONFIG_INVALID",
|
|
93
|
-
context: { field: "url", rawUrl }
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
97
|
-
throw new ConfigError({
|
|
98
|
-
message: `MCP transport: unsupported protocol "${url.protocol}" \u2014 only http/https allowed`,
|
|
99
|
-
code: "CONFIG_INVALID",
|
|
100
|
-
context: { field: "url", rawUrl, protocol: url.protocol }
|
|
101
|
-
});
|
|
1017
|
+
// src/transport-jsonrpc.ts
|
|
1018
|
+
import { ToolError } from "@wrongstack/core";
|
|
1019
|
+
function isJsonRpcResult(v) {
|
|
1020
|
+
if (typeof v !== "object" || v === null) return false;
|
|
1021
|
+
const r = v;
|
|
1022
|
+
if (r["jsonrpc"] !== "2.0" || typeof r["id"] !== "number") return false;
|
|
1023
|
+
if (Object.hasOwn(r, "method")) return false;
|
|
1024
|
+
const hasResult = Object.hasOwn(r, "result");
|
|
1025
|
+
const hasError = Object.hasOwn(r, "error");
|
|
1026
|
+
if (hasResult === hasError) return false;
|
|
1027
|
+
if (hasError) {
|
|
1028
|
+
const error = r["error"];
|
|
1029
|
+
return typeof error === "object" && error !== null && typeof error["code"] === "number" && typeof error["message"] === "string";
|
|
102
1030
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
1031
|
+
return true;
|
|
1032
|
+
}
|
|
1033
|
+
function isJsonRpcMethodEnvelope(v) {
|
|
1034
|
+
if (typeof v !== "object" || v === null) return false;
|
|
1035
|
+
const envelope = v;
|
|
1036
|
+
if (envelope["jsonrpc"] !== "2.0" || typeof envelope["method"] !== "string") return false;
|
|
1037
|
+
const id = envelope["id"];
|
|
1038
|
+
return id === void 0 || typeof id === "number" || typeof id === "string";
|
|
1039
|
+
}
|
|
1040
|
+
function extractJsonRpcEnvelopes(text) {
|
|
1041
|
+
const out = [];
|
|
1042
|
+
let dataBuf = [];
|
|
1043
|
+
const flush = () => {
|
|
1044
|
+
if (dataBuf.length === 0) return;
|
|
1045
|
+
const joined = dataBuf.join("\n").trim();
|
|
1046
|
+
dataBuf = [];
|
|
1047
|
+
if (!joined) return;
|
|
1048
|
+
try {
|
|
1049
|
+
const parsed = JSON.parse(joined);
|
|
1050
|
+
if (isJsonRpcResult(parsed) || isJsonRpcMethodEnvelope(parsed)) out.push(parsed);
|
|
1051
|
+
} catch {
|
|
114
1052
|
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
code: "CONFIG_INVALID",
|
|
122
|
-
context: { field: "url", rawUrl, hostname }
|
|
123
|
-
});
|
|
1053
|
+
};
|
|
1054
|
+
for (const raw of text.split("\n")) {
|
|
1055
|
+
const line = raw.replace(/\r$/, "");
|
|
1056
|
+
if (line === "") {
|
|
1057
|
+
flush();
|
|
1058
|
+
continue;
|
|
124
1059
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
1060
|
+
if (line.startsWith(":")) continue;
|
|
1061
|
+
if (line.startsWith("data:")) {
|
|
1062
|
+
let v = line.slice(5);
|
|
1063
|
+
if (v.startsWith(" ")) v = v.slice(1);
|
|
1064
|
+
dataBuf.push(v);
|
|
1065
|
+
continue;
|
|
1066
|
+
}
|
|
1067
|
+
if (line.startsWith("event:") || line.startsWith("id:") || line.startsWith("retry:")) {
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
const trimmed = line.trim();
|
|
1071
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
1072
|
+
try {
|
|
1073
|
+
const parsed = JSON.parse(trimmed);
|
|
1074
|
+
if (isJsonRpcResult(parsed) || isJsonRpcMethodEnvelope(parsed)) out.push(parsed);
|
|
1075
|
+
} catch {
|
|
1076
|
+
}
|
|
134
1077
|
}
|
|
135
1078
|
}
|
|
1079
|
+
flush();
|
|
1080
|
+
return out;
|
|
1081
|
+
}
|
|
1082
|
+
function extractJsonRpcResults(text) {
|
|
1083
|
+
return extractJsonRpcEnvelopes(text).filter(isJsonRpcResult);
|
|
1084
|
+
}
|
|
1085
|
+
function assertMatchingJsonRpcResult(data, expectedId, method) {
|
|
1086
|
+
if (!isJsonRpcResult(data)) {
|
|
1087
|
+
throw new ToolError({
|
|
1088
|
+
message: "Invalid JSON-RPC response: not a JSON-RPC 2.0 envelope",
|
|
1089
|
+
code: "TOOL_EXECUTION_FAILED",
|
|
1090
|
+
toolName: "mcp_transport_jsonrpc",
|
|
1091
|
+
context: { method, expectedId, reason: "not-jsonrpc-envelope" }
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
if (data.id !== expectedId) {
|
|
1095
|
+
throw new ToolError({
|
|
1096
|
+
message: `Invalid JSON-RPC response: id mismatch for ${method} (expected ${expectedId}, got ${data.id})`,
|
|
1097
|
+
code: "TOOL_EXECUTION_FAILED",
|
|
1098
|
+
toolName: "mcp_transport_jsonrpc",
|
|
1099
|
+
context: { method, expectedId, actualId: data.id, reason: "id-mismatch" }
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
return data;
|
|
136
1103
|
}
|
|
1104
|
+
|
|
1105
|
+
// src/sse-reader.ts
|
|
1106
|
+
import { ToolError as ToolError2 } from "@wrongstack/core";
|
|
137
1107
|
var SSE_READER_MAX_BUFFER = 256 * 1024;
|
|
138
1108
|
var SSE_READER_MAX_DATA_LINES = 1024;
|
|
139
1109
|
var SSEReader = class {
|
|
@@ -149,7 +1119,7 @@ var SSEReader = class {
|
|
|
149
1119
|
}
|
|
150
1120
|
feed(chunk) {
|
|
151
1121
|
if (chunk.length > SSE_READER_MAX_BUFFER) {
|
|
152
|
-
throw new
|
|
1122
|
+
throw new ToolError2({
|
|
153
1123
|
message: `SSE: chunk size ${chunk.length} exceeds max buffer ${SSE_READER_MAX_BUFFER} \u2014 refusing to accumulate`,
|
|
154
1124
|
code: "TOOL_EXECUTION_FAILED",
|
|
155
1125
|
toolName: "mcp_transport_sse_reader",
|
|
@@ -158,11 +1128,15 @@ var SSEReader = class {
|
|
|
158
1128
|
}
|
|
159
1129
|
this.buffer += chunk;
|
|
160
1130
|
if (this.buffer.length > SSE_READER_MAX_BUFFER) {
|
|
161
|
-
throw new
|
|
1131
|
+
throw new ToolError2({
|
|
162
1132
|
message: `SSE: pending line exceeds ${SSE_READER_MAX_BUFFER} bytes \u2014 upstream is not framing events`,
|
|
163
1133
|
code: "TOOL_EXECUTION_FAILED",
|
|
164
1134
|
toolName: "mcp_transport_sse_reader",
|
|
165
|
-
context: {
|
|
1135
|
+
context: {
|
|
1136
|
+
phase: "feed",
|
|
1137
|
+
bufferLength: this.buffer.length,
|
|
1138
|
+
maxBuffer: SSE_READER_MAX_BUFFER
|
|
1139
|
+
}
|
|
166
1140
|
});
|
|
167
1141
|
}
|
|
168
1142
|
let idx = this.buffer.indexOf("\n");
|
|
@@ -186,11 +1160,15 @@ var SSEReader = class {
|
|
|
186
1160
|
if (field === "event") {
|
|
187
1161
|
} else if (field === "data") {
|
|
188
1162
|
if (this.dataLines.length >= SSE_READER_MAX_DATA_LINES) {
|
|
189
|
-
throw new
|
|
1163
|
+
throw new ToolError2({
|
|
190
1164
|
message: `SSE: exceeded ${SSE_READER_MAX_DATA_LINES} data lines per event \u2014 upstream is not sending blank-line delimiters`,
|
|
191
1165
|
code: "TOOL_EXECUTION_FAILED",
|
|
192
1166
|
toolName: "mcp_transport_sse_reader",
|
|
193
|
-
context: {
|
|
1167
|
+
context: {
|
|
1168
|
+
phase: "processLine",
|
|
1169
|
+
dataLineCount: this.dataLines.length,
|
|
1170
|
+
maxDataLines: SSE_READER_MAX_DATA_LINES
|
|
1171
|
+
}
|
|
194
1172
|
});
|
|
195
1173
|
}
|
|
196
1174
|
this.dataLines.push(value);
|
|
@@ -223,88 +1201,71 @@ var SSEReader = class {
|
|
|
223
1201
|
this.listeners = [];
|
|
224
1202
|
}
|
|
225
1203
|
};
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
const out = [];
|
|
237
|
-
let dataBuf = [];
|
|
238
|
-
const flush = () => {
|
|
239
|
-
if (dataBuf.length === 0) return;
|
|
240
|
-
const joined = dataBuf.join("\n").trim();
|
|
241
|
-
dataBuf = [];
|
|
242
|
-
if (!joined) return;
|
|
243
|
-
try {
|
|
244
|
-
const parsed = JSON.parse(joined);
|
|
245
|
-
if (isJsonRpcResult(parsed)) out.push(parsed);
|
|
246
|
-
} catch {
|
|
247
|
-
}
|
|
248
|
-
};
|
|
249
|
-
for (const raw of text.split("\n")) {
|
|
250
|
-
const line = raw.replace(/\r$/, "");
|
|
251
|
-
if (line === "") {
|
|
252
|
-
flush();
|
|
253
|
-
continue;
|
|
254
|
-
}
|
|
255
|
-
if (line.startsWith(":")) continue;
|
|
256
|
-
if (line.startsWith("data:")) {
|
|
257
|
-
let v = line.slice(5);
|
|
258
|
-
if (v.startsWith(" ")) v = v.slice(1);
|
|
259
|
-
dataBuf.push(v);
|
|
260
|
-
continue;
|
|
261
|
-
}
|
|
262
|
-
if (line.startsWith("event:") || line.startsWith("id:") || line.startsWith("retry:")) {
|
|
263
|
-
continue;
|
|
264
|
-
}
|
|
265
|
-
const trimmed = line.trim();
|
|
266
|
-
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
267
|
-
try {
|
|
268
|
-
const parsed = JSON.parse(trimmed);
|
|
269
|
-
if (isJsonRpcResult(parsed)) out.push(parsed);
|
|
270
|
-
} catch {
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
flush();
|
|
275
|
-
return out;
|
|
276
|
-
}
|
|
277
|
-
function pickJsonRpcResult(text, id) {
|
|
278
|
-
const results = extractJsonRpcResults(text);
|
|
279
|
-
return results.find((r) => r.id === id) ?? results[0];
|
|
1204
|
+
|
|
1205
|
+
// src/transport-base.ts
|
|
1206
|
+
import * as https2 from "node:https";
|
|
1207
|
+
import { ConfigError as ConfigError2 } from "@wrongstack/core";
|
|
1208
|
+
|
|
1209
|
+
// src/transport-security.ts
|
|
1210
|
+
import * as net2 from "node:net";
|
|
1211
|
+
import { ConfigError } from "@wrongstack/core";
|
|
1212
|
+
function isTlsUnsafeAllowed() {
|
|
1213
|
+
return process.env["WRONGSTACK_UNSAFE_MCP_TLS"] === "1";
|
|
280
1214
|
}
|
|
281
|
-
function
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
1215
|
+
function validateTransportUrl(rawUrl) {
|
|
1216
|
+
let url;
|
|
1217
|
+
try {
|
|
1218
|
+
url = new URL(rawUrl);
|
|
1219
|
+
} catch {
|
|
1220
|
+
throw new ConfigError({
|
|
1221
|
+
message: `MCP transport: invalid URL "${rawUrl}"`,
|
|
1222
|
+
code: "CONFIG_INVALID",
|
|
1223
|
+
context: { field: "url", rawUrl }
|
|
288
1224
|
});
|
|
289
1225
|
}
|
|
290
|
-
if (
|
|
291
|
-
throw new
|
|
292
|
-
message: `
|
|
293
|
-
code: "
|
|
294
|
-
|
|
295
|
-
context: { method, expectedId, actualId: data.id, reason: "id-mismatch" }
|
|
1226
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
1227
|
+
throw new ConfigError({
|
|
1228
|
+
message: `MCP transport: unsupported protocol "${url.protocol}" \u2014 only http/https allowed`,
|
|
1229
|
+
code: "CONFIG_INVALID",
|
|
1230
|
+
context: { field: "url", rawUrl, protocol: url.protocol }
|
|
296
1231
|
});
|
|
297
1232
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
1233
|
+
const hostname = url.hostname;
|
|
1234
|
+
const host = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
1235
|
+
const ipVersion = net2.isIP(host);
|
|
1236
|
+
if (ipVersion === 4) {
|
|
1237
|
+
const parts = host.split(".").map(Number);
|
|
1238
|
+
if (parts[0] === 169 && parts[1] === 254) {
|
|
1239
|
+
throw new ConfigError({
|
|
1240
|
+
message: `MCP transport: blocked link-local/IMDS address "${hostname}" \u2014 likely not a valid MCP server`,
|
|
1241
|
+
code: "CONFIG_INVALID",
|
|
1242
|
+
context: { field: "url", rawUrl, hostname }
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
} else if (ipVersion === 6) {
|
|
1246
|
+
const lower = host.toLowerCase();
|
|
1247
|
+
const linkLocal = /^fe[89ab]/.test(lower);
|
|
1248
|
+
if (linkLocal || lower === "fd00:ec2::254") {
|
|
1249
|
+
throw new ConfigError({
|
|
1250
|
+
message: `MCP transport: blocked link-local/IMDS address "${hostname}" \u2014 likely not a valid MCP server`,
|
|
1251
|
+
code: "CONFIG_INVALID",
|
|
1252
|
+
context: { field: "url", rawUrl, hostname }
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
if (url.protocol === "http:") {
|
|
1257
|
+
const isLoopback = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
|
|
1258
|
+
if (!isLoopback) {
|
|
1259
|
+
throw new ConfigError({
|
|
1260
|
+
message: `MCP transport: http:// is only allowed for loopback addresses; use https:// for "${hostname}"`,
|
|
1261
|
+
code: "CONFIG_INVALID",
|
|
1262
|
+
context: { field: "url", rawUrl, hostname, protocol: url.protocol }
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
305
1265
|
}
|
|
306
|
-
return data;
|
|
307
1266
|
}
|
|
1267
|
+
|
|
1268
|
+
// src/transport-base.ts
|
|
308
1269
|
function makeAbortError(method) {
|
|
309
1270
|
const err = new Error(`MCP request "${method}" aborted by client`);
|
|
310
1271
|
err.name = "AbortError";
|
|
@@ -336,22 +1297,32 @@ var BaseHTTPTransport = class {
|
|
|
336
1297
|
headers;
|
|
337
1298
|
timeout;
|
|
338
1299
|
requestTimeout;
|
|
1300
|
+
name;
|
|
1301
|
+
authorizationProvider;
|
|
1302
|
+
authorizationResource;
|
|
339
1303
|
/** Per-request TLS agent — created once from HttpTransportOptions.tls */
|
|
340
1304
|
tlsAgent;
|
|
341
1305
|
tools = [];
|
|
1306
|
+
serverMetadata;
|
|
342
1307
|
abortController;
|
|
343
1308
|
disconnectHandlers = [];
|
|
344
1309
|
toolsChangedListeners = /* @__PURE__ */ new Set();
|
|
1310
|
+
resourcesChangedListeners = /* @__PURE__ */ new Set();
|
|
1311
|
+
promptsChangedListeners = /* @__PURE__ */ new Set();
|
|
1312
|
+
protocolVersion;
|
|
345
1313
|
constructor(opts, transportName) {
|
|
346
1314
|
validateTransportUrl(opts.url);
|
|
1315
|
+
this.name = opts.name;
|
|
347
1316
|
this.url = opts.url;
|
|
348
1317
|
this.headers = { ...opts.headers };
|
|
1318
|
+
this.authorizationProvider = opts.authorizationProvider;
|
|
1319
|
+
this.authorizationResource = canonicalMcpResource(opts.url);
|
|
349
1320
|
this.timeout = opts.startupTimeoutMs ?? 1e4;
|
|
350
1321
|
this.requestTimeout = opts.requestTimeoutMs ?? 6e4;
|
|
351
1322
|
if (opts.tls) {
|
|
352
1323
|
if (opts.tls.rejectUnauthorized === false) {
|
|
353
1324
|
if (!isTlsUnsafeAllowed()) {
|
|
354
|
-
throw new
|
|
1325
|
+
throw new ConfigError2({
|
|
355
1326
|
message: `[mcp:${transportName}] TLS verification disabled \u2014 set WRONGSTACK_UNSAFE_MCP_TLS=1 to allow. Rejecting insecure configuration for ${this.url}.`,
|
|
356
1327
|
code: "CONFIG_INVALID",
|
|
357
1328
|
context: { field: "tls.rejectUnauthorized", transportName, url: this.url }
|
|
@@ -361,7 +1332,7 @@ var BaseHTTPTransport = class {
|
|
|
361
1332
|
`[mcp:${transportName}] \u26A0\uFE0F TLS verification DISABLED for ${this.url}. Network attacks are possible \u2014 only use on localhost.`
|
|
362
1333
|
);
|
|
363
1334
|
}
|
|
364
|
-
this.tlsAgent = new
|
|
1335
|
+
this.tlsAgent = new https2.Agent({
|
|
365
1336
|
ca: opts.tls.ca,
|
|
366
1337
|
rejectUnauthorized: opts.tls.rejectUnauthorized
|
|
367
1338
|
});
|
|
@@ -370,9 +1341,52 @@ var BaseHTTPTransport = class {
|
|
|
370
1341
|
getState() {
|
|
371
1342
|
return this.state;
|
|
372
1343
|
}
|
|
1344
|
+
async fetchWithAuthorization(input, init, signal) {
|
|
1345
|
+
const context = {
|
|
1346
|
+
serverName: this.name,
|
|
1347
|
+
resource: this.authorizationResource,
|
|
1348
|
+
signal
|
|
1349
|
+
};
|
|
1350
|
+
const send = async () => {
|
|
1351
|
+
signal?.throwIfAborted();
|
|
1352
|
+
const headers = new Headers(init.headers);
|
|
1353
|
+
if (this.protocolVersion) headers.set("MCP-Protocol-Version", this.protocolVersion);
|
|
1354
|
+
const token = await this.authorizationProvider?.getAccessToken(context);
|
|
1355
|
+
signal?.throwIfAborted();
|
|
1356
|
+
if (token) {
|
|
1357
|
+
headers.set(
|
|
1358
|
+
"Authorization",
|
|
1359
|
+
authorizationHeaderForToken(token, this.authorizationResource)
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
return fetch(input, { ...init, headers });
|
|
1363
|
+
};
|
|
1364
|
+
let response = await send();
|
|
1365
|
+
if (response.status !== 401 || !this.authorizationProvider?.handleUnauthorized) {
|
|
1366
|
+
return response;
|
|
1367
|
+
}
|
|
1368
|
+
const challenge = parseMcpBearerChallenge(
|
|
1369
|
+
response.headers.get("www-authenticate"),
|
|
1370
|
+
this.authorizationResource
|
|
1371
|
+
);
|
|
1372
|
+
const retry = await this.authorizationProvider.handleUnauthorized(challenge, context);
|
|
1373
|
+
if (!retry) return response;
|
|
1374
|
+
await response.body?.cancel().catch(() => void 0);
|
|
1375
|
+
response = await send();
|
|
1376
|
+
return response;
|
|
1377
|
+
}
|
|
373
1378
|
listTools() {
|
|
374
1379
|
return [...this.tools];
|
|
375
1380
|
}
|
|
1381
|
+
getServerMetadata() {
|
|
1382
|
+
const metadata = this.serverMetadata;
|
|
1383
|
+
if (!metadata) return void 0;
|
|
1384
|
+
return {
|
|
1385
|
+
...metadata,
|
|
1386
|
+
capabilities: { ...metadata.capabilities },
|
|
1387
|
+
serverInfo: { ...metadata.serverInfo }
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
376
1390
|
onDisconnect(cb) {
|
|
377
1391
|
this.disconnectHandlers.push(cb);
|
|
378
1392
|
return () => {
|
|
@@ -386,6 +1400,14 @@ var BaseHTTPTransport = class {
|
|
|
386
1400
|
this.toolsChangedListeners.delete(cb);
|
|
387
1401
|
};
|
|
388
1402
|
}
|
|
1403
|
+
onResourcesChanged(cb) {
|
|
1404
|
+
this.resourcesChangedListeners.add(cb);
|
|
1405
|
+
return () => this.resourcesChangedListeners.delete(cb);
|
|
1406
|
+
}
|
|
1407
|
+
onPromptsChanged(cb) {
|
|
1408
|
+
this.promptsChangedListeners.add(cb);
|
|
1409
|
+
return () => this.promptsChangedListeners.delete(cb);
|
|
1410
|
+
}
|
|
389
1411
|
/**
|
|
390
1412
|
* Fire all disconnect handlers. Subclasses call this when the connection
|
|
391
1413
|
* drops so the registry can schedule reconnects.
|
|
@@ -398,6 +1420,22 @@ var BaseHTTPTransport = class {
|
|
|
398
1420
|
}
|
|
399
1421
|
}
|
|
400
1422
|
}
|
|
1423
|
+
notifyResourcesChanged() {
|
|
1424
|
+
for (const cb of this.resourcesChangedListeners) {
|
|
1425
|
+
try {
|
|
1426
|
+
cb();
|
|
1427
|
+
} catch {
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
notifyPromptsChanged() {
|
|
1432
|
+
for (const cb of this.promptsChangedListeners) {
|
|
1433
|
+
try {
|
|
1434
|
+
cb();
|
|
1435
|
+
} catch {
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
401
1439
|
/**
|
|
402
1440
|
* Apply the pinned TLS agent (if configured) to a `RequestInit` object.
|
|
403
1441
|
* Uses `HttpDispatcher` from `@wrongstack/core`'s dispatcher-types shim,
|
|
@@ -411,6 +1449,10 @@ var BaseHTTPTransport = class {
|
|
|
411
1449
|
}
|
|
412
1450
|
}
|
|
413
1451
|
};
|
|
1452
|
+
|
|
1453
|
+
// src/transport-sse.ts
|
|
1454
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
1455
|
+
import { ToolError as ToolError3 } from "@wrongstack/core";
|
|
414
1456
|
var SSETransport = class extends BaseHTTPTransport {
|
|
415
1457
|
_nextId = 1;
|
|
416
1458
|
readerDone = false;
|
|
@@ -444,6 +1486,7 @@ var SSETransport = class extends BaseHTTPTransport {
|
|
|
444
1486
|
}
|
|
445
1487
|
async connect() {
|
|
446
1488
|
this.state = "connecting";
|
|
1489
|
+
this.serverMetadata = void 0;
|
|
447
1490
|
this.abortController = new AbortController();
|
|
448
1491
|
const signal = this.abortController.signal;
|
|
449
1492
|
const startupTimer = setTimeout(() => this.abortController?.abort(), this.timeout);
|
|
@@ -454,9 +1497,9 @@ var SSETransport = class extends BaseHTTPTransport {
|
|
|
454
1497
|
signal
|
|
455
1498
|
};
|
|
456
1499
|
this.applyTlsAgent(fetchOpts);
|
|
457
|
-
const response = await
|
|
1500
|
+
const response = await this.fetchWithAuthorization(sseUrl, fetchOpts, signal);
|
|
458
1501
|
if (!response.ok) {
|
|
459
|
-
throw new
|
|
1502
|
+
throw new ToolError3({
|
|
460
1503
|
message: `SSE connect HTTP ${response.status}: ${response.statusText}`,
|
|
461
1504
|
code: "TOOL_EXECUTION_FAILED",
|
|
462
1505
|
toolName: "mcp_transport_sse_connect",
|
|
@@ -464,7 +1507,7 @@ var SSETransport = class extends BaseHTTPTransport {
|
|
|
464
1507
|
});
|
|
465
1508
|
}
|
|
466
1509
|
if (!response.body) {
|
|
467
|
-
throw new
|
|
1510
|
+
throw new ToolError3({
|
|
468
1511
|
message: "SSE response has no body",
|
|
469
1512
|
code: "TOOL_EXECUTION_FAILED",
|
|
470
1513
|
toolName: "mcp_transport_sse_connect",
|
|
@@ -478,6 +1521,10 @@ var SSETransport = class extends BaseHTTPTransport {
|
|
|
478
1521
|
if (msg.method && !msg.id) {
|
|
479
1522
|
if (msg.method === "notifications/tools/list_changed") {
|
|
480
1523
|
void this.handleToolsListChanged();
|
|
1524
|
+
} else if (msg.method === "notifications/resources/list_changed") {
|
|
1525
|
+
this.notifyResourcesChanged();
|
|
1526
|
+
} else if (msg.method === "notifications/prompts/list_changed") {
|
|
1527
|
+
this.notifyPromptsChanged();
|
|
481
1528
|
}
|
|
482
1529
|
}
|
|
483
1530
|
});
|
|
@@ -493,13 +1540,15 @@ var SSETransport = class extends BaseHTTPTransport {
|
|
|
493
1540
|
clientInfo: MCP_CONSTANTS.CLIENT_INFO
|
|
494
1541
|
});
|
|
495
1542
|
if (initRes.error) {
|
|
496
|
-
throw new
|
|
1543
|
+
throw new ToolError3({
|
|
497
1544
|
message: `initialize failed: ${initRes.error.message}`,
|
|
498
1545
|
code: "TOOL_EXECUTION_FAILED",
|
|
499
1546
|
toolName: "mcp_transport_initialize",
|
|
500
1547
|
context: { transport: "sse", url: this.url }
|
|
501
1548
|
});
|
|
502
1549
|
}
|
|
1550
|
+
this.serverMetadata = parseServerMetadata(initRes.result);
|
|
1551
|
+
this.protocolVersion = this.serverMetadata.protocolVersion;
|
|
503
1552
|
try {
|
|
504
1553
|
await this.httpPost("notifications/initialized", {});
|
|
505
1554
|
} catch {
|
|
@@ -509,11 +1558,7 @@ var SSETransport = class extends BaseHTTPTransport {
|
|
|
509
1558
|
this.tools.splice(0, this.tools.length);
|
|
510
1559
|
} else {
|
|
511
1560
|
const result = toolsRes.result;
|
|
512
|
-
this.tools.splice(
|
|
513
|
-
0,
|
|
514
|
-
this.tools.length,
|
|
515
|
-
...normalizeMCPTools(result?.tools)
|
|
516
|
-
);
|
|
1561
|
+
this.tools.splice(0, this.tools.length, ...normalizeMCPTools(result?.tools));
|
|
517
1562
|
}
|
|
518
1563
|
this.state = "connected";
|
|
519
1564
|
clearTimeout(startupTimer);
|
|
@@ -542,7 +1587,7 @@ var SSETransport = class extends BaseHTTPTransport {
|
|
|
542
1587
|
buildSSEUrl() {
|
|
543
1588
|
try {
|
|
544
1589
|
const url = new URL(this.url);
|
|
545
|
-
url.searchParams.set("session",
|
|
1590
|
+
url.searchParams.set("session", randomBytes2(16).toString("hex"));
|
|
546
1591
|
return url.toString();
|
|
547
1592
|
} catch {
|
|
548
1593
|
return this.url;
|
|
@@ -565,12 +1610,12 @@ var SSETransport = class extends BaseHTTPTransport {
|
|
|
565
1610
|
};
|
|
566
1611
|
this.applyTlsAgent(fetchOpts);
|
|
567
1612
|
try {
|
|
568
|
-
const res = await
|
|
1613
|
+
const res = await this.fetchWithAuthorization(this.url, fetchOpts, timeoutSignal.signal);
|
|
569
1614
|
if (!res.ok) {
|
|
570
1615
|
const body2 = await res.text();
|
|
571
1616
|
const cap = MCP_CONSTANTS.REQUEST_LOG_CAP;
|
|
572
1617
|
const snippet = body2.length > cap ? `${body2.slice(0, cap)}\u2026 [${body2.length} bytes total]` : body2;
|
|
573
|
-
throw new
|
|
1618
|
+
throw new ToolError3({
|
|
574
1619
|
message: `HTTP ${res.status}: ${snippet}`,
|
|
575
1620
|
code: "TOOL_EXECUTION_FAILED",
|
|
576
1621
|
toolName: method,
|
|
@@ -581,7 +1626,7 @@ var SSETransport = class extends BaseHTTPTransport {
|
|
|
581
1626
|
try {
|
|
582
1627
|
data = await res.json();
|
|
583
1628
|
} catch (err) {
|
|
584
|
-
throw new
|
|
1629
|
+
throw new ToolError3({
|
|
585
1630
|
message: `Invalid JSON-RPC response: ${err instanceof Error ? err.message : "parse failed"}`,
|
|
586
1631
|
code: "TOOL_EXECUTION_FAILED",
|
|
587
1632
|
toolName: method,
|
|
@@ -606,7 +1651,7 @@ var SSETransport = class extends BaseHTTPTransport {
|
|
|
606
1651
|
}
|
|
607
1652
|
async callTool(name, input, opts) {
|
|
608
1653
|
if (this.state !== "connected") {
|
|
609
|
-
throw new
|
|
1654
|
+
throw new ToolError3({
|
|
610
1655
|
message: `SSE transport not connected (state=${this.state})`,
|
|
611
1656
|
code: "TOOL_EXECUTION_FAILED",
|
|
612
1657
|
toolName: name,
|
|
@@ -624,13 +1669,12 @@ var SSETransport = class extends BaseHTTPTransport {
|
|
|
624
1669
|
};
|
|
625
1670
|
}
|
|
626
1671
|
/** Generic JSON-RPC request — used by MCPClient.request() for SSE transports. */
|
|
627
|
-
async request(method, params, timeoutMs) {
|
|
1672
|
+
async request(method, params, timeoutMs, opts) {
|
|
628
1673
|
const id = this.genId();
|
|
629
1674
|
const body = JSON.stringify({ jsonrpc: "2.0", id, method, params });
|
|
630
|
-
const
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
);
|
|
1675
|
+
const external = opts?.signal;
|
|
1676
|
+
const parent = external && this.abortController ? AbortSignal.any([this.abortController.signal, external]) : external ?? this.abortController?.signal;
|
|
1677
|
+
const timeoutSignal = createTimeoutSignal(parent, timeoutMs ?? this.requestTimeout);
|
|
634
1678
|
const fetchOpts = {
|
|
635
1679
|
method: "POST",
|
|
636
1680
|
headers: {
|
|
@@ -642,9 +1686,9 @@ var SSETransport = class extends BaseHTTPTransport {
|
|
|
642
1686
|
};
|
|
643
1687
|
this.applyTlsAgent(fetchOpts);
|
|
644
1688
|
try {
|
|
645
|
-
const res = await
|
|
1689
|
+
const res = await this.fetchWithAuthorization(this.url, fetchOpts, timeoutSignal.signal);
|
|
646
1690
|
if (!res.ok) {
|
|
647
|
-
throw new
|
|
1691
|
+
throw new ToolError3({
|
|
648
1692
|
message: `HTTP ${res.status}: ${res.statusText}`,
|
|
649
1693
|
code: "TOOL_EXECUTION_FAILED",
|
|
650
1694
|
toolName: method,
|
|
@@ -660,7 +1704,7 @@ var SSETransport = class extends BaseHTTPTransport {
|
|
|
660
1704
|
try {
|
|
661
1705
|
data = await res.json();
|
|
662
1706
|
} catch (err) {
|
|
663
|
-
throw new
|
|
1707
|
+
throw new ToolError3({
|
|
664
1708
|
message: `Invalid JSON-RPC response: ${err instanceof Error ? err.message : "parse failed"}`,
|
|
665
1709
|
code: "TOOL_EXECUTION_FAILED",
|
|
666
1710
|
toolName: method,
|
|
@@ -670,6 +1714,16 @@ var SSETransport = class extends BaseHTTPTransport {
|
|
|
670
1714
|
}
|
|
671
1715
|
const result = assertMatchingJsonRpcResult(data, id, method);
|
|
672
1716
|
return { jsonrpc: "2.0", id, result: result.result, error: result.error };
|
|
1717
|
+
} catch (err) {
|
|
1718
|
+
if (external?.aborted && !method.startsWith("notifications/")) {
|
|
1719
|
+
void this.httpPost("notifications/cancelled", {
|
|
1720
|
+
requestId: id,
|
|
1721
|
+
reason: "client aborted"
|
|
1722
|
+
}).catch(() => {
|
|
1723
|
+
});
|
|
1724
|
+
throw makeAbortError(method);
|
|
1725
|
+
}
|
|
1726
|
+
throw err;
|
|
673
1727
|
} finally {
|
|
674
1728
|
timeoutSignal.dispose();
|
|
675
1729
|
}
|
|
@@ -691,6 +1745,8 @@ var SSETransport = class extends BaseHTTPTransport {
|
|
|
691
1745
|
this.state = "disconnected";
|
|
692
1746
|
}
|
|
693
1747
|
};
|
|
1748
|
+
|
|
1749
|
+
// src/transport-streamable.ts
|
|
694
1750
|
var StreamableHTTPTransport = class extends BaseHTTPTransport {
|
|
695
1751
|
_nextId = 1;
|
|
696
1752
|
sessionId;
|
|
@@ -700,13 +1756,50 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
|
|
|
700
1756
|
genId() {
|
|
701
1757
|
return this._nextId++;
|
|
702
1758
|
}
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
1759
|
+
consumeResponseText(text, requestId) {
|
|
1760
|
+
const envelopes = extractJsonRpcEnvelopes(text);
|
|
1761
|
+
for (const envelope of envelopes) {
|
|
1762
|
+
if ("method" in envelope && envelope.id === void 0) {
|
|
1763
|
+
this.handleNotification(envelope.method);
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
const responses = envelopes.filter(isJsonRpcResult);
|
|
1767
|
+
return responses.find((envelope) => envelope.id === requestId) ?? responses.find((envelope) => envelope.id !== void 0) ?? responses[0];
|
|
1768
|
+
}
|
|
1769
|
+
handleNotification(method) {
|
|
1770
|
+
if (method === "notifications/resources/list_changed") {
|
|
1771
|
+
this.notifyResourcesChanged();
|
|
1772
|
+
} else if (method === "notifications/prompts/list_changed") {
|
|
1773
|
+
this.notifyPromptsChanged();
|
|
1774
|
+
} else if (method === "notifications/tools/list_changed") {
|
|
1775
|
+
void this.refreshTools();
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
async refreshTools() {
|
|
708
1779
|
try {
|
|
709
|
-
const
|
|
1780
|
+
const response = await this.postRaw("tools/list", {});
|
|
1781
|
+
if (response.error) return;
|
|
1782
|
+
const tools = normalizeMCPTools(
|
|
1783
|
+
response.result?.tools
|
|
1784
|
+
);
|
|
1785
|
+
this.tools.splice(0, this.tools.length, ...tools);
|
|
1786
|
+
for (const listener of this.toolsChangedListeners) {
|
|
1787
|
+
try {
|
|
1788
|
+
listener([...tools]);
|
|
1789
|
+
} catch {
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
} catch {
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
async connect() {
|
|
1796
|
+
this.state = "connecting";
|
|
1797
|
+
this.serverMetadata = void 0;
|
|
1798
|
+
this.abortController = new AbortController();
|
|
1799
|
+
const signal = this.abortController.signal;
|
|
1800
|
+
const startupTimer = setTimeout(() => this.abortController?.abort(), this.timeout);
|
|
1801
|
+
try {
|
|
1802
|
+
const initFetchOpts = {
|
|
710
1803
|
method: "POST",
|
|
711
1804
|
headers: {
|
|
712
1805
|
"Content-Type": "application/json",
|
|
@@ -726,7 +1819,7 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
|
|
|
726
1819
|
signal
|
|
727
1820
|
};
|
|
728
1821
|
this.applyTlsAgent(initFetchOpts);
|
|
729
|
-
const initRes = await
|
|
1822
|
+
const initRes = await this.fetchWithAuthorization(this.url, initFetchOpts, signal);
|
|
730
1823
|
if (!initRes.ok) {
|
|
731
1824
|
throw new Error(`initialize HTTP ${initRes.status}: ${initRes.statusText}`);
|
|
732
1825
|
}
|
|
@@ -745,6 +1838,8 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
|
|
|
745
1838
|
if (data.error) {
|
|
746
1839
|
throw new Error(`initialize failed: ${data.error.message}`);
|
|
747
1840
|
}
|
|
1841
|
+
this.serverMetadata = parseServerMetadata(data.result);
|
|
1842
|
+
this.protocolVersion = this.serverMetadata.protocolVersion;
|
|
748
1843
|
this.sessionId = initRes.headers.get("mcp-session-id") ?? void 0;
|
|
749
1844
|
await this.postRaw("notifications/initialized", {});
|
|
750
1845
|
const toolsRes = await this.postRaw("tools/list", {});
|
|
@@ -782,7 +1877,7 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
|
|
|
782
1877
|
};
|
|
783
1878
|
this.applyTlsAgent(fetchOpts);
|
|
784
1879
|
try {
|
|
785
|
-
const res = await
|
|
1880
|
+
const res = await this.fetchWithAuthorization(this.url, fetchOpts, timeoutSignal.signal);
|
|
786
1881
|
if (!res.ok) {
|
|
787
1882
|
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
|
788
1883
|
}
|
|
@@ -790,7 +1885,7 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
|
|
|
790
1885
|
await res.text().catch(() => void 0);
|
|
791
1886
|
return { jsonrpc: "2.0" };
|
|
792
1887
|
}
|
|
793
|
-
const match =
|
|
1888
|
+
const match = this.consumeResponseText(await res.text(), id);
|
|
794
1889
|
if (match) {
|
|
795
1890
|
return assertMatchingJsonRpcResult(match, id, method);
|
|
796
1891
|
}
|
|
@@ -810,13 +1905,12 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
|
|
|
810
1905
|
}
|
|
811
1906
|
}
|
|
812
1907
|
/** Generic JSON-RPC request — used by MCPClient.request() for SSE/streamable-http transports. */
|
|
813
|
-
async request(method, params, timeoutMs) {
|
|
1908
|
+
async request(method, params, timeoutMs, opts) {
|
|
814
1909
|
const id = this.genId();
|
|
815
1910
|
const body = JSON.stringify({ jsonrpc: "2.0", id, method, params });
|
|
816
|
-
const
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
);
|
|
1911
|
+
const external = opts?.signal;
|
|
1912
|
+
const parent = external && this.abortController ? AbortSignal.any([this.abortController.signal, external]) : external ?? this.abortController?.signal;
|
|
1913
|
+
const timeoutSignal = createTimeoutSignal(parent, timeoutMs ?? this.requestTimeout);
|
|
820
1914
|
const fetchOpts = {
|
|
821
1915
|
method: "POST",
|
|
822
1916
|
headers: {
|
|
@@ -829,8 +1923,8 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
|
|
|
829
1923
|
signal: timeoutSignal.signal
|
|
830
1924
|
};
|
|
831
1925
|
this.applyTlsAgent(fetchOpts);
|
|
832
|
-
const res = await fetch(this.url, fetchOpts);
|
|
833
1926
|
try {
|
|
1927
|
+
const res = await this.fetchWithAuthorization(this.url, fetchOpts, timeoutSignal.signal);
|
|
834
1928
|
if (!res.ok) {
|
|
835
1929
|
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
|
836
1930
|
}
|
|
@@ -838,7 +1932,7 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
|
|
|
838
1932
|
await res.text().catch(() => void 0);
|
|
839
1933
|
return { jsonrpc: "2.0", id };
|
|
840
1934
|
}
|
|
841
|
-
const parsed =
|
|
1935
|
+
const parsed = this.consumeResponseText(await res.text(), id);
|
|
842
1936
|
if (parsed) {
|
|
843
1937
|
return {
|
|
844
1938
|
jsonrpc: "2.0",
|
|
@@ -848,6 +1942,16 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
|
|
|
848
1942
|
};
|
|
849
1943
|
}
|
|
850
1944
|
throw new Error("Could not parse response as JSON-RPC");
|
|
1945
|
+
} catch (err) {
|
|
1946
|
+
if (external?.aborted && !method.startsWith("notifications/")) {
|
|
1947
|
+
void this.postRaw("notifications/cancelled", {
|
|
1948
|
+
requestId: id,
|
|
1949
|
+
reason: "client aborted"
|
|
1950
|
+
}).catch(() => {
|
|
1951
|
+
});
|
|
1952
|
+
throw makeAbortError(method);
|
|
1953
|
+
}
|
|
1954
|
+
throw err;
|
|
851
1955
|
} finally {
|
|
852
1956
|
timeoutSignal.dispose();
|
|
853
1957
|
}
|
|
@@ -875,6 +1979,18 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
|
|
|
875
1979
|
};
|
|
876
1980
|
|
|
877
1981
|
// src/client.ts
|
|
1982
|
+
function isJsonRpcResponse(value) {
|
|
1983
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1984
|
+
const response = value;
|
|
1985
|
+
if (response["jsonrpc"] !== "2.0" || typeof response["id"] !== "number") return false;
|
|
1986
|
+
if (Object.hasOwn(response, "method")) return false;
|
|
1987
|
+
const hasResult = Object.hasOwn(response, "result");
|
|
1988
|
+
const hasError = Object.hasOwn(response, "error");
|
|
1989
|
+
if (hasResult === hasError) return false;
|
|
1990
|
+
if (!hasError) return true;
|
|
1991
|
+
const error = response["error"];
|
|
1992
|
+
return typeof error === "object" && error !== null && typeof error["code"] === "number" && typeof error["message"] === "string";
|
|
1993
|
+
}
|
|
878
1994
|
var MCPClient = class {
|
|
879
1995
|
constructor(opts) {
|
|
880
1996
|
this.opts = opts;
|
|
@@ -891,6 +2007,8 @@ var MCPClient = class {
|
|
|
891
2007
|
pending = /* @__PURE__ */ new Map();
|
|
892
2008
|
rxBuffer = "";
|
|
893
2009
|
_tools = [];
|
|
2010
|
+
/** Server-declared handshake metadata. Populated for stdio in the first protocol slice. */
|
|
2011
|
+
_serverMetadata;
|
|
894
2012
|
/** Cached tool list — survives reconnects so the registry can re-register without re-discovering. */
|
|
895
2013
|
_toolsCache;
|
|
896
2014
|
_drainPending = false;
|
|
@@ -902,11 +2020,22 @@ var MCPClient = class {
|
|
|
902
2020
|
exitListeners = /* @__PURE__ */ new Set();
|
|
903
2021
|
/** Notified when the server announces a tools/list_changed notification. */
|
|
904
2022
|
toolsChangedListeners = /* @__PURE__ */ new Set();
|
|
2023
|
+
resourcesChangedListeners = /* @__PURE__ */ new Set();
|
|
2024
|
+
promptsChangedListeners = /* @__PURE__ */ new Set();
|
|
905
2025
|
/** Notified when an HTTP transport (SSE or streamable-http) disconnects. */
|
|
906
2026
|
disconnectListeners = /* @__PURE__ */ new Set();
|
|
907
2027
|
getState() {
|
|
908
2028
|
return this.state;
|
|
909
2029
|
}
|
|
2030
|
+
getServerMetadata() {
|
|
2031
|
+
const metadata = this._serverMetadata;
|
|
2032
|
+
if (!metadata) return void 0;
|
|
2033
|
+
return {
|
|
2034
|
+
...metadata,
|
|
2035
|
+
capabilities: { ...metadata.capabilities },
|
|
2036
|
+
serverInfo: { ...metadata.serverInfo }
|
|
2037
|
+
};
|
|
2038
|
+
}
|
|
910
2039
|
listTools() {
|
|
911
2040
|
return this._tools.length > 0 ? [...this._tools] : this._toolsCache ? [...this._toolsCache] : [];
|
|
912
2041
|
}
|
|
@@ -936,6 +2065,7 @@ var MCPClient = class {
|
|
|
936
2065
|
}
|
|
937
2066
|
async connect() {
|
|
938
2067
|
this.state = "connecting";
|
|
2068
|
+
this._serverMetadata = void 0;
|
|
939
2069
|
if (this.opts.transport === "stdio") {
|
|
940
2070
|
await this.connectStdio();
|
|
941
2071
|
} else if (this.opts.transport === "sse") {
|
|
@@ -1007,6 +2137,12 @@ var MCPClient = class {
|
|
|
1007
2137
|
this.state = "failed";
|
|
1008
2138
|
throw new Error(`MCP initialize failed: ${initialize.error.message}`);
|
|
1009
2139
|
}
|
|
2140
|
+
try {
|
|
2141
|
+
this._serverMetadata = parseServerMetadata(initialize.result);
|
|
2142
|
+
} catch (err) {
|
|
2143
|
+
this.state = "failed";
|
|
2144
|
+
throw new Error(`MCP initialize returned malformed server metadata: ${toErrorMessage(err)}`);
|
|
2145
|
+
}
|
|
1010
2146
|
try {
|
|
1011
2147
|
await this.notify("notifications/initialized", {});
|
|
1012
2148
|
} catch (err) {
|
|
@@ -1034,7 +2170,8 @@ var MCPClient = class {
|
|
|
1034
2170
|
url: this.opts.url,
|
|
1035
2171
|
headers: this.opts.headers,
|
|
1036
2172
|
startupTimeoutMs: this.opts.startupTimeoutMs,
|
|
1037
|
-
requestTimeoutMs: this.opts.requestTimeoutMs
|
|
2173
|
+
requestTimeoutMs: this.opts.requestTimeoutMs,
|
|
2174
|
+
authorizationProvider: this.opts.authorizationProvider
|
|
1038
2175
|
};
|
|
1039
2176
|
this.sseTransport = new SSETransport(httpOpts);
|
|
1040
2177
|
this.sseTransport.onDisconnect(() => {
|
|
@@ -1056,6 +2193,8 @@ var MCPClient = class {
|
|
|
1056
2193
|
}
|
|
1057
2194
|
}
|
|
1058
2195
|
});
|
|
2196
|
+
this.sseTransport.onResourcesChanged(() => this.emitCapabilityChanged("resources"));
|
|
2197
|
+
this.sseTransport.onPromptsChanged(() => this.emitCapabilityChanged("prompts"));
|
|
1059
2198
|
try {
|
|
1060
2199
|
await this.sseTransport.connect();
|
|
1061
2200
|
} catch (err) {
|
|
@@ -1068,6 +2207,7 @@ var MCPClient = class {
|
|
|
1068
2207
|
}
|
|
1069
2208
|
this._tools = this.sseTransport.listTools();
|
|
1070
2209
|
this._toolsCache = this._tools;
|
|
2210
|
+
this._serverMetadata = this.sseTransport.getServerMetadata();
|
|
1071
2211
|
this.state = "connected";
|
|
1072
2212
|
}
|
|
1073
2213
|
async connectStreamableHTTP() {
|
|
@@ -1080,7 +2220,8 @@ var MCPClient = class {
|
|
|
1080
2220
|
url: this.opts.url,
|
|
1081
2221
|
headers: this.opts.headers,
|
|
1082
2222
|
startupTimeoutMs: this.opts.startupTimeoutMs,
|
|
1083
|
-
requestTimeoutMs: this.opts.requestTimeoutMs
|
|
2223
|
+
requestTimeoutMs: this.opts.requestTimeoutMs,
|
|
2224
|
+
authorizationProvider: this.opts.authorizationProvider
|
|
1084
2225
|
};
|
|
1085
2226
|
this.httpTransport = new StreamableHTTPTransport(httpOpts);
|
|
1086
2227
|
this.httpTransport.onDisconnect(() => {
|
|
@@ -1102,6 +2243,8 @@ var MCPClient = class {
|
|
|
1102
2243
|
}
|
|
1103
2244
|
}
|
|
1104
2245
|
});
|
|
2246
|
+
this.httpTransport.onResourcesChanged(() => this.emitCapabilityChanged("resources"));
|
|
2247
|
+
this.httpTransport.onPromptsChanged(() => this.emitCapabilityChanged("prompts"));
|
|
1105
2248
|
try {
|
|
1106
2249
|
await this.httpTransport.connect();
|
|
1107
2250
|
} catch (err) {
|
|
@@ -1114,6 +2257,7 @@ var MCPClient = class {
|
|
|
1114
2257
|
}
|
|
1115
2258
|
this._tools = this.httpTransport.listTools();
|
|
1116
2259
|
this._toolsCache = this._tools;
|
|
2260
|
+
this._serverMetadata = this.httpTransport.getServerMetadata();
|
|
1117
2261
|
this.state = "connected";
|
|
1118
2262
|
}
|
|
1119
2263
|
async callTool(name, input, opts) {
|
|
@@ -1136,6 +2280,79 @@ var MCPClient = class {
|
|
|
1136
2280
|
isError: Boolean(result?.isError)
|
|
1137
2281
|
};
|
|
1138
2282
|
}
|
|
2283
|
+
async listResources(opts = {}) {
|
|
2284
|
+
const params = pageParams(opts.cursor, "resources/list cursor");
|
|
2285
|
+
return this.requestCapability(
|
|
2286
|
+
"resources",
|
|
2287
|
+
"resources/list",
|
|
2288
|
+
params,
|
|
2289
|
+
parseListResourcesResult,
|
|
2290
|
+
opts
|
|
2291
|
+
);
|
|
2292
|
+
}
|
|
2293
|
+
async listResourceTemplates(opts = {}) {
|
|
2294
|
+
const params = pageParams(opts.cursor, "resources/templates/list cursor");
|
|
2295
|
+
return this.requestCapability(
|
|
2296
|
+
"resources",
|
|
2297
|
+
"resources/templates/list",
|
|
2298
|
+
params,
|
|
2299
|
+
parseListResourceTemplatesResult,
|
|
2300
|
+
opts
|
|
2301
|
+
);
|
|
2302
|
+
}
|
|
2303
|
+
async readResource(uri, opts = {}) {
|
|
2304
|
+
validateProtocolString(uri, "resource URI");
|
|
2305
|
+
return this.requestCapability(
|
|
2306
|
+
"resources",
|
|
2307
|
+
"resources/read",
|
|
2308
|
+
{ uri },
|
|
2309
|
+
parseReadResourceResult,
|
|
2310
|
+
opts
|
|
2311
|
+
);
|
|
2312
|
+
}
|
|
2313
|
+
async subscribeResource(uri, opts = {}) {
|
|
2314
|
+
validateProtocolString(uri, "resource URI");
|
|
2315
|
+
this.requireResourceSubscriptions("resources/subscribe");
|
|
2316
|
+
await this.requestCapability(
|
|
2317
|
+
"resources",
|
|
2318
|
+
"resources/subscribe",
|
|
2319
|
+
{ uri },
|
|
2320
|
+
parseEmptyResult,
|
|
2321
|
+
opts
|
|
2322
|
+
);
|
|
2323
|
+
}
|
|
2324
|
+
async unsubscribeResource(uri, opts = {}) {
|
|
2325
|
+
validateProtocolString(uri, "resource URI");
|
|
2326
|
+
this.requireResourceSubscriptions("resources/unsubscribe");
|
|
2327
|
+
await this.requestCapability(
|
|
2328
|
+
"resources",
|
|
2329
|
+
"resources/unsubscribe",
|
|
2330
|
+
{ uri },
|
|
2331
|
+
parseEmptyResult,
|
|
2332
|
+
opts
|
|
2333
|
+
);
|
|
2334
|
+
}
|
|
2335
|
+
async listPrompts(opts = {}) {
|
|
2336
|
+
const params = pageParams(opts.cursor, "prompts/list cursor");
|
|
2337
|
+
return this.requestCapability("prompts", "prompts/list", params, parseListPromptsResult, opts);
|
|
2338
|
+
}
|
|
2339
|
+
async getPrompt(name, args, opts = {}) {
|
|
2340
|
+
validateProtocolString(name, "prompt name");
|
|
2341
|
+
if (args && Object.keys(args).length > 64) {
|
|
2342
|
+
throw new Error("MCP prompt arguments exceed the limit of 64");
|
|
2343
|
+
}
|
|
2344
|
+
for (const [key, value] of Object.entries(args ?? {})) {
|
|
2345
|
+
validateProtocolString(key, "prompt argument name");
|
|
2346
|
+
validateProtocolString(value, `prompt argument "${key}"`, true);
|
|
2347
|
+
}
|
|
2348
|
+
return this.requestCapability(
|
|
2349
|
+
"prompts",
|
|
2350
|
+
"prompts/get",
|
|
2351
|
+
args === void 0 ? { name } : { name, arguments: args },
|
|
2352
|
+
parseGetPromptResult,
|
|
2353
|
+
opts
|
|
2354
|
+
);
|
|
2355
|
+
}
|
|
1139
2356
|
async close() {
|
|
1140
2357
|
if (this.child) {
|
|
1141
2358
|
const child = this.child;
|
|
@@ -1170,8 +2387,8 @@ var MCPClient = class {
|
|
|
1170
2387
|
this.state = "disconnected";
|
|
1171
2388
|
}
|
|
1172
2389
|
request(method, params, timeoutMs = this.opts.requestTimeoutMs ?? 6e4, opts) {
|
|
1173
|
-
if (this.sseTransport) return this.sseTransport.request(method, params, timeoutMs);
|
|
1174
|
-
if (this.httpTransport) return this.httpTransport.request(method, params, timeoutMs);
|
|
2390
|
+
if (this.sseTransport) return this.sseTransport.request(method, params, timeoutMs, opts);
|
|
2391
|
+
if (this.httpTransport) return this.httpTransport.request(method, params, timeoutMs, opts);
|
|
1175
2392
|
const signal = opts?.signal;
|
|
1176
2393
|
if (signal?.aborted) {
|
|
1177
2394
|
const err = new Error(`MCP "${this.opts.name}" request "${method}" aborted before send`);
|
|
@@ -1238,6 +2455,37 @@ var MCPClient = class {
|
|
|
1238
2455
|
}
|
|
1239
2456
|
});
|
|
1240
2457
|
}
|
|
2458
|
+
async requestCapability(capability, method, params, parse, opts) {
|
|
2459
|
+
if (this.state !== "connected") {
|
|
2460
|
+
throw new Error(`MCP client "${this.opts.name}" not connected (state=${this.state})`);
|
|
2461
|
+
}
|
|
2462
|
+
const metadata = this._serverMetadata;
|
|
2463
|
+
if (!metadata) {
|
|
2464
|
+
throw new Error(
|
|
2465
|
+
`MCP server "${this.opts.name}" capability metadata is unavailable for ${method}`
|
|
2466
|
+
);
|
|
2467
|
+
}
|
|
2468
|
+
if (!metadata.capabilities[capability]) {
|
|
2469
|
+
throw new Error(
|
|
2470
|
+
`MCP server "${this.opts.name}" does not advertise the ${capability} capability`
|
|
2471
|
+
);
|
|
2472
|
+
}
|
|
2473
|
+
const response = await this.request(method, params, void 0, opts);
|
|
2474
|
+
if (response.error) {
|
|
2475
|
+
throw new Error(`MCP ${method} failed: ${response.error.message}`);
|
|
2476
|
+
}
|
|
2477
|
+
return parse(response.result);
|
|
2478
|
+
}
|
|
2479
|
+
requireResourceSubscriptions(method) {
|
|
2480
|
+
if (this.state !== "connected") {
|
|
2481
|
+
throw new Error(`MCP client "${this.opts.name}" not connected (state=${this.state})`);
|
|
2482
|
+
}
|
|
2483
|
+
if (this._serverMetadata?.capabilities.resources?.subscribe !== true) {
|
|
2484
|
+
throw new Error(
|
|
2485
|
+
`MCP server "${this.opts.name}" does not advertise resource subscriptions for ${method}`
|
|
2486
|
+
);
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
1241
2489
|
/**
|
|
1242
2490
|
* Reject every in-flight {@link request} call. Used when the underlying
|
|
1243
2491
|
* transport dies — without this, callers awaiting `tools/call` over a
|
|
@@ -1305,68 +2553,702 @@ var MCPClient = class {
|
|
|
1305
2553
|
throw new Error(`[MCP] notify("${method}") failed: ${toErrorMessage(err)}`);
|
|
1306
2554
|
}
|
|
1307
2555
|
}
|
|
1308
|
-
onData(s) {
|
|
1309
|
-
this.rxBuffer += s;
|
|
1310
|
-
let idx = this.rxBuffer.indexOf("\n");
|
|
1311
|
-
while (idx !== -1) {
|
|
1312
|
-
const line = this.rxBuffer.slice(0, idx).trim();
|
|
1313
|
-
this.rxBuffer = this.rxBuffer.slice(idx + 1);
|
|
1314
|
-
if (line) this.onLine(line);
|
|
1315
|
-
idx = this.rxBuffer.indexOf("\n");
|
|
1316
|
-
}
|
|
2556
|
+
onData(s) {
|
|
2557
|
+
this.rxBuffer += s;
|
|
2558
|
+
let idx = this.rxBuffer.indexOf("\n");
|
|
2559
|
+
while (idx !== -1) {
|
|
2560
|
+
const line = this.rxBuffer.slice(0, idx).trim();
|
|
2561
|
+
this.rxBuffer = this.rxBuffer.slice(idx + 1);
|
|
2562
|
+
if (line) this.onLine(line);
|
|
2563
|
+
idx = this.rxBuffer.indexOf("\n");
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
onLine(line) {
|
|
2567
|
+
let msg;
|
|
2568
|
+
try {
|
|
2569
|
+
msg = JSON.parse(line);
|
|
2570
|
+
} catch {
|
|
2571
|
+
return;
|
|
2572
|
+
}
|
|
2573
|
+
if (typeof msg !== "object" || msg === null) return;
|
|
2574
|
+
const envelope = msg;
|
|
2575
|
+
if (envelope["jsonrpc"] !== "2.0") return;
|
|
2576
|
+
if (typeof envelope["method"] === "string") {
|
|
2577
|
+
const id = envelope["id"];
|
|
2578
|
+
if (typeof id === "number" || typeof id === "string") {
|
|
2579
|
+
this.handleServerRequest({
|
|
2580
|
+
jsonrpc: "2.0",
|
|
2581
|
+
id,
|
|
2582
|
+
method: envelope["method"],
|
|
2583
|
+
params: envelope["params"]
|
|
2584
|
+
});
|
|
2585
|
+
return;
|
|
2586
|
+
}
|
|
2587
|
+
if (Object.hasOwn(envelope, "id")) return;
|
|
2588
|
+
if (envelope["method"] === "notifications/tools/list_changed") {
|
|
2589
|
+
void this.handleToolsListChanged();
|
|
2590
|
+
} else if (envelope["method"] === "notifications/resources/list_changed") {
|
|
2591
|
+
this.emitCapabilityChanged("resources");
|
|
2592
|
+
} else if (envelope["method"] === "notifications/prompts/list_changed") {
|
|
2593
|
+
this.emitCapabilityChanged("prompts");
|
|
2594
|
+
}
|
|
2595
|
+
return;
|
|
2596
|
+
}
|
|
2597
|
+
if (!isJsonRpcResponse(msg)) return;
|
|
2598
|
+
if (this.pending.has(msg.id)) {
|
|
2599
|
+
const entry = this.pending.get(msg.id);
|
|
2600
|
+
this.pending.delete(msg.id);
|
|
2601
|
+
entry?.resolve(msg);
|
|
2602
|
+
}
|
|
2603
|
+
}
|
|
2604
|
+
handleServerRequest(request3) {
|
|
2605
|
+
const message = request3.method === "sampling/createMessage" ? "Client sampling is disabled by policy" : `Method not found: ${request3.method}`;
|
|
2606
|
+
const response = {
|
|
2607
|
+
jsonrpc: "2.0",
|
|
2608
|
+
id: request3.id,
|
|
2609
|
+
error: { code: -32601, message }
|
|
2610
|
+
};
|
|
2611
|
+
try {
|
|
2612
|
+
this.child?.stdin?.write(`${JSON.stringify(response)}
|
|
2613
|
+
`);
|
|
2614
|
+
} catch {
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
/**
|
|
2618
|
+
* L2-C: refresh the cached tool list when the server announces a
|
|
2619
|
+
* `tools/list_changed`. Listeners (the registry) re-wrap and
|
|
2620
|
+
* re-register. Failures are swallowed — a stale cache is preferable
|
|
2621
|
+
* to a hard crash on a transient notification glitch.
|
|
2622
|
+
*/
|
|
2623
|
+
async handleToolsListChanged() {
|
|
2624
|
+
try {
|
|
2625
|
+
const toolsRes = await this.request("tools/list", {});
|
|
2626
|
+
const tools = normalizeMCPTools(
|
|
2627
|
+
toolsRes.result?.tools
|
|
2628
|
+
);
|
|
2629
|
+
this._tools = tools;
|
|
2630
|
+
this._toolsCache = tools;
|
|
2631
|
+
for (const listener of this.toolsChangedListeners) {
|
|
2632
|
+
try {
|
|
2633
|
+
listener(this.opts.name, [...tools]);
|
|
2634
|
+
} catch {
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
} catch {
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2640
|
+
addToolsChangedListener(listener) {
|
|
2641
|
+
this.toolsChangedListeners.add(listener);
|
|
2642
|
+
}
|
|
2643
|
+
removeToolsChangedListener(listener) {
|
|
2644
|
+
this.toolsChangedListeners.delete(listener);
|
|
2645
|
+
}
|
|
2646
|
+
addResourcesChangedListener(listener) {
|
|
2647
|
+
this.resourcesChangedListeners.add(listener);
|
|
2648
|
+
}
|
|
2649
|
+
removeResourcesChangedListener(listener) {
|
|
2650
|
+
this.resourcesChangedListeners.delete(listener);
|
|
2651
|
+
}
|
|
2652
|
+
addPromptsChangedListener(listener) {
|
|
2653
|
+
this.promptsChangedListeners.add(listener);
|
|
2654
|
+
}
|
|
2655
|
+
removePromptsChangedListener(listener) {
|
|
2656
|
+
this.promptsChangedListeners.delete(listener);
|
|
2657
|
+
}
|
|
2658
|
+
emitCapabilityChanged(capability) {
|
|
2659
|
+
const listeners = capability === "resources" ? this.resourcesChangedListeners : this.promptsChangedListeners;
|
|
2660
|
+
for (const listener of listeners) {
|
|
2661
|
+
try {
|
|
2662
|
+
listener(this.opts.name);
|
|
2663
|
+
} catch {
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
};
|
|
2668
|
+
function quoteWindowsArg(arg) {
|
|
2669
|
+
if (!/[\s"]/.test(arg)) return arg;
|
|
2670
|
+
return `"${arg.replace(/"/g, '""')}"`;
|
|
2671
|
+
}
|
|
2672
|
+
var MAX_PROTOCOL_INPUT_CHARS = 8192;
|
|
2673
|
+
function validateProtocolString(value, label, allowEmpty = false) {
|
|
2674
|
+
if (typeof value !== "string" || !allowEmpty && value.length === 0) {
|
|
2675
|
+
throw new Error(`MCP ${label} must be ${allowEmpty ? "a string" : "a non-empty string"}`);
|
|
2676
|
+
}
|
|
2677
|
+
if (value.length > MAX_PROTOCOL_INPUT_CHARS) {
|
|
2678
|
+
throw new Error(`MCP ${label} exceeds ${MAX_PROTOCOL_INPUT_CHARS} characters`);
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
function pageParams(cursor, label) {
|
|
2682
|
+
if (cursor === void 0) return {};
|
|
2683
|
+
validateProtocolString(cursor, label);
|
|
2684
|
+
return { cursor };
|
|
2685
|
+
}
|
|
2686
|
+
function parseEmptyResult(value) {
|
|
2687
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
2688
|
+
throw new Error("Malformed MCP empty result: expected object");
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
// src/content-selection.ts
|
|
2693
|
+
var DEFAULT_MCP_INSERTION_MAX_BYTES = 256 * 1024;
|
|
2694
|
+
var DEFAULT_MCP_RESOURCE_SCHEMES = [
|
|
2695
|
+
"file",
|
|
2696
|
+
"git",
|
|
2697
|
+
"http",
|
|
2698
|
+
"https",
|
|
2699
|
+
"mcp",
|
|
2700
|
+
"mem",
|
|
2701
|
+
"repo",
|
|
2702
|
+
"resource"
|
|
2703
|
+
];
|
|
2704
|
+
function prepareResourceInsertion(serverName, requestedUri, result, policy = {}) {
|
|
2705
|
+
requireIdentity(serverName, "server name");
|
|
2706
|
+
validateUri(requestedUri, policy);
|
|
2707
|
+
if (result.contents.length > 64) {
|
|
2708
|
+
throw new Error("MCP resource insertion exceeds the limit of 64 content blocks");
|
|
2709
|
+
}
|
|
2710
|
+
let byteSize = 0;
|
|
2711
|
+
for (const content of result.contents) {
|
|
2712
|
+
validateUri(content.uri, policy);
|
|
2713
|
+
if (content.text !== void 0) byteSize += utf8Bytes(content.text);
|
|
2714
|
+
if (content.blob !== void 0) byteSize += base64DecodedBytes(content.blob);
|
|
2715
|
+
enforceSize(byteSize, policy);
|
|
2716
|
+
}
|
|
2717
|
+
return {
|
|
2718
|
+
kind: "resource",
|
|
2719
|
+
untrusted: true,
|
|
2720
|
+
byteSize,
|
|
2721
|
+
provenance: {
|
|
2722
|
+
origin: "mcp",
|
|
2723
|
+
serverName,
|
|
2724
|
+
capability: "resource",
|
|
2725
|
+
resourceUri: requestedUri
|
|
2726
|
+
},
|
|
2727
|
+
contents: structuredClone(result.contents)
|
|
2728
|
+
};
|
|
2729
|
+
}
|
|
2730
|
+
function preparePromptInsertion(serverName, promptName, args, result, policy = {}) {
|
|
2731
|
+
requireIdentity(serverName, "server name");
|
|
2732
|
+
requireIdentity(promptName, "prompt name");
|
|
2733
|
+
if (result.messages.length > 128) {
|
|
2734
|
+
throw new Error("MCP prompt insertion exceeds the limit of 128 messages");
|
|
2735
|
+
}
|
|
2736
|
+
for (const message of result.messages) validateEmbeddedUris(message.content, policy, 0);
|
|
2737
|
+
let serialized;
|
|
2738
|
+
try {
|
|
2739
|
+
serialized = JSON.stringify(result.messages);
|
|
2740
|
+
} catch {
|
|
2741
|
+
throw new Error("MCP prompt insertion contains non-serializable content");
|
|
2742
|
+
}
|
|
2743
|
+
const byteSize = utf8Bytes(serialized);
|
|
2744
|
+
enforceSize(byteSize, policy);
|
|
2745
|
+
return {
|
|
2746
|
+
kind: "prompt",
|
|
2747
|
+
untrusted: true,
|
|
2748
|
+
byteSize,
|
|
2749
|
+
provenance: {
|
|
2750
|
+
origin: "mcp",
|
|
2751
|
+
serverName,
|
|
2752
|
+
capability: "prompt",
|
|
2753
|
+
promptName,
|
|
2754
|
+
promptArgumentNames: Object.keys(args ?? {}).sort()
|
|
2755
|
+
},
|
|
2756
|
+
description: result.description,
|
|
2757
|
+
messages: structuredClone(result.messages)
|
|
2758
|
+
};
|
|
2759
|
+
}
|
|
2760
|
+
function validateEmbeddedUris(value, policy, depth) {
|
|
2761
|
+
if (depth > 32) throw new Error("MCP prompt insertion exceeds the nesting depth limit");
|
|
2762
|
+
if (Array.isArray(value)) {
|
|
2763
|
+
for (const item of value) validateEmbeddedUris(item, policy, depth + 1);
|
|
2764
|
+
return;
|
|
2765
|
+
}
|
|
2766
|
+
if (!value || typeof value !== "object") return;
|
|
2767
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
2768
|
+
if (key === "uri" && typeof nested === "string") validateUri(nested, policy);
|
|
2769
|
+
validateEmbeddedUris(nested, policy, depth + 1);
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
function validateUri(uri, policy) {
|
|
2773
|
+
if (uri.length === 0 || uri.length > 8192) {
|
|
2774
|
+
throw new Error("MCP insertion URI must contain 1\u20138192 characters");
|
|
2775
|
+
}
|
|
2776
|
+
let parsed;
|
|
2777
|
+
try {
|
|
2778
|
+
parsed = new URL(uri);
|
|
2779
|
+
} catch {
|
|
2780
|
+
throw new Error("MCP insertion URI must be absolute");
|
|
2781
|
+
}
|
|
2782
|
+
const scheme = parsed.protocol.slice(0, -1).toLowerCase();
|
|
2783
|
+
const allowed = new Set(
|
|
2784
|
+
(policy.allowedUriSchemes ?? DEFAULT_MCP_RESOURCE_SCHEMES).map((value) => value.toLowerCase())
|
|
2785
|
+
);
|
|
2786
|
+
if (!allowed.has(scheme)) {
|
|
2787
|
+
throw new Error(`MCP insertion URI scheme "${scheme}" is not allowed`);
|
|
2788
|
+
}
|
|
2789
|
+
if ((scheme === "http" || scheme === "https") && (parsed.username || parsed.password)) {
|
|
2790
|
+
throw new Error("MCP insertion URI must not contain credentials");
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2793
|
+
function enforceSize(byteSize, policy) {
|
|
2794
|
+
const maxBytes = policy.maxBytes ?? DEFAULT_MCP_INSERTION_MAX_BYTES;
|
|
2795
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
|
|
2796
|
+
throw new Error("MCP insertion maxBytes must be a positive safe integer");
|
|
2797
|
+
}
|
|
2798
|
+
if (byteSize > maxBytes) {
|
|
2799
|
+
throw new Error(`MCP insertion exceeds the ${maxBytes}-byte content limit`);
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
function base64DecodedBytes(blob) {
|
|
2803
|
+
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(blob)) {
|
|
2804
|
+
throw new Error("MCP resource insertion contains invalid base64 content");
|
|
2805
|
+
}
|
|
2806
|
+
const padding = blob.endsWith("==") ? 2 : blob.endsWith("=") ? 1 : 0;
|
|
2807
|
+
return blob.length / 4 * 3 - padding;
|
|
2808
|
+
}
|
|
2809
|
+
function utf8Bytes(value) {
|
|
2810
|
+
return new TextEncoder().encode(value).byteLength;
|
|
2811
|
+
}
|
|
2812
|
+
function requireIdentity(value, label) {
|
|
2813
|
+
if (value.length === 0 || value.length > 256) {
|
|
2814
|
+
throw new Error(`MCP insertion ${label} must contain 1\u2013256 characters`);
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
// src/manage.ts
|
|
2819
|
+
import { randomBytes as randomBytes3 } from "node:crypto";
|
|
2820
|
+
import * as fs from "node:fs/promises";
|
|
2821
|
+
async function readConfig(path2) {
|
|
2822
|
+
try {
|
|
2823
|
+
return JSON.parse(await fs.readFile(path2, "utf8"));
|
|
2824
|
+
} catch {
|
|
2825
|
+
return {};
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
async function writeConfig(path2, cfg) {
|
|
2829
|
+
const raw = JSON.stringify(cfg, null, 2);
|
|
2830
|
+
const tmp = `${path2}.${process.pid}.${randomBytes3(6).toString("hex")}.tmp`;
|
|
2831
|
+
await fs.writeFile(tmp, raw, "utf8");
|
|
2832
|
+
try {
|
|
2833
|
+
await fs.rename(tmp, path2);
|
|
2834
|
+
} catch (err) {
|
|
2835
|
+
await fs.rm(tmp, { force: true }).catch(() => void 0);
|
|
2836
|
+
throw err;
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
function isMcpServerRecord(value) {
|
|
2840
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
2841
|
+
}
|
|
2842
|
+
async function readServers(configPath) {
|
|
2843
|
+
const full = await readConfig(configPath);
|
|
2844
|
+
const servers = isMcpServerRecord(full.mcpServers) ? { ...full.mcpServers } : {};
|
|
2845
|
+
return { full, servers };
|
|
2846
|
+
}
|
|
2847
|
+
async function persist(configPath, full, servers) {
|
|
2848
|
+
full.mcpServers = servers;
|
|
2849
|
+
await writeConfig(configPath, full);
|
|
2850
|
+
}
|
|
2851
|
+
function normalizeTransport(t) {
|
|
2852
|
+
if (t === "sse") return "sse";
|
|
2853
|
+
if (t === "http" || t === "streamable-http") return "streamable-http";
|
|
2854
|
+
return "stdio";
|
|
2855
|
+
}
|
|
2856
|
+
function buildConfig(input, base) {
|
|
2857
|
+
const cfg = {
|
|
2858
|
+
name: input.name,
|
|
2859
|
+
transport: input.transport ? normalizeTransport(String(input.transport)) : base?.transport ?? "stdio"
|
|
2860
|
+
};
|
|
2861
|
+
const description = input.description ?? base?.description;
|
|
2862
|
+
if (description !== void 0) cfg.description = description;
|
|
2863
|
+
const command = input.command ?? base?.command;
|
|
2864
|
+
if (command !== void 0) cfg.command = command;
|
|
2865
|
+
const args = input.args ?? base?.args;
|
|
2866
|
+
if (args !== void 0) cfg.args = args;
|
|
2867
|
+
const env = input.env ?? base?.env;
|
|
2868
|
+
if (env !== void 0) cfg.env = env;
|
|
2869
|
+
const url = input.url ?? base?.url;
|
|
2870
|
+
if (url !== void 0) cfg.url = url;
|
|
2871
|
+
const headers = input.headers ?? base?.headers;
|
|
2872
|
+
if (headers !== void 0) cfg.headers = headers;
|
|
2873
|
+
const allowedTools = input.allowedTools ?? base?.allowedTools;
|
|
2874
|
+
if (allowedTools !== void 0) cfg.allowedTools = allowedTools;
|
|
2875
|
+
const permission = input.permission ?? base?.permission;
|
|
2876
|
+
if (permission !== void 0) cfg.permission = permission;
|
|
2877
|
+
const enabled = input.enabled ?? base?.enabled;
|
|
2878
|
+
if (enabled !== void 0) cfg.enabled = enabled;
|
|
2879
|
+
const lazy = input.lazy ?? base?.lazy;
|
|
2880
|
+
if (lazy !== void 0) cfg.lazy = lazy;
|
|
2881
|
+
const passthroughEnv = input.passthroughEnv ?? base?.passthroughEnv;
|
|
2882
|
+
if (passthroughEnv !== void 0) cfg.passthroughEnv = passthroughEnv;
|
|
2883
|
+
const health = input.health ?? base?.health;
|
|
2884
|
+
if (health !== void 0) cfg.health = health;
|
|
2885
|
+
return cfg;
|
|
2886
|
+
}
|
|
2887
|
+
function projectServer(name, cfg, registry) {
|
|
2888
|
+
const live = registry.list().find((s) => s.name === name);
|
|
2889
|
+
const info = {
|
|
2890
|
+
name,
|
|
2891
|
+
transport: cfg.transport,
|
|
2892
|
+
enabled: cfg.enabled !== false,
|
|
2893
|
+
status: live ? live.state : "stopped",
|
|
2894
|
+
tools: live?.tools ?? []
|
|
2895
|
+
};
|
|
2896
|
+
if (cfg.description !== void 0) info.description = cfg.description;
|
|
2897
|
+
if (cfg.url !== void 0) info.url = cfg.url;
|
|
2898
|
+
if (cfg.command !== void 0) info.command = cfg.command;
|
|
2899
|
+
if (cfg.lazy !== void 0) info.lazy = cfg.lazy;
|
|
2900
|
+
return info;
|
|
2901
|
+
}
|
|
2902
|
+
function liveState(name, registry) {
|
|
2903
|
+
const live = registry.list().find((s) => s.name === name);
|
|
2904
|
+
return { state: live?.state ?? "stopped", tools: live?.tools ?? [] };
|
|
2905
|
+
}
|
|
2906
|
+
function errMessage(err) {
|
|
2907
|
+
return err instanceof Error ? err.message : String(err);
|
|
2908
|
+
}
|
|
2909
|
+
async function listMcp(deps) {
|
|
2910
|
+
const { servers } = await readServers(deps.configPath);
|
|
2911
|
+
return Object.entries(servers).map(
|
|
2912
|
+
([name, cfg]) => projectServer(name, { ...cfg, name }, deps.registry)
|
|
2913
|
+
);
|
|
2914
|
+
}
|
|
2915
|
+
async function addMcp(input, deps) {
|
|
2916
|
+
if (!input.name) return { ok: false, message: "Server name is required" };
|
|
2917
|
+
const { full, servers } = await readServers(deps.configPath);
|
|
2918
|
+
if (servers[input.name]) {
|
|
2919
|
+
return { ok: false, message: `Server "${input.name}" already exists` };
|
|
2920
|
+
}
|
|
2921
|
+
const preset = deps.presets?.[input.name];
|
|
2922
|
+
const hasExplicitConfig = !!(input.transport || input.command || input.url);
|
|
2923
|
+
const cfg = hasExplicitConfig ? buildConfig(input, preset) : preset ? buildConfig({ ...input, name: input.name }, preset) : buildConfig(input);
|
|
2924
|
+
if (!hasExplicitConfig && !preset) {
|
|
2925
|
+
const known = Object.keys(deps.presets ?? {}).join(", ");
|
|
2926
|
+
return {
|
|
2927
|
+
ok: false,
|
|
2928
|
+
message: known ? `Unknown server "${input.name}". Available presets: ${known}` : `No configuration provided for "${input.name}"`
|
|
2929
|
+
};
|
|
2930
|
+
}
|
|
2931
|
+
cfg.enabled = input.enabled ?? false;
|
|
2932
|
+
servers[input.name] = cfg;
|
|
2933
|
+
await persist(deps.configPath, full, servers);
|
|
2934
|
+
if (cfg.enabled) {
|
|
2935
|
+
return startServer(input.name, cfg, deps, `Server "${input.name}" added`);
|
|
2936
|
+
}
|
|
2937
|
+
trackDisabled(deps.registry, cfg);
|
|
2938
|
+
return {
|
|
2939
|
+
ok: true,
|
|
2940
|
+
message: `Server "${input.name}" added (disabled)`,
|
|
2941
|
+
server: projectServer(input.name, cfg, deps.registry)
|
|
2942
|
+
};
|
|
2943
|
+
}
|
|
2944
|
+
async function updateMcp(input, deps) {
|
|
2945
|
+
if (!input.name) return { ok: false, message: "Server name is required" };
|
|
2946
|
+
const { full, servers } = await readServers(deps.configPath);
|
|
2947
|
+
const existing = servers[input.name];
|
|
2948
|
+
if (!existing) return { ok: false, message: `Server "${input.name}" not found` };
|
|
2949
|
+
const cfg = buildConfig(input, { ...existing, name: input.name });
|
|
2950
|
+
servers[input.name] = cfg;
|
|
2951
|
+
await persist(deps.configPath, full, servers);
|
|
2952
|
+
if (cfg.enabled !== false) {
|
|
2953
|
+
return startServer(input.name, cfg, deps, `Server "${input.name}" updated`, { restart: true });
|
|
2954
|
+
}
|
|
2955
|
+
await safeStop(input.name, deps);
|
|
2956
|
+
trackDisabled(deps.registry, cfg);
|
|
2957
|
+
return {
|
|
2958
|
+
ok: true,
|
|
2959
|
+
message: `Server "${input.name}" updated`,
|
|
2960
|
+
server: projectServer(input.name, cfg, deps.registry)
|
|
2961
|
+
};
|
|
2962
|
+
}
|
|
2963
|
+
async function removeMcp(name, deps) {
|
|
2964
|
+
if (!name) return { ok: false, message: "Server name is required" };
|
|
2965
|
+
const { full, servers } = await readServers(deps.configPath);
|
|
2966
|
+
if (!servers[name]) return { ok: false, message: `Server "${name}" not found` };
|
|
2967
|
+
await safeStop(name, deps);
|
|
2968
|
+
forgetRegistryState(deps.registry, name);
|
|
2969
|
+
delete servers[name];
|
|
2970
|
+
await persist(deps.configPath, full, servers);
|
|
2971
|
+
return { ok: true, message: `Server "${name}" removed` };
|
|
2972
|
+
}
|
|
2973
|
+
async function enableMcp(name, deps) {
|
|
2974
|
+
if (!name) return { ok: false, message: "Server name is required" };
|
|
2975
|
+
const { full, servers } = await readServers(deps.configPath);
|
|
2976
|
+
const cfg = servers[name];
|
|
2977
|
+
if (!cfg) {
|
|
2978
|
+
return { ok: false, message: `Server "${name}" is not in config. Add it first.` };
|
|
2979
|
+
}
|
|
2980
|
+
cfg.enabled = true;
|
|
2981
|
+
servers[name] = cfg;
|
|
2982
|
+
await persist(deps.configPath, full, servers);
|
|
2983
|
+
return startServer(name, cfg, deps, `Server "${name}" enabled`, { restart: true });
|
|
2984
|
+
}
|
|
2985
|
+
async function disableMcp(name, deps) {
|
|
2986
|
+
if (!name) return { ok: false, message: "Server name is required" };
|
|
2987
|
+
const { full, servers } = await readServers(deps.configPath);
|
|
2988
|
+
const cfg = servers[name];
|
|
2989
|
+
if (!cfg) return { ok: false, message: `Server "${name}" is not in config.` };
|
|
2990
|
+
await safeStop(name, deps);
|
|
2991
|
+
cfg.enabled = false;
|
|
2992
|
+
trackDisabled(deps.registry, { ...cfg, name });
|
|
2993
|
+
servers[name] = cfg;
|
|
2994
|
+
await persist(deps.configPath, full, servers);
|
|
2995
|
+
return {
|
|
2996
|
+
ok: true,
|
|
2997
|
+
message: `Server "${name}" disabled`,
|
|
2998
|
+
server: projectServer(name, cfg, deps.registry)
|
|
2999
|
+
};
|
|
3000
|
+
}
|
|
3001
|
+
async function restartMcp(name, deps) {
|
|
3002
|
+
if (!name) return { ok: false, message: "Server name is required" };
|
|
3003
|
+
const registered = deps.registry.list().some((s) => s.name === name);
|
|
3004
|
+
if (registered) {
|
|
3005
|
+
try {
|
|
3006
|
+
await deps.registry.restart(name);
|
|
3007
|
+
const { state, tools } = liveState(name, deps.registry);
|
|
3008
|
+
return { ok: true, message: `Server "${name}" restarted`, state, tools };
|
|
3009
|
+
} catch (err) {
|
|
3010
|
+
return { ok: false, message: `Failed to restart "${name}": ${errMessage(err)}` };
|
|
3011
|
+
}
|
|
3012
|
+
}
|
|
3013
|
+
const { servers } = await readServers(deps.configPath);
|
|
3014
|
+
const cfg = servers[name];
|
|
3015
|
+
if (!cfg) return { ok: false, message: `Server "${name}" is not in config.` };
|
|
3016
|
+
return startServer(name, { ...cfg, name }, deps, `Server "${name}" started`, { restart: true });
|
|
3017
|
+
}
|
|
3018
|
+
async function discoverMcp(name, deps) {
|
|
3019
|
+
if (!name) return { ok: false, message: "Server name is required" };
|
|
3020
|
+
const result = await restartMcp(name, deps);
|
|
3021
|
+
if (!result.ok) return result;
|
|
3022
|
+
const { state, tools } = liveState(name, deps.registry);
|
|
3023
|
+
return {
|
|
3024
|
+
ok: true,
|
|
3025
|
+
message: `Discovered ${tools.length} tool${tools.length === 1 ? "" : "s"} from "${name}"`,
|
|
3026
|
+
state,
|
|
3027
|
+
tools
|
|
3028
|
+
};
|
|
3029
|
+
}
|
|
3030
|
+
async function startServer(name, cfg, deps, okMessage, opts) {
|
|
3031
|
+
try {
|
|
3032
|
+
const alreadyRegistered = deps.registry.list().some((s) => s.name === name);
|
|
3033
|
+
if (alreadyRegistered && opts?.restart) {
|
|
3034
|
+
await deps.registry.restart(name);
|
|
3035
|
+
} else if (alreadyRegistered) {
|
|
3036
|
+
await deps.registry.restart(name);
|
|
3037
|
+
} else {
|
|
3038
|
+
await deps.registry.start({ ...cfg, enabled: true });
|
|
3039
|
+
}
|
|
3040
|
+
const { state, tools } = liveState(name, deps.registry);
|
|
3041
|
+
return {
|
|
3042
|
+
ok: true,
|
|
3043
|
+
message: okMessage,
|
|
3044
|
+
server: projectServer(name, cfg, deps.registry),
|
|
3045
|
+
state,
|
|
3046
|
+
tools
|
|
3047
|
+
};
|
|
3048
|
+
} catch (err) {
|
|
3049
|
+
const message = errMessage(err);
|
|
3050
|
+
return {
|
|
3051
|
+
ok: true,
|
|
3052
|
+
// config persisted — surface a soft warning, not a hard failure
|
|
3053
|
+
message: `${okMessage} in config, but failed to start: ${message}`,
|
|
3054
|
+
server: projectServer(name, cfg, deps.registry),
|
|
3055
|
+
registryError: message
|
|
3056
|
+
};
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
async function safeStop(name, deps) {
|
|
3060
|
+
try {
|
|
3061
|
+
await deps.registry.stop(name);
|
|
3062
|
+
} catch {
|
|
1317
3063
|
}
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
3064
|
+
}
|
|
3065
|
+
function trackDisabled(registry, cfg) {
|
|
3066
|
+
if (typeof registry.markDisabled === "function") registry.markDisabled(cfg);
|
|
3067
|
+
}
|
|
3068
|
+
function forgetRegistryState(registry, name) {
|
|
3069
|
+
if (typeof registry.forget === "function") registry.forget(name);
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
// src/manifest-cache.ts
|
|
3073
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
3074
|
+
import * as fs2 from "node:fs/promises";
|
|
3075
|
+
import * as path from "node:path";
|
|
3076
|
+
function manifestConfigHash(cfg) {
|
|
3077
|
+
const basis = JSON.stringify({
|
|
3078
|
+
transport: cfg.transport,
|
|
3079
|
+
command: cfg.command ?? null,
|
|
3080
|
+
args: cfg.args ?? null,
|
|
3081
|
+
url: cfg.url ?? null
|
|
3082
|
+
});
|
|
3083
|
+
return createHash2("sha256").update(basis).digest("hex").slice(0, 16);
|
|
3084
|
+
}
|
|
3085
|
+
function manifestFile(cacheDir, name) {
|
|
3086
|
+
const safe = name.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
3087
|
+
return path.join(cacheDir, "mcp-tools", `${safe}.json`);
|
|
3088
|
+
}
|
|
3089
|
+
async function readManifest(cacheDir, name, configHash) {
|
|
3090
|
+
const manifest = await readCapabilityManifest(cacheDir, name, configHash);
|
|
3091
|
+
return manifest?.tools ?? null;
|
|
3092
|
+
}
|
|
3093
|
+
async function readCapabilityManifest(cacheDir, name, configHash) {
|
|
3094
|
+
try {
|
|
3095
|
+
const raw = await fs2.readFile(manifestFile(cacheDir, name), "utf8");
|
|
3096
|
+
const parsed = JSON.parse(raw);
|
|
3097
|
+
if (parsed.configHash !== configHash || !Array.isArray(parsed.tools)) return null;
|
|
3098
|
+
return {
|
|
3099
|
+
tools: parsed.tools,
|
|
3100
|
+
serverMetadata: parsed.serverMetadata === void 0 ? void 0 : parseServerMetadata(parsed.serverMetadata),
|
|
3101
|
+
resources: parsed.resources === void 0 ? void 0 : parseListResourcesResult({ resources: parsed.resources }).resources,
|
|
3102
|
+
resourceTemplates: parsed.resourceTemplates === void 0 ? void 0 : parseListResourceTemplatesResult({ resourceTemplates: parsed.resourceTemplates }).resourceTemplates,
|
|
3103
|
+
prompts: parsed.prompts === void 0 ? void 0 : parseListPromptsResult({ prompts: parsed.prompts }).prompts
|
|
3104
|
+
};
|
|
3105
|
+
} catch {
|
|
3106
|
+
return null;
|
|
1334
3107
|
}
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
} catch {
|
|
1353
|
-
}
|
|
1354
|
-
}
|
|
1355
|
-
} catch {
|
|
1356
|
-
}
|
|
3108
|
+
}
|
|
3109
|
+
async function writeManifest(cacheDir, name, configHash, tools) {
|
|
3110
|
+
const previous = await readCapabilityManifest(cacheDir, name, configHash);
|
|
3111
|
+
await writeCapabilityManifest(cacheDir, name, configHash, {
|
|
3112
|
+
...previous,
|
|
3113
|
+
tools
|
|
3114
|
+
});
|
|
3115
|
+
}
|
|
3116
|
+
async function writeCapabilityManifest(cacheDir, name, configHash, manifest) {
|
|
3117
|
+
try {
|
|
3118
|
+
const file = manifestFile(cacheDir, name);
|
|
3119
|
+
await fs2.mkdir(path.dirname(file), { recursive: true });
|
|
3120
|
+
const body = { version: 2, configHash, ...manifest };
|
|
3121
|
+
const tmp = `${file}.tmp`;
|
|
3122
|
+
await fs2.writeFile(tmp, JSON.stringify(body, null, 2), "utf8");
|
|
3123
|
+
await fs2.rename(tmp, file);
|
|
3124
|
+
} catch {
|
|
1357
3125
|
}
|
|
1358
|
-
|
|
1359
|
-
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3128
|
+
// src/operations.ts
|
|
3129
|
+
var MCP_OPERATION_LIMITS = Object.freeze({
|
|
3130
|
+
LATENCY_SAMPLES: 128,
|
|
3131
|
+
RECENT_EVENTS: 32,
|
|
3132
|
+
REASON_CHARS: 64
|
|
3133
|
+
});
|
|
3134
|
+
var SAFE_OPERATION_REASONS = /* @__PURE__ */ new Set([
|
|
3135
|
+
"automatic",
|
|
3136
|
+
"complete",
|
|
3137
|
+
"connect-attempt-failed",
|
|
3138
|
+
"connected",
|
|
3139
|
+
"http-disconnect",
|
|
3140
|
+
"http-disconnect-lazy",
|
|
3141
|
+
"idle-timeout",
|
|
3142
|
+
"lazy-demand",
|
|
3143
|
+
"manual",
|
|
3144
|
+
"ok",
|
|
3145
|
+
"process-exit",
|
|
3146
|
+
"process-exit-lazy",
|
|
3147
|
+
"prompt-discovery-failed",
|
|
3148
|
+
"reconnect-exhausted",
|
|
3149
|
+
"resource-discovery-failed",
|
|
3150
|
+
"resource-template-discovery-failed",
|
|
3151
|
+
"started",
|
|
3152
|
+
"tool-call-failed"
|
|
3153
|
+
]);
|
|
3154
|
+
function createMCPServerOperationState() {
|
|
3155
|
+
return {
|
|
3156
|
+
consecutiveFailures: 0,
|
|
3157
|
+
failures: { transport: 0, protocol: 0, tool: 0 },
|
|
3158
|
+
reconnectCount: 0,
|
|
3159
|
+
wakeCount: 0,
|
|
3160
|
+
sleepCount: 0,
|
|
3161
|
+
restartCount: 0,
|
|
3162
|
+
connectionSamples: [],
|
|
3163
|
+
discoverySamples: [],
|
|
3164
|
+
callSamples: [],
|
|
3165
|
+
inFlightCalls: 0,
|
|
3166
|
+
peakInFlightCalls: 0,
|
|
3167
|
+
recentEvents: []
|
|
3168
|
+
};
|
|
3169
|
+
}
|
|
3170
|
+
function healthStateFor(connectionState, operations, enabled = true) {
|
|
3171
|
+
if (!enabled) return "disabled";
|
|
3172
|
+
if (connectionState === "dormant") return "dormant";
|
|
3173
|
+
if (connectionState === "connecting" || connectionState === "reconnecting" || connectionState === "idle") {
|
|
3174
|
+
return "connecting";
|
|
1360
3175
|
}
|
|
1361
|
-
|
|
1362
|
-
|
|
3176
|
+
if (connectionState === "failed") return "failed";
|
|
3177
|
+
if (connectionState === "disconnected" || operations.consecutiveFailures > 0) return "degraded";
|
|
3178
|
+
return "healthy";
|
|
3179
|
+
}
|
|
3180
|
+
function evaluateHealthThresholds(operations, thresholds) {
|
|
3181
|
+
if (!thresholds) return [];
|
|
3182
|
+
const checks = [];
|
|
3183
|
+
if (thresholds.connectionLatencyP95Ms !== void 0 && operations.connectionSamples.length > 0) {
|
|
3184
|
+
const value = percentile([...operations.connectionSamples].sort((a, b) => a - b), 0.95);
|
|
3185
|
+
checks.push({
|
|
3186
|
+
name: "connection-latency-p95",
|
|
3187
|
+
passed: value <= thresholds.connectionLatencyP95Ms,
|
|
3188
|
+
value,
|
|
3189
|
+
threshold: thresholds.connectionLatencyP95Ms
|
|
3190
|
+
});
|
|
1363
3191
|
}
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
3192
|
+
if (thresholds.discoveryLatencyP95Ms !== void 0 && operations.discoverySamples.length > 0) {
|
|
3193
|
+
const value = percentile([...operations.discoverySamples].sort((a, b) => a - b), 0.95);
|
|
3194
|
+
checks.push({
|
|
3195
|
+
name: "discovery-latency-p95",
|
|
3196
|
+
passed: value <= thresholds.discoveryLatencyP95Ms,
|
|
3197
|
+
value,
|
|
3198
|
+
threshold: thresholds.discoveryLatencyP95Ms
|
|
3199
|
+
});
|
|
3200
|
+
}
|
|
3201
|
+
if (thresholds.callLatencyP95Ms !== void 0 && operations.callSamples.length > 0) {
|
|
3202
|
+
const value = percentile([...operations.callSamples].sort((a, b) => a - b), 0.95);
|
|
3203
|
+
checks.push({
|
|
3204
|
+
name: "call-latency-p95",
|
|
3205
|
+
passed: value <= thresholds.callLatencyP95Ms,
|
|
3206
|
+
value,
|
|
3207
|
+
threshold: thresholds.callLatencyP95Ms
|
|
3208
|
+
});
|
|
3209
|
+
}
|
|
3210
|
+
if (thresholds.inFlightCalls !== void 0) {
|
|
3211
|
+
checks.push({
|
|
3212
|
+
name: "in-flight-calls",
|
|
3213
|
+
passed: operations.peakInFlightCalls <= thresholds.inFlightCalls,
|
|
3214
|
+
value: operations.peakInFlightCalls,
|
|
3215
|
+
threshold: thresholds.inFlightCalls
|
|
3216
|
+
});
|
|
3217
|
+
}
|
|
3218
|
+
return checks;
|
|
3219
|
+
}
|
|
3220
|
+
function applyHealthThresholds(state, checks) {
|
|
3221
|
+
if (state !== "healthy") return state;
|
|
3222
|
+
return checks.some((c) => !c.passed) ? "degraded" : "healthy";
|
|
3223
|
+
}
|
|
3224
|
+
function summarizeLatency(samples) {
|
|
3225
|
+
if (samples.length === 0) return { count: 0 };
|
|
3226
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
3227
|
+
return {
|
|
3228
|
+
count: samples.length,
|
|
3229
|
+
lastMs: samples[samples.length - 1],
|
|
3230
|
+
minMs: sorted[0],
|
|
3231
|
+
maxMs: sorted[sorted.length - 1],
|
|
3232
|
+
p50Ms: percentile(sorted, 0.5),
|
|
3233
|
+
p95Ms: percentile(sorted, 0.95)
|
|
3234
|
+
};
|
|
3235
|
+
}
|
|
3236
|
+
function pushBounded(target, value, limit) {
|
|
3237
|
+
target.push(value);
|
|
3238
|
+
if (target.length > limit) target.splice(0, target.length - limit);
|
|
3239
|
+
}
|
|
3240
|
+
function safeOperationReason(reason) {
|
|
3241
|
+
const normalized = reason.toLowerCase().replace(/[^a-z0-9_.:-]+/g, "-");
|
|
3242
|
+
const bounded = normalized.slice(0, MCP_OPERATION_LIMITS.REASON_CHARS);
|
|
3243
|
+
return SAFE_OPERATION_REASONS.has(bounded) ? bounded : "other";
|
|
3244
|
+
}
|
|
3245
|
+
function percentile(sorted, ratio) {
|
|
3246
|
+
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * ratio) - 1))];
|
|
1368
3247
|
}
|
|
1369
3248
|
|
|
3249
|
+
// src/registry.ts
|
|
3250
|
+
import { expectDefined } from "@wrongstack/core";
|
|
3251
|
+
|
|
1370
3252
|
// src/wrap-tool.ts
|
|
1371
3253
|
import { ToolCapabilities } from "@wrongstack/core";
|
|
1372
3254
|
var MUTATING_RE = /create|update|delete|write|send|set|put|post|patch|remove|rename|move/i;
|
|
@@ -1383,7 +3265,7 @@ function isMutatingTool(mcpTool) {
|
|
|
1383
3265
|
}
|
|
1384
3266
|
return false;
|
|
1385
3267
|
}
|
|
1386
|
-
function wrapMCPTool(serverName, mcpTool, client, permission = "confirm") {
|
|
3268
|
+
function wrapMCPTool(serverName, mcpTool, client, permission = "confirm", observer) {
|
|
1387
3269
|
const qualifiedName = `mcp__${serverName}__${mcpTool.name}`;
|
|
1388
3270
|
return {
|
|
1389
3271
|
name: qualifiedName,
|
|
@@ -1394,12 +3276,20 @@ function wrapMCPTool(serverName, mcpTool, client, permission = "confirm") {
|
|
|
1394
3276
|
capabilities: [ToolCapabilities.MCP_PROXY],
|
|
1395
3277
|
inputSchema: mcpTool.inputSchema ?? { type: "object", properties: {} },
|
|
1396
3278
|
async execute(input, _ctx, opts) {
|
|
1397
|
-
const
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
3279
|
+
const startedAt = Date.now();
|
|
3280
|
+
observer?.onStart();
|
|
3281
|
+
let ok = false;
|
|
3282
|
+
try {
|
|
3283
|
+
const live = typeof client === "function" ? await client() : client;
|
|
3284
|
+
const res = await live.callTool(mcpTool.name, input, { signal: opts.signal });
|
|
3285
|
+
if (res.isError) {
|
|
3286
|
+
throw new Error(stringify(res.content));
|
|
3287
|
+
}
|
|
3288
|
+
ok = true;
|
|
3289
|
+
return stringify(res.content);
|
|
3290
|
+
} finally {
|
|
3291
|
+
observer?.onFinish({ durationMs: Date.now() - startedAt, ok });
|
|
1401
3292
|
}
|
|
1402
|
-
return stringify(res.content);
|
|
1403
3293
|
}
|
|
1404
3294
|
};
|
|
1405
3295
|
}
|
|
@@ -1424,57 +3314,20 @@ function stringify(c) {
|
|
|
1424
3314
|
return String(c ?? "");
|
|
1425
3315
|
}
|
|
1426
3316
|
|
|
1427
|
-
// src/registry.ts
|
|
1428
|
-
import { expectDefined } from "@wrongstack/core";
|
|
1429
|
-
|
|
1430
|
-
// src/manifest-cache.ts
|
|
1431
|
-
import { createHash } from "node:crypto";
|
|
1432
|
-
import * as fs from "node:fs/promises";
|
|
1433
|
-
import * as path from "node:path";
|
|
1434
|
-
function manifestConfigHash(cfg) {
|
|
1435
|
-
const basis = JSON.stringify({
|
|
1436
|
-
transport: cfg.transport,
|
|
1437
|
-
command: cfg.command ?? null,
|
|
1438
|
-
args: cfg.args ?? null,
|
|
1439
|
-
url: cfg.url ?? null
|
|
1440
|
-
});
|
|
1441
|
-
return createHash("sha256").update(basis).digest("hex").slice(0, 16);
|
|
1442
|
-
}
|
|
1443
|
-
function manifestFile(cacheDir, name) {
|
|
1444
|
-
const safe = name.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
1445
|
-
return path.join(cacheDir, "mcp-tools", `${safe}.json`);
|
|
1446
|
-
}
|
|
1447
|
-
async function readManifest(cacheDir, name, configHash) {
|
|
1448
|
-
try {
|
|
1449
|
-
const raw = await fs.readFile(manifestFile(cacheDir, name), "utf8");
|
|
1450
|
-
const parsed = JSON.parse(raw);
|
|
1451
|
-
if (parsed.configHash !== configHash || !Array.isArray(parsed.tools)) return null;
|
|
1452
|
-
return parsed.tools;
|
|
1453
|
-
} catch {
|
|
1454
|
-
return null;
|
|
1455
|
-
}
|
|
1456
|
-
}
|
|
1457
|
-
async function writeManifest(cacheDir, name, configHash, tools) {
|
|
1458
|
-
try {
|
|
1459
|
-
const file = manifestFile(cacheDir, name);
|
|
1460
|
-
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
1461
|
-
const body = { configHash, tools };
|
|
1462
|
-
const tmp = `${file}.tmp`;
|
|
1463
|
-
await fs.writeFile(tmp, JSON.stringify(body, null, 2), "utf8");
|
|
1464
|
-
await fs.rename(tmp, file);
|
|
1465
|
-
} catch {
|
|
1466
|
-
}
|
|
1467
|
-
}
|
|
1468
|
-
|
|
1469
3317
|
// src/registry.ts
|
|
1470
3318
|
var MCPRegistry = class _MCPRegistry {
|
|
1471
3319
|
servers = /* @__PURE__ */ new Map();
|
|
3320
|
+
/** Configured-off servers are tracked without creating a transport/client. */
|
|
3321
|
+
disabledServers = /* @__PURE__ */ new Map();
|
|
1472
3322
|
toolRegistry;
|
|
1473
3323
|
events;
|
|
1474
3324
|
log;
|
|
1475
3325
|
lazyMode;
|
|
1476
3326
|
cacheDir;
|
|
1477
3327
|
idleTimeoutMs;
|
|
3328
|
+
authorizationProviderFactory;
|
|
3329
|
+
authorizationManager;
|
|
3330
|
+
operationListeners = /* @__PURE__ */ new Set();
|
|
1478
3331
|
/** Single shared idle sweep timer (started lazily; unref'd; cleared on stopAll). */
|
|
1479
3332
|
idleTimer;
|
|
1480
3333
|
constructor(opts) {
|
|
@@ -1484,9 +3337,61 @@ var MCPRegistry = class _MCPRegistry {
|
|
|
1484
3337
|
this.lazyMode = opts.lazyMode ?? false;
|
|
1485
3338
|
this.cacheDir = opts.cacheDir;
|
|
1486
3339
|
this.idleTimeoutMs = opts.idleTimeoutMs ?? MCP_CONSTANTS.IDLE.DEFAULT_TIMEOUT_MS;
|
|
3340
|
+
this.authorizationProviderFactory = opts.authorizationProviderFactory;
|
|
3341
|
+
this.authorizationManager = opts.authorizationManager;
|
|
3342
|
+
}
|
|
3343
|
+
requireSlot(name) {
|
|
3344
|
+
const slot = this.servers.get(name);
|
|
3345
|
+
if (!slot) throw new Error(`MCP server "${name}" not registered`);
|
|
3346
|
+
return slot;
|
|
3347
|
+
}
|
|
3348
|
+
async beginAuthorization(name, input) {
|
|
3349
|
+
const manager = this.requireAuthorizationManager();
|
|
3350
|
+
const cfg = this.requireHttpServerConfig(name);
|
|
3351
|
+
return manager.begin({
|
|
3352
|
+
serverName: name,
|
|
3353
|
+
resource: cfg.url,
|
|
3354
|
+
...input
|
|
3355
|
+
});
|
|
3356
|
+
}
|
|
3357
|
+
async completeAuthorization(name, callbackUrl, signal) {
|
|
3358
|
+
const manager = this.requireAuthorizationManager();
|
|
3359
|
+
const cfg = this.requireHttpServerConfig(name);
|
|
3360
|
+
return manager.complete({ serverName: name, resource: cfg.url, callbackUrl, signal });
|
|
3361
|
+
}
|
|
3362
|
+
async authorizationStatus(name) {
|
|
3363
|
+
const manager = this.requireAuthorizationManager();
|
|
3364
|
+
const cfg = this.requireHttpServerConfig(name);
|
|
3365
|
+
return manager.status(name, cfg.url);
|
|
3366
|
+
}
|
|
3367
|
+
async disconnectAuthorization(name) {
|
|
3368
|
+
const manager = this.requireAuthorizationManager();
|
|
3369
|
+
const cfg = this.requireHttpServerConfig(name);
|
|
3370
|
+
return manager.disconnect(name, cfg.url);
|
|
3371
|
+
}
|
|
3372
|
+
requireAuthorizationManager() {
|
|
3373
|
+
if (!this.authorizationManager) {
|
|
3374
|
+
throw new Error("MCP authorization management is not configured for this host");
|
|
3375
|
+
}
|
|
3376
|
+
return this.authorizationManager;
|
|
3377
|
+
}
|
|
3378
|
+
requireHttpServerConfig(name) {
|
|
3379
|
+
const cfg = this.servers.get(name)?.cfg ?? this.disabledServers.get(name);
|
|
3380
|
+
if (!cfg) throw new Error(`MCP server "${name}" not registered`);
|
|
3381
|
+
if (cfg.transport === "stdio" || !cfg.url) {
|
|
3382
|
+
throw new Error(`MCP server "${name}" does not use an HTTP transport`);
|
|
3383
|
+
}
|
|
3384
|
+
return cfg;
|
|
1487
3385
|
}
|
|
1488
3386
|
async start(cfg) {
|
|
1489
|
-
if (cfg.enabled === false)
|
|
3387
|
+
if (cfg.enabled === false) {
|
|
3388
|
+
if (this.servers.has(cfg.name)) {
|
|
3389
|
+
await this.stop(cfg.name);
|
|
3390
|
+
}
|
|
3391
|
+
this.markDisabled(cfg);
|
|
3392
|
+
return;
|
|
3393
|
+
}
|
|
3394
|
+
this.disabledServers.delete(cfg.name);
|
|
1490
3395
|
if (this.servers.has(cfg.name)) {
|
|
1491
3396
|
throw new Error(
|
|
1492
3397
|
`MCP server "${cfg.name}" is already registered \u2014 use restart() to re-cycle a running server`
|
|
@@ -1503,7 +3408,8 @@ var MCPRegistry = class _MCPRegistry {
|
|
|
1503
3408
|
reconnectCycles: 0,
|
|
1504
3409
|
lazy,
|
|
1505
3410
|
lastUsed: Date.now(),
|
|
1506
|
-
registeredLazy: false
|
|
3411
|
+
registeredLazy: false,
|
|
3412
|
+
operations: createMCPServerOperationState()
|
|
1507
3413
|
};
|
|
1508
3414
|
this.servers.set(cfg.name, slot);
|
|
1509
3415
|
if (lazy) {
|
|
@@ -1512,6 +3418,16 @@ var MCPRegistry = class _MCPRegistry {
|
|
|
1512
3418
|
await this.attemptConnect(slot);
|
|
1513
3419
|
}
|
|
1514
3420
|
}
|
|
3421
|
+
/** Record an intentionally disabled configuration without opening a transport. */
|
|
3422
|
+
markDisabled(cfg) {
|
|
3423
|
+
this.servers.delete(cfg.name);
|
|
3424
|
+
this.disabledServers.set(cfg.name, { ...cfg, enabled: false });
|
|
3425
|
+
}
|
|
3426
|
+
/** Remove residual operational/configuration state after a management delete. */
|
|
3427
|
+
forget(name) {
|
|
3428
|
+
this.servers.delete(name);
|
|
3429
|
+
this.disabledServers.delete(name);
|
|
3430
|
+
}
|
|
1515
3431
|
/**
|
|
1516
3432
|
* Boot a lazy server WITHOUT spawning it. If a tool manifest is cached (from a
|
|
1517
3433
|
* prior connect with matching config), register resolver-backed wrappers and
|
|
@@ -1525,13 +3441,17 @@ var MCPRegistry = class _MCPRegistry {
|
|
|
1525
3441
|
return;
|
|
1526
3442
|
}
|
|
1527
3443
|
const hash = manifestConfigHash(slot.cfg);
|
|
1528
|
-
const cached = await
|
|
1529
|
-
if (cached
|
|
1530
|
-
|
|
3444
|
+
const cached = await readCapabilityManifest(cacheDir, slot.cfg.name, hash);
|
|
3445
|
+
if (cached) {
|
|
3446
|
+
slot.serverMetadata = cached.serverMetadata;
|
|
3447
|
+
slot.resources = cached.resources;
|
|
3448
|
+
slot.resourceTemplates = cached.resourceTemplates;
|
|
3449
|
+
slot.prompts = cached.prompts;
|
|
3450
|
+
this.applyTools(slot, cached.tools);
|
|
1531
3451
|
slot.state = "dormant";
|
|
1532
3452
|
this.ensureIdleSweep();
|
|
1533
3453
|
this.log.info(
|
|
1534
|
-
`MCP server "${slot.cfg.name}" registered lazily from cache (${cached.length} tools, dormant)`
|
|
3454
|
+
`MCP server "${slot.cfg.name}" registered lazily from cache (${cached.tools.length} tools, dormant)`
|
|
1535
3455
|
);
|
|
1536
3456
|
return;
|
|
1537
3457
|
}
|
|
@@ -1547,6 +3467,11 @@ var MCPRegistry = class _MCPRegistry {
|
|
|
1547
3467
|
slot.lastUsed = Date.now();
|
|
1548
3468
|
if (slot.client && slot.state === "connected") return slot.client;
|
|
1549
3469
|
if (slot.connecting) return slot.connecting;
|
|
3470
|
+
const waking = slot.state === "dormant";
|
|
3471
|
+
if (waking) {
|
|
3472
|
+
slot.operations.wakeCount++;
|
|
3473
|
+
this.recordOperation(slot, "wake", "lazy-demand");
|
|
3474
|
+
}
|
|
1550
3475
|
slot.connecting = (async () => {
|
|
1551
3476
|
try {
|
|
1552
3477
|
slot.attempts = 0;
|
|
@@ -1627,6 +3552,7 @@ var MCPRegistry = class _MCPRegistry {
|
|
|
1627
3552
|
slot.client.removeExitListener(this.onChildExit);
|
|
1628
3553
|
if (slot.onDisconnect) slot.client.removeDisconnectListener(slot.onDisconnect);
|
|
1629
3554
|
slot.client.removeToolsChangedListener(this.onToolsChanged);
|
|
3555
|
+
this.removeCatalogListeners(slot.client);
|
|
1630
3556
|
await slot.client.close();
|
|
1631
3557
|
slot.client = void 0;
|
|
1632
3558
|
}
|
|
@@ -1635,13 +3561,20 @@ var MCPRegistry = class _MCPRegistry {
|
|
|
1635
3561
|
for (const t of slot.toolNames) this.toolRegistry.unregister(t);
|
|
1636
3562
|
slot.toolNames = [];
|
|
1637
3563
|
slot.lazyTools = [];
|
|
3564
|
+
slot.serverMetadata = void 0;
|
|
3565
|
+
slot.resources = void 0;
|
|
3566
|
+
slot.resourceTemplates = void 0;
|
|
3567
|
+
slot.prompts = void 0;
|
|
1638
3568
|
slot.registeredLazy = false;
|
|
1639
3569
|
slot.state = "disconnected";
|
|
3570
|
+
this.recordOperation(slot, "stop", "manual");
|
|
1640
3571
|
this.events.emit("mcp.server.disconnected", { name, reason: "stop" });
|
|
1641
3572
|
}
|
|
1642
3573
|
async restart(name) {
|
|
1643
3574
|
const slot = this.servers.get(name);
|
|
1644
3575
|
if (!slot) throw new Error(`MCP server "${name}" not registered`);
|
|
3576
|
+
slot.operations.restartCount++;
|
|
3577
|
+
this.recordOperation(slot, "restart", "manual");
|
|
1645
3578
|
await this.stop(name);
|
|
1646
3579
|
slot.attempts = 0;
|
|
1647
3580
|
slot.reconnectCycles = 0;
|
|
@@ -1658,6 +3591,131 @@ var MCPRegistry = class _MCPRegistry {
|
|
|
1658
3591
|
};
|
|
1659
3592
|
});
|
|
1660
3593
|
}
|
|
3594
|
+
/**
|
|
3595
|
+
* Subscribe to payload-free operational signals. Callers must still avoid
|
|
3596
|
+
* using `serverName` as an unbounded metric label.
|
|
3597
|
+
*/
|
|
3598
|
+
onOperation(listener) {
|
|
3599
|
+
this.operationListeners.add(listener);
|
|
3600
|
+
return () => this.operationListeners.delete(listener);
|
|
3601
|
+
}
|
|
3602
|
+
/** Detailed, defensively-copied operational snapshots for CLI/WebUI/HQ. */
|
|
3603
|
+
operationalHealth() {
|
|
3604
|
+
const active = Array.from(this.servers.values()).map((slot) => {
|
|
3605
|
+
const op = slot.operations;
|
|
3606
|
+
const baseHealth = healthStateFor(slot.state, op, slot.cfg.enabled !== false);
|
|
3607
|
+
const checks = evaluateHealthThresholds(op, slot.cfg.health?.thresholds);
|
|
3608
|
+
return {
|
|
3609
|
+
name: slot.cfg.name,
|
|
3610
|
+
connectionState: slot.state,
|
|
3611
|
+
healthState: applyHealthThresholds(baseHealth, checks),
|
|
3612
|
+
lastSuccessAt: op.lastSuccessAt,
|
|
3613
|
+
lastFailureAt: op.lastFailureAt,
|
|
3614
|
+
lastFailureKind: op.lastFailureKind,
|
|
3615
|
+
lastReason: op.lastReason,
|
|
3616
|
+
consecutiveFailures: op.consecutiveFailures,
|
|
3617
|
+
failures: { ...op.failures },
|
|
3618
|
+
reconnectCount: op.reconnectCount,
|
|
3619
|
+
wakeCount: op.wakeCount,
|
|
3620
|
+
sleepCount: op.sleepCount,
|
|
3621
|
+
restartCount: op.restartCount,
|
|
3622
|
+
connectionLatency: summarizeLatency(op.connectionSamples),
|
|
3623
|
+
discoveryLatency: summarizeLatency(op.discoverySamples),
|
|
3624
|
+
callLatency: summarizeLatency(op.callSamples),
|
|
3625
|
+
inFlightCalls: op.inFlightCalls,
|
|
3626
|
+
peakInFlightCalls: op.peakInFlightCalls,
|
|
3627
|
+
recentEvents: op.recentEvents.map((event) => ({ ...event })),
|
|
3628
|
+
healthChecks: checks
|
|
3629
|
+
};
|
|
3630
|
+
});
|
|
3631
|
+
const disabled = Array.from(this.disabledServers.values()).map((cfg) => {
|
|
3632
|
+
const operations = createMCPServerOperationState();
|
|
3633
|
+
return {
|
|
3634
|
+
name: cfg.name,
|
|
3635
|
+
connectionState: "idle",
|
|
3636
|
+
healthState: "disabled",
|
|
3637
|
+
consecutiveFailures: 0,
|
|
3638
|
+
failures: { ...operations.failures },
|
|
3639
|
+
reconnectCount: 0,
|
|
3640
|
+
wakeCount: 0,
|
|
3641
|
+
sleepCount: 0,
|
|
3642
|
+
restartCount: 0,
|
|
3643
|
+
connectionLatency: summarizeLatency([]),
|
|
3644
|
+
discoveryLatency: summarizeLatency([]),
|
|
3645
|
+
callLatency: summarizeLatency([]),
|
|
3646
|
+
inFlightCalls: 0,
|
|
3647
|
+
peakInFlightCalls: 0,
|
|
3648
|
+
recentEvents: [],
|
|
3649
|
+
healthChecks: []
|
|
3650
|
+
};
|
|
3651
|
+
});
|
|
3652
|
+
return [...active, ...disabled];
|
|
3653
|
+
}
|
|
3654
|
+
getCatalog(name) {
|
|
3655
|
+
const slot = this.servers.get(name);
|
|
3656
|
+
if (!slot) return void 0;
|
|
3657
|
+
return catalogSnapshot(slot);
|
|
3658
|
+
}
|
|
3659
|
+
async listResources(name, opts = {}) {
|
|
3660
|
+
const slot = this.requireSlot(name);
|
|
3661
|
+
if (!opts.refresh && slot.resources) return cloneRecords(slot.resources);
|
|
3662
|
+
const client = await this.ensureConnected(name);
|
|
3663
|
+
if (!client.getServerMetadata()?.capabilities.resources) return [];
|
|
3664
|
+
slot.resources = await collectPages(
|
|
3665
|
+
(cursor) => client.listResources(cursor ? { cursor } : {}),
|
|
3666
|
+
(page) => page.resources
|
|
3667
|
+
);
|
|
3668
|
+
await this.persistCapabilityManifest(slot);
|
|
3669
|
+
return cloneRecords(slot.resources);
|
|
3670
|
+
}
|
|
3671
|
+
async listResourceTemplates(name, opts = {}) {
|
|
3672
|
+
const slot = this.requireSlot(name);
|
|
3673
|
+
if (!opts.refresh && slot.resourceTemplates) return cloneRecords(slot.resourceTemplates);
|
|
3674
|
+
const client = await this.ensureConnected(name);
|
|
3675
|
+
if (!client.getServerMetadata()?.capabilities.resources) return [];
|
|
3676
|
+
slot.resourceTemplates = await collectPages(
|
|
3677
|
+
(cursor) => client.listResourceTemplates(cursor ? { cursor } : {}),
|
|
3678
|
+
(page) => page.resourceTemplates
|
|
3679
|
+
);
|
|
3680
|
+
await this.persistCapabilityManifest(slot);
|
|
3681
|
+
return cloneRecords(slot.resourceTemplates);
|
|
3682
|
+
}
|
|
3683
|
+
async readResource(name, uri) {
|
|
3684
|
+
return (await this.ensureConnected(name)).readResource(uri);
|
|
3685
|
+
}
|
|
3686
|
+
async selectResourceForInsertion(name, uri, policy) {
|
|
3687
|
+
return prepareResourceInsertion(name, uri, await this.readResource(name, uri), policy);
|
|
3688
|
+
}
|
|
3689
|
+
async subscribeResource(name, uri) {
|
|
3690
|
+
await (await this.ensureConnected(name)).subscribeResource(uri);
|
|
3691
|
+
}
|
|
3692
|
+
async unsubscribeResource(name, uri) {
|
|
3693
|
+
await (await this.ensureConnected(name)).unsubscribeResource(uri);
|
|
3694
|
+
}
|
|
3695
|
+
async listPrompts(name, opts = {}) {
|
|
3696
|
+
const slot = this.requireSlot(name);
|
|
3697
|
+
if (!opts.refresh && slot.prompts) return cloneRecords(slot.prompts);
|
|
3698
|
+
const client = await this.ensureConnected(name);
|
|
3699
|
+
if (!client.getServerMetadata()?.capabilities.prompts) return [];
|
|
3700
|
+
slot.prompts = await collectPages(
|
|
3701
|
+
(cursor) => client.listPrompts(cursor ? { cursor } : {}),
|
|
3702
|
+
(page) => page.prompts
|
|
3703
|
+
);
|
|
3704
|
+
await this.persistCapabilityManifest(slot);
|
|
3705
|
+
return cloneRecords(slot.prompts);
|
|
3706
|
+
}
|
|
3707
|
+
async getPrompt(serverName, promptName, args) {
|
|
3708
|
+
return (await this.ensureConnected(serverName)).getPrompt(promptName, args);
|
|
3709
|
+
}
|
|
3710
|
+
async selectPromptForInsertion(serverName, promptName, args, policy) {
|
|
3711
|
+
return preparePromptInsertion(
|
|
3712
|
+
serverName,
|
|
3713
|
+
promptName,
|
|
3714
|
+
args,
|
|
3715
|
+
await this.getPrompt(serverName, promptName, args),
|
|
3716
|
+
policy
|
|
3717
|
+
);
|
|
3718
|
+
}
|
|
1661
3719
|
/**
|
|
1662
3720
|
* Resolve the live tool names for a slot — the registered names in normal
|
|
1663
3721
|
* mode, or the cached lazy-tool names when running in lazy mode (where
|
|
@@ -1679,7 +3737,30 @@ var MCPRegistry = class _MCPRegistry {
|
|
|
1679
3737
|
const filtered = tools.filter((t) => !allowed || allowed.includes(t.name));
|
|
1680
3738
|
const clientArg = slot.lazy ? () => this.ensureConnected(slot.cfg.name) : expectDefined(client);
|
|
1681
3739
|
const wrapped = filtered.map(
|
|
1682
|
-
(t) => wrapMCPTool(slot.cfg.name, t, clientArg, slot.cfg.permission ?? "confirm"
|
|
3740
|
+
(t) => wrapMCPTool(slot.cfg.name, t, clientArg, slot.cfg.permission ?? "confirm", {
|
|
3741
|
+
onStart: () => {
|
|
3742
|
+
slot.operations.inFlightCalls++;
|
|
3743
|
+
slot.operations.peakInFlightCalls = Math.max(
|
|
3744
|
+
slot.operations.peakInFlightCalls,
|
|
3745
|
+
slot.operations.inFlightCalls
|
|
3746
|
+
);
|
|
3747
|
+
this.recordOperation(slot, "call", "started", void 0, void 0, false);
|
|
3748
|
+
},
|
|
3749
|
+
onFinish: ({ durationMs, ok }) => {
|
|
3750
|
+
slot.operations.inFlightCalls = Math.max(0, slot.operations.inFlightCalls - 1);
|
|
3751
|
+
pushBounded(
|
|
3752
|
+
slot.operations.callSamples,
|
|
3753
|
+
durationMs,
|
|
3754
|
+
MCP_OPERATION_LIMITS.LATENCY_SAMPLES
|
|
3755
|
+
);
|
|
3756
|
+
if (ok) {
|
|
3757
|
+
this.recordSuccess(slot);
|
|
3758
|
+
this.recordOperation(slot, "call", "ok", void 0, durationMs, false);
|
|
3759
|
+
} else {
|
|
3760
|
+
this.recordFailure(slot, "tool", "tool-call-failed", durationMs);
|
|
3761
|
+
}
|
|
3762
|
+
}
|
|
3763
|
+
})
|
|
1683
3764
|
);
|
|
1684
3765
|
if (this.lazyMode) {
|
|
1685
3766
|
slot.lazyTools = wrapped;
|
|
@@ -1693,7 +3774,71 @@ var MCPRegistry = class _MCPRegistry {
|
|
|
1693
3774
|
this.log.warn(`MCP tool "${tool.name}" not registered`, err);
|
|
1694
3775
|
}
|
|
1695
3776
|
}
|
|
1696
|
-
if (slot.lazy) slot.registeredLazy = true;
|
|
3777
|
+
if (slot.lazy && wrapped.length > 0) slot.registeredLazy = true;
|
|
3778
|
+
}
|
|
3779
|
+
async discoverCapabilities(slot, client) {
|
|
3780
|
+
const startedAt = Date.now();
|
|
3781
|
+
slot.serverMetadata = client.getServerMetadata();
|
|
3782
|
+
const capabilities = slot.serverMetadata?.capabilities;
|
|
3783
|
+
if (capabilities?.resources) {
|
|
3784
|
+
try {
|
|
3785
|
+
slot.resources = await collectPages(
|
|
3786
|
+
(cursor) => client.listResources(cursor ? { cursor } : {}),
|
|
3787
|
+
(page) => page.resources
|
|
3788
|
+
);
|
|
3789
|
+
} catch (err) {
|
|
3790
|
+
slot.resources = void 0;
|
|
3791
|
+
this.recordFailure(slot, "protocol", "resource-discovery-failed");
|
|
3792
|
+
this.log.warn(`MCP server "${slot.cfg.name}" resource discovery failed`, err);
|
|
3793
|
+
}
|
|
3794
|
+
try {
|
|
3795
|
+
slot.resourceTemplates = await collectPages(
|
|
3796
|
+
(cursor) => client.listResourceTemplates(cursor ? { cursor } : {}),
|
|
3797
|
+
(page) => page.resourceTemplates
|
|
3798
|
+
);
|
|
3799
|
+
} catch (err) {
|
|
3800
|
+
slot.resourceTemplates = void 0;
|
|
3801
|
+
this.recordFailure(slot, "protocol", "resource-template-discovery-failed");
|
|
3802
|
+
this.log.warn(`MCP server "${slot.cfg.name}" resource template discovery failed`, err);
|
|
3803
|
+
}
|
|
3804
|
+
} else {
|
|
3805
|
+
slot.resources = void 0;
|
|
3806
|
+
slot.resourceTemplates = void 0;
|
|
3807
|
+
}
|
|
3808
|
+
if (capabilities?.prompts) {
|
|
3809
|
+
try {
|
|
3810
|
+
slot.prompts = await collectPages(
|
|
3811
|
+
(cursor) => client.listPrompts(cursor ? { cursor } : {}),
|
|
3812
|
+
(page) => page.prompts
|
|
3813
|
+
);
|
|
3814
|
+
} catch (err) {
|
|
3815
|
+
slot.prompts = void 0;
|
|
3816
|
+
this.recordFailure(slot, "protocol", "prompt-discovery-failed");
|
|
3817
|
+
this.log.warn(`MCP server "${slot.cfg.name}" prompt discovery failed`, err);
|
|
3818
|
+
}
|
|
3819
|
+
} else {
|
|
3820
|
+
slot.prompts = void 0;
|
|
3821
|
+
}
|
|
3822
|
+
const durationMs = Date.now() - startedAt;
|
|
3823
|
+
pushBounded(slot.operations.discoverySamples, durationMs, MCP_OPERATION_LIMITS.LATENCY_SAMPLES);
|
|
3824
|
+
this.recordOperation(slot, "discover", "complete", void 0, durationMs, false);
|
|
3825
|
+
}
|
|
3826
|
+
async persistCapabilityManifest(slot) {
|
|
3827
|
+
if (!slot.lazy || !this.cacheDir) return;
|
|
3828
|
+
const cacheDir = this.cacheDir;
|
|
3829
|
+
const previous = slot.manifestWrite ?? Promise.resolve();
|
|
3830
|
+
const pending = previous.then(
|
|
3831
|
+
() => writeCapabilityManifest(cacheDir, slot.cfg.name, manifestConfigHash(slot.cfg), {
|
|
3832
|
+
tools: slot.client?.listTools() ?? [],
|
|
3833
|
+
serverMetadata: slot.serverMetadata,
|
|
3834
|
+
resources: slot.resources,
|
|
3835
|
+
resourceTemplates: slot.resourceTemplates,
|
|
3836
|
+
prompts: slot.prompts
|
|
3837
|
+
})
|
|
3838
|
+
);
|
|
3839
|
+
slot.manifestWrite = pending;
|
|
3840
|
+
await pending;
|
|
3841
|
+
if (slot.manifestWrite === pending) slot.manifestWrite = void 0;
|
|
1697
3842
|
}
|
|
1698
3843
|
/** Start the shared idle sweep timer once (unref'd so it never holds the process). */
|
|
1699
3844
|
ensureIdleSweep() {
|
|
@@ -1728,11 +3873,14 @@ var MCPRegistry = class _MCPRegistry {
|
|
|
1728
3873
|
slot.client.removeExitListener(this.onChildExit);
|
|
1729
3874
|
if (slot.onDisconnect) slot.client.removeDisconnectListener(slot.onDisconnect);
|
|
1730
3875
|
slot.client.removeToolsChangedListener(this.onToolsChanged);
|
|
3876
|
+
this.removeCatalogListeners(slot.client);
|
|
1731
3877
|
await slot.client.close();
|
|
1732
3878
|
slot.client = void 0;
|
|
1733
3879
|
}
|
|
1734
3880
|
slot.onDisconnect = void 0;
|
|
1735
3881
|
slot.state = "dormant";
|
|
3882
|
+
slot.operations.sleepCount++;
|
|
3883
|
+
this.recordOperation(slot, "sleep", "idle-timeout");
|
|
1736
3884
|
this.log.info(`MCP server "${slot.cfg.name}" idle \u2014 sleeping (tools stay registered)`);
|
|
1737
3885
|
this.events.emit("mcp.server.disconnected", { name: slot.cfg.name, reason: "idle-sleep" });
|
|
1738
3886
|
}
|
|
@@ -1743,7 +3891,7 @@ var MCPRegistry = class _MCPRegistry {
|
|
|
1743
3891
|
* triggering connections.
|
|
1744
3892
|
*/
|
|
1745
3893
|
describe() {
|
|
1746
|
-
|
|
3894
|
+
const active = Array.from(this.servers.values()).map((s) => {
|
|
1747
3895
|
const tools = this.toolNamesForSlot(s);
|
|
1748
3896
|
return {
|
|
1749
3897
|
name: s.cfg.name,
|
|
@@ -1753,6 +3901,14 @@ var MCPRegistry = class _MCPRegistry {
|
|
|
1753
3901
|
tools
|
|
1754
3902
|
};
|
|
1755
3903
|
});
|
|
3904
|
+
const disabled = Array.from(this.disabledServers.values()).map((cfg) => ({
|
|
3905
|
+
name: cfg.name,
|
|
3906
|
+
state: "idle",
|
|
3907
|
+
toolCount: 0,
|
|
3908
|
+
enabled: false,
|
|
3909
|
+
tools: []
|
|
3910
|
+
}));
|
|
3911
|
+
return [...active, ...disabled];
|
|
1756
3912
|
}
|
|
1757
3913
|
async stopAll() {
|
|
1758
3914
|
if (this.idleTimer) {
|
|
@@ -1762,6 +3918,7 @@ var MCPRegistry = class _MCPRegistry {
|
|
|
1762
3918
|
for (const name of Array.from(this.servers.keys())) {
|
|
1763
3919
|
await this.stop(name);
|
|
1764
3920
|
}
|
|
3921
|
+
this.disabledServers.clear();
|
|
1765
3922
|
}
|
|
1766
3923
|
/**
|
|
1767
3924
|
* Health check — returns 'ok' for connected servers, the current state otherwise.
|
|
@@ -1792,10 +3949,8 @@ var MCPRegistry = class _MCPRegistry {
|
|
|
1792
3949
|
slot.toolNames = [];
|
|
1793
3950
|
slot.registeredLazy = false;
|
|
1794
3951
|
const discovered = slot.client.listTools();
|
|
1795
|
-
if (slot.lazy && this.cacheDir) {
|
|
1796
|
-
void writeManifest(this.cacheDir, slot.cfg.name, manifestConfigHash(slot.cfg), discovered);
|
|
1797
|
-
}
|
|
1798
3952
|
this.applyTools(slot, discovered, slot.client);
|
|
3953
|
+
void this.persistCapabilityManifest(slot);
|
|
1799
3954
|
this.events.emit("mcp.server.connected", {
|
|
1800
3955
|
name: slot.cfg.name,
|
|
1801
3956
|
toolCount: slot.toolNames.length
|
|
@@ -1804,12 +3959,36 @@ var MCPRegistry = class _MCPRegistry {
|
|
|
1804
3959
|
`MCP server "${slot.cfg.name}" tools refreshed (${this.toolNamesForSlot(slot).length} active)`
|
|
1805
3960
|
);
|
|
1806
3961
|
};
|
|
3962
|
+
onResourcesChanged = (name) => {
|
|
3963
|
+
const slot = this.servers.get(name);
|
|
3964
|
+
if (!slot) return;
|
|
3965
|
+
slot.resources = void 0;
|
|
3966
|
+
slot.resourceTemplates = void 0;
|
|
3967
|
+
void this.persistCapabilityManifest(slot);
|
|
3968
|
+
this.log.info(`MCP server "${name}" resource catalog invalidated`);
|
|
3969
|
+
};
|
|
3970
|
+
onPromptsChanged = (name) => {
|
|
3971
|
+
const slot = this.servers.get(name);
|
|
3972
|
+
if (!slot) return;
|
|
3973
|
+
slot.prompts = void 0;
|
|
3974
|
+
void this.persistCapabilityManifest(slot);
|
|
3975
|
+
this.log.info(`MCP server "${name}" prompt catalog invalidated`);
|
|
3976
|
+
};
|
|
3977
|
+
addCatalogListeners(client) {
|
|
3978
|
+
client.addResourcesChangedListener(this.onResourcesChanged);
|
|
3979
|
+
client.addPromptsChangedListener(this.onPromptsChanged);
|
|
3980
|
+
}
|
|
3981
|
+
removeCatalogListeners(client) {
|
|
3982
|
+
client.removeResourcesChangedListener(this.onResourcesChanged);
|
|
3983
|
+
client.removePromptsChangedListener(this.onPromptsChanged);
|
|
3984
|
+
}
|
|
1807
3985
|
onChildExit = (name, code, _signal) => {
|
|
1808
3986
|
const slot = this.servers.get(name);
|
|
1809
3987
|
if (!slot) return;
|
|
1810
3988
|
if (slot.lazy) {
|
|
1811
3989
|
slot.client = void 0;
|
|
1812
3990
|
slot.state = "dormant";
|
|
3991
|
+
this.recordFailure(slot, "transport", "process-exit-lazy");
|
|
1813
3992
|
this.events.emit("mcp.server.disconnected", {
|
|
1814
3993
|
name,
|
|
1815
3994
|
reason: `exit:${code ?? "unknown"} (dormant)`
|
|
@@ -1824,7 +4003,12 @@ var MCPRegistry = class _MCPRegistry {
|
|
|
1824
4003
|
}
|
|
1825
4004
|
slot.toolNames = [];
|
|
1826
4005
|
slot.lazyTools = [];
|
|
4006
|
+
slot.serverMetadata = void 0;
|
|
4007
|
+
slot.resources = void 0;
|
|
4008
|
+
slot.resourceTemplates = void 0;
|
|
4009
|
+
slot.prompts = void 0;
|
|
1827
4010
|
slot.state = "disconnected";
|
|
4011
|
+
this.recordFailure(slot, "transport", "process-exit");
|
|
1828
4012
|
this.events.emit("mcp.server.disconnected", { name, reason: `exit:${code ?? "unknown"}` });
|
|
1829
4013
|
this.scheduleReconnect(slot);
|
|
1830
4014
|
};
|
|
@@ -1835,6 +4019,7 @@ var MCPRegistry = class _MCPRegistry {
|
|
|
1835
4019
|
if (slot.lazy) {
|
|
1836
4020
|
slot.client = void 0;
|
|
1837
4021
|
slot.state = "dormant";
|
|
4022
|
+
this.recordFailure(slot, "transport", "http-disconnect-lazy");
|
|
1838
4023
|
this.events.emit("mcp.server.disconnected", { name, reason: "http-disconnect (dormant)" });
|
|
1839
4024
|
return;
|
|
1840
4025
|
}
|
|
@@ -1846,7 +4031,12 @@ var MCPRegistry = class _MCPRegistry {
|
|
|
1846
4031
|
}
|
|
1847
4032
|
slot.toolNames = [];
|
|
1848
4033
|
slot.lazyTools = [];
|
|
4034
|
+
slot.serverMetadata = void 0;
|
|
4035
|
+
slot.resources = void 0;
|
|
4036
|
+
slot.resourceTemplates = void 0;
|
|
4037
|
+
slot.prompts = void 0;
|
|
1849
4038
|
slot.state = "disconnected";
|
|
4039
|
+
this.recordFailure(slot, "transport", "http-disconnect");
|
|
1850
4040
|
this.events.emit("mcp.server.disconnected", { name, reason: "http-disconnect" });
|
|
1851
4041
|
this.scheduleReconnect(slot);
|
|
1852
4042
|
};
|
|
@@ -1865,6 +4055,7 @@ var MCPRegistry = class _MCPRegistry {
|
|
|
1865
4055
|
if (slot.reconnectPending) return;
|
|
1866
4056
|
if (slot.reconnectCycles >= _MCPRegistry.MAX_RECONNECT_CYCLES) {
|
|
1867
4057
|
slot.state = "failed";
|
|
4058
|
+
this.recordFailure(slot, "transport", "reconnect-exhausted");
|
|
1868
4059
|
this.log.error(
|
|
1869
4060
|
`MCP server "${slot.cfg.name}" giving up after ${slot.reconnectCycles} reconnect cycles. Use \`/mcp restart ${slot.cfg.name}\` to retry.`
|
|
1870
4061
|
);
|
|
@@ -1893,345 +4084,196 @@ var MCPRegistry = class _MCPRegistry {
|
|
|
1893
4084
|
async attemptReconnect(slot) {
|
|
1894
4085
|
slot.reconnectPending = false;
|
|
1895
4086
|
slot.reconnectCycles++;
|
|
4087
|
+
slot.operations.reconnectCount++;
|
|
4088
|
+
this.recordOperation(slot, "reconnect", "automatic");
|
|
1896
4089
|
await this.attemptConnect(slot);
|
|
1897
4090
|
}
|
|
1898
|
-
|
|
1899
|
-
const
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
attempt++;
|
|
1903
|
-
slot.state = attempt === 1 ? "connecting" : "reconnecting";
|
|
1904
|
-
slot.attempts = attempt;
|
|
1905
|
-
let client;
|
|
1906
|
-
let boundDisconnect;
|
|
1907
|
-
try {
|
|
1908
|
-
client = new MCPClient({
|
|
1909
|
-
name: slot.cfg.name,
|
|
1910
|
-
transport: slot.cfg.transport,
|
|
1911
|
-
command: slot.cfg.command,
|
|
1912
|
-
args: slot.cfg.args,
|
|
1913
|
-
env: slot.cfg.env,
|
|
1914
|
-
url: slot.cfg.url,
|
|
1915
|
-
headers: slot.cfg.headers,
|
|
1916
|
-
startupTimeoutMs: slot.cfg.startupTimeoutMs,
|
|
1917
|
-
requestTimeoutMs: slot.cfg.requestTimeoutMs,
|
|
1918
|
-
passthroughEnv: slot.cfg.passthroughEnv
|
|
1919
|
-
});
|
|
1920
|
-
if (slot.cfg.transport === "stdio") {
|
|
1921
|
-
client.addExitListener(this.onChildExit);
|
|
1922
|
-
} else {
|
|
1923
|
-
boundDisconnect = () => this.onTransportDisconnect(slot.cfg.name);
|
|
1924
|
-
client.addDisconnectListener(boundDisconnect);
|
|
1925
|
-
}
|
|
1926
|
-
client.addToolsChangedListener(this.onToolsChanged);
|
|
1927
|
-
await client.connect();
|
|
1928
|
-
if (slot.client && slot.client !== client) {
|
|
1929
|
-
const prior = slot.client;
|
|
1930
|
-
const priorDisconnect = slot.onDisconnect;
|
|
1931
|
-
slot.client.removeExitListener(this.onChildExit);
|
|
1932
|
-
if (priorDisconnect) prior.removeDisconnectListener(priorDisconnect);
|
|
1933
|
-
prior.removeToolsChangedListener(this.onToolsChanged);
|
|
1934
|
-
prior.close().catch(() => {
|
|
1935
|
-
});
|
|
1936
|
-
}
|
|
1937
|
-
slot.client = client;
|
|
1938
|
-
slot.onDisconnect = boundDisconnect;
|
|
1939
|
-
const isReconnect = attempt > 1;
|
|
1940
|
-
slot.state = "connected";
|
|
1941
|
-
slot.reconnectCycles = 0;
|
|
1942
|
-
const mc = client;
|
|
1943
|
-
const discovered = mc.listTools();
|
|
1944
|
-
if (slot.lazy && this.cacheDir) {
|
|
1945
|
-
await writeManifest(
|
|
1946
|
-
this.cacheDir,
|
|
1947
|
-
slot.cfg.name,
|
|
1948
|
-
manifestConfigHash(slot.cfg),
|
|
1949
|
-
discovered
|
|
1950
|
-
);
|
|
1951
|
-
}
|
|
1952
|
-
this.applyTools(slot, discovered, mc);
|
|
1953
|
-
slot.lastUsed = Date.now();
|
|
1954
|
-
if (slot.lazy) this.ensureIdleSweep();
|
|
1955
|
-
this.events.emit(isReconnect ? "mcp.server.reconnected" : "mcp.server.connected", {
|
|
1956
|
-
name: slot.cfg.name,
|
|
1957
|
-
toolCount: slot.toolNames.length
|
|
1958
|
-
});
|
|
1959
|
-
return;
|
|
1960
|
-
} catch (err) {
|
|
1961
|
-
this.log.warn(`MCP server "${slot.cfg.name}" connect attempt ${attempt} failed`, err);
|
|
1962
|
-
if (client) {
|
|
1963
|
-
client.removeExitListener(this.onChildExit);
|
|
1964
|
-
if (boundDisconnect) client.removeDisconnectListener(boundDisconnect);
|
|
1965
|
-
client.removeToolsChangedListener(this.onToolsChanged);
|
|
1966
|
-
await client.close().catch(() => {
|
|
1967
|
-
});
|
|
1968
|
-
}
|
|
1969
|
-
if (attempt >= MAX_ATTEMPTS) {
|
|
1970
|
-
this.log.error(
|
|
1971
|
-
`MCP server "${slot.cfg.name}" connect exhausted after ${MAX_ATTEMPTS} attempts`,
|
|
1972
|
-
err
|
|
1973
|
-
);
|
|
1974
|
-
slot.state = "failed";
|
|
1975
|
-
slot.client = void 0;
|
|
1976
|
-
if (slot.reconnectTimer) {
|
|
1977
|
-
clearTimeout(slot.reconnectTimer);
|
|
1978
|
-
slot.reconnectTimer = void 0;
|
|
1979
|
-
}
|
|
1980
|
-
slot.reconnectPending = false;
|
|
1981
|
-
this.events.emit("mcp.server.disconnected", {
|
|
1982
|
-
name: slot.cfg.name,
|
|
1983
|
-
reason: err instanceof Error ? err.message : "unknown"
|
|
1984
|
-
});
|
|
1985
|
-
return;
|
|
1986
|
-
}
|
|
1987
|
-
const delay = 500 * 2 ** attempt;
|
|
1988
|
-
await new Promise((r) => setTimeout(r, delay));
|
|
1989
|
-
}
|
|
1990
|
-
}
|
|
1991
|
-
}
|
|
1992
|
-
};
|
|
1993
|
-
|
|
1994
|
-
// src/manage.ts
|
|
1995
|
-
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
1996
|
-
import * as fs2 from "node:fs/promises";
|
|
1997
|
-
async function readConfig(path2) {
|
|
1998
|
-
try {
|
|
1999
|
-
return JSON.parse(await fs2.readFile(path2, "utf8"));
|
|
2000
|
-
} catch {
|
|
2001
|
-
return {};
|
|
2002
|
-
}
|
|
2003
|
-
}
|
|
2004
|
-
async function writeConfig(path2, cfg) {
|
|
2005
|
-
const raw = JSON.stringify(cfg, null, 2);
|
|
2006
|
-
const tmp = `${path2}.${process.pid}.${randomBytes2(6).toString("hex")}.tmp`;
|
|
2007
|
-
await fs2.writeFile(tmp, raw, "utf8");
|
|
2008
|
-
try {
|
|
2009
|
-
await fs2.rename(tmp, path2);
|
|
2010
|
-
} catch (err) {
|
|
2011
|
-
await fs2.rm(tmp, { force: true }).catch(() => void 0);
|
|
2012
|
-
throw err;
|
|
4091
|
+
recordSuccess(slot, resetFailures = true) {
|
|
4092
|
+
const operations = this.operationsFor(slot);
|
|
4093
|
+
operations.lastSuccessAt = Date.now();
|
|
4094
|
+
if (resetFailures) operations.consecutiveFailures = 0;
|
|
2013
4095
|
}
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
async function persist(configPath, full, servers) {
|
|
2024
|
-
full.mcpServers = servers;
|
|
2025
|
-
await writeConfig(configPath, full);
|
|
2026
|
-
}
|
|
2027
|
-
function normalizeTransport(t) {
|
|
2028
|
-
if (t === "sse") return "sse";
|
|
2029
|
-
if (t === "http" || t === "streamable-http") return "streamable-http";
|
|
2030
|
-
return "stdio";
|
|
2031
|
-
}
|
|
2032
|
-
function buildConfig(input, base) {
|
|
2033
|
-
const cfg = {
|
|
2034
|
-
name: input.name,
|
|
2035
|
-
transport: input.transport ? normalizeTransport(String(input.transport)) : base?.transport ?? "stdio"
|
|
2036
|
-
};
|
|
2037
|
-
const description = input.description ?? base?.description;
|
|
2038
|
-
if (description !== void 0) cfg.description = description;
|
|
2039
|
-
const command = input.command ?? base?.command;
|
|
2040
|
-
if (command !== void 0) cfg.command = command;
|
|
2041
|
-
const args = input.args ?? base?.args;
|
|
2042
|
-
if (args !== void 0) cfg.args = args;
|
|
2043
|
-
const env = input.env ?? base?.env;
|
|
2044
|
-
if (env !== void 0) cfg.env = env;
|
|
2045
|
-
const url = input.url ?? base?.url;
|
|
2046
|
-
if (url !== void 0) cfg.url = url;
|
|
2047
|
-
const headers = input.headers ?? base?.headers;
|
|
2048
|
-
if (headers !== void 0) cfg.headers = headers;
|
|
2049
|
-
const allowedTools = input.allowedTools ?? base?.allowedTools;
|
|
2050
|
-
if (allowedTools !== void 0) cfg.allowedTools = allowedTools;
|
|
2051
|
-
const permission = input.permission ?? base?.permission;
|
|
2052
|
-
if (permission !== void 0) cfg.permission = permission;
|
|
2053
|
-
const enabled = input.enabled ?? base?.enabled;
|
|
2054
|
-
if (enabled !== void 0) cfg.enabled = enabled;
|
|
2055
|
-
const lazy = input.lazy ?? base?.lazy;
|
|
2056
|
-
if (lazy !== void 0) cfg.lazy = lazy;
|
|
2057
|
-
const passthroughEnv = input.passthroughEnv ?? base?.passthroughEnv;
|
|
2058
|
-
if (passthroughEnv !== void 0) cfg.passthroughEnv = passthroughEnv;
|
|
2059
|
-
return cfg;
|
|
2060
|
-
}
|
|
2061
|
-
function projectServer(name, cfg, registry) {
|
|
2062
|
-
const live = registry.list().find((s) => s.name === name);
|
|
2063
|
-
const info = {
|
|
2064
|
-
name,
|
|
2065
|
-
transport: cfg.transport,
|
|
2066
|
-
enabled: cfg.enabled !== false,
|
|
2067
|
-
status: live ? live.state : "stopped",
|
|
2068
|
-
tools: live?.tools ?? []
|
|
2069
|
-
};
|
|
2070
|
-
if (cfg.description !== void 0) info.description = cfg.description;
|
|
2071
|
-
if (cfg.url !== void 0) info.url = cfg.url;
|
|
2072
|
-
if (cfg.command !== void 0) info.command = cfg.command;
|
|
2073
|
-
if (cfg.lazy !== void 0) info.lazy = cfg.lazy;
|
|
2074
|
-
return info;
|
|
2075
|
-
}
|
|
2076
|
-
function liveState(name, registry) {
|
|
2077
|
-
const live = registry.list().find((s) => s.name === name);
|
|
2078
|
-
return { state: live?.state ?? "stopped", tools: live?.tools ?? [] };
|
|
2079
|
-
}
|
|
2080
|
-
function errMessage(err) {
|
|
2081
|
-
return err instanceof Error ? err.message : String(err);
|
|
2082
|
-
}
|
|
2083
|
-
async function listMcp(deps) {
|
|
2084
|
-
const { servers } = await readServers(deps.configPath);
|
|
2085
|
-
return Object.entries(servers).map(
|
|
2086
|
-
([name, cfg]) => projectServer(name, { ...cfg, name }, deps.registry)
|
|
2087
|
-
);
|
|
2088
|
-
}
|
|
2089
|
-
async function addMcp(input, deps) {
|
|
2090
|
-
if (!input.name) return { ok: false, message: "Server name is required" };
|
|
2091
|
-
const { full, servers } = await readServers(deps.configPath);
|
|
2092
|
-
if (servers[input.name]) {
|
|
2093
|
-
return { ok: false, message: `Server "${input.name}" already exists` };
|
|
4096
|
+
recordFailure(slot, failureKind, reason, durationMs) {
|
|
4097
|
+
const operations = this.operationsFor(slot);
|
|
4098
|
+
const safeReason = safeOperationReason(reason);
|
|
4099
|
+
operations.lastFailureAt = Date.now();
|
|
4100
|
+
operations.lastFailureKind = failureKind;
|
|
4101
|
+
operations.lastReason = safeReason;
|
|
4102
|
+
operations.consecutiveFailures++;
|
|
4103
|
+
operations.failures[failureKind]++;
|
|
4104
|
+
this.recordOperation(slot, "failure", safeReason, failureKind, durationMs);
|
|
2094
4105
|
}
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
const
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
4106
|
+
recordOperation(slot, kind, reason, failureKind, durationMs, retain = true) {
|
|
4107
|
+
const operations = this.operationsFor(slot);
|
|
4108
|
+
const baseHealth = healthStateFor(slot.state, operations, slot.cfg.enabled !== false);
|
|
4109
|
+
const checks = evaluateHealthThresholds(operations, slot.cfg.health?.thresholds);
|
|
4110
|
+
const event = {
|
|
4111
|
+
serverName: slot.cfg.name,
|
|
4112
|
+
kind,
|
|
4113
|
+
at: Date.now(),
|
|
4114
|
+
connectionState: slot.state,
|
|
4115
|
+
healthState: applyHealthThresholds(baseHealth, checks)
|
|
2103
4116
|
};
|
|
4117
|
+
if (reason !== void 0) event.reason = safeOperationReason(reason);
|
|
4118
|
+
if (failureKind !== void 0) event.failureKind = failureKind;
|
|
4119
|
+
if (durationMs !== void 0) event.durationMs = Math.max(0, Math.round(durationMs));
|
|
4120
|
+
if (retain) {
|
|
4121
|
+
pushBounded(operations.recentEvents, event, MCP_OPERATION_LIMITS.RECENT_EVENTS);
|
|
4122
|
+
}
|
|
4123
|
+
for (const listener of this.operationListeners) {
|
|
4124
|
+
try {
|
|
4125
|
+
listener({ ...event });
|
|
4126
|
+
} catch {
|
|
4127
|
+
}
|
|
4128
|
+
}
|
|
2104
4129
|
}
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
return startServer(input.name, cfg, deps, `Server "${input.name}" added`);
|
|
2110
|
-
}
|
|
2111
|
-
return {
|
|
2112
|
-
ok: true,
|
|
2113
|
-
message: `Server "${input.name}" added (disabled)`,
|
|
2114
|
-
server: projectServer(input.name, cfg, deps.registry)
|
|
2115
|
-
};
|
|
2116
|
-
}
|
|
2117
|
-
async function updateMcp(input, deps) {
|
|
2118
|
-
if (!input.name) return { ok: false, message: "Server name is required" };
|
|
2119
|
-
const { full, servers } = await readServers(deps.configPath);
|
|
2120
|
-
const existing = servers[input.name];
|
|
2121
|
-
if (!existing) return { ok: false, message: `Server "${input.name}" not found` };
|
|
2122
|
-
const cfg = buildConfig(input, { ...existing, name: input.name });
|
|
2123
|
-
servers[input.name] = cfg;
|
|
2124
|
-
await persist(deps.configPath, full, servers);
|
|
2125
|
-
if (cfg.enabled !== false) {
|
|
2126
|
-
return startServer(input.name, cfg, deps, `Server "${input.name}" updated`, { restart: true });
|
|
4130
|
+
/** Keeps private-method unit fixtures from needing to duplicate every slot field. */
|
|
4131
|
+
operationsFor(slot) {
|
|
4132
|
+
if (!slot.operations) slot.operations = createMCPServerOperationState();
|
|
4133
|
+
return slot.operations;
|
|
2127
4134
|
}
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
4135
|
+
async attemptConnect(slot) {
|
|
4136
|
+
const MAX_ATTEMPTS = MCP_CONSTANTS.RECONNECT.MAX_ATTEMPTS;
|
|
4137
|
+
let attempt = 0;
|
|
4138
|
+
while (attempt < MAX_ATTEMPTS) {
|
|
4139
|
+
attempt++;
|
|
4140
|
+
const startedAt = Date.now();
|
|
4141
|
+
slot.state = attempt === 1 ? "connecting" : "reconnecting";
|
|
4142
|
+
slot.attempts = attempt;
|
|
4143
|
+
let client;
|
|
4144
|
+
let boundDisconnect;
|
|
4145
|
+
try {
|
|
4146
|
+
client = new MCPClient({
|
|
4147
|
+
name: slot.cfg.name,
|
|
4148
|
+
transport: slot.cfg.transport,
|
|
4149
|
+
command: slot.cfg.command,
|
|
4150
|
+
args: slot.cfg.args,
|
|
4151
|
+
env: slot.cfg.env,
|
|
4152
|
+
url: slot.cfg.url,
|
|
4153
|
+
headers: slot.cfg.headers,
|
|
4154
|
+
startupTimeoutMs: slot.cfg.startupTimeoutMs,
|
|
4155
|
+
requestTimeoutMs: slot.cfg.requestTimeoutMs,
|
|
4156
|
+
passthroughEnv: slot.cfg.passthroughEnv,
|
|
4157
|
+
authorizationProvider: this.authorizationProviderFactory?.(slot.cfg)
|
|
4158
|
+
});
|
|
4159
|
+
if (slot.cfg.transport === "stdio") {
|
|
4160
|
+
client.addExitListener(this.onChildExit);
|
|
4161
|
+
} else {
|
|
4162
|
+
boundDisconnect = () => this.onTransportDisconnect(slot.cfg.name);
|
|
4163
|
+
client.addDisconnectListener(boundDisconnect);
|
|
4164
|
+
}
|
|
4165
|
+
client.addToolsChangedListener(this.onToolsChanged);
|
|
4166
|
+
this.addCatalogListeners(client);
|
|
4167
|
+
await client.connect();
|
|
4168
|
+
if (slot.client && slot.client !== client) {
|
|
4169
|
+
const prior = slot.client;
|
|
4170
|
+
const priorDisconnect = slot.onDisconnect;
|
|
4171
|
+
slot.client.removeExitListener(this.onChildExit);
|
|
4172
|
+
if (priorDisconnect) prior.removeDisconnectListener(priorDisconnect);
|
|
4173
|
+
prior.removeToolsChangedListener(this.onToolsChanged);
|
|
4174
|
+
this.removeCatalogListeners(prior);
|
|
4175
|
+
prior.close().catch(() => {
|
|
4176
|
+
});
|
|
4177
|
+
}
|
|
4178
|
+
slot.client = client;
|
|
4179
|
+
slot.onDisconnect = boundDisconnect;
|
|
4180
|
+
const isReconnect = slot.reconnectCycles > 0 || attempt > 1;
|
|
4181
|
+
slot.state = "connected";
|
|
4182
|
+
slot.reconnectCycles = 0;
|
|
4183
|
+
const mc = client;
|
|
4184
|
+
const discovered = mc.listTools();
|
|
4185
|
+
await this.discoverCapabilities(slot, mc);
|
|
4186
|
+
await this.persistCapabilityManifest(slot);
|
|
4187
|
+
this.applyTools(slot, discovered, mc);
|
|
4188
|
+
const durationMs = Date.now() - startedAt;
|
|
4189
|
+
pushBounded(
|
|
4190
|
+
slot.operations.connectionSamples,
|
|
4191
|
+
durationMs,
|
|
4192
|
+
MCP_OPERATION_LIMITS.LATENCY_SAMPLES
|
|
4193
|
+
);
|
|
4194
|
+
this.recordSuccess(slot, (slot.operations.lastFailureAt ?? 0) < startedAt);
|
|
4195
|
+
this.recordOperation(
|
|
4196
|
+
slot,
|
|
4197
|
+
isReconnect ? "reconnect" : "connect",
|
|
4198
|
+
"connected",
|
|
4199
|
+
void 0,
|
|
4200
|
+
durationMs
|
|
4201
|
+
);
|
|
4202
|
+
slot.lastUsed = Date.now();
|
|
4203
|
+
if (slot.lazy) this.ensureIdleSweep();
|
|
4204
|
+
this.events.emit(isReconnect ? "mcp.server.reconnected" : "mcp.server.connected", {
|
|
4205
|
+
name: slot.cfg.name,
|
|
4206
|
+
toolCount: slot.toolNames.length
|
|
4207
|
+
});
|
|
4208
|
+
return;
|
|
4209
|
+
} catch (err) {
|
|
4210
|
+
this.recordFailure(slot, "transport", "connect-attempt-failed", Date.now() - startedAt);
|
|
4211
|
+
this.log.warn(`MCP server "${slot.cfg.name}" connect attempt ${attempt} failed`, err);
|
|
4212
|
+
if (client) {
|
|
4213
|
+
client.removeExitListener(this.onChildExit);
|
|
4214
|
+
if (boundDisconnect) client.removeDisconnectListener(boundDisconnect);
|
|
4215
|
+
client.removeToolsChangedListener(this.onToolsChanged);
|
|
4216
|
+
this.removeCatalogListeners(client);
|
|
4217
|
+
await client.close().catch(() => {
|
|
4218
|
+
});
|
|
4219
|
+
}
|
|
4220
|
+
if (attempt >= MAX_ATTEMPTS) {
|
|
4221
|
+
this.log.error(
|
|
4222
|
+
`MCP server "${slot.cfg.name}" connect exhausted after ${MAX_ATTEMPTS} attempts`,
|
|
4223
|
+
err
|
|
4224
|
+
);
|
|
4225
|
+
slot.state = "failed";
|
|
4226
|
+
slot.client = void 0;
|
|
4227
|
+
if (slot.reconnectTimer) {
|
|
4228
|
+
clearTimeout(slot.reconnectTimer);
|
|
4229
|
+
slot.reconnectTimer = void 0;
|
|
4230
|
+
}
|
|
4231
|
+
slot.reconnectPending = false;
|
|
4232
|
+
this.events.emit("mcp.server.disconnected", {
|
|
4233
|
+
name: slot.cfg.name,
|
|
4234
|
+
reason: err instanceof Error ? err.message : "unknown"
|
|
4235
|
+
});
|
|
4236
|
+
return;
|
|
4237
|
+
}
|
|
4238
|
+
const delay = 500 * 2 ** attempt;
|
|
4239
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
4240
|
+
}
|
|
4241
|
+
}
|
|
2150
4242
|
}
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
servers[name] = cfg;
|
|
2164
|
-
await persist(deps.configPath, full, servers);
|
|
2165
|
-
return {
|
|
2166
|
-
ok: true,
|
|
2167
|
-
message: `Server "${name}" disabled`,
|
|
2168
|
-
server: projectServer(name, cfg, deps.registry)
|
|
2169
|
-
};
|
|
2170
|
-
}
|
|
2171
|
-
async function restartMcp(name, deps) {
|
|
2172
|
-
if (!name) return { ok: false, message: "Server name is required" };
|
|
2173
|
-
const registered = deps.registry.list().some((s) => s.name === name);
|
|
2174
|
-
if (registered) {
|
|
2175
|
-
try {
|
|
2176
|
-
await deps.registry.restart(name);
|
|
2177
|
-
const { state, tools } = liveState(name, deps.registry);
|
|
2178
|
-
return { ok: true, message: `Server "${name}" restarted`, state, tools };
|
|
2179
|
-
} catch (err) {
|
|
2180
|
-
return { ok: false, message: `Failed to restart "${name}": ${errMessage(err)}` };
|
|
4243
|
+
};
|
|
4244
|
+
var MAX_CATALOG_PAGES = 100;
|
|
4245
|
+
var MAX_CATALOG_ITEMS = 1e4;
|
|
4246
|
+
async function collectPages(load, select) {
|
|
4247
|
+
const items = [];
|
|
4248
|
+
const seenCursors = /* @__PURE__ */ new Set();
|
|
4249
|
+
let cursor;
|
|
4250
|
+
for (let pageNumber = 0; pageNumber < MAX_CATALOG_PAGES; pageNumber++) {
|
|
4251
|
+
const page = await load(cursor);
|
|
4252
|
+
items.push(...select(page));
|
|
4253
|
+
if (items.length > MAX_CATALOG_ITEMS) {
|
|
4254
|
+
throw new Error(`MCP catalog exceeds ${MAX_CATALOG_ITEMS} items`);
|
|
2181
4255
|
}
|
|
4256
|
+
const next = page.nextCursor;
|
|
4257
|
+
if (!next) return items;
|
|
4258
|
+
if (seenCursors.has(next)) throw new Error(`MCP catalog repeated cursor "${next}"`);
|
|
4259
|
+
seenCursors.add(next);
|
|
4260
|
+
cursor = next;
|
|
2182
4261
|
}
|
|
2183
|
-
|
|
2184
|
-
const cfg = servers[name];
|
|
2185
|
-
if (!cfg) return { ok: false, message: `Server "${name}" is not in config.` };
|
|
2186
|
-
return startServer(name, { ...cfg, name }, deps, `Server "${name}" started`, { restart: true });
|
|
4262
|
+
throw new Error(`MCP catalog exceeds ${MAX_CATALOG_PAGES} pages`);
|
|
2187
4263
|
}
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
const { state, tools } = liveState(name, deps.registry);
|
|
4264
|
+
function cloneRecords(records) {
|
|
4265
|
+
return structuredClone(records);
|
|
4266
|
+
}
|
|
4267
|
+
function catalogSnapshot(slot) {
|
|
2193
4268
|
return {
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
4269
|
+
name: slot.cfg.name,
|
|
4270
|
+
state: slot.state,
|
|
4271
|
+
serverMetadata: slot.serverMetadata ? structuredClone(slot.serverMetadata) : void 0,
|
|
4272
|
+
resources: slot.resources ? cloneRecords(slot.resources) : void 0,
|
|
4273
|
+
resourceTemplates: slot.resourceTemplates ? cloneRecords(slot.resourceTemplates) : void 0,
|
|
4274
|
+
prompts: slot.prompts ? cloneRecords(slot.prompts) : void 0
|
|
2198
4275
|
};
|
|
2199
4276
|
}
|
|
2200
|
-
async function startServer(name, cfg, deps, okMessage, opts) {
|
|
2201
|
-
try {
|
|
2202
|
-
const alreadyRegistered = deps.registry.list().some((s) => s.name === name);
|
|
2203
|
-
if (alreadyRegistered && opts?.restart) {
|
|
2204
|
-
await deps.registry.restart(name);
|
|
2205
|
-
} else if (alreadyRegistered) {
|
|
2206
|
-
await deps.registry.restart(name);
|
|
2207
|
-
} else {
|
|
2208
|
-
await deps.registry.start({ ...cfg, enabled: true });
|
|
2209
|
-
}
|
|
2210
|
-
const { state, tools } = liveState(name, deps.registry);
|
|
2211
|
-
return {
|
|
2212
|
-
ok: true,
|
|
2213
|
-
message: okMessage,
|
|
2214
|
-
server: projectServer(name, cfg, deps.registry),
|
|
2215
|
-
state,
|
|
2216
|
-
tools
|
|
2217
|
-
};
|
|
2218
|
-
} catch (err) {
|
|
2219
|
-
const message = errMessage(err);
|
|
2220
|
-
return {
|
|
2221
|
-
ok: true,
|
|
2222
|
-
// config persisted — surface a soft warning, not a hard failure
|
|
2223
|
-
message: `${okMessage} in config, but failed to start: ${message}`,
|
|
2224
|
-
server: projectServer(name, cfg, deps.registry),
|
|
2225
|
-
registryError: message
|
|
2226
|
-
};
|
|
2227
|
-
}
|
|
2228
|
-
}
|
|
2229
|
-
async function safeStop(name, deps) {
|
|
2230
|
-
try {
|
|
2231
|
-
await deps.registry.stop(name);
|
|
2232
|
-
} catch {
|
|
2233
|
-
}
|
|
2234
|
-
}
|
|
2235
4277
|
|
|
2236
4278
|
// src/server.ts
|
|
2237
4279
|
import { createServer } from "node:http";
|
|
@@ -2245,6 +4287,8 @@ var MCPServer = class {
|
|
|
2245
4287
|
host;
|
|
2246
4288
|
serverInfo;
|
|
2247
4289
|
logger;
|
|
4290
|
+
resources;
|
|
4291
|
+
prompts;
|
|
2248
4292
|
constructor(opts) {
|
|
2249
4293
|
this.host = opts.host;
|
|
2250
4294
|
this.serverInfo = opts.serverInfo ?? {
|
|
@@ -2252,6 +4296,8 @@ var MCPServer = class {
|
|
|
2252
4296
|
version: MCP_CONSTANTS.CLIENT_INFO.version
|
|
2253
4297
|
};
|
|
2254
4298
|
this.logger = opts.logger;
|
|
4299
|
+
this.resources = structuredClone(opts.resources ?? []);
|
|
4300
|
+
this.prompts = structuredClone(opts.prompts ?? []);
|
|
2255
4301
|
}
|
|
2256
4302
|
/**
|
|
2257
4303
|
* Handle one raw JSON-RPC line. Returns the response JSON string for
|
|
@@ -2296,7 +4342,11 @@ var MCPServer = class {
|
|
|
2296
4342
|
case "initialize":
|
|
2297
4343
|
return {
|
|
2298
4344
|
protocolVersion: MCP_CONSTANTS.PROTOCOL_VERSION,
|
|
2299
|
-
capabilities: {
|
|
4345
|
+
capabilities: {
|
|
4346
|
+
tools: { listChanged: false },
|
|
4347
|
+
...this.resources.length > 0 ? { resources: { subscribe: false, listChanged: false } } : {},
|
|
4348
|
+
...this.prompts.length > 0 ? { prompts: { listChanged: false } } : {}
|
|
4349
|
+
},
|
|
2300
4350
|
serverInfo: this.serverInfo
|
|
2301
4351
|
};
|
|
2302
4352
|
case "ping":
|
|
@@ -2314,6 +4364,54 @@ var MCPServer = class {
|
|
|
2314
4364
|
const res = await this.host.callTool(p.name, args);
|
|
2315
4365
|
return { content: toContentBlocks(res.content), isError: res.isError };
|
|
2316
4366
|
}
|
|
4367
|
+
case "resources/list": {
|
|
4368
|
+
if (this.resources.length === 0) return METHOD_NOT_FOUND_SENTINEL;
|
|
4369
|
+
const page = paginate(this.resources, params);
|
|
4370
|
+
return {
|
|
4371
|
+
resources: page.items.map(({ contents: _contents, ...resource }) => resource),
|
|
4372
|
+
...page.nextCursor ? { nextCursor: page.nextCursor } : {}
|
|
4373
|
+
};
|
|
4374
|
+
}
|
|
4375
|
+
case "resources/templates/list":
|
|
4376
|
+
if (this.resources.length === 0) return METHOD_NOT_FOUND_SENTINEL;
|
|
4377
|
+
return { resourceTemplates: [] };
|
|
4378
|
+
case "resources/read": {
|
|
4379
|
+
if (this.resources.length === 0) return METHOD_NOT_FOUND_SENTINEL;
|
|
4380
|
+
const uri = requiredParamString(params, "uri", "resources/read");
|
|
4381
|
+
const resource = this.resources.find((candidate) => candidate.uri === uri);
|
|
4382
|
+
if (!resource) throw new Error(`Resource not found: ${uri}`);
|
|
4383
|
+
return { contents: structuredClone(resource.contents) };
|
|
4384
|
+
}
|
|
4385
|
+
case "prompts/list": {
|
|
4386
|
+
if (this.prompts.length === 0) return METHOD_NOT_FOUND_SENTINEL;
|
|
4387
|
+
const page = paginate(this.prompts, params);
|
|
4388
|
+
return {
|
|
4389
|
+
prompts: page.items.map(
|
|
4390
|
+
({ messages: _messages, template: _template, ...prompt }) => prompt
|
|
4391
|
+
),
|
|
4392
|
+
...page.nextCursor ? { nextCursor: page.nextCursor } : {}
|
|
4393
|
+
};
|
|
4394
|
+
}
|
|
4395
|
+
case "prompts/get": {
|
|
4396
|
+
if (this.prompts.length === 0) return METHOD_NOT_FOUND_SENTINEL;
|
|
4397
|
+
const name = requiredParamString(params, "name", "prompts/get");
|
|
4398
|
+
const prompt = this.prompts.find((candidate) => candidate.name === name);
|
|
4399
|
+
if (!prompt) throw new Error(`Prompt not found: ${name}`);
|
|
4400
|
+
const input = paramsRecord(params);
|
|
4401
|
+
const args = stringRecord(input["arguments"], "prompts/get arguments");
|
|
4402
|
+
for (const argument of prompt.arguments ?? []) {
|
|
4403
|
+
if (argument.required && args[argument.name] === void 0) {
|
|
4404
|
+
throw new Error(`Prompt "${name}" requires argument "${argument.name}"`);
|
|
4405
|
+
}
|
|
4406
|
+
}
|
|
4407
|
+
const messages = prompt.template ? [
|
|
4408
|
+
{
|
|
4409
|
+
role: "user",
|
|
4410
|
+
content: { type: "text", text: renderPromptTemplate(prompt.template, args) }
|
|
4411
|
+
}
|
|
4412
|
+
] : structuredClone(prompt.messages ?? []);
|
|
4413
|
+
return { description: prompt.description, messages };
|
|
4414
|
+
}
|
|
2317
4415
|
default:
|
|
2318
4416
|
return METHOD_NOT_FOUND_SENTINEL;
|
|
2319
4417
|
}
|
|
@@ -2322,6 +4420,53 @@ var MCPServer = class {
|
|
|
2322
4420
|
return JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } });
|
|
2323
4421
|
}
|
|
2324
4422
|
};
|
|
4423
|
+
var SERVER_PAGE_SIZE = 100;
|
|
4424
|
+
function paginate(items, params) {
|
|
4425
|
+
const cursor = paramsRecord(params)["cursor"];
|
|
4426
|
+
let offset = 0;
|
|
4427
|
+
if (cursor !== void 0) {
|
|
4428
|
+
if (typeof cursor !== "string" || !/^\d+$/.test(cursor)) {
|
|
4429
|
+
throw new Error("MCP pagination cursor must be a non-negative integer string");
|
|
4430
|
+
}
|
|
4431
|
+
offset = Number(cursor);
|
|
4432
|
+
if (!Number.isSafeInteger(offset)) throw new Error("MCP pagination cursor is too large");
|
|
4433
|
+
}
|
|
4434
|
+
const page = items.slice(offset, offset + SERVER_PAGE_SIZE);
|
|
4435
|
+
const next = offset + page.length;
|
|
4436
|
+
return {
|
|
4437
|
+
items: page,
|
|
4438
|
+
...next < items.length ? { nextCursor: String(next) } : {}
|
|
4439
|
+
};
|
|
4440
|
+
}
|
|
4441
|
+
function paramsRecord(params) {
|
|
4442
|
+
return params && typeof params === "object" && !Array.isArray(params) ? params : {};
|
|
4443
|
+
}
|
|
4444
|
+
function requiredParamString(params, field, method) {
|
|
4445
|
+
const value = paramsRecord(params)[field];
|
|
4446
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
4447
|
+
throw new Error(`${method} requires a non-empty string "${field}"`);
|
|
4448
|
+
}
|
|
4449
|
+
return value;
|
|
4450
|
+
}
|
|
4451
|
+
function stringRecord(value, label) {
|
|
4452
|
+
if (value === void 0) return {};
|
|
4453
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
4454
|
+
throw new Error(`${label} must be an object`);
|
|
4455
|
+
}
|
|
4456
|
+
const result = {};
|
|
4457
|
+
for (const [key, item] of Object.entries(value)) {
|
|
4458
|
+
if (typeof item !== "string") throw new Error(`${label}.${key} must be a string`);
|
|
4459
|
+
result[key] = item;
|
|
4460
|
+
}
|
|
4461
|
+
return result;
|
|
4462
|
+
}
|
|
4463
|
+
function renderPromptTemplate(template, args) {
|
|
4464
|
+
return template.replace(/\{\{([A-Za-z_][A-Za-z0-9_.-]*)\}\}/g, (_match, name) => {
|
|
4465
|
+
const value = args[name];
|
|
4466
|
+
if (value === void 0) throw new Error(`Missing prompt template argument "${name}"`);
|
|
4467
|
+
return value;
|
|
4468
|
+
});
|
|
4469
|
+
}
|
|
2325
4470
|
var METHOD_NOT_FOUND_SENTINEL = /* @__PURE__ */ Symbol("method-not-found");
|
|
2326
4471
|
function toContentBlocks(content) {
|
|
2327
4472
|
if (typeof content === "string") return [{ type: "text", text: content }];
|
|
@@ -2512,28 +4657,349 @@ async function handleHttpRequest(server, req, res, token, log) {
|
|
|
2512
4657
|
});
|
|
2513
4658
|
});
|
|
2514
4659
|
}
|
|
4660
|
+
|
|
4661
|
+
// src/token-store.ts
|
|
4662
|
+
import * as fs3 from "node:fs/promises";
|
|
4663
|
+
import { atomicWrite, withFileLock } from "@wrongstack/core/utils";
|
|
4664
|
+
var TOKEN_STORE_VERSION = 1;
|
|
4665
|
+
var MAX_STORE_BYTES = 1024 * 1024;
|
|
4666
|
+
var MAX_ENTRIES = 256;
|
|
4667
|
+
var DEFAULT_REFRESH_SKEW_MS = 6e4;
|
|
4668
|
+
var MCPVaultTokenStore = class {
|
|
4669
|
+
constructor(filePath, vault) {
|
|
4670
|
+
this.filePath = filePath;
|
|
4671
|
+
this.vault = vault;
|
|
4672
|
+
}
|
|
4673
|
+
filePath;
|
|
4674
|
+
vault;
|
|
4675
|
+
async load(serverName, resource) {
|
|
4676
|
+
const canonicalResource = canonicalMcpResource(resource);
|
|
4677
|
+
return withFileLock(this.filePath, async () => {
|
|
4678
|
+
const file = await this.readFile();
|
|
4679
|
+
const entry = file.entries.find(
|
|
4680
|
+
(candidate) => candidate.serverName === serverName && candidate.resource === canonicalResource
|
|
4681
|
+
);
|
|
4682
|
+
return entry ? this.decryptEntry(entry) : void 0;
|
|
4683
|
+
});
|
|
4684
|
+
}
|
|
4685
|
+
async save(value) {
|
|
4686
|
+
const normalized = normalizeStoredAuthorization(value);
|
|
4687
|
+
await withFileLock(this.filePath, async () => {
|
|
4688
|
+
const file = await this.readFile();
|
|
4689
|
+
const next = file.entries.filter(
|
|
4690
|
+
(entry) => !(entry.serverName === normalized.serverName && entry.resource === normalized.resource)
|
|
4691
|
+
);
|
|
4692
|
+
next.push(this.encryptEntry(normalized));
|
|
4693
|
+
if (next.length > MAX_ENTRIES)
|
|
4694
|
+
throw new Error(`MCP token store exceeds ${MAX_ENTRIES} entries`);
|
|
4695
|
+
await this.writeFile(next);
|
|
4696
|
+
});
|
|
4697
|
+
}
|
|
4698
|
+
async remove(serverName, resource) {
|
|
4699
|
+
const canonicalResource = canonicalMcpResource(resource);
|
|
4700
|
+
return withFileLock(this.filePath, async () => {
|
|
4701
|
+
const file = await this.readFile();
|
|
4702
|
+
const next = file.entries.filter(
|
|
4703
|
+
(entry) => !(entry.serverName === serverName && entry.resource === canonicalResource)
|
|
4704
|
+
);
|
|
4705
|
+
if (next.length === file.entries.length) return false;
|
|
4706
|
+
await this.writeFile(next);
|
|
4707
|
+
return true;
|
|
4708
|
+
});
|
|
4709
|
+
}
|
|
4710
|
+
async readFile() {
|
|
4711
|
+
let raw;
|
|
4712
|
+
try {
|
|
4713
|
+
const stat2 = await fs3.stat(this.filePath);
|
|
4714
|
+
if (stat2.size > MAX_STORE_BYTES) throw new Error("MCP token store exceeds size limit");
|
|
4715
|
+
raw = await fs3.readFile(this.filePath, "utf8");
|
|
4716
|
+
} catch (error) {
|
|
4717
|
+
if (error.code === "ENOENT") return emptyFile();
|
|
4718
|
+
throw error;
|
|
4719
|
+
}
|
|
4720
|
+
let parsed;
|
|
4721
|
+
try {
|
|
4722
|
+
parsed = JSON.parse(raw);
|
|
4723
|
+
} catch {
|
|
4724
|
+
throw new Error("MCP token store is not valid JSON");
|
|
4725
|
+
}
|
|
4726
|
+
return validateStoreFile(parsed);
|
|
4727
|
+
}
|
|
4728
|
+
async writeFile(entries) {
|
|
4729
|
+
const file = {
|
|
4730
|
+
version: TOKEN_STORE_VERSION,
|
|
4731
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4732
|
+
entries
|
|
4733
|
+
};
|
|
4734
|
+
await atomicWrite(this.filePath, `${JSON.stringify(file, null, 2)}
|
|
4735
|
+
`, { mode: 384 });
|
|
4736
|
+
}
|
|
4737
|
+
encryptEntry(value) {
|
|
4738
|
+
const accessToken = this.vault.encrypt(value.tokenSet.accessToken);
|
|
4739
|
+
const refreshToken = value.tokenSet.refreshToken ? this.vault.encrypt(value.tokenSet.refreshToken) : void 0;
|
|
4740
|
+
if (!this.vault.isEncrypted(accessToken) || refreshToken && !this.vault.isEncrypted(refreshToken)) {
|
|
4741
|
+
throw new Error("MCP token store requires an encrypting SecretVault");
|
|
4742
|
+
}
|
|
4743
|
+
return {
|
|
4744
|
+
serverName: value.serverName,
|
|
4745
|
+
resource: value.resource,
|
|
4746
|
+
clientId: value.clientId,
|
|
4747
|
+
authorizationServer: value.authorizationServer,
|
|
4748
|
+
accessToken,
|
|
4749
|
+
refreshToken,
|
|
4750
|
+
tokenType: value.tokenSet.tokenType ?? "Bearer",
|
|
4751
|
+
expiresAt: value.tokenSet.expiresAt,
|
|
4752
|
+
scopes: [...value.tokenSet.scopes ?? []],
|
|
4753
|
+
updatedAt: value.updatedAt
|
|
4754
|
+
};
|
|
4755
|
+
}
|
|
4756
|
+
decryptEntry(entry) {
|
|
4757
|
+
if (!this.vault.isEncrypted(entry.accessToken) || entry.refreshToken !== void 0 && !this.vault.isEncrypted(entry.refreshToken)) {
|
|
4758
|
+
throw new Error("MCP token store contains an unencrypted token");
|
|
4759
|
+
}
|
|
4760
|
+
const value = {
|
|
4761
|
+
serverName: entry.serverName,
|
|
4762
|
+
resource: entry.resource,
|
|
4763
|
+
clientId: entry.clientId,
|
|
4764
|
+
authorizationServer: entry.authorizationServer,
|
|
4765
|
+
tokenSet: {
|
|
4766
|
+
accessToken: this.vault.decrypt(entry.accessToken),
|
|
4767
|
+
refreshToken: entry.refreshToken ? this.vault.decrypt(entry.refreshToken) : void 0,
|
|
4768
|
+
tokenType: entry.tokenType,
|
|
4769
|
+
resource: entry.resource,
|
|
4770
|
+
expiresAt: entry.expiresAt,
|
|
4771
|
+
scopes: [...entry.scopes]
|
|
4772
|
+
},
|
|
4773
|
+
updatedAt: entry.updatedAt
|
|
4774
|
+
};
|
|
4775
|
+
return normalizeStoredAuthorization(value);
|
|
4776
|
+
}
|
|
4777
|
+
};
|
|
4778
|
+
var MCPRefreshingAuthorizationProvider = class {
|
|
4779
|
+
constructor(options) {
|
|
4780
|
+
this.options = options;
|
|
4781
|
+
this.resource = canonicalMcpResource(options.resource);
|
|
4782
|
+
this.refreshSkewMs = options.refreshSkewMs ?? DEFAULT_REFRESH_SKEW_MS;
|
|
4783
|
+
}
|
|
4784
|
+
options;
|
|
4785
|
+
refreshPromise;
|
|
4786
|
+
resource;
|
|
4787
|
+
refreshSkewMs;
|
|
4788
|
+
async getAccessToken(context) {
|
|
4789
|
+
this.assertContext(context);
|
|
4790
|
+
let state = await this.options.store.load(this.options.serverName, this.resource);
|
|
4791
|
+
if (!state) return void 0;
|
|
4792
|
+
if (state.tokenSet.expiresAt !== void 0 && state.tokenSet.expiresAt <= Date.now() + this.refreshSkewMs) {
|
|
4793
|
+
state = await this.refresh(state, context.signal);
|
|
4794
|
+
}
|
|
4795
|
+
if (!state) return void 0;
|
|
4796
|
+
if (state.tokenSet.expiresAt !== void 0 && state.tokenSet.expiresAt <= Date.now()) {
|
|
4797
|
+
this.emit("reauth_required", state);
|
|
4798
|
+
return void 0;
|
|
4799
|
+
}
|
|
4800
|
+
authorizationHeaderForToken(state.tokenSet, this.resource);
|
|
4801
|
+
return { ...state.tokenSet, scopes: [...state.tokenSet.scopes ?? []] };
|
|
4802
|
+
}
|
|
4803
|
+
async handleUnauthorized(challenge, context) {
|
|
4804
|
+
this.assertContext(context);
|
|
4805
|
+
if (challenge.resource !== this.resource) return false;
|
|
4806
|
+
const state = await this.options.store.load(this.options.serverName, this.resource);
|
|
4807
|
+
if (!state?.tokenSet.refreshToken) {
|
|
4808
|
+
if (state) this.emit("reauth_required", state);
|
|
4809
|
+
return false;
|
|
4810
|
+
}
|
|
4811
|
+
return await this.refresh(state, context.signal) !== void 0;
|
|
4812
|
+
}
|
|
4813
|
+
refresh(state, signal) {
|
|
4814
|
+
if (this.refreshPromise) return this.refreshPromise;
|
|
4815
|
+
this.refreshPromise = this.refreshInner(state, signal).finally(() => {
|
|
4816
|
+
this.refreshPromise = void 0;
|
|
4817
|
+
});
|
|
4818
|
+
return this.refreshPromise;
|
|
4819
|
+
}
|
|
4820
|
+
async refreshInner(state, signal) {
|
|
4821
|
+
const refreshToken = state.tokenSet.refreshToken;
|
|
4822
|
+
if (!refreshToken) {
|
|
4823
|
+
this.emit("reauth_required", state);
|
|
4824
|
+
return void 0;
|
|
4825
|
+
}
|
|
4826
|
+
const tokenSet = await refreshMcpAccessToken({
|
|
4827
|
+
authorizationServer: state.authorizationServer,
|
|
4828
|
+
clientId: state.clientId,
|
|
4829
|
+
resource: state.resource,
|
|
4830
|
+
refreshToken,
|
|
4831
|
+
signal
|
|
4832
|
+
});
|
|
4833
|
+
const next = normalizeStoredAuthorization({
|
|
4834
|
+
...state,
|
|
4835
|
+
tokenSet,
|
|
4836
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4837
|
+
});
|
|
4838
|
+
await this.options.store.save(next);
|
|
4839
|
+
this.emit("refreshed", next);
|
|
4840
|
+
return next;
|
|
4841
|
+
}
|
|
4842
|
+
assertContext(context) {
|
|
4843
|
+
if (context.serverName !== this.options.serverName || context.resource !== this.resource) {
|
|
4844
|
+
throw new Error("MCP authorization provider context does not match its server/resource");
|
|
4845
|
+
}
|
|
4846
|
+
}
|
|
4847
|
+
emit(state, value) {
|
|
4848
|
+
this.options.onStateChange?.({
|
|
4849
|
+
serverName: value.serverName,
|
|
4850
|
+
state,
|
|
4851
|
+
resource: value.resource,
|
|
4852
|
+
expiresAt: value.tokenSet.expiresAt,
|
|
4853
|
+
scopes: [...value.tokenSet.scopes ?? []]
|
|
4854
|
+
});
|
|
4855
|
+
}
|
|
4856
|
+
};
|
|
4857
|
+
function createVaultBackedMcpAuthorizationProviderFactory(options) {
|
|
4858
|
+
const providers = /* @__PURE__ */ new Map();
|
|
4859
|
+
return (server) => {
|
|
4860
|
+
if (server.transport === "stdio" || !server.url) return void 0;
|
|
4861
|
+
const resource = canonicalMcpResource(server.url);
|
|
4862
|
+
const key = `${server.name}\0${resource}`;
|
|
4863
|
+
let provider = providers.get(key);
|
|
4864
|
+
if (!provider) {
|
|
4865
|
+
provider = new MCPRefreshingAuthorizationProvider({
|
|
4866
|
+
serverName: server.name,
|
|
4867
|
+
resource,
|
|
4868
|
+
store: options.store,
|
|
4869
|
+
refreshSkewMs: options.refreshSkewMs,
|
|
4870
|
+
onStateChange: options.onStateChange
|
|
4871
|
+
});
|
|
4872
|
+
providers.set(key, provider);
|
|
4873
|
+
}
|
|
4874
|
+
return provider;
|
|
4875
|
+
};
|
|
4876
|
+
}
|
|
4877
|
+
function emptyFile() {
|
|
4878
|
+
return { version: TOKEN_STORE_VERSION, updatedAt: (/* @__PURE__ */ new Date(0)).toISOString(), entries: [] };
|
|
4879
|
+
}
|
|
4880
|
+
function validateStoreFile(value) {
|
|
4881
|
+
if (!isRecord(value) || value["version"] !== TOKEN_STORE_VERSION || !Array.isArray(value["entries"])) {
|
|
4882
|
+
throw new Error("MCP token store has an unsupported or malformed structure");
|
|
4883
|
+
}
|
|
4884
|
+
if (value["entries"].length > MAX_ENTRIES)
|
|
4885
|
+
throw new Error("MCP token store has too many entries");
|
|
4886
|
+
return {
|
|
4887
|
+
version: TOKEN_STORE_VERSION,
|
|
4888
|
+
updatedAt: boundedString(value["updatedAt"], "updatedAt", 128),
|
|
4889
|
+
entries: value["entries"].map(validateEncryptedEntry)
|
|
4890
|
+
};
|
|
4891
|
+
}
|
|
4892
|
+
function validateEncryptedEntry(value) {
|
|
4893
|
+
if (!isRecord(value)) throw new Error("MCP token store entry must be an object");
|
|
4894
|
+
const resource = canonicalMcpResource(boundedString(value["resource"], "resource", 4096));
|
|
4895
|
+
const authorizationServer = validateMcpAuthorizationServerMetadata(value["authorizationServer"]);
|
|
4896
|
+
const scopes = stringArray(value["scopes"], "scopes", 128);
|
|
4897
|
+
const expiresAt = value["expiresAt"];
|
|
4898
|
+
if (expiresAt !== void 0 && (typeof expiresAt !== "number" || !Number.isFinite(expiresAt))) {
|
|
4899
|
+
throw new Error("MCP token store expiresAt must be a finite number");
|
|
4900
|
+
}
|
|
4901
|
+
return {
|
|
4902
|
+
serverName: boundedString(value["serverName"], "serverName", 256),
|
|
4903
|
+
resource,
|
|
4904
|
+
clientId: boundedString(value["clientId"], "clientId", 4096),
|
|
4905
|
+
authorizationServer,
|
|
4906
|
+
accessToken: boundedString(value["accessToken"], "accessToken", 32768),
|
|
4907
|
+
refreshToken: value["refreshToken"] === void 0 ? void 0 : boundedString(value["refreshToken"], "refreshToken", 32768),
|
|
4908
|
+
tokenType: boundedString(value["tokenType"], "tokenType", 64),
|
|
4909
|
+
expiresAt,
|
|
4910
|
+
scopes,
|
|
4911
|
+
updatedAt: boundedString(value["updatedAt"], "updatedAt", 128)
|
|
4912
|
+
};
|
|
4913
|
+
}
|
|
4914
|
+
function normalizeStoredAuthorization(value) {
|
|
4915
|
+
const serverName = boundedString(value.serverName, "serverName", 256);
|
|
4916
|
+
const resource = canonicalMcpResource(value.resource);
|
|
4917
|
+
const authorizationServer = validateMcpAuthorizationServerMetadata(value.authorizationServer);
|
|
4918
|
+
if (canonicalMcpResource(value.tokenSet.resource) !== resource) {
|
|
4919
|
+
throw new Error("MCP token resource mismatch");
|
|
4920
|
+
}
|
|
4921
|
+
const tokenSet = {
|
|
4922
|
+
...value.tokenSet,
|
|
4923
|
+
resource,
|
|
4924
|
+
scopes: stringArray(value.tokenSet.scopes ?? [], "scopes", 128)
|
|
4925
|
+
};
|
|
4926
|
+
authorizationHeaderForToken({ ...tokenSet, expiresAt: void 0 }, resource);
|
|
4927
|
+
return {
|
|
4928
|
+
serverName,
|
|
4929
|
+
resource,
|
|
4930
|
+
clientId: boundedString(value.clientId, "clientId", 4096),
|
|
4931
|
+
authorizationServer,
|
|
4932
|
+
tokenSet,
|
|
4933
|
+
updatedAt: boundedString(value.updatedAt, "updatedAt", 128)
|
|
4934
|
+
};
|
|
4935
|
+
}
|
|
4936
|
+
function isRecord(value) {
|
|
4937
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4938
|
+
}
|
|
4939
|
+
function boundedString(value, field, maxLength) {
|
|
4940
|
+
if (typeof value !== "string" || value.length === 0 || value.length > maxLength || /[\r\n]/.test(value)) {
|
|
4941
|
+
throw new Error(`MCP token store field "${field}" is invalid`);
|
|
4942
|
+
}
|
|
4943
|
+
return value;
|
|
4944
|
+
}
|
|
4945
|
+
function stringArray(value, field, maxItems) {
|
|
4946
|
+
if (!Array.isArray(value) || value.length > maxItems) {
|
|
4947
|
+
throw new Error(`MCP token store field "${field}" must be a bounded array`);
|
|
4948
|
+
}
|
|
4949
|
+
return [...new Set(value.map((entry) => boundedString(entry, field, 256)))];
|
|
4950
|
+
}
|
|
2515
4951
|
export {
|
|
4952
|
+
DEFAULT_MCP_INSERTION_MAX_BYTES,
|
|
4953
|
+
DEFAULT_MCP_RESOURCE_SCHEMES,
|
|
4954
|
+
MCPAuthorizationManager,
|
|
2516
4955
|
MCPClient,
|
|
4956
|
+
MCPRefreshingAuthorizationProvider,
|
|
2517
4957
|
MCPRegistry,
|
|
2518
4958
|
MCPServer,
|
|
4959
|
+
MCPVaultTokenStore,
|
|
2519
4960
|
MCP_CONSTANTS,
|
|
4961
|
+
MCP_OPERATION_LIMITS,
|
|
2520
4962
|
SSEReader,
|
|
2521
4963
|
SSETransport,
|
|
2522
4964
|
StreamableHTTPTransport,
|
|
2523
4965
|
addMcp,
|
|
4966
|
+
authorizationHeaderForToken,
|
|
4967
|
+
authorizationServerMetadataUrls,
|
|
4968
|
+
canonicalMcpResource,
|
|
4969
|
+
createMcpAuthorizationRequest,
|
|
4970
|
+
createVaultBackedMcpAuthorizationProviderFactory,
|
|
2524
4971
|
disableMcp,
|
|
2525
4972
|
discoverMcp,
|
|
4973
|
+
discoverMcpAuthorization,
|
|
2526
4974
|
enableMcp,
|
|
4975
|
+
exchangeMcpAuthorizationCode,
|
|
2527
4976
|
listMcp,
|
|
2528
4977
|
manifestConfigHash,
|
|
4978
|
+
parseAuthorizationServerMetadata,
|
|
4979
|
+
parseGetPromptResult,
|
|
4980
|
+
parseListPromptsResult,
|
|
4981
|
+
parseListResourceTemplatesResult,
|
|
4982
|
+
parseListResourcesResult,
|
|
4983
|
+
parseMcpAuthorizationCallback,
|
|
4984
|
+
parseMcpBearerChallenge,
|
|
4985
|
+
parseProtectedResourceMetadata,
|
|
4986
|
+
parseReadResourceResult,
|
|
4987
|
+
parseServerMetadata,
|
|
4988
|
+
preparePromptInsertion,
|
|
4989
|
+
prepareResourceInsertion,
|
|
4990
|
+
protectedResourceMetadataUrls,
|
|
4991
|
+
readCapabilityManifest,
|
|
2529
4992
|
readManifest,
|
|
4993
|
+
refreshMcpAccessToken,
|
|
2530
4994
|
removeMcp,
|
|
2531
4995
|
restartMcp,
|
|
2532
4996
|
serveHttp,
|
|
2533
4997
|
serveStdio,
|
|
2534
4998
|
toContentBlocks,
|
|
2535
4999
|
updateMcp,
|
|
5000
|
+
validateMcpAuthorizationServerMetadata,
|
|
2536
5001
|
wrapMCPTool,
|
|
5002
|
+
writeCapabilityManifest,
|
|
2537
5003
|
writeManifest
|
|
2538
5004
|
};
|
|
2539
5005
|
//# sourceMappingURL=index.js.map
|