@frontmcp/testing 1.10.9-rc.1 → 1.10.9-rc.3
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/esm/fixtures/index.mjs +11 -4251
- package/esm/index.mjs +30 -9082
- package/esm/matchers/index.mjs +1 -611
- package/esm/package.json +6 -6
- package/esm/perf/index.mjs +19 -5373
- package/esm/perf/perf-setup.mjs +1 -31
- package/esm/playwright/index.mjs +1 -25
- package/esm/setup.mjs +1 -654
- package/fixtures/index.js +11 -4275
- package/index.js +30 -9203
- package/matchers/index.js +1 -638
- package/package.json +6 -6
- package/perf/index.js +19 -5426
- package/perf/perf-setup.js +1 -50
- package/playwright/index.js +1 -52
- package/setup.js +1 -616
package/esm/fixtures/index.mjs
CHANGED
|
@@ -1,4275 +1,35 @@
|
|
|
1
1
|
var __frontmcpModule = typeof process !== "undefined" && typeof process.getBuiltinModule === "function" ? process.getBuiltinModule("module") : undefined;
|
|
2
2
|
const require = __frontmcpModule ? __frontmcpModule.createRequire(import.meta.url || 'file:///') : undefined;
|
|
3
3
|
// __frontmcpCreateRequire
|
|
4
|
-
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
5
|
-
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
6
|
-
}) : x)(function(x) {
|
|
7
|
-
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
8
|
-
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
// libs/testing/src/auth/token-factory.ts
|
|
12
|
-
import { exportJWK, generateKeyPair, SignJWT } from "jose";
|
|
13
|
-
var TestTokenFactory = class {
|
|
14
|
-
issuer;
|
|
15
|
-
audience;
|
|
16
|
-
privateKey = null;
|
|
17
|
-
publicKey = null;
|
|
18
|
-
jwk = null;
|
|
19
|
-
keyId;
|
|
20
|
-
/** HS256 shared secret (gateway mode). Null → RS256 + JWKS (default). */
|
|
21
|
-
hmacSecret;
|
|
22
|
-
constructor(options = {}) {
|
|
23
|
-
this.issuer = options.issuer ?? "https://test.frontmcp.local";
|
|
24
|
-
this.audience = options.audience ?? "frontmcp-test";
|
|
25
|
-
this.keyId = `test-key-${Date.now()}`;
|
|
26
|
-
this.hmacSecret = options.hmacSecret ? new TextEncoder().encode(options.hmacSecret) : null;
|
|
27
|
-
}
|
|
28
|
-
/** True when this factory signs HS256 with a shared secret (gateway mode). */
|
|
29
|
-
get isHmac() {
|
|
30
|
-
return this.hmacSecret !== null;
|
|
31
|
-
}
|
|
32
|
-
/** The JWS `alg` this factory signs with. */
|
|
33
|
-
get alg() {
|
|
34
|
-
return this.isHmac ? "HS256" : "RS256";
|
|
35
|
-
}
|
|
36
|
-
/**
|
|
37
|
-
* The signing key: the shared HMAC secret in gateway mode, otherwise the
|
|
38
|
-
* generated RSA private key (lazily created via ensureKeys()).
|
|
39
|
-
*/
|
|
40
|
-
signingKey() {
|
|
41
|
-
if (this.hmacSecret) return this.hmacSecret;
|
|
42
|
-
if (!this.privateKey) {
|
|
43
|
-
throw new Error("Private key not initialized");
|
|
44
|
-
}
|
|
45
|
-
return this.privateKey;
|
|
46
|
-
}
|
|
47
|
-
/**
|
|
48
|
-
* Initialize the key pair (called automatically on first use). No-op in HS256
|
|
49
|
-
* mode — there is no asymmetric keypair, just the shared secret.
|
|
50
|
-
*/
|
|
51
|
-
async ensureKeys() {
|
|
52
|
-
if (this.isHmac) return;
|
|
53
|
-
if (this.privateKey && this.publicKey) return;
|
|
54
|
-
const { publicKey, privateKey } = await generateKeyPair("RS256", {
|
|
55
|
-
extractable: true
|
|
56
|
-
});
|
|
57
|
-
this.privateKey = privateKey;
|
|
58
|
-
this.publicKey = publicKey;
|
|
59
|
-
this.jwk = await exportJWK(publicKey);
|
|
60
|
-
this.jwk.kid = this.keyId;
|
|
61
|
-
this.jwk.use = "sig";
|
|
62
|
-
this.jwk.alg = "RS256";
|
|
63
|
-
}
|
|
64
|
-
/**
|
|
65
|
-
* Create a JWT token with the specified claims
|
|
66
|
-
*/
|
|
67
|
-
async createTestToken(options) {
|
|
68
|
-
await this.ensureKeys();
|
|
69
|
-
const nowMs = Date.now();
|
|
70
|
-
const iat = Math.floor(nowMs / 1e3);
|
|
71
|
-
const exp = options.exp ?? 3600;
|
|
72
|
-
const payload = {
|
|
73
|
-
iss: options.iss ?? this.issuer,
|
|
74
|
-
sub: options.sub,
|
|
75
|
-
aud: options.aud ?? this.audience,
|
|
76
|
-
iat,
|
|
77
|
-
exp: Math.ceil(nowMs / 1e3) + exp,
|
|
78
|
-
scope: options.scopes?.join(" "),
|
|
79
|
-
...options.claims
|
|
80
|
-
};
|
|
81
|
-
const header = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
|
|
82
|
-
const token = await new SignJWT(payload).setProtectedHeader(header).sign(this.signingKey());
|
|
83
|
-
return token;
|
|
84
|
-
}
|
|
85
|
-
/**
|
|
86
|
-
* Create an admin token with full access
|
|
87
|
-
*/
|
|
88
|
-
async createAdminToken(sub = "admin-001") {
|
|
89
|
-
return this.createTestToken({
|
|
90
|
-
sub,
|
|
91
|
-
scopes: ["admin:*", "read", "write", "delete"],
|
|
92
|
-
claims: {
|
|
93
|
-
email: "admin@test.local",
|
|
94
|
-
name: "Test Admin",
|
|
95
|
-
role: "admin"
|
|
96
|
-
}
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
/**
|
|
100
|
-
* Create a regular user token
|
|
101
|
-
*/
|
|
102
|
-
async createUserToken(sub = "user-001", scopes2 = ["read", "write"]) {
|
|
103
|
-
return this.createTestToken({
|
|
104
|
-
sub,
|
|
105
|
-
scopes: scopes2,
|
|
106
|
-
claims: {
|
|
107
|
-
email: "user@test.local",
|
|
108
|
-
name: "Test User",
|
|
109
|
-
role: "user"
|
|
110
|
-
}
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
/**
|
|
114
|
-
* Create an anonymous user token
|
|
115
|
-
*/
|
|
116
|
-
async createAnonymousToken(expiresIn) {
|
|
117
|
-
return this.createTestToken({
|
|
118
|
-
sub: `anon:${Date.now()}`,
|
|
119
|
-
scopes: ["anonymous"],
|
|
120
|
-
exp: expiresIn,
|
|
121
|
-
claims: {
|
|
122
|
-
name: "Anonymous",
|
|
123
|
-
role: "anonymous"
|
|
124
|
-
}
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
/**
|
|
128
|
-
* Create an expired token (for testing token expiration)
|
|
129
|
-
*/
|
|
130
|
-
async createExpiredToken(options) {
|
|
131
|
-
await this.ensureKeys();
|
|
132
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
133
|
-
const payload = {
|
|
134
|
-
iss: this.issuer,
|
|
135
|
-
sub: options.sub,
|
|
136
|
-
aud: this.audience,
|
|
137
|
-
iat: now - 7200,
|
|
138
|
-
// 2 hours ago
|
|
139
|
-
exp: now - 3600
|
|
140
|
-
// Expired 1 hour ago
|
|
141
|
-
};
|
|
142
|
-
const header = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
|
|
143
|
-
const token = await new SignJWT(payload).setProtectedHeader(header).sign(this.signingKey());
|
|
144
|
-
return token;
|
|
145
|
-
}
|
|
146
|
-
/**
|
|
147
|
-
* Create a token with an invalid signature (for testing signature validation)
|
|
148
|
-
*/
|
|
149
|
-
createTokenWithInvalidSignature(options) {
|
|
150
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
151
|
-
const headerObj = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
|
|
152
|
-
const header = Buffer.from(JSON.stringify(headerObj)).toString("base64url");
|
|
153
|
-
const payload = Buffer.from(
|
|
154
|
-
JSON.stringify({
|
|
155
|
-
iss: this.issuer,
|
|
156
|
-
sub: options.sub,
|
|
157
|
-
aud: this.audience,
|
|
158
|
-
iat: now,
|
|
159
|
-
exp: now + 3600
|
|
160
|
-
})
|
|
161
|
-
).toString("base64url");
|
|
162
|
-
const signature = Buffer.from("invalid-signature-" + Date.now()).toString("base64url");
|
|
163
|
-
return `${header}.${payload}.${signature}`;
|
|
164
|
-
}
|
|
165
|
-
/**
|
|
166
|
-
* Get the public JWKS for verifying tokens.
|
|
167
|
-
*
|
|
168
|
-
* Only meaningful in RS256 mode — HS256 (gateway) tokens are verified against
|
|
169
|
-
* the shared secret, not a published public key, so there is no JWKS to serve.
|
|
170
|
-
*/
|
|
171
|
-
async getPublicJwks() {
|
|
172
|
-
if (this.isHmac) {
|
|
173
|
-
throw new Error("getPublicJwks() is not available in HS256 mode (gateway tokens use a shared secret)");
|
|
174
|
-
}
|
|
175
|
-
await this.ensureKeys();
|
|
176
|
-
if (!this.jwk) {
|
|
177
|
-
throw new Error("JWK not initialized");
|
|
178
|
-
}
|
|
179
|
-
return {
|
|
180
|
-
keys: [this.jwk]
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
/**
|
|
184
|
-
* Get the issuer URL
|
|
185
|
-
*/
|
|
186
|
-
getIssuer() {
|
|
187
|
-
return this.issuer;
|
|
188
|
-
}
|
|
189
|
-
/**
|
|
190
|
-
* Get the audience
|
|
191
|
-
*/
|
|
192
|
-
getAudience() {
|
|
193
|
-
return this.audience;
|
|
194
|
-
}
|
|
195
|
-
};
|
|
196
|
-
|
|
197
|
-
// libs/testing/src/interceptor/mock-registry.ts
|
|
198
|
-
var DefaultMockRegistry = class {
|
|
199
|
-
mocks = [];
|
|
200
|
-
add(mock) {
|
|
201
|
-
const entry = {
|
|
202
|
-
definition: mock,
|
|
203
|
-
callCount: 0,
|
|
204
|
-
calls: [],
|
|
205
|
-
remainingUses: mock.times ?? Infinity
|
|
206
|
-
};
|
|
207
|
-
this.mocks.push(entry);
|
|
208
|
-
return {
|
|
209
|
-
remove: () => {
|
|
210
|
-
const index = this.mocks.indexOf(entry);
|
|
211
|
-
if (index !== -1) {
|
|
212
|
-
this.mocks.splice(index, 1);
|
|
213
|
-
}
|
|
214
|
-
},
|
|
215
|
-
callCount: () => entry.callCount,
|
|
216
|
-
calls: () => [...entry.calls]
|
|
217
|
-
};
|
|
218
|
-
}
|
|
219
|
-
clear() {
|
|
220
|
-
this.mocks = [];
|
|
221
|
-
}
|
|
222
|
-
getAll() {
|
|
223
|
-
return this.mocks.map((e) => e.definition);
|
|
224
|
-
}
|
|
225
|
-
match(request) {
|
|
226
|
-
for (const entry of this.mocks) {
|
|
227
|
-
if (entry.remainingUses <= 0) continue;
|
|
228
|
-
const { definition } = entry;
|
|
229
|
-
if (definition.method !== request.method) continue;
|
|
230
|
-
if (definition.params !== void 0) {
|
|
231
|
-
const params = request.params ?? {};
|
|
232
|
-
if (typeof definition.params === "function") {
|
|
233
|
-
if (!definition.params(params)) continue;
|
|
234
|
-
} else {
|
|
235
|
-
if (!this.paramsMatch(definition.params, params)) continue;
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
entry.callCount++;
|
|
239
|
-
entry.calls.push(request);
|
|
240
|
-
entry.remainingUses--;
|
|
241
|
-
return definition;
|
|
242
|
-
}
|
|
243
|
-
return void 0;
|
|
244
|
-
}
|
|
245
|
-
/**
|
|
246
|
-
* Check if request params match the mock params definition
|
|
247
|
-
*/
|
|
248
|
-
paramsMatch(expected, actual) {
|
|
249
|
-
for (const [key, value] of Object.entries(expected)) {
|
|
250
|
-
if (!(key in actual)) return false;
|
|
251
|
-
const actualValue = actual[key];
|
|
252
|
-
if (Array.isArray(value)) {
|
|
253
|
-
if (!Array.isArray(actualValue)) return false;
|
|
254
|
-
if (value.length !== actualValue.length) return false;
|
|
255
|
-
for (let i = 0; i < value.length; i++) {
|
|
256
|
-
const expectedItem = value[i];
|
|
257
|
-
const actualItem = actualValue[i];
|
|
258
|
-
if (typeof expectedItem === "object" && expectedItem !== null) {
|
|
259
|
-
if (typeof actualItem !== "object" || actualItem === null) return false;
|
|
260
|
-
if (!this.paramsMatch(expectedItem, actualItem)) {
|
|
261
|
-
return false;
|
|
262
|
-
}
|
|
263
|
-
} else if (actualItem !== expectedItem) {
|
|
264
|
-
return false;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
} else if (typeof value === "object" && value !== null) {
|
|
268
|
-
if (typeof actualValue !== "object" || actualValue === null) return false;
|
|
269
|
-
if (!this.paramsMatch(value, actualValue)) {
|
|
270
|
-
return false;
|
|
271
|
-
}
|
|
272
|
-
} else if (actualValue !== value) {
|
|
273
|
-
return false;
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
return true;
|
|
277
|
-
}
|
|
278
|
-
};
|
|
279
|
-
var mockResponse = {
|
|
280
|
-
/**
|
|
281
|
-
* Create a successful JSON-RPC response
|
|
282
|
-
*/
|
|
283
|
-
success(result, id = 1) {
|
|
284
|
-
return {
|
|
285
|
-
jsonrpc: "2.0",
|
|
286
|
-
id,
|
|
287
|
-
result
|
|
288
|
-
};
|
|
289
|
-
},
|
|
290
|
-
/**
|
|
291
|
-
* Create an error JSON-RPC response
|
|
292
|
-
*/
|
|
293
|
-
error(code, message, data, id = 1) {
|
|
294
|
-
return {
|
|
295
|
-
jsonrpc: "2.0",
|
|
296
|
-
id,
|
|
297
|
-
error: { code, message, data }
|
|
298
|
-
};
|
|
299
|
-
},
|
|
300
|
-
/**
|
|
301
|
-
* Create a tool result response
|
|
302
|
-
*/
|
|
303
|
-
toolResult(content, id = 1) {
|
|
304
|
-
return {
|
|
305
|
-
jsonrpc: "2.0",
|
|
306
|
-
id,
|
|
307
|
-
result: { content }
|
|
308
|
-
};
|
|
309
|
-
},
|
|
310
|
-
/**
|
|
311
|
-
* Create a tools/list response
|
|
312
|
-
*/
|
|
313
|
-
toolsList(tools, id = 1) {
|
|
314
|
-
return {
|
|
315
|
-
jsonrpc: "2.0",
|
|
316
|
-
id,
|
|
317
|
-
result: { tools }
|
|
318
|
-
};
|
|
319
|
-
},
|
|
320
|
-
/**
|
|
321
|
-
* Create a resources/list response
|
|
322
|
-
*/
|
|
323
|
-
resourcesList(resources, id = 1) {
|
|
324
|
-
return {
|
|
325
|
-
jsonrpc: "2.0",
|
|
326
|
-
id,
|
|
327
|
-
result: { resources }
|
|
328
|
-
};
|
|
329
|
-
},
|
|
330
|
-
/**
|
|
331
|
-
* Create a resources/read response
|
|
332
|
-
*/
|
|
333
|
-
resourceRead(contents, id = 1) {
|
|
334
|
-
return {
|
|
335
|
-
jsonrpc: "2.0",
|
|
336
|
-
id,
|
|
337
|
-
result: { contents }
|
|
338
|
-
};
|
|
339
|
-
},
|
|
340
|
-
/**
|
|
341
|
-
* Common MCP errors
|
|
342
|
-
*/
|
|
343
|
-
errors: {
|
|
344
|
-
methodNotFound: (method, id = 1) => mockResponse.error(-32601, `Method not found: ${method}`, void 0, id),
|
|
345
|
-
invalidParams: (message, id = 1) => mockResponse.error(-32602, message, void 0, id),
|
|
346
|
-
internalError: (message, id = 1) => mockResponse.error(-32603, message, void 0, id),
|
|
347
|
-
resourceNotFound: (uri, id = 1) => mockResponse.error(-32002, `Resource not found: ${uri}`, { uri }, id),
|
|
348
|
-
toolNotFound: (name, id = 1) => mockResponse.error(-32601, `Tool not found: ${name}`, { name }, id),
|
|
349
|
-
unauthorized: (id = 1) => mockResponse.error(-32001, "Unauthorized", void 0, id),
|
|
350
|
-
forbidden: (id = 1) => mockResponse.error(-32003, "Forbidden", void 0, id)
|
|
351
|
-
}
|
|
352
|
-
};
|
|
353
|
-
|
|
354
|
-
// libs/testing/src/interceptor/interceptor-chain.ts
|
|
355
|
-
var DefaultInterceptorChain = class {
|
|
356
|
-
request = [];
|
|
357
|
-
response = [];
|
|
358
|
-
mocks;
|
|
359
|
-
constructor() {
|
|
360
|
-
this.mocks = new DefaultMockRegistry();
|
|
361
|
-
}
|
|
362
|
-
/**
|
|
363
|
-
* Add a request interceptor
|
|
364
|
-
*/
|
|
365
|
-
addRequestInterceptor(interceptor) {
|
|
366
|
-
this.request.push(interceptor);
|
|
367
|
-
return () => {
|
|
368
|
-
const index = this.request.indexOf(interceptor);
|
|
369
|
-
if (index !== -1) {
|
|
370
|
-
this.request.splice(index, 1);
|
|
371
|
-
}
|
|
372
|
-
};
|
|
373
|
-
}
|
|
374
|
-
/**
|
|
375
|
-
* Add a response interceptor
|
|
376
|
-
*/
|
|
377
|
-
addResponseInterceptor(interceptor) {
|
|
378
|
-
this.response.push(interceptor);
|
|
379
|
-
return () => {
|
|
380
|
-
const index = this.response.indexOf(interceptor);
|
|
381
|
-
if (index !== -1) {
|
|
382
|
-
this.response.splice(index, 1);
|
|
383
|
-
}
|
|
384
|
-
};
|
|
385
|
-
}
|
|
386
|
-
/**
|
|
387
|
-
* Process a request through the interceptor chain
|
|
388
|
-
* Returns either:
|
|
389
|
-
* - { type: 'continue', request } - continue with (possibly modified) request
|
|
390
|
-
* - { type: 'mock', response } - return mock response immediately
|
|
391
|
-
* - { type: 'error', error } - throw error
|
|
392
|
-
*/
|
|
393
|
-
async processRequest(request, meta) {
|
|
394
|
-
let currentRequest = request;
|
|
395
|
-
const mockDef = this.mocks.match(request);
|
|
396
|
-
if (mockDef) {
|
|
397
|
-
if (mockDef.delay && mockDef.delay > 0) {
|
|
398
|
-
await sleep(mockDef.delay);
|
|
399
|
-
}
|
|
400
|
-
let mockResponse2;
|
|
401
|
-
if (typeof mockDef.response === "function") {
|
|
402
|
-
mockResponse2 = await mockDef.response(request);
|
|
403
|
-
} else {
|
|
404
|
-
mockResponse2 = mockDef.response;
|
|
405
|
-
}
|
|
406
|
-
return {
|
|
407
|
-
type: "mock",
|
|
408
|
-
response: { ...mockResponse2, id: request.id ?? mockResponse2.id }
|
|
409
|
-
};
|
|
410
|
-
}
|
|
411
|
-
for (const interceptor of this.request) {
|
|
412
|
-
const ctx = {
|
|
413
|
-
request: currentRequest,
|
|
414
|
-
meta
|
|
415
|
-
};
|
|
416
|
-
const result = await interceptor(ctx);
|
|
417
|
-
switch (result.action) {
|
|
418
|
-
case "passthrough":
|
|
419
|
-
break;
|
|
420
|
-
case "modify":
|
|
421
|
-
currentRequest = result.request;
|
|
422
|
-
break;
|
|
423
|
-
case "mock":
|
|
424
|
-
return {
|
|
425
|
-
type: "mock",
|
|
426
|
-
response: { ...result.response, id: request.id ?? result.response.id }
|
|
427
|
-
};
|
|
428
|
-
case "error":
|
|
429
|
-
return { type: "error", error: result.error };
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
return { type: "continue", request: currentRequest };
|
|
433
|
-
}
|
|
434
|
-
/**
|
|
435
|
-
* Process a response through the interceptor chain
|
|
436
|
-
*/
|
|
437
|
-
async processResponse(request, response, durationMs) {
|
|
438
|
-
let currentResponse = response;
|
|
439
|
-
for (const interceptor of this.response) {
|
|
440
|
-
const ctx = {
|
|
441
|
-
request,
|
|
442
|
-
response: currentResponse,
|
|
443
|
-
durationMs
|
|
444
|
-
};
|
|
445
|
-
const result = await interceptor(ctx);
|
|
446
|
-
switch (result.action) {
|
|
447
|
-
case "passthrough":
|
|
448
|
-
break;
|
|
449
|
-
case "modify":
|
|
450
|
-
currentResponse = result.response;
|
|
451
|
-
break;
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
return currentResponse;
|
|
455
|
-
}
|
|
456
|
-
/**
|
|
457
|
-
* Clear all interceptors and mocks
|
|
458
|
-
*/
|
|
459
|
-
clear() {
|
|
460
|
-
this.request = [];
|
|
461
|
-
this.response = [];
|
|
462
|
-
this.mocks.clear();
|
|
463
|
-
}
|
|
464
|
-
};
|
|
465
|
-
function sleep(ms) {
|
|
466
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
// libs/testing/src/transport/error-utils.ts
|
|
470
|
-
function errorName(err) {
|
|
471
|
-
if (typeof err !== "object" || err === null) return void 0;
|
|
472
|
-
const name = err.name;
|
|
473
|
-
return typeof name === "string" ? name : void 0;
|
|
474
|
-
}
|
|
475
|
-
function isAbortError(err) {
|
|
476
|
-
return errorName(err) === "AbortError";
|
|
477
|
-
}
|
|
478
|
-
function errorMessage(err, fallback = "Unknown error") {
|
|
479
|
-
if (typeof err === "string" && err) return err;
|
|
480
|
-
if (typeof err === "object" && err !== null) {
|
|
481
|
-
const message = err.message;
|
|
482
|
-
if (typeof message === "string" && message) return message;
|
|
483
|
-
}
|
|
484
|
-
return fallback;
|
|
485
|
-
}
|
|
486
|
-
var interceptedErrors = /* @__PURE__ */ new WeakSet();
|
|
487
|
-
function markInterceptedError(err) {
|
|
488
|
-
interceptedErrors.add(err);
|
|
489
|
-
return err;
|
|
490
|
-
}
|
|
491
|
-
function isInterceptedError(err) {
|
|
492
|
-
return typeof err === "object" && err !== null && interceptedErrors.has(err);
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// libs/testing/src/transport/sse.transport.ts
|
|
496
|
-
var DEFAULT_TIMEOUT = 3e4;
|
|
497
|
-
var SseTransport = class {
|
|
498
|
-
baseUrl;
|
|
499
|
-
entryPath;
|
|
500
|
-
timeout;
|
|
501
|
-
authHeaders;
|
|
502
|
-
publicMode;
|
|
503
|
-
debug;
|
|
504
|
-
clientInfo;
|
|
505
|
-
notificationHandler;
|
|
506
|
-
interceptors;
|
|
507
|
-
elicitationHandler;
|
|
508
|
-
state = "disconnected";
|
|
509
|
-
authToken;
|
|
510
|
-
sessionId;
|
|
511
|
-
messageEndpoint;
|
|
512
|
-
stream;
|
|
513
|
-
pending = /* @__PURE__ */ new Map();
|
|
514
|
-
connectionCount = 0;
|
|
515
|
-
reconnectCount = 0;
|
|
516
|
-
lastRequestHeaders = {};
|
|
517
|
-
constructor(config) {
|
|
518
|
-
this.baseUrl = config.baseUrl.replace(/\/$/, "");
|
|
519
|
-
this.entryPath = config.entryPath;
|
|
520
|
-
this.timeout = config.timeout ?? DEFAULT_TIMEOUT;
|
|
521
|
-
this.authHeaders = config.auth?.headers ?? {};
|
|
522
|
-
this.authToken = config.auth?.token;
|
|
523
|
-
this.publicMode = config.publicMode ?? false;
|
|
524
|
-
this.debug = config.debug ?? false;
|
|
525
|
-
this.clientInfo = config.clientInfo;
|
|
526
|
-
this.notificationHandler = config.notificationHandler;
|
|
527
|
-
this.interceptors = config.interceptors;
|
|
528
|
-
this.elicitationHandler = config.elicitationHandler;
|
|
529
|
-
}
|
|
530
|
-
async connect() {
|
|
531
|
-
this.state = "connecting";
|
|
532
|
-
this.connectionCount++;
|
|
533
|
-
try {
|
|
534
|
-
if (!this.publicMode && !this.authToken) {
|
|
535
|
-
await this.requestAnonymousToken();
|
|
536
|
-
}
|
|
537
|
-
await this.openStream();
|
|
538
|
-
this.state = "connected";
|
|
539
|
-
this.log(`Connected; messages go to ${this.messageEndpoint}`);
|
|
540
|
-
} catch (error) {
|
|
541
|
-
this.state = "error";
|
|
542
|
-
this.stream?.abort();
|
|
543
|
-
this.stream = void 0;
|
|
544
|
-
throw error;
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
async request(message) {
|
|
548
|
-
this.ensureConnected();
|
|
549
|
-
const startTime = Date.now();
|
|
550
|
-
if (this.interceptors) {
|
|
551
|
-
const intercepted = await this.interceptors.processRequest(message, {
|
|
552
|
-
timestamp: /* @__PURE__ */ new Date(),
|
|
553
|
-
transport: "sse",
|
|
554
|
-
sessionId: this.sessionId
|
|
555
|
-
});
|
|
556
|
-
switch (intercepted.type) {
|
|
557
|
-
case "mock":
|
|
558
|
-
return await this.interceptors.processResponse(
|
|
559
|
-
message,
|
|
560
|
-
intercepted.response,
|
|
561
|
-
Date.now() - startTime
|
|
562
|
-
);
|
|
563
|
-
case "error":
|
|
564
|
-
throw markInterceptedError(intercepted.error);
|
|
565
|
-
case "continue":
|
|
566
|
-
message = intercepted.request;
|
|
567
|
-
break;
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
let response;
|
|
571
|
-
if (message.id === void 0) {
|
|
572
|
-
await this.post(JSON.stringify(message));
|
|
573
|
-
response = { jsonrpc: "2.0", id: null, result: void 0 };
|
|
574
|
-
} else {
|
|
575
|
-
response = await this.postAndAwait(message.id, JSON.stringify(message));
|
|
576
|
-
}
|
|
577
|
-
if (this.interceptors) {
|
|
578
|
-
response = await this.interceptors.processResponse(message, response, Date.now() - startTime);
|
|
579
|
-
}
|
|
580
|
-
return response;
|
|
581
|
-
}
|
|
582
|
-
async notify(message) {
|
|
583
|
-
this.ensureConnected();
|
|
584
|
-
const result = await this.post(JSON.stringify(message));
|
|
585
|
-
if (!result.ok) this.log(`HTTP ${result.status} on notification: ${result.body}`);
|
|
586
|
-
}
|
|
587
|
-
async sendRaw(data) {
|
|
588
|
-
this.ensureConnected();
|
|
589
|
-
let id;
|
|
590
|
-
try {
|
|
591
|
-
const parsed = JSON.parse(data);
|
|
592
|
-
if (typeof parsed.id === "string" || typeof parsed.id === "number") id = parsed.id;
|
|
593
|
-
} catch {
|
|
594
|
-
}
|
|
595
|
-
if (id !== void 0) return this.postAndAwait(id, data);
|
|
596
|
-
const result = await this.post(data);
|
|
597
|
-
if (!result.ok) {
|
|
598
|
-
return { jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error", data: result.body } };
|
|
599
|
-
}
|
|
600
|
-
return { jsonrpc: "2.0", id: null, result: void 0 };
|
|
601
|
-
}
|
|
602
|
-
async close() {
|
|
603
|
-
this.closeStream("Transport closed");
|
|
604
|
-
this.state = "disconnected";
|
|
605
|
-
this.sessionId = void 0;
|
|
606
|
-
this.messageEndpoint = void 0;
|
|
607
|
-
this.log("SSE transport closed");
|
|
608
|
-
}
|
|
609
|
-
isConnected() {
|
|
610
|
-
return this.state === "connected";
|
|
611
|
-
}
|
|
612
|
-
getState() {
|
|
613
|
-
return this.state;
|
|
614
|
-
}
|
|
615
|
-
getSessionId() {
|
|
616
|
-
return this.sessionId;
|
|
617
|
-
}
|
|
618
|
-
setAuthToken(token) {
|
|
619
|
-
this.authToken = token;
|
|
620
|
-
}
|
|
621
|
-
setTimeout(ms) {
|
|
622
|
-
this.timeout = ms;
|
|
623
|
-
}
|
|
624
|
-
getMessageEndpoint() {
|
|
625
|
-
return this.messageEndpoint;
|
|
626
|
-
}
|
|
627
|
-
getConnectionCount() {
|
|
628
|
-
return this.connectionCount;
|
|
629
|
-
}
|
|
630
|
-
getReconnectCount() {
|
|
631
|
-
return this.reconnectCount;
|
|
632
|
-
}
|
|
633
|
-
getLastRequestHeaders() {
|
|
634
|
-
return { ...this.lastRequestHeaders };
|
|
635
|
-
}
|
|
636
|
-
setInterceptors(interceptors2) {
|
|
637
|
-
this.interceptors = interceptors2;
|
|
638
|
-
}
|
|
639
|
-
getInterceptors() {
|
|
640
|
-
return this.interceptors;
|
|
641
|
-
}
|
|
642
|
-
setElicitationHandler(handler) {
|
|
643
|
-
this.elicitationHandler = handler;
|
|
644
|
-
}
|
|
645
|
-
async simulateDisconnect() {
|
|
646
|
-
this.closeStream("Simulated disconnect");
|
|
647
|
-
this.state = "disconnected";
|
|
648
|
-
this.sessionId = void 0;
|
|
649
|
-
this.messageEndpoint = void 0;
|
|
650
|
-
}
|
|
651
|
-
async waitForReconnect(timeoutMs) {
|
|
652
|
-
this.reconnectCount++;
|
|
653
|
-
let timer;
|
|
654
|
-
try {
|
|
655
|
-
await Promise.race([
|
|
656
|
-
this.connect(),
|
|
657
|
-
new Promise((_, reject) => {
|
|
658
|
-
timer = setTimeout(() => reject(new Error("Timeout waiting for reconnection")), timeoutMs);
|
|
659
|
-
})
|
|
660
|
-
]);
|
|
661
|
-
} finally {
|
|
662
|
-
clearTimeout(timer);
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
666
|
-
// STREAM
|
|
667
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
668
|
-
/** Open `GET <entry>/sse` and wait for the `endpoint` event. */
|
|
669
|
-
async openStream() {
|
|
670
|
-
const controller = new AbortController();
|
|
671
|
-
this.stream = controller;
|
|
672
|
-
const url = this.sseUrl();
|
|
673
|
-
const { "Content-Type": _contentType, ...headers } = this.buildHeaders();
|
|
674
|
-
this.log(`GET ${url}`);
|
|
675
|
-
const connectTimer = setTimeout(() => controller.abort(), this.timeout);
|
|
676
|
-
let response;
|
|
677
|
-
try {
|
|
678
|
-
response = await fetch(url, {
|
|
679
|
-
method: "GET",
|
|
680
|
-
headers: { ...headers, Accept: "text/event-stream" },
|
|
681
|
-
signal: controller.signal
|
|
682
|
-
});
|
|
683
|
-
} catch (error) {
|
|
684
|
-
clearTimeout(connectTimer);
|
|
685
|
-
throw new Error(
|
|
686
|
-
isAbortError(error) ? `SSE connection to ${url} timed out after ${this.timeout}ms` : `SSE connection to ${url} failed: ${errorMessage(error)}`,
|
|
687
|
-
{ cause: error }
|
|
688
|
-
);
|
|
689
|
-
}
|
|
690
|
-
const isEventStream = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
|
|
691
|
-
if (!response.ok || !response.body || !isEventStream) {
|
|
692
|
-
clearTimeout(connectTimer);
|
|
693
|
-
const body = await response.text().catch(() => "");
|
|
694
|
-
throw new Error(
|
|
695
|
-
`SSE connection to ${url} failed: HTTP ${response.status} ${response.statusText}${body ? ` \u2014 ${body}` : ""}. The legacy HTTP+SSE transport needs a server that serves it (@FrontMcp({ transport: { protocol: { legacy: true } } })).`
|
|
696
|
-
);
|
|
697
|
-
}
|
|
698
|
-
const endpoint = new Promise((resolve, reject) => {
|
|
699
|
-
const reader = response.body?.getReader();
|
|
700
|
-
if (!reader) {
|
|
701
|
-
reject(new Error(`SSE connection to ${url} has no body`));
|
|
702
|
-
return;
|
|
703
|
-
}
|
|
704
|
-
void this.readStream(controller, reader, resolve, reject);
|
|
705
|
-
});
|
|
706
|
-
try {
|
|
707
|
-
const data = await endpoint;
|
|
708
|
-
const messageUrl = new URL(data, url);
|
|
709
|
-
this.messageEndpoint = messageUrl.toString();
|
|
710
|
-
this.sessionId = messageUrl.searchParams.get("sessionId") ?? void 0;
|
|
711
|
-
} finally {
|
|
712
|
-
clearTimeout(connectTimer);
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
async readStream(controller, reader, onEndpoint, onEndpointError) {
|
|
716
|
-
const decoder = new TextDecoder();
|
|
717
|
-
let buffer = "";
|
|
718
|
-
let endpointSeen = false;
|
|
719
|
-
try {
|
|
720
|
-
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
|
|
721
|
-
const { completeEvents, remainder } = splitSSEEvents(buffer + decoder.decode(chunk.value, { stream: true }));
|
|
722
|
-
buffer = remainder;
|
|
723
|
-
for (const text of completeEvents) {
|
|
724
|
-
const event = parseSSEEvent(text);
|
|
725
|
-
if (!event) continue;
|
|
726
|
-
if (event.type === "endpoint") {
|
|
727
|
-
if (!endpointSeen) {
|
|
728
|
-
endpointSeen = true;
|
|
729
|
-
onEndpoint(event.data);
|
|
730
|
-
}
|
|
731
|
-
continue;
|
|
732
|
-
}
|
|
733
|
-
await this.handleMessage(event.data);
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
} catch (error) {
|
|
737
|
-
if (!controller.signal.aborted) this.log("SSE stream failed:", error);
|
|
738
|
-
} finally {
|
|
739
|
-
reader.releaseLock();
|
|
740
|
-
}
|
|
741
|
-
if (!endpointSeen) {
|
|
742
|
-
onEndpointError(
|
|
743
|
-
new Error(
|
|
744
|
-
controller.signal.aborted ? `SSE stream at ${this.sseUrl()} sent no endpoint event within ${this.timeout}ms` : `SSE stream at ${this.sseUrl()} ended before the server sent its endpoint`
|
|
745
|
-
)
|
|
746
|
-
);
|
|
747
|
-
}
|
|
748
|
-
if (this.stream === controller) {
|
|
749
|
-
this.stream = void 0;
|
|
750
|
-
this.rejectPending("SSE stream closed by the server");
|
|
751
|
-
if (this.state === "connected") this.state = "disconnected";
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
async handleMessage(data) {
|
|
755
|
-
let message;
|
|
756
|
-
try {
|
|
757
|
-
message = JSON.parse(data);
|
|
758
|
-
} catch {
|
|
759
|
-
this.log("Ignoring SSE event that is not JSON:", data);
|
|
760
|
-
return;
|
|
761
|
-
}
|
|
762
|
-
if (!message || typeof message !== "object") return;
|
|
763
|
-
if (typeof message.method === "string") {
|
|
764
|
-
const request = message;
|
|
765
|
-
if (request.id === void 0 || request.id === null) {
|
|
766
|
-
this.notificationHandler?.(request);
|
|
767
|
-
return;
|
|
768
|
-
}
|
|
769
|
-
await this.answerServerRequest(request);
|
|
770
|
-
return;
|
|
771
|
-
}
|
|
772
|
-
if (message.id !== void 0 && message.id !== null) {
|
|
773
|
-
const pending = this.pending.get(message.id);
|
|
774
|
-
if (pending) {
|
|
775
|
-
clearTimeout(pending.timer);
|
|
776
|
-
this.pending.delete(message.id);
|
|
777
|
-
pending.resolve(message);
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
/** Server→client requests: elicitation goes to the handler, `ping` is answered, the rest refused. */
|
|
782
|
-
async answerServerRequest(request) {
|
|
783
|
-
const id = request.id;
|
|
784
|
-
let reply;
|
|
785
|
-
if (request.method === "elicitation/create") {
|
|
786
|
-
reply = { jsonrpc: "2.0", id, result: await this.elicit(request.params) };
|
|
787
|
-
} else if (request.method === "ping") {
|
|
788
|
-
reply = { jsonrpc: "2.0", id, result: {} };
|
|
789
|
-
} else {
|
|
790
|
-
reply = { jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${request.method}` } };
|
|
791
|
-
}
|
|
792
|
-
const result = await this.post(JSON.stringify(reply));
|
|
793
|
-
if (!result.ok) this.log(`HTTP ${result.status} answering ${request.method}: ${result.body}`);
|
|
794
|
-
}
|
|
795
|
-
async elicit(params) {
|
|
796
|
-
if (!this.elicitationHandler) return { action: "decline" };
|
|
797
|
-
try {
|
|
798
|
-
return await this.elicitationHandler(params);
|
|
799
|
-
} catch (error) {
|
|
800
|
-
this.log("Elicitation handler error:", error);
|
|
801
|
-
return { action: "cancel" };
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
closeStream(reason) {
|
|
805
|
-
const controller = this.stream;
|
|
806
|
-
this.stream = void 0;
|
|
807
|
-
controller?.abort();
|
|
808
|
-
this.rejectPending(reason);
|
|
809
|
-
}
|
|
810
|
-
rejectPending(reason) {
|
|
811
|
-
for (const [id, pending] of this.pending) {
|
|
812
|
-
clearTimeout(pending.timer);
|
|
813
|
-
pending.resolve({ jsonrpc: "2.0", id, error: { code: -32e3, message: reason } });
|
|
814
|
-
}
|
|
815
|
-
this.pending.clear();
|
|
816
|
-
}
|
|
817
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
818
|
-
// POSTING
|
|
819
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
820
|
-
/** POST a message and resolve with the response the stream carries for `id`. */
|
|
821
|
-
async postAndAwait(id, body) {
|
|
822
|
-
const answer = new Promise((resolve) => {
|
|
823
|
-
const timer = setTimeout(() => {
|
|
824
|
-
this.pending.delete(id);
|
|
825
|
-
resolve({ jsonrpc: "2.0", id, error: { code: -32e3, message: `Request timeout after ${this.timeout}ms` } });
|
|
826
|
-
}, this.timeout);
|
|
827
|
-
this.pending.set(id, { resolve, timer });
|
|
828
|
-
});
|
|
829
|
-
const result = await this.post(body);
|
|
830
|
-
if (!result.ok) {
|
|
831
|
-
const pending = this.pending.get(id);
|
|
832
|
-
if (pending) {
|
|
833
|
-
clearTimeout(pending.timer);
|
|
834
|
-
this.pending.delete(id);
|
|
835
|
-
}
|
|
836
|
-
return {
|
|
837
|
-
jsonrpc: "2.0",
|
|
838
|
-
id,
|
|
839
|
-
error: { code: -32e3, message: `HTTP ${result.status}: ${result.statusText}`, data: result.body }
|
|
840
|
-
};
|
|
841
|
-
}
|
|
842
|
-
return answer;
|
|
843
|
-
}
|
|
844
|
-
async post(body) {
|
|
845
|
-
const endpoint = this.messageEndpoint;
|
|
846
|
-
if (!endpoint) throw new Error("SSE transport has no message endpoint. Call connect() first.");
|
|
847
|
-
const headers = this.buildHeaders();
|
|
848
|
-
this.lastRequestHeaders = headers;
|
|
849
|
-
this.log(`POST ${endpoint}`, body);
|
|
850
|
-
const controller = new AbortController();
|
|
851
|
-
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
852
|
-
try {
|
|
853
|
-
const response = await fetch(endpoint, { method: "POST", headers, body, signal: controller.signal });
|
|
854
|
-
const text = await response.text();
|
|
855
|
-
return { ok: response.ok, status: response.status, statusText: response.statusText, body: text };
|
|
856
|
-
} catch (error) {
|
|
857
|
-
if (isAbortError(error)) {
|
|
858
|
-
return { ok: false, status: 0, statusText: `timeout after ${this.timeout}ms`, body: "" };
|
|
859
|
-
}
|
|
860
|
-
throw error;
|
|
861
|
-
} finally {
|
|
862
|
-
clearTimeout(timer);
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
866
|
-
// HELPERS
|
|
867
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
868
|
-
async requestAnonymousToken() {
|
|
869
|
-
const url = new URL(this.baseUrl);
|
|
870
|
-
url.pathname = `${url.pathname.replace(/\/+$/, "")}/oauth/token`;
|
|
871
|
-
url.search = "";
|
|
872
|
-
const tokenUrl = url.toString();
|
|
873
|
-
try {
|
|
874
|
-
const response = await fetch(tokenUrl, {
|
|
875
|
-
method: "POST",
|
|
876
|
-
headers: { "Content-Type": "application/json" },
|
|
877
|
-
body: JSON.stringify({ grant_type: "anonymous", client_id: crypto.randomUUID(), resource: this.baseUrl })
|
|
878
|
-
});
|
|
879
|
-
if (!response.ok) {
|
|
880
|
-
this.log(`No anonymous token (HTTP ${response.status})`);
|
|
881
|
-
return;
|
|
882
|
-
}
|
|
883
|
-
const token = await response.json();
|
|
884
|
-
if (token.access_token) this.authToken = token.access_token;
|
|
885
|
-
} catch (error) {
|
|
886
|
-
this.log("Error requesting anonymous token:", error);
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
/** `<baseUrl><entryPath>/sse`, built from URL parts so query params on `baseUrl` survive. */
|
|
890
|
-
sseUrl() {
|
|
891
|
-
const url = new URL(this.baseUrl);
|
|
892
|
-
const entry = (this.entryPath ?? "").replace(/^\/+|\/+$/g, "");
|
|
893
|
-
const basePath = url.pathname.replace(/\/+$/, "");
|
|
894
|
-
url.pathname = entry ? `${basePath}/${entry}/sse` : `${basePath}/sse`;
|
|
895
|
-
return url.toString();
|
|
896
|
-
}
|
|
897
|
-
buildHeaders() {
|
|
898
|
-
const headers = {
|
|
899
|
-
"Content-Type": "application/json",
|
|
900
|
-
Accept: "application/json, text/event-stream"
|
|
901
|
-
};
|
|
902
|
-
if (this.clientInfo) headers["User-Agent"] = `${this.clientInfo.name}/${this.clientInfo.version}`;
|
|
903
|
-
if (this.authToken) headers["Authorization"] = `Bearer ${this.authToken}`;
|
|
904
|
-
if (this.sessionId) headers["mcp-session-id"] = this.sessionId;
|
|
905
|
-
Object.assign(headers, this.authHeaders);
|
|
906
|
-
return headers;
|
|
907
|
-
}
|
|
908
|
-
ensureConnected() {
|
|
909
|
-
if (this.state !== "connected") {
|
|
910
|
-
throw new Error("Transport not connected. Call connect() first.");
|
|
911
|
-
}
|
|
912
|
-
}
|
|
913
|
-
log(message, data) {
|
|
914
|
-
if (this.debug) {
|
|
915
|
-
console.log(`[SSE] ${message}`, data ?? "");
|
|
916
|
-
}
|
|
917
|
-
}
|
|
918
|
-
};
|
|
919
|
-
function splitSSEEvents(buffer) {
|
|
920
|
-
const endsWithCarriageReturn = buffer.endsWith("\r");
|
|
921
|
-
const normalized = (endsWithCarriageReturn ? buffer.slice(0, -1) : buffer).replace(/\r\n?/g, "\n");
|
|
922
|
-
const completeEvents = normalized.split("\n\n");
|
|
923
|
-
const remainder = completeEvents.pop() ?? "";
|
|
924
|
-
return { completeEvents, remainder: endsWithCarriageReturn ? `${remainder}\r` : remainder };
|
|
925
|
-
}
|
|
926
|
-
function parseSSEEvent(text) {
|
|
927
|
-
let type = "message";
|
|
928
|
-
const data = [];
|
|
929
|
-
for (const line of text.split("\n")) {
|
|
930
|
-
if (line.startsWith(":")) continue;
|
|
931
|
-
const colon = line.indexOf(":");
|
|
932
|
-
const field = colon === -1 ? line : line.slice(0, colon);
|
|
933
|
-
const value = colon === -1 ? "" : line.slice(colon + 1).replace(/^ /, "");
|
|
934
|
-
if (field === "event") type = value;
|
|
935
|
-
else if (field === "data") data.push(value);
|
|
936
|
-
}
|
|
937
|
-
return data.length > 0 ? { type, data: data.join("\n") } : void 0;
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
// libs/testing/src/transport/streamable-http.transport.ts
|
|
941
|
-
var DEFAULT_TIMEOUT2 = 3e4;
|
|
942
|
-
var NOTIFICATION_STREAM_REOPEN_DELAY_MS = 250;
|
|
943
|
-
var StreamableHttpTransport = class {
|
|
944
|
-
config;
|
|
945
|
-
state = "disconnected";
|
|
946
|
-
sessionId;
|
|
947
|
-
authToken;
|
|
948
|
-
connectionCount = 0;
|
|
949
|
-
reconnectCount = 0;
|
|
950
|
-
lastRequestHeaders = {};
|
|
951
|
-
interceptors;
|
|
952
|
-
publicMode;
|
|
953
|
-
elicitationHandler;
|
|
954
|
-
notificationHandler;
|
|
955
|
-
notificationStream;
|
|
956
|
-
notificationProtocolVersion = "";
|
|
957
|
-
notificationStreamReopenTimer;
|
|
958
|
-
constructor(config) {
|
|
959
|
-
this.config = {
|
|
960
|
-
baseUrl: config.baseUrl.replace(/\/$/, ""),
|
|
961
|
-
// Remove trailing slash
|
|
962
|
-
entryPath: config.entryPath,
|
|
963
|
-
timeout: config.timeout ?? DEFAULT_TIMEOUT2,
|
|
964
|
-
auth: config.auth ?? {},
|
|
965
|
-
publicMode: config.publicMode ?? false,
|
|
966
|
-
debug: config.debug ?? false,
|
|
967
|
-
interceptors: config.interceptors,
|
|
968
|
-
clientInfo: config.clientInfo
|
|
969
|
-
};
|
|
970
|
-
this.authToken = config.auth?.token;
|
|
971
|
-
this.interceptors = config.interceptors;
|
|
972
|
-
this.publicMode = config.publicMode ?? false;
|
|
973
|
-
this.elicitationHandler = config.elicitationHandler;
|
|
974
|
-
this.notificationHandler = config.notificationHandler;
|
|
975
|
-
}
|
|
976
|
-
async connect() {
|
|
977
|
-
this.state = "connecting";
|
|
978
|
-
this.connectionCount++;
|
|
979
|
-
try {
|
|
980
|
-
if (this.publicMode) {
|
|
981
|
-
this.log("Public mode: connecting without authentication");
|
|
982
|
-
this.state = "connected";
|
|
983
|
-
return;
|
|
984
|
-
}
|
|
985
|
-
if (!this.authToken) {
|
|
986
|
-
await this.requestAnonymousToken();
|
|
987
|
-
}
|
|
988
|
-
this.state = "connected";
|
|
989
|
-
this.log("Connected to StreamableHTTP transport");
|
|
990
|
-
} catch (error) {
|
|
991
|
-
this.state = "error";
|
|
992
|
-
throw error;
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
/**
|
|
996
|
-
* Request an anonymous token from the FrontMCP OAuth endpoint
|
|
997
|
-
* This allows the test client to authenticate without user interaction
|
|
998
|
-
*/
|
|
999
|
-
async requestAnonymousToken() {
|
|
1000
|
-
const clientId = crypto.randomUUID();
|
|
1001
|
-
const tokenUrl = `${this.config.baseUrl}/oauth/token`;
|
|
1002
|
-
this.log(`Requesting anonymous token from ${tokenUrl}`);
|
|
1003
|
-
try {
|
|
1004
|
-
const response = await fetch(tokenUrl, {
|
|
1005
|
-
method: "POST",
|
|
1006
|
-
headers: {
|
|
1007
|
-
"Content-Type": "application/json"
|
|
1008
|
-
},
|
|
1009
|
-
body: JSON.stringify({
|
|
1010
|
-
grant_type: "anonymous",
|
|
1011
|
-
client_id: clientId,
|
|
1012
|
-
resource: this.config.baseUrl
|
|
1013
|
-
})
|
|
1014
|
-
});
|
|
1015
|
-
if (!response.ok) {
|
|
1016
|
-
const errorText = await response.text();
|
|
1017
|
-
this.log(`Failed to get anonymous token: ${response.status} ${errorText}`);
|
|
1018
|
-
return;
|
|
1019
|
-
}
|
|
1020
|
-
const tokenResponse = await response.json();
|
|
1021
|
-
if (tokenResponse.access_token) {
|
|
1022
|
-
this.authToken = tokenResponse.access_token;
|
|
1023
|
-
this.log("Anonymous token acquired successfully");
|
|
1024
|
-
}
|
|
1025
|
-
} catch (error) {
|
|
1026
|
-
this.log(`Error requesting anonymous token: ${error}`);
|
|
1027
|
-
}
|
|
1028
|
-
}
|
|
1029
|
-
async request(message) {
|
|
1030
|
-
this.ensureConnected();
|
|
1031
|
-
const startTime = Date.now();
|
|
1032
|
-
if (this.interceptors) {
|
|
1033
|
-
const interceptResult = await this.interceptors.processRequest(message, {
|
|
1034
|
-
timestamp: /* @__PURE__ */ new Date(),
|
|
1035
|
-
transport: "streamable-http",
|
|
1036
|
-
sessionId: this.sessionId
|
|
1037
|
-
});
|
|
1038
|
-
switch (interceptResult.type) {
|
|
1039
|
-
case "mock": {
|
|
1040
|
-
const mockResponse2 = await this.interceptors.processResponse(
|
|
1041
|
-
message,
|
|
1042
|
-
interceptResult.response,
|
|
1043
|
-
Date.now() - startTime
|
|
1044
|
-
);
|
|
1045
|
-
return mockResponse2;
|
|
1046
|
-
}
|
|
1047
|
-
case "error":
|
|
1048
|
-
throw markInterceptedError(interceptResult.error);
|
|
1049
|
-
case "continue":
|
|
1050
|
-
message = interceptResult.request;
|
|
1051
|
-
break;
|
|
1052
|
-
}
|
|
1053
|
-
}
|
|
1054
|
-
const headers = this.buildHeaders();
|
|
1055
|
-
this.lastRequestHeaders = headers;
|
|
1056
|
-
const url = this.mcpUrl();
|
|
1057
|
-
this.log(`POST ${url}`, message);
|
|
1058
|
-
const controller = new AbortController();
|
|
1059
|
-
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
1060
|
-
try {
|
|
1061
|
-
const response = await fetch(url, {
|
|
1062
|
-
method: "POST",
|
|
1063
|
-
headers,
|
|
1064
|
-
body: JSON.stringify(message),
|
|
1065
|
-
signal: controller.signal
|
|
1066
|
-
});
|
|
1067
|
-
const newSessionId = response.headers.get("mcp-session-id");
|
|
1068
|
-
if (newSessionId) {
|
|
1069
|
-
this.sessionId = newSessionId;
|
|
1070
|
-
}
|
|
1071
|
-
let jsonResponse;
|
|
1072
|
-
if (!response.ok) {
|
|
1073
|
-
const errorText = await response.text();
|
|
1074
|
-
this.log(`HTTP Error ${response.status}: ${errorText}`);
|
|
1075
|
-
jsonResponse = {
|
|
1076
|
-
jsonrpc: "2.0",
|
|
1077
|
-
id: message.id ?? null,
|
|
1078
|
-
error: {
|
|
1079
|
-
code: -32e3,
|
|
1080
|
-
message: `HTTP ${response.status}: ${response.statusText}`,
|
|
1081
|
-
data: errorText
|
|
1082
|
-
}
|
|
1083
|
-
};
|
|
1084
|
-
} else {
|
|
1085
|
-
const contentType = response.headers.get("content-type") ?? "";
|
|
1086
|
-
if (contentType.includes("text/event-stream")) {
|
|
1087
|
-
jsonResponse = await this.handleSSEResponseWithElicitation(response, message);
|
|
1088
|
-
} else {
|
|
1089
|
-
const text = await response.text();
|
|
1090
|
-
this.log("Response:", text);
|
|
1091
|
-
if (!text.trim()) {
|
|
1092
|
-
jsonResponse = {
|
|
1093
|
-
jsonrpc: "2.0",
|
|
1094
|
-
id: message.id ?? null,
|
|
1095
|
-
result: void 0
|
|
1096
|
-
};
|
|
1097
|
-
} else {
|
|
1098
|
-
jsonResponse = JSON.parse(text);
|
|
1099
|
-
}
|
|
1100
|
-
}
|
|
1101
|
-
}
|
|
1102
|
-
if (this.interceptors) {
|
|
1103
|
-
jsonResponse = await this.interceptors.processResponse(message, jsonResponse, Date.now() - startTime);
|
|
1104
|
-
}
|
|
1105
|
-
clearTimeout(timeoutId);
|
|
1106
|
-
return jsonResponse;
|
|
1107
|
-
} catch (error) {
|
|
1108
|
-
clearTimeout(timeoutId);
|
|
1109
|
-
if (isAbortError(error)) {
|
|
1110
|
-
return {
|
|
1111
|
-
jsonrpc: "2.0",
|
|
1112
|
-
id: message.id ?? null,
|
|
1113
|
-
error: {
|
|
1114
|
-
code: -32e3,
|
|
1115
|
-
message: `Request timeout after ${this.config.timeout}ms`
|
|
1116
|
-
}
|
|
1117
|
-
};
|
|
1118
|
-
}
|
|
1119
|
-
throw error;
|
|
1120
|
-
}
|
|
1121
|
-
}
|
|
1122
|
-
async notify(message) {
|
|
1123
|
-
this.ensureConnected();
|
|
1124
|
-
const headers = this.buildHeaders();
|
|
1125
|
-
this.lastRequestHeaders = headers;
|
|
1126
|
-
const url = this.mcpUrl();
|
|
1127
|
-
this.log(`POST ${url} (notification)`, message);
|
|
1128
|
-
const controller = new AbortController();
|
|
1129
|
-
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
1130
|
-
try {
|
|
1131
|
-
const response = await fetch(url, {
|
|
1132
|
-
method: "POST",
|
|
1133
|
-
headers,
|
|
1134
|
-
body: JSON.stringify(message),
|
|
1135
|
-
signal: controller.signal
|
|
1136
|
-
});
|
|
1137
|
-
clearTimeout(timeoutId);
|
|
1138
|
-
const newSessionId = response.headers.get("mcp-session-id");
|
|
1139
|
-
if (newSessionId) {
|
|
1140
|
-
this.sessionId = newSessionId;
|
|
1141
|
-
}
|
|
1142
|
-
if (!response.ok) {
|
|
1143
|
-
const errorText = await response.text();
|
|
1144
|
-
this.log(`HTTP Error ${response.status} on notification: ${errorText}`);
|
|
1145
|
-
}
|
|
1146
|
-
} catch (error) {
|
|
1147
|
-
clearTimeout(timeoutId);
|
|
1148
|
-
if (!isAbortError(error)) {
|
|
1149
|
-
throw error;
|
|
1150
|
-
}
|
|
1151
|
-
}
|
|
1152
|
-
}
|
|
1153
|
-
async sendRaw(data) {
|
|
1154
|
-
this.ensureConnected();
|
|
1155
|
-
const headers = this.buildHeaders();
|
|
1156
|
-
this.lastRequestHeaders = headers;
|
|
1157
|
-
const url = this.mcpUrl();
|
|
1158
|
-
this.log(`POST ${url} (raw)`, data);
|
|
1159
|
-
const controller = new AbortController();
|
|
1160
|
-
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
1161
|
-
try {
|
|
1162
|
-
const response = await fetch(url, {
|
|
1163
|
-
method: "POST",
|
|
1164
|
-
headers,
|
|
1165
|
-
body: data,
|
|
1166
|
-
signal: controller.signal
|
|
1167
|
-
});
|
|
1168
|
-
clearTimeout(timeoutId);
|
|
1169
|
-
const text = await response.text();
|
|
1170
|
-
if (!text.trim()) {
|
|
1171
|
-
return {
|
|
1172
|
-
jsonrpc: "2.0",
|
|
1173
|
-
id: null,
|
|
1174
|
-
error: {
|
|
1175
|
-
code: -32700,
|
|
1176
|
-
message: "Parse error"
|
|
1177
|
-
}
|
|
1178
|
-
};
|
|
1179
|
-
}
|
|
1180
|
-
return JSON.parse(text);
|
|
1181
|
-
} catch (error) {
|
|
1182
|
-
clearTimeout(timeoutId);
|
|
1183
|
-
return {
|
|
1184
|
-
jsonrpc: "2.0",
|
|
1185
|
-
id: null,
|
|
1186
|
-
error: {
|
|
1187
|
-
code: -32700,
|
|
1188
|
-
message: "Parse error",
|
|
1189
|
-
data: errorMessage(error)
|
|
1190
|
-
}
|
|
1191
|
-
};
|
|
1192
|
-
}
|
|
1193
|
-
}
|
|
1194
|
-
async openNotificationStream(protocolVersion) {
|
|
1195
|
-
this.notificationProtocolVersion = protocolVersion;
|
|
1196
|
-
await this.connectNotificationStream();
|
|
1197
|
-
}
|
|
1198
|
-
async close() {
|
|
1199
|
-
this.closeNotificationStream();
|
|
1200
|
-
this.state = "disconnected";
|
|
1201
|
-
this.sessionId = void 0;
|
|
1202
|
-
this.log("StreamableHTTP transport closed");
|
|
1203
|
-
}
|
|
1204
|
-
isConnected() {
|
|
1205
|
-
return this.state === "connected";
|
|
1206
|
-
}
|
|
1207
|
-
getState() {
|
|
1208
|
-
return this.state;
|
|
1209
|
-
}
|
|
1210
|
-
getSessionId() {
|
|
1211
|
-
return this.sessionId;
|
|
1212
|
-
}
|
|
1213
|
-
setAuthToken(token) {
|
|
1214
|
-
this.authToken = token;
|
|
1215
|
-
}
|
|
1216
|
-
setTimeout(ms) {
|
|
1217
|
-
this.config.timeout = ms;
|
|
1218
|
-
}
|
|
1219
|
-
setInterceptors(interceptors2) {
|
|
1220
|
-
this.interceptors = interceptors2;
|
|
1221
|
-
}
|
|
1222
|
-
getInterceptors() {
|
|
1223
|
-
return this.interceptors;
|
|
1224
|
-
}
|
|
1225
|
-
setElicitationHandler(handler) {
|
|
1226
|
-
this.elicitationHandler = handler;
|
|
1227
|
-
}
|
|
1228
|
-
getConnectionCount() {
|
|
1229
|
-
return this.connectionCount;
|
|
1230
|
-
}
|
|
1231
|
-
getReconnectCount() {
|
|
1232
|
-
return this.reconnectCount;
|
|
1233
|
-
}
|
|
1234
|
-
getLastRequestHeaders() {
|
|
1235
|
-
return { ...this.lastRequestHeaders };
|
|
1236
|
-
}
|
|
1237
|
-
async simulateDisconnect() {
|
|
1238
|
-
this.closeNotificationStream();
|
|
1239
|
-
this.state = "disconnected";
|
|
1240
|
-
this.sessionId = void 0;
|
|
1241
|
-
}
|
|
1242
|
-
async waitForReconnect(timeoutMs) {
|
|
1243
|
-
const deadline = Date.now() + timeoutMs;
|
|
1244
|
-
this.reconnectCount++;
|
|
1245
|
-
await this.connect();
|
|
1246
|
-
while (Date.now() < deadline) {
|
|
1247
|
-
if (this.state === "connected") {
|
|
1248
|
-
return;
|
|
1249
|
-
}
|
|
1250
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
1251
|
-
}
|
|
1252
|
-
throw new Error("Timeout waiting for reconnection");
|
|
1253
|
-
}
|
|
1254
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
1255
|
-
// PRIVATE HELPERS
|
|
1256
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
1257
|
-
closeNotificationStream() {
|
|
1258
|
-
clearTimeout(this.notificationStreamReopenTimer);
|
|
1259
|
-
this.notificationStream?.abort();
|
|
1260
|
-
this.notificationStream = void 0;
|
|
1261
|
-
}
|
|
1262
|
-
/** Opens the stream without holding up the caller past the request timeout; close() aborts it at any point. */
|
|
1263
|
-
async connectNotificationStream() {
|
|
1264
|
-
if (!this.sessionId || this.notificationStream) return;
|
|
1265
|
-
const controller = new AbortController();
|
|
1266
|
-
this.notificationStream = controller;
|
|
1267
|
-
const { "Content-Type": _contentType, ...headers } = this.buildHeaders();
|
|
1268
|
-
const established = fetch(this.mcpUrl(), {
|
|
1269
|
-
method: "GET",
|
|
1270
|
-
headers: { ...headers, Accept: "text/event-stream", "MCP-Protocol-Version": this.notificationProtocolVersion },
|
|
1271
|
-
signal: controller.signal
|
|
1272
|
-
}).then(
|
|
1273
|
-
async (response) => {
|
|
1274
|
-
const isEventStream = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
|
|
1275
|
-
if (!response.ok || !response.body || !isEventStream) {
|
|
1276
|
-
await response.body?.cancel();
|
|
1277
|
-
this.log(`Server offers no notification stream (HTTP ${response.status})`);
|
|
1278
|
-
this.releaseNotificationStream(controller);
|
|
1279
|
-
return;
|
|
1280
|
-
}
|
|
1281
|
-
void this.readNotificationStream(controller, response.body.getReader());
|
|
1282
|
-
},
|
|
1283
|
-
(error) => {
|
|
1284
|
-
this.log("Failed to open the notification stream:", error);
|
|
1285
|
-
this.releaseNotificationStream(controller);
|
|
1286
|
-
}
|
|
1287
|
-
);
|
|
1288
|
-
let establishTimer;
|
|
1289
|
-
const establishDeadline = new Promise((resolve) => {
|
|
1290
|
-
establishTimer = setTimeout(resolve, this.config.timeout);
|
|
1291
|
-
});
|
|
1292
|
-
await Promise.race([established, establishDeadline]);
|
|
1293
|
-
clearTimeout(establishTimer);
|
|
1294
|
-
}
|
|
1295
|
-
releaseNotificationStream(controller) {
|
|
1296
|
-
if (this.notificationStream === controller) this.notificationStream = void 0;
|
|
1297
|
-
}
|
|
1298
|
-
async readNotificationStream(controller, reader) {
|
|
1299
|
-
const decoder = new TextDecoder();
|
|
1300
|
-
let buffer = "";
|
|
1301
|
-
try {
|
|
1302
|
-
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
|
|
1303
|
-
const { completeEvents, remainder } = splitSSEEvents2(buffer + decoder.decode(chunk.value, { stream: true }));
|
|
1304
|
-
buffer = remainder;
|
|
1305
|
-
for (const eventText of completeEvents) {
|
|
1306
|
-
for (const event of this.parseSSEEvents(eventText, void 0).events) {
|
|
1307
|
-
await this.handleSSEEvent(event);
|
|
1308
|
-
}
|
|
1309
|
-
}
|
|
1310
|
-
}
|
|
1311
|
-
} catch (error) {
|
|
1312
|
-
this.log("Notification stream ended:", error);
|
|
1313
|
-
} finally {
|
|
1314
|
-
reader.releaseLock();
|
|
1315
|
-
}
|
|
1316
|
-
if (controller.signal.aborted || this.notificationStream !== controller) return;
|
|
1317
|
-
this.releaseNotificationStream(controller);
|
|
1318
|
-
this.notificationStreamReopenTimer = setTimeout(
|
|
1319
|
-
() => void this.connectNotificationStream(),
|
|
1320
|
-
NOTIFICATION_STREAM_REOPEN_DELAY_MS
|
|
1321
|
-
);
|
|
1322
|
-
}
|
|
1323
|
-
/**
|
|
1324
|
-
* Handle SSE response with elicitation support.
|
|
1325
|
-
*
|
|
1326
|
-
* Streams the SSE response, detects elicitation/create requests, and handles them
|
|
1327
|
-
* by calling the registered handler and sending the response back to the server.
|
|
1328
|
-
*/
|
|
1329
|
-
async handleSSEResponseWithElicitation(response, originalRequest) {
|
|
1330
|
-
this.log("handleSSEResponseWithElicitation: starting", { requestId: originalRequest.id });
|
|
1331
|
-
const reader = response.body?.getReader();
|
|
1332
|
-
if (!reader) {
|
|
1333
|
-
this.log("handleSSEResponseWithElicitation: no response body");
|
|
1334
|
-
return {
|
|
1335
|
-
jsonrpc: "2.0",
|
|
1336
|
-
id: originalRequest.id ?? null,
|
|
1337
|
-
error: { code: -32e3, message: "No response body" }
|
|
1338
|
-
};
|
|
1339
|
-
}
|
|
1340
|
-
const decoder = new TextDecoder();
|
|
1341
|
-
let buffer = "";
|
|
1342
|
-
let finalResponse = null;
|
|
1343
|
-
let sseSessionId;
|
|
1344
|
-
try {
|
|
1345
|
-
let readCount = 0;
|
|
1346
|
-
while (true) {
|
|
1347
|
-
readCount++;
|
|
1348
|
-
this.log(`handleSSEResponseWithElicitation: reading chunk ${readCount}`);
|
|
1349
|
-
const { done, value } = await reader.read();
|
|
1350
|
-
this.log(`handleSSEResponseWithElicitation: read result`, { done, valueLength: value?.length });
|
|
1351
|
-
if (done) {
|
|
1352
|
-
if (buffer.trim()) {
|
|
1353
|
-
const parsed = this.parseSSEEvents(buffer, originalRequest.id);
|
|
1354
|
-
for (const event of parsed.events) {
|
|
1355
|
-
const handled = await this.handleSSEEvent(event);
|
|
1356
|
-
if (handled.isFinal) {
|
|
1357
|
-
finalResponse = handled.response;
|
|
1358
|
-
}
|
|
1359
|
-
}
|
|
1360
|
-
if (parsed.sessionId && !sseSessionId) {
|
|
1361
|
-
sseSessionId = parsed.sessionId;
|
|
1362
|
-
}
|
|
1363
|
-
}
|
|
1364
|
-
break;
|
|
1365
|
-
}
|
|
1366
|
-
buffer += decoder.decode(value, { stream: true });
|
|
1367
|
-
const eventEndPattern = /\n\n/g;
|
|
1368
|
-
let lastEventEnd = 0;
|
|
1369
|
-
let match;
|
|
1370
|
-
while ((match = eventEndPattern.exec(buffer)) !== null) {
|
|
1371
|
-
const eventText = buffer.slice(lastEventEnd, match.index);
|
|
1372
|
-
lastEventEnd = match.index + 2;
|
|
1373
|
-
if (eventText.trim()) {
|
|
1374
|
-
const parsed = this.parseSSEEvents(eventText, originalRequest.id);
|
|
1375
|
-
for (const event of parsed.events) {
|
|
1376
|
-
const handled = await this.handleSSEEvent(event);
|
|
1377
|
-
if (handled.isFinal) {
|
|
1378
|
-
finalResponse = handled.response;
|
|
1379
|
-
}
|
|
1380
|
-
}
|
|
1381
|
-
if (parsed.sessionId && !sseSessionId) {
|
|
1382
|
-
sseSessionId = parsed.sessionId;
|
|
1383
|
-
}
|
|
1384
|
-
}
|
|
1385
|
-
}
|
|
1386
|
-
buffer = buffer.slice(lastEventEnd);
|
|
1387
|
-
}
|
|
1388
|
-
} finally {
|
|
1389
|
-
reader.releaseLock();
|
|
1390
|
-
}
|
|
1391
|
-
if (sseSessionId && !this.sessionId) {
|
|
1392
|
-
this.sessionId = sseSessionId;
|
|
1393
|
-
this.log("Session ID from SSE:", this.sessionId);
|
|
1394
|
-
}
|
|
1395
|
-
if (finalResponse) {
|
|
1396
|
-
return finalResponse;
|
|
1397
|
-
}
|
|
1398
|
-
return {
|
|
1399
|
-
jsonrpc: "2.0",
|
|
1400
|
-
id: originalRequest.id ?? null,
|
|
1401
|
-
error: { code: -32e3, message: "No final response received in SSE stream" }
|
|
1402
|
-
};
|
|
1403
|
-
}
|
|
1404
|
-
/**
|
|
1405
|
-
* Parse SSE event text into structured events
|
|
1406
|
-
*/
|
|
1407
|
-
parseSSEEvents(text, _requestId) {
|
|
1408
|
-
const lines = text.split("\n");
|
|
1409
|
-
const events = [];
|
|
1410
|
-
let currentEvent = { type: "message", data: [] };
|
|
1411
|
-
let sessionId;
|
|
1412
|
-
for (const line of lines) {
|
|
1413
|
-
if (line.startsWith("event: ")) {
|
|
1414
|
-
currentEvent.type = line.slice(7);
|
|
1415
|
-
} else if (line.startsWith("data: ")) {
|
|
1416
|
-
currentEvent.data.push(line.slice(6));
|
|
1417
|
-
} else if (line === "data:") {
|
|
1418
|
-
currentEvent.data.push("");
|
|
1419
|
-
} else if (line.startsWith("id: ")) {
|
|
1420
|
-
const idValue = line.slice(4);
|
|
1421
|
-
currentEvent.id = idValue;
|
|
1422
|
-
const colonIndex = idValue.lastIndexOf(":");
|
|
1423
|
-
if (colonIndex > 0) {
|
|
1424
|
-
sessionId = idValue.substring(0, colonIndex);
|
|
1425
|
-
} else {
|
|
1426
|
-
sessionId = idValue;
|
|
1427
|
-
}
|
|
1428
|
-
} else if (line === "" && currentEvent.data.length > 0) {
|
|
1429
|
-
events.push({
|
|
1430
|
-
type: currentEvent.type,
|
|
1431
|
-
data: currentEvent.data.join("\n"),
|
|
1432
|
-
id: currentEvent.id
|
|
1433
|
-
});
|
|
1434
|
-
currentEvent = { type: "message", data: [] };
|
|
1435
|
-
}
|
|
1436
|
-
}
|
|
1437
|
-
if (currentEvent.data.length > 0) {
|
|
1438
|
-
events.push({
|
|
1439
|
-
type: currentEvent.type,
|
|
1440
|
-
data: currentEvent.data.join("\n"),
|
|
1441
|
-
id: currentEvent.id
|
|
1442
|
-
});
|
|
1443
|
-
}
|
|
1444
|
-
return { events, sessionId };
|
|
1445
|
-
}
|
|
1446
|
-
/**
|
|
1447
|
-
* Handle a single SSE event, including elicitation requests
|
|
1448
|
-
*/
|
|
1449
|
-
async handleSSEEvent(event) {
|
|
1450
|
-
this.log("SSE Event:", { type: event.type, data: event.data.slice(0, 200) });
|
|
1451
|
-
try {
|
|
1452
|
-
const parsed = JSON.parse(event.data);
|
|
1453
|
-
if ("method" in parsed && parsed.method === "elicitation/create") {
|
|
1454
|
-
await this.handleElicitationRequest(parsed);
|
|
1455
|
-
return {
|
|
1456
|
-
isFinal: false,
|
|
1457
|
-
response: { jsonrpc: "2.0", id: null, result: void 0 }
|
|
1458
|
-
};
|
|
1459
|
-
}
|
|
1460
|
-
if ("method" in parsed && parsed.id === void 0) {
|
|
1461
|
-
this.notificationHandler?.(parsed);
|
|
1462
|
-
return {
|
|
1463
|
-
isFinal: false,
|
|
1464
|
-
response: { jsonrpc: "2.0", id: null, result: void 0 }
|
|
1465
|
-
};
|
|
1466
|
-
}
|
|
1467
|
-
if ("result" in parsed || "error" in parsed) {
|
|
1468
|
-
return { isFinal: true, response: parsed };
|
|
1469
|
-
}
|
|
1470
|
-
return {
|
|
1471
|
-
isFinal: false,
|
|
1472
|
-
response: { jsonrpc: "2.0", id: null, result: void 0 }
|
|
1473
|
-
};
|
|
1474
|
-
} catch {
|
|
1475
|
-
this.log("Failed to parse SSE event data:", event.data);
|
|
1476
|
-
return {
|
|
1477
|
-
isFinal: false,
|
|
1478
|
-
response: { jsonrpc: "2.0", id: null, result: void 0 }
|
|
1479
|
-
};
|
|
1480
|
-
}
|
|
1481
|
-
}
|
|
1482
|
-
/**
|
|
1483
|
-
* Handle an elicitation/create request from the server
|
|
1484
|
-
*/
|
|
1485
|
-
async handleElicitationRequest(request) {
|
|
1486
|
-
const params = request.params;
|
|
1487
|
-
this.log("Elicitation request received:", {
|
|
1488
|
-
mode: params?.mode,
|
|
1489
|
-
message: params?.message?.slice(0, 100)
|
|
1490
|
-
});
|
|
1491
|
-
const requestId = request.id;
|
|
1492
|
-
if (requestId === void 0 || requestId === null) {
|
|
1493
|
-
this.log("Elicitation request has no ID, cannot respond");
|
|
1494
|
-
return;
|
|
1495
|
-
}
|
|
1496
|
-
if (!this.elicitationHandler) {
|
|
1497
|
-
this.log("No elicitation handler registered, sending error");
|
|
1498
|
-
await this.sendElicitationResponse(requestId, {
|
|
1499
|
-
action: "decline"
|
|
1500
|
-
});
|
|
1501
|
-
return;
|
|
1502
|
-
}
|
|
1503
|
-
try {
|
|
1504
|
-
const response = await this.elicitationHandler(params);
|
|
1505
|
-
this.log("Elicitation handler response:", response);
|
|
1506
|
-
await this.sendElicitationResponse(requestId, response);
|
|
1507
|
-
} catch (error) {
|
|
1508
|
-
this.log("Elicitation handler error:", error);
|
|
1509
|
-
await this.sendElicitationResponse(requestId, {
|
|
1510
|
-
action: "cancel"
|
|
1511
|
-
});
|
|
1512
|
-
}
|
|
1513
|
-
}
|
|
1514
|
-
/**
|
|
1515
|
-
* Send an elicitation response back to the server
|
|
1516
|
-
*/
|
|
1517
|
-
async sendElicitationResponse(requestId, response) {
|
|
1518
|
-
const headers = this.buildHeaders();
|
|
1519
|
-
const url = this.mcpUrl();
|
|
1520
|
-
const rpcResponse = {
|
|
1521
|
-
jsonrpc: "2.0",
|
|
1522
|
-
id: requestId,
|
|
1523
|
-
result: response
|
|
1524
|
-
};
|
|
1525
|
-
this.log("Sending elicitation response:", rpcResponse);
|
|
1526
|
-
try {
|
|
1527
|
-
const fetchResponse = await fetch(url, {
|
|
1528
|
-
method: "POST",
|
|
1529
|
-
headers,
|
|
1530
|
-
body: JSON.stringify(rpcResponse)
|
|
1531
|
-
});
|
|
1532
|
-
if (!fetchResponse.ok) {
|
|
1533
|
-
this.log(`Elicitation response HTTP error: ${fetchResponse.status}`);
|
|
1534
|
-
}
|
|
1535
|
-
} catch (error) {
|
|
1536
|
-
this.log("Failed to send elicitation response:", error);
|
|
1537
|
-
}
|
|
1538
|
-
}
|
|
1539
|
-
/**
|
|
1540
|
-
* The MCP endpoint URL. `entryPath` is where the server mounts MCP; the
|
|
1541
|
-
* trailing slash is kept because both hosts normalize it and the original
|
|
1542
|
-
* behaviour (`${baseUrl}/`) relied on it (issue #543).
|
|
1543
|
-
*/
|
|
1544
|
-
mcpUrl() {
|
|
1545
|
-
const url = new URL(this.config.baseUrl);
|
|
1546
|
-
const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
|
|
1547
|
-
const basePath = url.pathname.replace(/\/+$/, "");
|
|
1548
|
-
url.pathname = entry ? `${basePath}/${entry}` : `${basePath}/`;
|
|
1549
|
-
return url.toString();
|
|
1550
|
-
}
|
|
1551
|
-
buildHeaders() {
|
|
1552
|
-
const headers = {
|
|
1553
|
-
"Content-Type": "application/json",
|
|
1554
|
-
Accept: "application/json, text/event-stream"
|
|
1555
|
-
};
|
|
1556
|
-
if (this.config.clientInfo) {
|
|
1557
|
-
headers["User-Agent"] = `${this.config.clientInfo.name}/${this.config.clientInfo.version}`;
|
|
1558
|
-
}
|
|
1559
|
-
if (this.authToken) {
|
|
1560
|
-
headers["Authorization"] = `Bearer ${this.authToken}`;
|
|
1561
|
-
}
|
|
1562
|
-
if (this.sessionId) {
|
|
1563
|
-
headers["mcp-session-id"] = this.sessionId;
|
|
1564
|
-
}
|
|
1565
|
-
if (this.config.auth.headers) {
|
|
1566
|
-
Object.assign(headers, this.config.auth.headers);
|
|
1567
|
-
}
|
|
1568
|
-
return headers;
|
|
1569
|
-
}
|
|
1570
|
-
ensureConnected() {
|
|
1571
|
-
if (this.state !== "connected") {
|
|
1572
|
-
throw new Error("Transport not connected. Call connect() first.");
|
|
1573
|
-
}
|
|
1574
|
-
}
|
|
1575
|
-
log(message, data) {
|
|
1576
|
-
if (this.config.debug) {
|
|
1577
|
-
console.log(`[StreamableHTTP] ${message}`, data ?? "");
|
|
1578
|
-
}
|
|
1579
|
-
}
|
|
1580
|
-
/**
|
|
1581
|
-
* Parse SSE (Server-Sent Events) response format with session ID extraction
|
|
1582
|
-
* SSE format is:
|
|
1583
|
-
* event: message
|
|
1584
|
-
* id: sessionId:messageId
|
|
1585
|
-
* data: {"jsonrpc":"2.0",...}
|
|
1586
|
-
*
|
|
1587
|
-
* The id field contains the session ID followed by a colon and the message ID.
|
|
1588
|
-
*
|
|
1589
|
-
* @param text - The raw SSE response text
|
|
1590
|
-
* @param requestId - The original request ID
|
|
1591
|
-
* @returns Object with parsed JSON-RPC response and session ID (if found)
|
|
1592
|
-
*/
|
|
1593
|
-
parseSSEResponseWithSession(text, requestId) {
|
|
1594
|
-
const lines = text.split("\n");
|
|
1595
|
-
const dataLines = [];
|
|
1596
|
-
let sseSessionId;
|
|
1597
|
-
for (const line of lines) {
|
|
1598
|
-
if (line.startsWith("data: ")) {
|
|
1599
|
-
dataLines.push(line.slice(6));
|
|
1600
|
-
} else if (line === "data:") {
|
|
1601
|
-
dataLines.push("");
|
|
1602
|
-
} else if (line.startsWith("id: ")) {
|
|
1603
|
-
const idValue = line.slice(4);
|
|
1604
|
-
const colonIndex = idValue.lastIndexOf(":");
|
|
1605
|
-
if (colonIndex > 0) {
|
|
1606
|
-
sseSessionId = idValue.substring(0, colonIndex);
|
|
1607
|
-
} else {
|
|
1608
|
-
sseSessionId = idValue;
|
|
1609
|
-
}
|
|
1610
|
-
}
|
|
1611
|
-
}
|
|
1612
|
-
if (dataLines.length > 0) {
|
|
1613
|
-
const jsonData = dataLines.join("\n");
|
|
1614
|
-
try {
|
|
1615
|
-
return {
|
|
1616
|
-
response: JSON.parse(jsonData),
|
|
1617
|
-
sseSessionId
|
|
1618
|
-
};
|
|
1619
|
-
} catch {
|
|
1620
|
-
this.log("Failed to parse SSE data as JSON:", jsonData);
|
|
1621
|
-
}
|
|
1622
|
-
}
|
|
1623
|
-
return {
|
|
1624
|
-
response: {
|
|
1625
|
-
jsonrpc: "2.0",
|
|
1626
|
-
id: requestId ?? null,
|
|
1627
|
-
error: {
|
|
1628
|
-
code: -32700,
|
|
1629
|
-
message: "Failed to parse SSE response",
|
|
1630
|
-
data: text
|
|
1631
|
-
}
|
|
1632
|
-
},
|
|
1633
|
-
sseSessionId
|
|
1634
|
-
};
|
|
1635
|
-
}
|
|
1636
|
-
};
|
|
1637
|
-
function splitSSEEvents2(buffer) {
|
|
1638
|
-
const endsWithCarriageReturn = buffer.endsWith("\r");
|
|
1639
|
-
const normalized = (endsWithCarriageReturn ? buffer.slice(0, -1) : buffer).replace(/\r\n?/g, "\n");
|
|
1640
|
-
const completeEvents = normalized.split("\n\n");
|
|
1641
|
-
const remainder = completeEvents.pop() ?? "";
|
|
1642
|
-
return { completeEvents, remainder: endsWithCarriageReturn ? `${remainder}\r` : remainder };
|
|
1643
|
-
}
|
|
1644
|
-
|
|
1645
|
-
// libs/testing/src/platform/platform-client-info.ts
|
|
1646
|
-
var MCP_APPS_EXTENSION_KEY = "io.modelcontextprotocol/ui";
|
|
1647
|
-
function getPlatformClientInfo(platform) {
|
|
1648
|
-
switch (platform) {
|
|
1649
|
-
case "openai":
|
|
1650
|
-
return {
|
|
1651
|
-
name: "ChatGPT",
|
|
1652
|
-
version: "1.0"
|
|
1653
|
-
};
|
|
1654
|
-
case "ext-apps":
|
|
1655
|
-
return {
|
|
1656
|
-
name: "mcp-ext-apps",
|
|
1657
|
-
version: "1.0"
|
|
1658
|
-
};
|
|
1659
|
-
case "claude":
|
|
1660
|
-
return {
|
|
1661
|
-
name: "claude-desktop",
|
|
1662
|
-
version: "1.0"
|
|
1663
|
-
};
|
|
1664
|
-
case "cursor":
|
|
1665
|
-
return {
|
|
1666
|
-
name: "cursor",
|
|
1667
|
-
version: "1.0"
|
|
1668
|
-
};
|
|
1669
|
-
case "continue":
|
|
1670
|
-
return {
|
|
1671
|
-
name: "continue",
|
|
1672
|
-
version: "1.0"
|
|
1673
|
-
};
|
|
1674
|
-
case "cody":
|
|
1675
|
-
return {
|
|
1676
|
-
name: "cody",
|
|
1677
|
-
version: "1.0"
|
|
1678
|
-
};
|
|
1679
|
-
case "gemini":
|
|
1680
|
-
return {
|
|
1681
|
-
name: "gemini",
|
|
1682
|
-
version: "1.0"
|
|
1683
|
-
};
|
|
1684
|
-
case "generic-mcp":
|
|
1685
|
-
return {
|
|
1686
|
-
name: "generic-mcp-client",
|
|
1687
|
-
version: "1.0"
|
|
1688
|
-
};
|
|
1689
|
-
case "unknown":
|
|
1690
|
-
default:
|
|
1691
|
-
return {
|
|
1692
|
-
name: "mcp-test-client",
|
|
1693
|
-
version: "1.0"
|
|
1694
|
-
};
|
|
1695
|
-
}
|
|
1696
|
-
}
|
|
1697
|
-
function getPlatformCapabilities(platform) {
|
|
1698
|
-
const baseCapabilities = {
|
|
1699
|
-
sampling: {},
|
|
1700
|
-
// Include elicitation.form by default for testing elicitation workflows
|
|
1701
|
-
// Note: MCP SDK expects form to be an object, not boolean
|
|
1702
|
-
elicitation: {
|
|
1703
|
-
form: {}
|
|
1704
|
-
}
|
|
1705
|
-
};
|
|
1706
|
-
if (platform === "ext-apps") {
|
|
1707
|
-
return {
|
|
1708
|
-
...baseCapabilities,
|
|
1709
|
-
experimental: {
|
|
1710
|
-
[MCP_APPS_EXTENSION_KEY]: {
|
|
1711
|
-
mimeTypes: ["text/html;profile=mcp-app"]
|
|
1712
|
-
}
|
|
1713
|
-
}
|
|
1714
|
-
};
|
|
1715
|
-
}
|
|
1716
|
-
return baseCapabilities;
|
|
1717
|
-
}
|
|
1718
|
-
|
|
1719
|
-
// libs/testing/src/client/mcp-test-client.builder.ts
|
|
1720
|
-
var McpTestClientBuilder = class {
|
|
1721
|
-
config;
|
|
1722
|
-
constructor(config) {
|
|
1723
|
-
this.config = { ...config };
|
|
1724
|
-
}
|
|
1725
|
-
/**
|
|
1726
|
-
* Set the authentication configuration
|
|
1727
|
-
*/
|
|
1728
|
-
withAuth(auth) {
|
|
1729
|
-
this.config.auth = { ...this.config.auth, ...auth };
|
|
1730
|
-
return this;
|
|
1731
|
-
}
|
|
1732
|
-
/**
|
|
1733
|
-
* Set the bearer token for authentication
|
|
1734
|
-
*/
|
|
1735
|
-
withToken(token) {
|
|
1736
|
-
this.config.auth = { ...this.config.auth, token };
|
|
1737
|
-
return this;
|
|
1738
|
-
}
|
|
1739
|
-
/**
|
|
1740
|
-
* Add custom headers to all requests
|
|
1741
|
-
*/
|
|
1742
|
-
withHeaders(headers) {
|
|
1743
|
-
this.config.auth = {
|
|
1744
|
-
...this.config.auth,
|
|
1745
|
-
headers: { ...this.config.auth?.headers, ...headers }
|
|
1746
|
-
};
|
|
1747
|
-
return this;
|
|
1748
|
-
}
|
|
1749
|
-
/**
|
|
1750
|
-
* Set the transport type
|
|
1751
|
-
*/
|
|
1752
|
-
withTransport(transport) {
|
|
1753
|
-
this.config.transport = transport;
|
|
1754
|
-
return this;
|
|
1755
|
-
}
|
|
1756
|
-
/**
|
|
1757
|
-
* Set the request timeout in milliseconds
|
|
1758
|
-
*/
|
|
1759
|
-
withTimeout(timeoutMs) {
|
|
1760
|
-
this.config.timeout = timeoutMs;
|
|
1761
|
-
return this;
|
|
1762
|
-
}
|
|
1763
|
-
/**
|
|
1764
|
-
* Enable debug logging
|
|
1765
|
-
*/
|
|
1766
|
-
withDebug(enabled = true) {
|
|
1767
|
-
this.config.debug = enabled;
|
|
1768
|
-
return this;
|
|
1769
|
-
}
|
|
1770
|
-
/**
|
|
1771
|
-
* Enable public mode - skip authentication entirely.
|
|
1772
|
-
* When true, no Authorization header is sent and anonymous token is not requested.
|
|
1773
|
-
* Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
|
|
1774
|
-
*/
|
|
1775
|
-
withPublicMode(enabled = true) {
|
|
1776
|
-
this.config.publicMode = enabled;
|
|
1777
|
-
return this;
|
|
1778
|
-
}
|
|
1779
|
-
/**
|
|
1780
|
-
* Set the MCP protocol version to request (default '2025-06-18').
|
|
1781
|
-
* '2026-07-28' has no initialize handshake, so `build()` throws for it.
|
|
1782
|
-
*/
|
|
1783
|
-
withProtocolVersion(version) {
|
|
1784
|
-
this.config.protocolVersion = version;
|
|
1785
|
-
return this;
|
|
1786
|
-
}
|
|
1787
|
-
/**
|
|
1788
|
-
* Set the client info sent during initialization
|
|
1789
|
-
*/
|
|
1790
|
-
withClientInfo(info) {
|
|
1791
|
-
this.config.clientInfo = info;
|
|
1792
|
-
return this;
|
|
1793
|
-
}
|
|
1794
|
-
/**
|
|
1795
|
-
* Set the platform type for testing platform-specific meta keys.
|
|
1796
|
-
* Automatically configures clientInfo and capabilities for platform detection.
|
|
1797
|
-
*
|
|
1798
|
-
* Platform-specific behavior:
|
|
1799
|
-
* - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
|
|
1800
|
-
* - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
|
|
1801
|
-
* - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
|
|
1802
|
-
* - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
|
|
1803
|
-
* - Other platforms follow similar patterns
|
|
1804
|
-
*
|
|
1805
|
-
* @example
|
|
1806
|
-
* ```typescript
|
|
1807
|
-
* const client = await McpTestClient.create({ baseUrl })
|
|
1808
|
-
* .withPlatform('openai')
|
|
1809
|
-
* .buildAndConnect();
|
|
1810
|
-
*
|
|
1811
|
-
* // ext-apps automatically sets the io.modelcontextprotocol/ui capability
|
|
1812
|
-
* const extAppsClient = await McpTestClient.create({ baseUrl })
|
|
1813
|
-
* .withPlatform('ext-apps')
|
|
1814
|
-
* .buildAndConnect();
|
|
1815
|
-
* ```
|
|
1816
|
-
*/
|
|
1817
|
-
withPlatform(platform) {
|
|
1818
|
-
this.config.platform = platform;
|
|
1819
|
-
this.config.clientInfo = getPlatformClientInfo(platform);
|
|
1820
|
-
this.config.capabilities = getPlatformCapabilities(platform);
|
|
1821
|
-
return this;
|
|
1822
|
-
}
|
|
1823
|
-
/**
|
|
1824
|
-
* Set custom client capabilities for MCP initialization.
|
|
1825
|
-
* Use this for fine-grained control over capabilities sent during initialization.
|
|
1826
|
-
*
|
|
1827
|
-
* @example
|
|
1828
|
-
* ```typescript
|
|
1829
|
-
* const client = await McpTestClient.create({ baseUrl })
|
|
1830
|
-
* .withCapabilities({
|
|
1831
|
-
* sampling: {},
|
|
1832
|
-
* experimental: {
|
|
1833
|
-
* 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
|
|
1834
|
-
* }
|
|
1835
|
-
* })
|
|
1836
|
-
* .buildAndConnect();
|
|
1837
|
-
* ```
|
|
1838
|
-
*/
|
|
1839
|
-
withCapabilities(capabilities) {
|
|
1840
|
-
this.config.capabilities = capabilities;
|
|
1841
|
-
return this;
|
|
1842
|
-
}
|
|
1843
|
-
/**
|
|
1844
|
-
* Set query parameters to append to the connection URL.
|
|
1845
|
-
* Useful for testing mode switches like `?mode=skills_only`.
|
|
1846
|
-
*
|
|
1847
|
-
* @example
|
|
1848
|
-
* ```typescript
|
|
1849
|
-
* const client = await McpTestClient.create({ baseUrl })
|
|
1850
|
-
* .withQueryParams({ mode: 'skills_only' })
|
|
1851
|
-
* .buildAndConnect();
|
|
1852
|
-
* ```
|
|
1853
|
-
*/
|
|
1854
|
-
withQueryParams(params) {
|
|
1855
|
-
this.config.queryParams = { ...this.config.queryParams, ...params };
|
|
1856
|
-
return this;
|
|
1857
|
-
}
|
|
1858
|
-
/**
|
|
1859
|
-
* Build the McpTestClient instance (does not connect)
|
|
1860
|
-
*/
|
|
1861
|
-
build() {
|
|
1862
|
-
return new McpTestClient(this.config);
|
|
1863
|
-
}
|
|
1864
|
-
/**
|
|
1865
|
-
* Build the McpTestClient and connect to the server
|
|
1866
|
-
*/
|
|
1867
|
-
async buildAndConnect() {
|
|
1868
|
-
const client = this.build();
|
|
1869
|
-
await client.connect();
|
|
1870
|
-
return client;
|
|
1871
|
-
}
|
|
1872
|
-
};
|
|
1873
|
-
|
|
1874
|
-
// libs/testing/src/client/mcp-test-client.ts
|
|
1875
|
-
var DEFAULT_TIMEOUT3 = 3e4;
|
|
1876
|
-
var DEFAULT_PROTOCOL_VERSION = "2025-06-18";
|
|
1877
|
-
var UNSUPPORTED_PROTOCOL_VERSIONS = ["2026-07-28"];
|
|
1878
|
-
var DEFAULT_CLIENT_INFO = {
|
|
1879
|
-
name: "@frontmcp/testing",
|
|
1880
|
-
version: "0.4.0"
|
|
1881
|
-
};
|
|
1882
|
-
var MAX_LIST_PAGES = 1e3;
|
|
1883
|
-
function isAuthFailure(error) {
|
|
1884
|
-
return typeof error?.message === "string" && /^HTTP (401|403)\b/.test(error.message);
|
|
1885
|
-
}
|
|
1886
|
-
function describeInitializeError(error) {
|
|
1887
|
-
const message = error?.message ?? "Unknown error";
|
|
1888
|
-
const data = error?.data;
|
|
1889
|
-
const detail = typeof data === "string" ? data.trim() : "";
|
|
1890
|
-
return detail ? `${message} - ${detail.slice(0, 500)}` : message;
|
|
1891
|
-
}
|
|
1892
|
-
function extractEntryPaths(data) {
|
|
1893
|
-
let payload = data;
|
|
1894
|
-
if (typeof payload === "string") {
|
|
1895
|
-
try {
|
|
1896
|
-
payload = JSON.parse(payload);
|
|
1897
|
-
} catch {
|
|
1898
|
-
return void 0;
|
|
1899
|
-
}
|
|
1900
|
-
}
|
|
1901
|
-
if (!payload || typeof payload !== "object") return void 0;
|
|
1902
|
-
const paths = payload.entryPaths;
|
|
1903
|
-
if (!Array.isArray(paths)) return void 0;
|
|
1904
|
-
const strings = paths.filter((p) => typeof p === "string");
|
|
1905
|
-
return strings.length > 0 ? strings : void 0;
|
|
1906
|
-
}
|
|
1907
|
-
var McpTestClient = class {
|
|
1908
|
-
// Platform, capabilities, and queryParams are optional - only set when needed
|
|
1909
|
-
config;
|
|
1910
|
-
transport = null;
|
|
1911
|
-
initResult = null;
|
|
1912
|
-
requestIdCounter = 0;
|
|
1913
|
-
_lastRequestId = 0;
|
|
1914
|
-
_sessionId;
|
|
1915
|
-
/** #543 — discovery is attempted at most once per connect. */
|
|
1916
|
-
triedDiscoveredEntryPath = false;
|
|
1917
|
-
_sessionInfo = null;
|
|
1918
|
-
_authState = { isAnonymous: true, scopes: [] };
|
|
1919
|
-
// Logging and tracing
|
|
1920
|
-
_logs = [];
|
|
1921
|
-
_traces = [];
|
|
1922
|
-
_notifications = [];
|
|
1923
|
-
_progressUpdates = [];
|
|
1924
|
-
collectingProgress = false;
|
|
1925
|
-
progressTokenCounter = 0;
|
|
1926
|
-
// Interceptor chain
|
|
1927
|
-
_interceptors;
|
|
1928
|
-
// Elicitation handler for server→client elicit requests
|
|
1929
|
-
_elicitationHandler;
|
|
1930
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
1931
|
-
// CONSTRUCTOR & FACTORY
|
|
1932
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
1933
|
-
constructor(config) {
|
|
1934
|
-
if (config.protocolVersion && UNSUPPORTED_PROTOCOL_VERSIONS.includes(config.protocolVersion)) {
|
|
1935
|
-
throw new Error(
|
|
1936
|
-
`McpTestClient does not support protocol version ${config.protocolVersion}: it has no initialize handshake. Use ${DEFAULT_PROTOCOL_VERSION} or earlier.`
|
|
1937
|
-
);
|
|
1938
|
-
}
|
|
1939
|
-
this.config = {
|
|
1940
|
-
baseUrl: config.baseUrl,
|
|
1941
|
-
entryPath: config.entryPath,
|
|
1942
|
-
transport: config.transport ?? "streamable-http",
|
|
1943
|
-
auth: config.auth ?? {},
|
|
1944
|
-
publicMode: config.publicMode ?? false,
|
|
1945
|
-
timeout: config.timeout ?? DEFAULT_TIMEOUT3,
|
|
1946
|
-
debug: config.debug ?? false,
|
|
1947
|
-
protocolVersion: config.protocolVersion ?? DEFAULT_PROTOCOL_VERSION,
|
|
1948
|
-
clientInfo: config.clientInfo ?? DEFAULT_CLIENT_INFO,
|
|
1949
|
-
platform: config.platform,
|
|
1950
|
-
capabilities: config.capabilities,
|
|
1951
|
-
queryParams: config.queryParams
|
|
1952
|
-
};
|
|
1953
|
-
if (config.auth?.token) {
|
|
1954
|
-
this._authState = {
|
|
1955
|
-
isAnonymous: false,
|
|
1956
|
-
token: config.auth.token,
|
|
1957
|
-
scopes: this.parseScopesFromToken(config.auth.token),
|
|
1958
|
-
user: this.parseUserFromToken(config.auth.token)
|
|
1959
|
-
};
|
|
1960
|
-
}
|
|
1961
|
-
this._interceptors = new DefaultInterceptorChain();
|
|
1962
|
-
}
|
|
1963
|
-
/**
|
|
1964
|
-
* Create a new McpTestClientBuilder for fluent configuration
|
|
1965
|
-
*/
|
|
1966
|
-
static create(config) {
|
|
1967
|
-
return new McpTestClientBuilder(config);
|
|
1968
|
-
}
|
|
1969
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
1970
|
-
// CONNECTION & LIFECYCLE
|
|
1971
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
1972
|
-
/**
|
|
1973
|
-
* Connect to the MCP server and perform initialization
|
|
1974
|
-
*/
|
|
1975
|
-
async connect() {
|
|
1976
|
-
this.log("debug", `Connecting to ${this.config.baseUrl}...`);
|
|
1977
|
-
this.triedDiscoveredEntryPath = false;
|
|
1978
|
-
this.initResult = null;
|
|
1979
|
-
this.transport = this.createTransport();
|
|
1980
|
-
await this.transport.connect();
|
|
1981
|
-
const maxRetries = 3;
|
|
1982
|
-
const retryDelayMs = 500;
|
|
1983
|
-
let lastError;
|
|
1984
|
-
let reportedEntryPaths;
|
|
1985
|
-
let attemptsMade = 0;
|
|
1986
|
-
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
1987
|
-
attemptsMade = attempt;
|
|
1988
|
-
const initResponse = await this.initialize();
|
|
1989
|
-
if (initResponse.success && initResponse.data) {
|
|
1990
|
-
this.initResult = initResponse.data;
|
|
1991
|
-
break;
|
|
1992
|
-
}
|
|
1993
|
-
lastError = describeInitializeError(initResponse.error);
|
|
1994
|
-
reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
|
|
1995
|
-
if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
|
|
1996
|
-
this.triedDiscoveredEntryPath = true;
|
|
1997
|
-
const discovered = reportedEntryPaths[0];
|
|
1998
|
-
this.log("info", `Server serves MCP at ${discovered}; reconnecting there (configured: ${this.mcpPathLabel()})`);
|
|
1999
|
-
this.config.entryPath = discovered;
|
|
2000
|
-
await this.transport.close();
|
|
2001
|
-
this.transport = this.createTransport();
|
|
2002
|
-
await this.transport.connect();
|
|
2003
|
-
continue;
|
|
2004
|
-
}
|
|
2005
|
-
if (isAuthFailure(initResponse.error)) break;
|
|
2006
|
-
if (attempt < maxRetries) {
|
|
2007
|
-
this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
|
|
2008
|
-
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
2009
|
-
}
|
|
2010
|
-
}
|
|
2011
|
-
if (!this.initResult) {
|
|
2012
|
-
const served = reportedEntryPaths?.length ? ` The server reports it serves MCP at ${reportedEntryPaths.join(", ")}; set \`entryPath\` in test.use() (or \`http.entryPath\` on the server) to match.` : "";
|
|
2013
|
-
throw new Error(
|
|
2014
|
-
`Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${attemptsMade} attempt${attemptsMade === 1 ? "" : "s"}: ${lastError}.${served}`
|
|
2015
|
-
);
|
|
2016
|
-
}
|
|
2017
|
-
this._sessionId = this.transport.getSessionId();
|
|
2018
|
-
this._sessionInfo = {
|
|
2019
|
-
id: this._sessionId ?? `session-${Date.now()}`,
|
|
2020
|
-
createdAt: /* @__PURE__ */ new Date(),
|
|
2021
|
-
lastActivityAt: /* @__PURE__ */ new Date(),
|
|
2022
|
-
requestCount: 1
|
|
2023
|
-
};
|
|
2024
|
-
await this.transport.notify({
|
|
2025
|
-
jsonrpc: "2.0",
|
|
2026
|
-
method: "notifications/initialized"
|
|
2027
|
-
});
|
|
2028
|
-
await this.transport.openNotificationStream?.(this.initResult.protocolVersion);
|
|
2029
|
-
this.log("info", `Connected to ${this.initResult.serverInfo?.name ?? "MCP Server"}`);
|
|
2030
|
-
return this.initResult;
|
|
2031
|
-
}
|
|
2032
|
-
/**
|
|
2033
|
-
* Disconnect from the MCP server
|
|
2034
|
-
*/
|
|
2035
|
-
async disconnect() {
|
|
2036
|
-
if (this.transport) {
|
|
2037
|
-
await this.transport.close();
|
|
2038
|
-
this.transport = null;
|
|
2039
|
-
}
|
|
2040
|
-
this.initResult = null;
|
|
2041
|
-
this.log("info", "Disconnected from MCP server");
|
|
2042
|
-
}
|
|
2043
|
-
/**
|
|
2044
|
-
* Reconnect to the server, optionally with an existing session ID
|
|
2045
|
-
*/
|
|
2046
|
-
async reconnect(options) {
|
|
2047
|
-
await this.disconnect();
|
|
2048
|
-
if (options?.sessionId && this.transport) {
|
|
2049
|
-
this._sessionId = options.sessionId;
|
|
2050
|
-
}
|
|
2051
|
-
await this.connect();
|
|
2052
|
-
}
|
|
2053
|
-
/**
|
|
2054
|
-
* Check if the client is currently connected
|
|
2055
|
-
*/
|
|
2056
|
-
isConnected() {
|
|
2057
|
-
return this.transport?.isConnected() ?? false;
|
|
2058
|
-
}
|
|
2059
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2060
|
-
// SESSION & AUTH PROPERTIES
|
|
2061
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2062
|
-
get sessionId() {
|
|
2063
|
-
return this._sessionId ?? "";
|
|
2064
|
-
}
|
|
2065
|
-
get session() {
|
|
2066
|
-
const info = this._sessionInfo ?? {
|
|
2067
|
-
id: "",
|
|
2068
|
-
createdAt: /* @__PURE__ */ new Date(),
|
|
2069
|
-
lastActivityAt: /* @__PURE__ */ new Date(),
|
|
2070
|
-
requestCount: 0
|
|
2071
|
-
};
|
|
2072
|
-
return {
|
|
2073
|
-
...info,
|
|
2074
|
-
expire: async () => {
|
|
2075
|
-
this._sessionId = void 0;
|
|
2076
|
-
this._sessionInfo = null;
|
|
2077
|
-
}
|
|
2078
|
-
};
|
|
2079
|
-
}
|
|
2080
|
-
get auth() {
|
|
2081
|
-
return this._authState;
|
|
2082
|
-
}
|
|
2083
|
-
/**
|
|
2084
|
-
* Authenticate with a token.
|
|
2085
|
-
*
|
|
2086
|
-
* When connected, this opens a NEW session with the token (the previous session was created
|
|
2087
|
-
* for the previous identity) and rejects if the server refuses it — for example because the
|
|
2088
|
-
* token is expired or badly signed. On rejection the client keeps its previous identity and
|
|
2089
|
-
* session. The token is kept in the client config, so `reconnect()` keeps using it.
|
|
2090
|
-
*/
|
|
2091
|
-
async authenticate(token) {
|
|
2092
|
-
if (typeof token !== "string" || token.trim() === "") {
|
|
2093
|
-
throw new Error("authenticate() requires a non-empty token");
|
|
2094
|
-
}
|
|
2095
|
-
const previousAuthState = this._authState;
|
|
2096
|
-
const previousConfigAuth = this.config.auth;
|
|
2097
|
-
this.config.auth = { ...previousConfigAuth, token };
|
|
2098
|
-
this._authState = {
|
|
2099
|
-
isAnonymous: false,
|
|
2100
|
-
token,
|
|
2101
|
-
scopes: this.parseScopesFromToken(token),
|
|
2102
|
-
user: this.parseUserFromToken(token)
|
|
2103
|
-
};
|
|
2104
|
-
const previousTransport = this.transport;
|
|
2105
|
-
if (!previousTransport) {
|
|
2106
|
-
this.log("debug", "Authentication updated (not connected yet)");
|
|
2107
|
-
return;
|
|
2108
|
-
}
|
|
2109
|
-
const previousInit = this.initResult;
|
|
2110
|
-
const previousSessionId = this._sessionId;
|
|
2111
|
-
const previousSessionInfo = this._sessionInfo;
|
|
2112
|
-
try {
|
|
2113
|
-
await this.connect();
|
|
2114
|
-
} catch (err) {
|
|
2115
|
-
await this.transport?.close().catch(() => void 0);
|
|
2116
|
-
this.transport = previousTransport;
|
|
2117
|
-
this.initResult = previousInit;
|
|
2118
|
-
this._sessionId = previousSessionId;
|
|
2119
|
-
this._sessionInfo = previousSessionInfo;
|
|
2120
|
-
this.config.auth = previousConfigAuth;
|
|
2121
|
-
this._authState = previousAuthState;
|
|
2122
|
-
throw err;
|
|
2123
|
-
}
|
|
2124
|
-
await previousTransport.close().catch(() => void 0);
|
|
2125
|
-
this.log("debug", "Authentication updated");
|
|
2126
|
-
}
|
|
2127
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2128
|
-
// SERVER INFO & CAPABILITIES
|
|
2129
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2130
|
-
get serverInfo() {
|
|
2131
|
-
return {
|
|
2132
|
-
name: this.initResult?.serverInfo?.name ?? "",
|
|
2133
|
-
version: this.initResult?.serverInfo?.version ?? ""
|
|
2134
|
-
};
|
|
2135
|
-
}
|
|
2136
|
-
get protocolVersion() {
|
|
2137
|
-
return this.initResult?.protocolVersion ?? "";
|
|
2138
|
-
}
|
|
2139
|
-
get instructions() {
|
|
2140
|
-
return this.initResult?.instructions ?? "";
|
|
2141
|
-
}
|
|
2142
|
-
get capabilities() {
|
|
2143
|
-
return this.initResult?.capabilities ?? {};
|
|
2144
|
-
}
|
|
2145
|
-
/**
|
|
2146
|
-
* Check if server has a specific capability
|
|
2147
|
-
*/
|
|
2148
|
-
hasCapability(name) {
|
|
2149
|
-
return !!this.capabilities[name];
|
|
2150
|
-
}
|
|
2151
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2152
|
-
// TOOLS API
|
|
2153
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2154
|
-
tools = {
|
|
2155
|
-
/**
|
|
2156
|
-
* List all available tools
|
|
2157
|
-
*/
|
|
2158
|
-
list: async () => this.listAllPages(
|
|
2159
|
-
"tools/list",
|
|
2160
|
-
"tools",
|
|
2161
|
-
(cursor) => this.listTools(cursor),
|
|
2162
|
-
(page) => page.tools
|
|
2163
|
-
),
|
|
2164
|
-
/**
|
|
2165
|
-
* Call a tool by name with arguments
|
|
2166
|
-
*/
|
|
2167
|
-
call: async (name, args, options = {}) => {
|
|
2168
|
-
const response = await this.callTool(name, args, this.progressTokenFor(options));
|
|
2169
|
-
return this.wrapToolResult(response);
|
|
2170
|
-
}
|
|
2171
|
-
};
|
|
2172
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2173
|
-
// RESOURCES API
|
|
2174
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2175
|
-
resources = {
|
|
2176
|
-
/**
|
|
2177
|
-
* List all static resources
|
|
2178
|
-
*/
|
|
2179
|
-
list: async () => this.listAllPages(
|
|
2180
|
-
"resources/list",
|
|
2181
|
-
"resources",
|
|
2182
|
-
(cursor) => this.listResources(cursor),
|
|
2183
|
-
(page) => page.resources
|
|
2184
|
-
),
|
|
2185
|
-
/**
|
|
2186
|
-
* List all resource templates
|
|
2187
|
-
*/
|
|
2188
|
-
listTemplates: async () => this.listAllPages(
|
|
2189
|
-
"resources/templates/list",
|
|
2190
|
-
"resource templates",
|
|
2191
|
-
(cursor) => this.listResourceTemplates(cursor),
|
|
2192
|
-
(page) => page.resourceTemplates
|
|
2193
|
-
),
|
|
2194
|
-
/**
|
|
2195
|
-
* Read a resource by URI
|
|
2196
|
-
*/
|
|
2197
|
-
read: async (uri) => {
|
|
2198
|
-
const response = await this.readResource(uri);
|
|
2199
|
-
return this.wrapResourceContent(response);
|
|
2200
|
-
},
|
|
2201
|
-
/**
|
|
2202
|
-
* Subscribe to resource changes (placeholder for future implementation)
|
|
2203
|
-
*/
|
|
2204
|
-
subscribe: async (_uri) => {
|
|
2205
|
-
this.log("warn", "Resource subscription not yet implemented");
|
|
2206
|
-
},
|
|
2207
|
-
/**
|
|
2208
|
-
* Unsubscribe from resource changes (placeholder for future implementation)
|
|
2209
|
-
*/
|
|
2210
|
-
unsubscribe: async (_uri) => {
|
|
2211
|
-
this.log("warn", "Resource unsubscription not yet implemented");
|
|
2212
|
-
}
|
|
2213
|
-
};
|
|
2214
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2215
|
-
// PROMPTS API
|
|
2216
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2217
|
-
prompts = {
|
|
2218
|
-
/**
|
|
2219
|
-
* List all available prompts
|
|
2220
|
-
*/
|
|
2221
|
-
list: async () => this.listAllPages(
|
|
2222
|
-
"prompts/list",
|
|
2223
|
-
"prompts",
|
|
2224
|
-
(cursor) => this.listPrompts(cursor),
|
|
2225
|
-
(page) => page.prompts
|
|
2226
|
-
),
|
|
2227
|
-
/**
|
|
2228
|
-
* Get a prompt with arguments
|
|
2229
|
-
*/
|
|
2230
|
-
get: async (name, args) => {
|
|
2231
|
-
const response = await this.getPrompt(name, args);
|
|
2232
|
-
return this.wrapPromptResult(response);
|
|
2233
|
-
}
|
|
2234
|
-
};
|
|
2235
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2236
|
-
// RAW PROTOCOL ACCESS
|
|
2237
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2238
|
-
raw = {
|
|
2239
|
-
/**
|
|
2240
|
-
* Send any JSON-RPC request
|
|
2241
|
-
*/
|
|
2242
|
-
request: async (message) => {
|
|
2243
|
-
const transport = this.getConnectedTransport();
|
|
2244
|
-
const start = Date.now();
|
|
2245
|
-
const response = await transport.request(message);
|
|
2246
|
-
this.traceRequest(message.method, message.params, message.id, response, Date.now() - start);
|
|
2247
|
-
return response;
|
|
2248
|
-
},
|
|
2249
|
-
/**
|
|
2250
|
-
* Send a notification (no response expected)
|
|
2251
|
-
*/
|
|
2252
|
-
notify: async (message) => {
|
|
2253
|
-
const transport = this.getConnectedTransport();
|
|
2254
|
-
await transport.notify(message);
|
|
2255
|
-
},
|
|
2256
|
-
/**
|
|
2257
|
-
* Send raw string data (for error testing)
|
|
2258
|
-
*/
|
|
2259
|
-
sendRaw: async (data) => {
|
|
2260
|
-
const transport = this.getConnectedTransport();
|
|
2261
|
-
return transport.sendRaw(data);
|
|
2262
|
-
}
|
|
2263
|
-
};
|
|
2264
|
-
get lastRequestId() {
|
|
2265
|
-
return this._lastRequestId;
|
|
2266
|
-
}
|
|
2267
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2268
|
-
// TRANSPORT INFO
|
|
2269
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2270
|
-
/**
|
|
2271
|
-
* Get transport information and utilities
|
|
2272
|
-
*/
|
|
2273
|
-
get transport_info() {
|
|
2274
|
-
return {
|
|
2275
|
-
type: this.config.transport,
|
|
2276
|
-
isConnected: () => this.transport?.isConnected() ?? false,
|
|
2277
|
-
messageEndpoint: this.transport?.getMessageEndpoint?.(),
|
|
2278
|
-
connectionCount: this.transport?.getConnectionCount?.() ?? 0,
|
|
2279
|
-
reconnectCount: this.transport?.getReconnectCount?.() ?? 0,
|
|
2280
|
-
lastRequestHeaders: this.transport?.getLastRequestHeaders?.() ?? {},
|
|
2281
|
-
simulateDisconnect: async () => {
|
|
2282
|
-
await this.transport?.simulateDisconnect?.();
|
|
2283
|
-
},
|
|
2284
|
-
waitForReconnect: async (timeoutMs) => {
|
|
2285
|
-
await this.transport?.waitForReconnect?.(timeoutMs);
|
|
2286
|
-
}
|
|
2287
|
-
};
|
|
2288
|
-
}
|
|
2289
|
-
// Alias for transport info
|
|
2290
|
-
get transport_() {
|
|
2291
|
-
return this.transport_info;
|
|
2292
|
-
}
|
|
2293
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2294
|
-
// NOTIFICATIONS
|
|
2295
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2296
|
-
notifications = {
|
|
2297
|
-
/**
|
|
2298
|
-
* Start collecting server notifications
|
|
2299
|
-
*/
|
|
2300
|
-
collect: () => {
|
|
2301
|
-
return new NotificationCollector(this._notifications);
|
|
2302
|
-
},
|
|
2303
|
-
/**
|
|
2304
|
-
* Collect progress notifications specifically
|
|
2305
|
-
*/
|
|
2306
|
-
collectProgress: () => {
|
|
2307
|
-
this.collectingProgress = true;
|
|
2308
|
-
return new ProgressCollector(this._progressUpdates);
|
|
2309
|
-
},
|
|
2310
|
-
/**
|
|
2311
|
-
* Send a notification to the server
|
|
2312
|
-
*/
|
|
2313
|
-
send: async (method, params) => {
|
|
2314
|
-
await this.raw.notify({ jsonrpc: "2.0", method, params });
|
|
2315
|
-
}
|
|
2316
|
-
};
|
|
2317
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2318
|
-
// ELICITATION
|
|
2319
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2320
|
-
/**
|
|
2321
|
-
* Register a handler for elicitation requests from the server.
|
|
2322
|
-
*
|
|
2323
|
-
* When a tool calls `this.elicit()` during execution, the server sends an
|
|
2324
|
-
* `elicitation/create` request to the client. This handler is called to
|
|
2325
|
-
* provide the response that would normally come from user interaction.
|
|
2326
|
-
*
|
|
2327
|
-
* @param handler - Function that receives the elicitation request and returns a response
|
|
2328
|
-
*
|
|
2329
|
-
* @example
|
|
2330
|
-
* ```typescript
|
|
2331
|
-
* // Simple acceptance
|
|
2332
|
-
* mcp.onElicitation(async () => ({
|
|
2333
|
-
* action: 'accept',
|
|
2334
|
-
* content: { confirmed: true }
|
|
2335
|
-
* }));
|
|
2336
|
-
*
|
|
2337
|
-
* // Conditional response based on request
|
|
2338
|
-
* mcp.onElicitation(async (request) => {
|
|
2339
|
-
* if (request.message.includes('delete')) {
|
|
2340
|
-
* return { action: 'decline' };
|
|
2341
|
-
* }
|
|
2342
|
-
* return { action: 'accept', content: { approved: true } };
|
|
2343
|
-
* });
|
|
2344
|
-
*
|
|
2345
|
-
* // Multi-step wizard
|
|
2346
|
-
* let step = 0;
|
|
2347
|
-
* mcp.onElicitation(async () => {
|
|
2348
|
-
* step++;
|
|
2349
|
-
* if (step === 1) return { action: 'accept', content: { name: 'Alice' } };
|
|
2350
|
-
* return { action: 'accept', content: { color: 'blue' } };
|
|
2351
|
-
* });
|
|
2352
|
-
* ```
|
|
2353
|
-
*/
|
|
2354
|
-
onElicitation(handler) {
|
|
2355
|
-
this._elicitationHandler = handler;
|
|
2356
|
-
if (this.transport?.setElicitationHandler) {
|
|
2357
|
-
this.transport.setElicitationHandler(handler);
|
|
2358
|
-
}
|
|
2359
|
-
this.log("debug", "Elicitation handler registered");
|
|
2360
|
-
}
|
|
2361
|
-
/**
|
|
2362
|
-
* Clear the elicitation handler.
|
|
2363
|
-
*
|
|
2364
|
-
* After calling this, elicitation requests from the server will not be
|
|
2365
|
-
* handled automatically. This can be used to test timeout scenarios.
|
|
2366
|
-
*/
|
|
2367
|
-
clearElicitationHandler() {
|
|
2368
|
-
this._elicitationHandler = void 0;
|
|
2369
|
-
if (this.transport?.setElicitationHandler) {
|
|
2370
|
-
this.transport.setElicitationHandler(void 0);
|
|
2371
|
-
}
|
|
2372
|
-
this.log("debug", "Elicitation handler cleared");
|
|
2373
|
-
}
|
|
2374
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2375
|
-
// LOGGING & DEBUGGING
|
|
2376
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2377
|
-
logs = {
|
|
2378
|
-
all: () => [...this._logs],
|
|
2379
|
-
filter: (level) => this._logs.filter((l) => l.level === level),
|
|
2380
|
-
search: (text) => this._logs.filter((l) => l.message.includes(text)),
|
|
2381
|
-
last: () => this._logs[this._logs.length - 1],
|
|
2382
|
-
clear: () => {
|
|
2383
|
-
this._logs = [];
|
|
2384
|
-
}
|
|
2385
|
-
};
|
|
2386
|
-
trace = {
|
|
2387
|
-
all: () => [...this._traces],
|
|
2388
|
-
last: () => this._traces[this._traces.length - 1],
|
|
2389
|
-
clear: () => {
|
|
2390
|
-
this._traces = [];
|
|
2391
|
-
}
|
|
2392
|
-
};
|
|
2393
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2394
|
-
// MOCKING & INTERCEPTION
|
|
2395
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2396
|
-
/**
|
|
2397
|
-
* API for mocking MCP requests
|
|
2398
|
-
*
|
|
2399
|
-
* @example
|
|
2400
|
-
* ```typescript
|
|
2401
|
-
* // Mock a specific tool call
|
|
2402
|
-
* const handle = mcp.mock.tool('my-tool', { result: 'mocked!' });
|
|
2403
|
-
*
|
|
2404
|
-
* // Mock with params matching
|
|
2405
|
-
* mcp.mock.add({
|
|
2406
|
-
* method: 'tools/call',
|
|
2407
|
-
* params: { name: 'my-tool' },
|
|
2408
|
-
* response: mockResponse.toolResult([{ type: 'text', text: 'mocked' }]),
|
|
2409
|
-
* });
|
|
2410
|
-
*
|
|
2411
|
-
* // Clear all mocks after test
|
|
2412
|
-
* mcp.mock.clear();
|
|
2413
|
-
* ```
|
|
2414
|
-
*/
|
|
2415
|
-
mock = {
|
|
2416
|
-
/**
|
|
2417
|
-
* Add a mock definition
|
|
2418
|
-
*/
|
|
2419
|
-
add: (mock) => {
|
|
2420
|
-
return this._interceptors.mocks.add(mock);
|
|
2421
|
-
},
|
|
2422
|
-
/**
|
|
2423
|
-
* Mock a tools/call request for a specific tool
|
|
2424
|
-
*/
|
|
2425
|
-
tool: (name, result, options) => {
|
|
2426
|
-
return this._interceptors.mocks.add({
|
|
2427
|
-
method: "tools/call",
|
|
2428
|
-
params: { name },
|
|
2429
|
-
response: mockResponse.toolResult([
|
|
2430
|
-
{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result) }
|
|
2431
|
-
]),
|
|
2432
|
-
times: options?.times,
|
|
2433
|
-
delay: options?.delay
|
|
2434
|
-
});
|
|
2435
|
-
},
|
|
2436
|
-
/**
|
|
2437
|
-
* Mock a tools/call request to return an error
|
|
2438
|
-
*/
|
|
2439
|
-
toolError: (name, code, message, options) => {
|
|
2440
|
-
return this._interceptors.mocks.add({
|
|
2441
|
-
method: "tools/call",
|
|
2442
|
-
params: { name },
|
|
2443
|
-
response: mockResponse.error(code, message),
|
|
2444
|
-
times: options?.times
|
|
2445
|
-
});
|
|
2446
|
-
},
|
|
2447
|
-
/**
|
|
2448
|
-
* Mock a resources/read request
|
|
2449
|
-
*/
|
|
2450
|
-
resource: (uri, content, options) => {
|
|
2451
|
-
const contentObj = typeof content === "string" ? { uri, text: content } : { uri, ...content };
|
|
2452
|
-
return this._interceptors.mocks.add({
|
|
2453
|
-
method: "resources/read",
|
|
2454
|
-
params: { uri },
|
|
2455
|
-
response: mockResponse.resourceRead([contentObj]),
|
|
2456
|
-
times: options?.times,
|
|
2457
|
-
delay: options?.delay
|
|
2458
|
-
});
|
|
2459
|
-
},
|
|
2460
|
-
/**
|
|
2461
|
-
* Mock a resources/read request to return an error
|
|
2462
|
-
*/
|
|
2463
|
-
resourceError: (uri, options) => {
|
|
2464
|
-
return this._interceptors.mocks.add({
|
|
2465
|
-
method: "resources/read",
|
|
2466
|
-
params: { uri },
|
|
2467
|
-
response: mockResponse.errors.resourceNotFound(uri),
|
|
2468
|
-
times: options?.times
|
|
2469
|
-
});
|
|
2470
|
-
},
|
|
2471
|
-
/**
|
|
2472
|
-
* Mock the tools/list response
|
|
2473
|
-
*/
|
|
2474
|
-
toolsList: (tools, options) => {
|
|
2475
|
-
return this._interceptors.mocks.add({
|
|
2476
|
-
method: "tools/list",
|
|
2477
|
-
response: mockResponse.toolsList(tools),
|
|
2478
|
-
times: options?.times
|
|
2479
|
-
});
|
|
2480
|
-
},
|
|
2481
|
-
/**
|
|
2482
|
-
* Mock the resources/list response
|
|
2483
|
-
*/
|
|
2484
|
-
resourcesList: (resources, options) => {
|
|
2485
|
-
return this._interceptors.mocks.add({
|
|
2486
|
-
method: "resources/list",
|
|
2487
|
-
response: mockResponse.resourcesList(resources),
|
|
2488
|
-
times: options?.times
|
|
2489
|
-
});
|
|
2490
|
-
},
|
|
2491
|
-
/**
|
|
2492
|
-
* Clear all mocks
|
|
2493
|
-
*/
|
|
2494
|
-
clear: () => {
|
|
2495
|
-
this._interceptors.mocks.clear();
|
|
2496
|
-
},
|
|
2497
|
-
/**
|
|
2498
|
-
* Get all active mocks
|
|
2499
|
-
*/
|
|
2500
|
-
all: () => {
|
|
2501
|
-
return this._interceptors.mocks.getAll();
|
|
2502
|
-
}
|
|
2503
|
-
};
|
|
2504
|
-
/**
|
|
2505
|
-
* API for intercepting requests and responses
|
|
2506
|
-
*
|
|
2507
|
-
* @example
|
|
2508
|
-
* ```typescript
|
|
2509
|
-
* // Log all requests
|
|
2510
|
-
* const remove = mcp.intercept.request((ctx) => {
|
|
2511
|
-
* console.log('Request:', ctx.request.method);
|
|
2512
|
-
* return { action: 'passthrough' };
|
|
2513
|
-
* });
|
|
2514
|
-
*
|
|
2515
|
-
* // Modify requests
|
|
2516
|
-
* mcp.intercept.request((ctx) => {
|
|
2517
|
-
* if (ctx.request.method === 'tools/call') {
|
|
2518
|
-
* return {
|
|
2519
|
-
* action: 'modify',
|
|
2520
|
-
* request: { ...ctx.request, params: { ...ctx.request.params, extra: true } },
|
|
2521
|
-
* };
|
|
2522
|
-
* }
|
|
2523
|
-
* return { action: 'passthrough' };
|
|
2524
|
-
* });
|
|
2525
|
-
*
|
|
2526
|
-
* // Add latency to all requests
|
|
2527
|
-
* mcp.intercept.delay(100);
|
|
2528
|
-
*
|
|
2529
|
-
* // Clean up
|
|
2530
|
-
* remove();
|
|
2531
|
-
* mcp.intercept.clear();
|
|
2532
|
-
* ```
|
|
2533
|
-
*/
|
|
2534
|
-
intercept = {
|
|
2535
|
-
/**
|
|
2536
|
-
* Add a request interceptor
|
|
2537
|
-
* @returns Function to remove the interceptor
|
|
2538
|
-
*/
|
|
2539
|
-
request: (interceptor) => {
|
|
2540
|
-
return this._interceptors.addRequestInterceptor(interceptor);
|
|
2541
|
-
},
|
|
2542
|
-
/**
|
|
2543
|
-
* Add a response interceptor
|
|
2544
|
-
* @returns Function to remove the interceptor
|
|
2545
|
-
*/
|
|
2546
|
-
response: (interceptor) => {
|
|
2547
|
-
return this._interceptors.addResponseInterceptor(interceptor);
|
|
2548
|
-
},
|
|
2549
|
-
/**
|
|
2550
|
-
* Add latency to all requests
|
|
2551
|
-
* @returns Function to remove the interceptor
|
|
2552
|
-
*/
|
|
2553
|
-
delay: (ms) => {
|
|
2554
|
-
return this._interceptors.addRequestInterceptor(async () => {
|
|
2555
|
-
await new Promise((r) => setTimeout(r, ms));
|
|
2556
|
-
return { action: "passthrough" };
|
|
2557
|
-
});
|
|
2558
|
-
},
|
|
2559
|
-
/**
|
|
2560
|
-
* Fail requests matching a method
|
|
2561
|
-
* @returns Function to remove the interceptor
|
|
2562
|
-
*/
|
|
2563
|
-
failMethod: (method, error) => {
|
|
2564
|
-
return this._interceptors.addRequestInterceptor((ctx) => {
|
|
2565
|
-
if (ctx.request.method === method) {
|
|
2566
|
-
return { action: "error", error: new Error(error ?? `Intercepted: ${method}`) };
|
|
2567
|
-
}
|
|
2568
|
-
return { action: "passthrough" };
|
|
2569
|
-
});
|
|
2570
|
-
},
|
|
2571
|
-
/**
|
|
2572
|
-
* Clear all interceptors (but not mocks)
|
|
2573
|
-
*/
|
|
2574
|
-
clear: () => {
|
|
2575
|
-
this._interceptors.request = [];
|
|
2576
|
-
this._interceptors.response = [];
|
|
2577
|
-
},
|
|
2578
|
-
/**
|
|
2579
|
-
* Clear everything (interceptors and mocks)
|
|
2580
|
-
*/
|
|
2581
|
-
clearAll: () => {
|
|
2582
|
-
this._interceptors.clear();
|
|
2583
|
-
}
|
|
2584
|
-
};
|
|
2585
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2586
|
-
// TIMEOUT
|
|
2587
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2588
|
-
setTimeout(ms) {
|
|
2589
|
-
this.config.timeout = ms;
|
|
2590
|
-
if (this.transport) {
|
|
2591
|
-
this.transport.setTimeout(ms);
|
|
2592
|
-
}
|
|
2593
|
-
}
|
|
2594
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2595
|
-
// PRIVATE: MCP OPERATIONS
|
|
2596
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2597
|
-
async initialize() {
|
|
2598
|
-
const capabilities = this.config.capabilities ?? {
|
|
2599
|
-
sampling: {},
|
|
2600
|
-
elicitation: {
|
|
2601
|
-
form: {}
|
|
2602
|
-
}
|
|
2603
|
-
};
|
|
2604
|
-
return this.request("initialize", {
|
|
2605
|
-
protocolVersion: this.config.protocolVersion,
|
|
2606
|
-
capabilities,
|
|
2607
|
-
clientInfo: this.config.clientInfo
|
|
2608
|
-
});
|
|
2609
|
-
}
|
|
2610
|
-
async listTools(cursor) {
|
|
2611
|
-
return this.request("tools/list", cursor ? { cursor } : {});
|
|
2612
|
-
}
|
|
2613
|
-
/**
|
|
2614
|
-
* Follow `nextCursor` until the server stops returning one, concatenating the
|
|
2615
|
-
* pages. A server that hands back a cursor it already returned, or never stops
|
|
2616
|
-
* paging, fails the call rather than looping.
|
|
2617
|
-
*/
|
|
2618
|
-
async listAllPages(method, label, fetchPage, itemsOf) {
|
|
2619
|
-
const items = [];
|
|
2620
|
-
const seenCursors = /* @__PURE__ */ new Set();
|
|
2621
|
-
let cursor;
|
|
2622
|
-
for (let page = 0; page < MAX_LIST_PAGES; page++) {
|
|
2623
|
-
const response = await fetchPage(cursor);
|
|
2624
|
-
if (!response.success || !response.data) {
|
|
2625
|
-
throw new Error(`Failed to list ${label}: ${response.error?.message}`);
|
|
2626
|
-
}
|
|
2627
|
-
items.push(...itemsOf(response.data) ?? []);
|
|
2628
|
-
const next = response.data.nextCursor;
|
|
2629
|
-
if (!next) return items;
|
|
2630
|
-
if (seenCursors.has(next)) {
|
|
2631
|
-
throw new Error(`Failed to list ${label}: ${method} returned the cursor "${next}" twice`);
|
|
2632
|
-
}
|
|
2633
|
-
seenCursors.add(next);
|
|
2634
|
-
cursor = next;
|
|
2635
|
-
}
|
|
2636
|
-
throw new Error(`Failed to list ${label}: ${method} did not finish within ${MAX_LIST_PAGES} pages`);
|
|
2637
|
-
}
|
|
2638
|
-
async callTool(name, args, progressToken) {
|
|
2639
|
-
return this.request("tools/call", {
|
|
2640
|
-
name,
|
|
2641
|
-
arguments: args ?? {},
|
|
2642
|
-
...progressToken !== void 0 && { _meta: { progressToken } }
|
|
2643
|
-
});
|
|
2644
|
-
}
|
|
2645
|
-
progressTokenFor(options) {
|
|
2646
|
-
if (options.progressToken !== void 0) return options.progressToken;
|
|
2647
|
-
return this.collectingProgress ? `progress-${++this.progressTokenCounter}` : void 0;
|
|
2648
|
-
}
|
|
2649
|
-
recordNotification = (notification) => {
|
|
2650
|
-
const timestamp = /* @__PURE__ */ new Date();
|
|
2651
|
-
this._notifications.push({ method: notification.method, params: notification.params, timestamp });
|
|
2652
|
-
if (notification.method !== "notifications/progress") return;
|
|
2653
|
-
const { progress, total, progressToken } = notification.params ?? {};
|
|
2654
|
-
this._progressUpdates.push({
|
|
2655
|
-
progress: Number(progress),
|
|
2656
|
-
total: typeof total === "number" ? total : void 0,
|
|
2657
|
-
progressToken: typeof progressToken === "string" || typeof progressToken === "number" ? progressToken : void 0,
|
|
2658
|
-
timestamp
|
|
2659
|
-
});
|
|
2660
|
-
};
|
|
2661
|
-
async listResources(cursor) {
|
|
2662
|
-
return this.request("resources/list", cursor ? { cursor } : {});
|
|
2663
|
-
}
|
|
2664
|
-
async listResourceTemplates(cursor) {
|
|
2665
|
-
return this.request("resources/templates/list", cursor ? { cursor } : {});
|
|
2666
|
-
}
|
|
2667
|
-
async readResource(uri) {
|
|
2668
|
-
return this.request("resources/read", { uri });
|
|
2669
|
-
}
|
|
2670
|
-
async listPrompts(cursor) {
|
|
2671
|
-
return this.request("prompts/list", cursor ? { cursor } : {});
|
|
2672
|
-
}
|
|
2673
|
-
async getPrompt(name, args) {
|
|
2674
|
-
return this.request("prompts/get", {
|
|
2675
|
-
name,
|
|
2676
|
-
arguments: args ?? {}
|
|
2677
|
-
});
|
|
2678
|
-
}
|
|
2679
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2680
|
-
// PRIVATE: TRANSPORT & REQUEST HELPERS
|
|
2681
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2682
|
-
/** The MCP path this client is currently pointed at, for log and error text. */
|
|
2683
|
-
mcpPathLabel() {
|
|
2684
|
-
const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
|
|
2685
|
-
return entry ? `/${entry}` : "/";
|
|
2686
|
-
}
|
|
2687
|
-
createTransport() {
|
|
2688
|
-
let baseUrl = this.config.baseUrl;
|
|
2689
|
-
if (this.config.queryParams && Object.keys(this.config.queryParams).length > 0) {
|
|
2690
|
-
const url = new URL(baseUrl);
|
|
2691
|
-
Object.entries(this.config.queryParams).forEach(([key, value]) => {
|
|
2692
|
-
url.searchParams.set(key, String(value));
|
|
2693
|
-
});
|
|
2694
|
-
baseUrl = url.toString();
|
|
2695
|
-
}
|
|
2696
|
-
switch (this.config.transport) {
|
|
2697
|
-
case "streamable-http":
|
|
2698
|
-
return new StreamableHttpTransport({
|
|
2699
|
-
baseUrl,
|
|
2700
|
-
entryPath: this.config.entryPath,
|
|
2701
|
-
timeout: this.config.timeout,
|
|
2702
|
-
auth: this.config.auth,
|
|
2703
|
-
publicMode: this.config.publicMode,
|
|
2704
|
-
debug: this.config.debug,
|
|
2705
|
-
interceptors: this._interceptors,
|
|
2706
|
-
clientInfo: this.config.clientInfo,
|
|
2707
|
-
elicitationHandler: this._elicitationHandler,
|
|
2708
|
-
notificationHandler: this.recordNotification
|
|
2709
|
-
});
|
|
2710
|
-
case "sse":
|
|
2711
|
-
return new SseTransport({
|
|
2712
|
-
baseUrl,
|
|
2713
|
-
entryPath: this.config.entryPath,
|
|
2714
|
-
timeout: this.config.timeout,
|
|
2715
|
-
auth: this.config.auth,
|
|
2716
|
-
publicMode: this.config.publicMode,
|
|
2717
|
-
debug: this.config.debug,
|
|
2718
|
-
interceptors: this._interceptors,
|
|
2719
|
-
clientInfo: this.config.clientInfo,
|
|
2720
|
-
elicitationHandler: this._elicitationHandler,
|
|
2721
|
-
notificationHandler: this.recordNotification
|
|
2722
|
-
});
|
|
2723
|
-
default:
|
|
2724
|
-
throw new Error(`Unknown transport type: ${this.config.transport}`);
|
|
2725
|
-
}
|
|
2726
|
-
}
|
|
2727
|
-
async request(method, params) {
|
|
2728
|
-
const transport = this.getConnectedTransport();
|
|
2729
|
-
const id = ++this.requestIdCounter;
|
|
2730
|
-
this._lastRequestId = id;
|
|
2731
|
-
const start = Date.now();
|
|
2732
|
-
try {
|
|
2733
|
-
const response = await transport.request({
|
|
2734
|
-
jsonrpc: "2.0",
|
|
2735
|
-
id,
|
|
2736
|
-
method,
|
|
2737
|
-
params
|
|
2738
|
-
});
|
|
2739
|
-
const durationMs = Date.now() - start;
|
|
2740
|
-
this.updateSessionActivity();
|
|
2741
|
-
if ("error" in response && response.error) {
|
|
2742
|
-
const error = response.error;
|
|
2743
|
-
this.traceRequest(method, params, id, response, durationMs);
|
|
2744
|
-
return {
|
|
2745
|
-
success: false,
|
|
2746
|
-
error,
|
|
2747
|
-
durationMs,
|
|
2748
|
-
requestId: id
|
|
2749
|
-
};
|
|
2750
|
-
}
|
|
2751
|
-
this.traceRequest(method, params, id, response, durationMs);
|
|
2752
|
-
return {
|
|
2753
|
-
success: true,
|
|
2754
|
-
data: response.result,
|
|
2755
|
-
durationMs,
|
|
2756
|
-
requestId: id
|
|
2757
|
-
};
|
|
2758
|
-
} catch (err) {
|
|
2759
|
-
if (isInterceptedError(err)) throw err;
|
|
2760
|
-
const durationMs = Date.now() - start;
|
|
2761
|
-
const error = {
|
|
2762
|
-
code: -32603,
|
|
2763
|
-
message: errorMessage(err)
|
|
2764
|
-
};
|
|
2765
|
-
return {
|
|
2766
|
-
success: false,
|
|
2767
|
-
error,
|
|
2768
|
-
durationMs,
|
|
2769
|
-
requestId: id
|
|
2770
|
-
};
|
|
2771
|
-
}
|
|
2772
|
-
}
|
|
2773
|
-
/**
|
|
2774
|
-
* Get the transport, throwing if not connected.
|
|
2775
|
-
*/
|
|
2776
|
-
getConnectedTransport() {
|
|
2777
|
-
if (!this.transport || !this.transport.isConnected()) {
|
|
2778
|
-
throw new Error("Not connected to MCP server. Call connect() first.");
|
|
2779
|
-
}
|
|
2780
|
-
return this.transport;
|
|
2781
|
-
}
|
|
2782
|
-
updateSessionActivity() {
|
|
2783
|
-
if (this._sessionInfo) {
|
|
2784
|
-
this._sessionInfo.lastActivityAt = /* @__PURE__ */ new Date();
|
|
2785
|
-
this._sessionInfo.requestCount++;
|
|
2786
|
-
}
|
|
2787
|
-
}
|
|
2788
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2789
|
-
// PRIVATE: RESULT WRAPPERS
|
|
2790
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2791
|
-
wrapToolResult(response) {
|
|
2792
|
-
const raw = response.data ?? { content: [] };
|
|
2793
|
-
const isError = !response.success || raw.isError === true;
|
|
2794
|
-
const meta = raw._meta;
|
|
2795
|
-
const hasUI = meta?.["ui/html"] !== void 0 || meta?.["ui/component"] !== void 0;
|
|
2796
|
-
const structuredContent = raw["structuredContent"];
|
|
2797
|
-
return {
|
|
2798
|
-
raw,
|
|
2799
|
-
isSuccess: !isError,
|
|
2800
|
-
isError,
|
|
2801
|
-
error: response.error,
|
|
2802
|
-
durationMs: response.durationMs,
|
|
2803
|
-
json() {
|
|
2804
|
-
if (hasUI && structuredContent !== void 0) {
|
|
2805
|
-
return structuredContent;
|
|
2806
|
-
}
|
|
2807
|
-
const textContent = raw.content?.find((c) => c.type === "text");
|
|
2808
|
-
if (textContent && "text" in textContent) {
|
|
2809
|
-
return JSON.parse(textContent.text);
|
|
2810
|
-
}
|
|
2811
|
-
throw new Error("No text content to parse as JSON");
|
|
2812
|
-
},
|
|
2813
|
-
text() {
|
|
2814
|
-
const textContent = raw.content?.find((c) => c.type === "text");
|
|
2815
|
-
if (textContent && "text" in textContent) {
|
|
2816
|
-
return textContent.text;
|
|
2817
|
-
}
|
|
2818
|
-
return void 0;
|
|
2819
|
-
},
|
|
2820
|
-
hasTextContent() {
|
|
2821
|
-
return raw.content?.some((c) => c.type === "text") ?? false;
|
|
2822
|
-
},
|
|
2823
|
-
hasImageContent() {
|
|
2824
|
-
return raw.content?.some((c) => c.type === "image") ?? false;
|
|
2825
|
-
},
|
|
2826
|
-
hasResourceContent() {
|
|
2827
|
-
return raw.content?.some((c) => c.type === "resource") ?? false;
|
|
2828
|
-
},
|
|
2829
|
-
hasToolUI() {
|
|
2830
|
-
return hasUI;
|
|
2831
|
-
}
|
|
2832
|
-
};
|
|
2833
|
-
}
|
|
2834
|
-
wrapResourceContent(response) {
|
|
2835
|
-
const raw = response.data ?? { contents: [] };
|
|
2836
|
-
const isError = !response.success;
|
|
2837
|
-
const firstContent = raw.contents?.[0];
|
|
2838
|
-
return {
|
|
2839
|
-
raw,
|
|
2840
|
-
isSuccess: !isError,
|
|
2841
|
-
isError,
|
|
2842
|
-
error: response.error,
|
|
2843
|
-
durationMs: response.durationMs,
|
|
2844
|
-
json() {
|
|
2845
|
-
if (firstContent && "text" in firstContent) {
|
|
2846
|
-
return JSON.parse(firstContent.text);
|
|
2847
|
-
}
|
|
2848
|
-
throw new Error("No text content to parse as JSON");
|
|
2849
|
-
},
|
|
2850
|
-
text() {
|
|
2851
|
-
if (firstContent && "text" in firstContent) {
|
|
2852
|
-
return firstContent.text;
|
|
2853
|
-
}
|
|
2854
|
-
return void 0;
|
|
2855
|
-
},
|
|
2856
|
-
mimeType() {
|
|
2857
|
-
return firstContent?.mimeType;
|
|
2858
|
-
},
|
|
2859
|
-
hasMimeType(type) {
|
|
2860
|
-
return firstContent?.mimeType === type;
|
|
2861
|
-
}
|
|
2862
|
-
};
|
|
2863
|
-
}
|
|
2864
|
-
wrapPromptResult(response) {
|
|
2865
|
-
const raw = response.data ?? { messages: [] };
|
|
2866
|
-
const isError = !response.success;
|
|
2867
|
-
return {
|
|
2868
|
-
raw,
|
|
2869
|
-
isSuccess: !isError,
|
|
2870
|
-
isError,
|
|
2871
|
-
error: response.error,
|
|
2872
|
-
durationMs: response.durationMs,
|
|
2873
|
-
messages: raw.messages ?? [],
|
|
2874
|
-
description: raw.description
|
|
2875
|
-
};
|
|
2876
|
-
}
|
|
2877
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2878
|
-
// PRIVATE: LOGGING & TRACING
|
|
2879
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2880
|
-
log(level, message, data) {
|
|
2881
|
-
const entry = {
|
|
2882
|
-
level,
|
|
2883
|
-
message,
|
|
2884
|
-
timestamp: /* @__PURE__ */ new Date(),
|
|
2885
|
-
data
|
|
2886
|
-
};
|
|
2887
|
-
this._logs.push(entry);
|
|
2888
|
-
if (this.config.debug) {
|
|
2889
|
-
console.log(`[${level.toUpperCase()}] ${message}`, data ?? "");
|
|
2890
|
-
}
|
|
2891
|
-
}
|
|
2892
|
-
traceRequest(method, params, id, response, durationMs) {
|
|
2893
|
-
this._traces.push({
|
|
2894
|
-
request: { method, params, id },
|
|
2895
|
-
response: {
|
|
2896
|
-
result: "result" in response ? response.result : void 0,
|
|
2897
|
-
error: "error" in response ? response.error : void 0
|
|
2898
|
-
},
|
|
2899
|
-
durationMs,
|
|
2900
|
-
timestamp: /* @__PURE__ */ new Date()
|
|
2901
|
-
});
|
|
2902
|
-
}
|
|
2903
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2904
|
-
// PRIVATE: TOKEN PARSING
|
|
2905
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2906
|
-
parseScopesFromToken(token) {
|
|
2907
|
-
try {
|
|
2908
|
-
const payload = this.decodeJwtPayload(token);
|
|
2909
|
-
if (!payload) return [];
|
|
2910
|
-
const scope = payload["scope"];
|
|
2911
|
-
const scopes2 = payload["scopes"];
|
|
2912
|
-
if (typeof scope === "string") {
|
|
2913
|
-
return scope.split(" ");
|
|
2914
|
-
}
|
|
2915
|
-
if (Array.isArray(scopes2)) {
|
|
2916
|
-
return scopes2;
|
|
2917
|
-
}
|
|
2918
|
-
return [];
|
|
2919
|
-
} catch {
|
|
2920
|
-
return [];
|
|
2921
|
-
}
|
|
2922
|
-
}
|
|
2923
|
-
parseUserFromToken(token) {
|
|
2924
|
-
try {
|
|
2925
|
-
const payload = this.decodeJwtPayload(token);
|
|
2926
|
-
const sub = payload?.["sub"];
|
|
2927
|
-
if (!sub || typeof sub !== "string") return void 0;
|
|
2928
|
-
return {
|
|
2929
|
-
sub,
|
|
2930
|
-
email: payload["email"],
|
|
2931
|
-
name: payload["name"]
|
|
2932
|
-
};
|
|
2933
|
-
} catch {
|
|
2934
|
-
return void 0;
|
|
2935
|
-
}
|
|
2936
|
-
}
|
|
2937
|
-
decodeJwtPayload(token) {
|
|
2938
|
-
try {
|
|
2939
|
-
const parts = token.split(".");
|
|
2940
|
-
if (parts.length !== 3) return null;
|
|
2941
|
-
const payload = Buffer.from(parts[1], "base64url").toString("utf-8");
|
|
2942
|
-
return JSON.parse(payload);
|
|
2943
|
-
} catch {
|
|
2944
|
-
return null;
|
|
2945
|
-
}
|
|
2946
|
-
}
|
|
2947
|
-
};
|
|
2948
|
-
var NotificationCollector = class {
|
|
2949
|
-
constructor(notifications) {
|
|
2950
|
-
this.notifications = notifications;
|
|
2951
|
-
}
|
|
2952
|
-
notifications;
|
|
2953
|
-
get received() {
|
|
2954
|
-
return [...this.notifications];
|
|
2955
|
-
}
|
|
2956
|
-
has(method) {
|
|
2957
|
-
return this.notifications.some((n) => n.method === method);
|
|
2958
|
-
}
|
|
2959
|
-
async waitFor(method, timeoutMs) {
|
|
2960
|
-
const deadline = Date.now() + timeoutMs;
|
|
2961
|
-
while (Date.now() < deadline) {
|
|
2962
|
-
const found = this.notifications.find((n) => n.method === method);
|
|
2963
|
-
if (found) return found;
|
|
2964
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
2965
|
-
}
|
|
2966
|
-
throw new Error(`Timeout waiting for notification: ${method}`);
|
|
2967
|
-
}
|
|
2968
|
-
};
|
|
2969
|
-
var ProgressCollector = class {
|
|
2970
|
-
constructor(updates) {
|
|
2971
|
-
this.updates = updates;
|
|
2972
|
-
}
|
|
2973
|
-
updates;
|
|
2974
|
-
get all() {
|
|
2975
|
-
return [...this.updates];
|
|
2976
|
-
}
|
|
2977
|
-
async waitForComplete(timeoutMs) {
|
|
2978
|
-
const deadline = Date.now() + timeoutMs;
|
|
2979
|
-
while (Date.now() < deadline) {
|
|
2980
|
-
const last = this.updates[this.updates.length - 1];
|
|
2981
|
-
if (last && last.total !== void 0 && last.progress >= last.total) {
|
|
2982
|
-
return;
|
|
2983
|
-
}
|
|
2984
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
2985
|
-
}
|
|
2986
|
-
throw new Error("Timeout waiting for progress to complete");
|
|
2987
|
-
}
|
|
2988
|
-
};
|
|
2989
|
-
|
|
2990
|
-
// libs/testing/src/server/test-server.ts
|
|
2991
|
-
import { execFile, spawn } from "child_process";
|
|
2992
|
-
import { connect } from "net";
|
|
2993
|
-
import { sha256Hex } from "@frontmcp/utils";
|
|
2994
|
-
|
|
2995
|
-
// libs/testing/src/errors/index.ts
|
|
2996
|
-
var TestClientError = class extends Error {
|
|
2997
|
-
constructor(message) {
|
|
2998
|
-
super(message);
|
|
2999
|
-
this.name = "TestClientError";
|
|
3000
|
-
Object.setPrototypeOf(this, new.target.prototype);
|
|
3001
|
-
}
|
|
3002
|
-
};
|
|
3003
|
-
var ServerStartError = class extends TestClientError {
|
|
3004
|
-
constructor(message, cause) {
|
|
3005
|
-
super(message);
|
|
3006
|
-
this.cause = cause;
|
|
3007
|
-
this.name = "ServerStartError";
|
|
3008
|
-
Object.setPrototypeOf(this, new.target.prototype);
|
|
3009
|
-
}
|
|
3010
|
-
cause;
|
|
3011
|
-
};
|
|
3012
|
-
|
|
3013
|
-
// libs/testing/src/server/port-registry.ts
|
|
3014
|
-
import { createServer } from "net";
|
|
3015
|
-
var E2E_PORT_RANGES = {
|
|
3016
|
-
// Core E2E tests (50000-50099)
|
|
3017
|
-
"demo-e2e-public": { start: 5e4, size: 10 },
|
|
3018
|
-
"demo-e2e-cache": { start: 50010, size: 10 },
|
|
3019
|
-
"demo-e2e-config": { start: 50020, size: 10 },
|
|
3020
|
-
"demo-e2e-direct": { start: 50030, size: 10 },
|
|
3021
|
-
"demo-e2e-errors": { start: 50040, size: 10 },
|
|
3022
|
-
"demo-e2e-hooks": { start: 50050, size: 10 },
|
|
3023
|
-
"demo-e2e-multiapp": { start: 50060, size: 10 },
|
|
3024
|
-
"demo-e2e-notifications": { start: 50070, size: 10 },
|
|
3025
|
-
"demo-e2e-providers": { start: 50080, size: 10 },
|
|
3026
|
-
"demo-e2e-standalone": { start: 50090, size: 10 },
|
|
3027
|
-
// Auth E2E tests (50100-50199)
|
|
3028
|
-
"demo-e2e-orchestrated": { start: 50100, size: 10 },
|
|
3029
|
-
"demo-e2e-transparent": { start: 50110, size: 10 },
|
|
3030
|
-
"demo-e2e-cimd": { start: 50120, size: 10 },
|
|
3031
|
-
"demo-e2e-remote-proxy": { start: 50130, size: 10 },
|
|
3032
|
-
// Feature E2E tests (50200-50299)
|
|
3033
|
-
"demo-e2e-skills": { start: 50200, size: 10 },
|
|
3034
|
-
"demo-e2e-remote": { start: 50210, size: 10 },
|
|
3035
|
-
"demo-e2e-openapi": { start: 50220, size: 10 },
|
|
3036
|
-
"demo-e2e-ui": { start: 50230, size: 10 },
|
|
3037
|
-
"demo-e2e-codecall": { start: 50240, size: 10 },
|
|
3038
|
-
"demo-e2e-remember": { start: 50250, size: 10 },
|
|
3039
|
-
"demo-e2e-elicitation": { start: 50260, size: 10 },
|
|
3040
|
-
"demo-e2e-agents": { start: 50270, size: 10 },
|
|
3041
|
-
"demo-e2e-transport-recreation": { start: 50280, size: 10 },
|
|
3042
|
-
"demo-e2e-jobs": { start: 50290, size: 10 },
|
|
3043
|
-
// Infrastructure E2E tests (50300-50409)
|
|
3044
|
-
"demo-e2e-redis": { start: 50300, size: 10 },
|
|
3045
|
-
"demo-e2e-serverless": { start: 50310, size: 10 },
|
|
3046
|
-
"demo-e2e-uipack": { start: 50320, size: 10 },
|
|
3047
|
-
"demo-e2e-agent-adapters": { start: 50330, size: 10 },
|
|
3048
|
-
"demo-e2e-guard": { start: 50340, size: 10 },
|
|
3049
|
-
"demo-e2e-cli-exec": { start: 50350, size: 10 },
|
|
3050
|
-
"demo-e2e-env-awareness": { start: 50360, size: 10 },
|
|
3051
|
-
"demo-e2e-esm": { start: 50370, size: 10 },
|
|
3052
|
-
"demo-e2e-feature-flags": { start: 50380, size: 10 },
|
|
3053
|
-
"demo-e2e-resource-providers": { start: 50390, size: 10 },
|
|
3054
|
-
// ESM E2E tests (50400-50439)
|
|
3055
|
-
"esm-package-server": { start: 50400, size: 10 },
|
|
3056
|
-
"esm-package-server-hot-reload": { start: 50410, size: 10 },
|
|
3057
|
-
"esm-package-server-cli": { start: 50420, size: 10 },
|
|
3058
|
-
"demo-e2e-unix-socket": { start: 50430, size: 10 },
|
|
3059
|
-
// Distributed E2E tests (50440-50459)
|
|
3060
|
-
"demo-e2e-distributed": { start: 50440, size: 20 },
|
|
3061
|
-
// Protocol revision E2E tests (50460-50479)
|
|
3062
|
-
"demo-e2e-protocol-20260728": { start: 50460, size: 20 },
|
|
3063
|
-
// Testing library E2E tests (50480-50499)
|
|
3064
|
-
"demo-e2e-testing": { start: 50480, size: 20 },
|
|
3065
|
-
// Mock servers and utilities (50900-50999)
|
|
3066
|
-
"mock-oauth": { start: 50900, size: 10 },
|
|
3067
|
-
"mock-api": { start: 50910, size: 10 },
|
|
3068
|
-
"mock-cimd": { start: 50920, size: 10 },
|
|
3069
|
-
// Dynamic/unknown projects (51000+)
|
|
3070
|
-
default: { start: 51e3, size: 100 }
|
|
3071
|
-
};
|
|
3072
|
-
var reservedPorts = /* @__PURE__ */ new Map();
|
|
3073
|
-
var lockedPorts = /* @__PURE__ */ new Set();
|
|
3074
|
-
var LOCK_MAX_AGE_MS = 10 * 60 * 1e3;
|
|
3075
|
-
function lockDir() {
|
|
3076
|
-
try {
|
|
3077
|
-
const os = __require("node:os");
|
|
3078
|
-
const path = __require("node:path");
|
|
3079
|
-
return path.join(os.tmpdir(), "frontmcp-testing-ports");
|
|
3080
|
-
} catch {
|
|
3081
|
-
return void 0;
|
|
3082
|
-
}
|
|
3083
|
-
}
|
|
3084
|
-
function pidIsAlive(pid) {
|
|
3085
|
-
try {
|
|
3086
|
-
process.kill(pid, 0);
|
|
3087
|
-
return true;
|
|
3088
|
-
} catch (error) {
|
|
3089
|
-
return error.code === "EPERM";
|
|
3090
|
-
}
|
|
3091
|
-
}
|
|
3092
|
-
function acquirePortLock(port) {
|
|
3093
|
-
if (lockedPorts.has(port)) return false;
|
|
3094
|
-
let fs;
|
|
3095
|
-
let file;
|
|
3096
|
-
try {
|
|
3097
|
-
fs = __require("node:fs");
|
|
3098
|
-
const dir = lockDir();
|
|
3099
|
-
if (!dir) return true;
|
|
3100
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
3101
|
-
file = __require("node:path").join(dir, `${port}.lock`);
|
|
3102
|
-
} catch {
|
|
3103
|
-
return true;
|
|
3104
|
-
}
|
|
3105
|
-
const payload = JSON.stringify({ pid: process.pid, at: Date.now() });
|
|
3106
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
3107
|
-
try {
|
|
3108
|
-
fs.writeFileSync(file, payload, { flag: "wx" });
|
|
3109
|
-
lockedPorts.add(port);
|
|
3110
|
-
return true;
|
|
3111
|
-
} catch (error) {
|
|
3112
|
-
if (error.code !== "EEXIST") return true;
|
|
3113
|
-
try {
|
|
3114
|
-
const held = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
3115
|
-
const fresh = typeof held.at === "number" && Date.now() - held.at < LOCK_MAX_AGE_MS;
|
|
3116
|
-
if (fresh && typeof held.pid === "number" && pidIsAlive(held.pid)) return false;
|
|
3117
|
-
fs.unlinkSync(file);
|
|
3118
|
-
} catch {
|
|
3119
|
-
}
|
|
3120
|
-
}
|
|
3121
|
-
}
|
|
3122
|
-
return false;
|
|
3123
|
-
}
|
|
3124
|
-
function releasePortLock(port) {
|
|
3125
|
-
if (!lockedPorts.delete(port)) return;
|
|
3126
|
-
try {
|
|
3127
|
-
const fs = __require("node:fs");
|
|
3128
|
-
const dir = lockDir();
|
|
3129
|
-
if (dir) fs.unlinkSync(__require("node:path").join(dir, `${port}.lock`));
|
|
3130
|
-
} catch {
|
|
3131
|
-
}
|
|
3132
|
-
}
|
|
3133
|
-
var projectPortIndex = /* @__PURE__ */ new Map();
|
|
3134
|
-
function getPortRange(project) {
|
|
3135
|
-
const key = project;
|
|
3136
|
-
if (key in E2E_PORT_RANGES) {
|
|
3137
|
-
return E2E_PORT_RANGES[key];
|
|
3138
|
-
}
|
|
3139
|
-
return E2E_PORT_RANGES.default;
|
|
3140
|
-
}
|
|
3141
|
-
async function reservePort(project, preferredPort) {
|
|
3142
|
-
const range = getPortRange(project);
|
|
3143
|
-
const handle = (port) => ({
|
|
3144
|
-
port,
|
|
3145
|
-
// Frees the socket only, so the server about to be spawned can bind the port; the lock file stays
|
|
3146
|
-
releaseSocket: async () => {
|
|
3147
|
-
await releasePort(port);
|
|
3148
|
-
},
|
|
3149
|
-
release: async () => {
|
|
3150
|
-
await releasePort(port);
|
|
3151
|
-
releasePortLock(port);
|
|
3152
|
-
}
|
|
3153
|
-
});
|
|
3154
|
-
const wantsSpecificPort = preferredPort !== void 0 && Number.isInteger(preferredPort) && preferredPort > 0;
|
|
3155
|
-
if (wantsSpecificPort) {
|
|
3156
|
-
const reservation = await tryReservePort(preferredPort, project);
|
|
3157
|
-
if (reservation) {
|
|
3158
|
-
return handle(preferredPort);
|
|
3159
|
-
}
|
|
3160
|
-
console.warn(`[PortRegistry] Preferred port ${preferredPort} not available for ${project}, allocating from range`);
|
|
3161
|
-
}
|
|
3162
|
-
let index = projectPortIndex.get(project) ?? 0;
|
|
3163
|
-
const maxAttempts = range.size;
|
|
3164
|
-
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
3165
|
-
const port = range.start + index % range.size;
|
|
3166
|
-
index = (index + 1) % range.size;
|
|
3167
|
-
if (reservedPorts.has(port) || lockedPorts.has(port)) {
|
|
3168
|
-
continue;
|
|
3169
|
-
}
|
|
3170
|
-
const reservation = await tryReservePort(port, project);
|
|
3171
|
-
if (reservation) {
|
|
3172
|
-
projectPortIndex.set(project, index);
|
|
3173
|
-
return handle(port);
|
|
3174
|
-
}
|
|
3175
|
-
}
|
|
3176
|
-
for (let port = 51e3; port < 52e3; port++) {
|
|
3177
|
-
if (reservedPorts.has(port) || lockedPorts.has(port)) continue;
|
|
3178
|
-
if (await tryReservePort(port, project)) {
|
|
3179
|
-
return handle(port);
|
|
3180
|
-
}
|
|
3181
|
-
}
|
|
3182
|
-
throw new Error(
|
|
3183
|
-
`[PortRegistry] Could not reserve a port for ${project}. Range: ${range.start}-${range.start + range.size - 1}. Currently reserved: ${Array.from(reservedPorts.keys()).join(", ")}`
|
|
3184
|
-
);
|
|
3185
|
-
}
|
|
3186
|
-
async function tryReservePort(port, project) {
|
|
3187
|
-
if (!acquirePortLock(port)) {
|
|
3188
|
-
return false;
|
|
3189
|
-
}
|
|
3190
|
-
return new Promise((resolve) => {
|
|
3191
|
-
const server = createServer();
|
|
3192
|
-
server.once("error", () => {
|
|
3193
|
-
releasePortLock(port);
|
|
3194
|
-
resolve(false);
|
|
3195
|
-
});
|
|
3196
|
-
server.listen(port, () => {
|
|
3197
|
-
reservedPorts.set(port, {
|
|
3198
|
-
port,
|
|
3199
|
-
project,
|
|
3200
|
-
holder: server,
|
|
3201
|
-
reservedAt: Date.now()
|
|
3202
|
-
});
|
|
3203
|
-
resolve(true);
|
|
3204
|
-
});
|
|
3205
|
-
});
|
|
3206
|
-
}
|
|
3207
|
-
async function releasePort(port) {
|
|
3208
|
-
const reservation = reservedPorts.get(port);
|
|
3209
|
-
if (!reservation) {
|
|
3210
|
-
return;
|
|
3211
|
-
}
|
|
3212
|
-
return new Promise((resolve) => {
|
|
3213
|
-
reservation.holder.close(() => {
|
|
3214
|
-
reservedPorts.delete(port);
|
|
3215
|
-
resolve();
|
|
3216
|
-
});
|
|
3217
|
-
});
|
|
3218
|
-
}
|
|
3219
|
-
|
|
3220
|
-
// libs/testing/src/server/test-server.ts
|
|
3221
|
-
var SERVER_EXIT_TIMEOUT_MS = 12e3;
|
|
3222
|
-
var DEBUG_SERVER = process.env["DEBUG_SERVER"] === "1" || process.env["DEBUG"] === "1";
|
|
3223
|
-
var TestServer = class _TestServer {
|
|
3224
|
-
process = null;
|
|
3225
|
-
options;
|
|
3226
|
-
_info;
|
|
3227
|
-
logs = [];
|
|
3228
|
-
/** Frees the port reservation (socket and cross-process lock); held until stop() */
|
|
3229
|
-
portRelease = null;
|
|
3230
|
-
/** Frees only the reservation socket, just before the server is spawned */
|
|
3231
|
-
portReleaseSocket = null;
|
|
3232
|
-
constructor(options, port, portRelease, portReleaseSocket) {
|
|
3233
|
-
this.options = {
|
|
3234
|
-
port,
|
|
3235
|
-
project: options.project,
|
|
3236
|
-
command: options.command ?? "",
|
|
3237
|
-
cwd: options.cwd ?? process.cwd(),
|
|
3238
|
-
env: options.env ?? {},
|
|
3239
|
-
startupTimeout: options.startupTimeout ?? 3e4,
|
|
3240
|
-
healthCheckPath: options.healthCheckPath ?? "/health",
|
|
3241
|
-
debug: options.debug ?? DEBUG_SERVER
|
|
3242
|
-
};
|
|
3243
|
-
this.portRelease = portRelease ?? null;
|
|
3244
|
-
this.portReleaseSocket = portReleaseSocket ?? portRelease ?? null;
|
|
3245
|
-
this._info = {
|
|
3246
|
-
baseUrl: `http://localhost:${port}`,
|
|
3247
|
-
port
|
|
3248
|
-
};
|
|
3249
|
-
}
|
|
3250
|
-
/**
|
|
3251
|
-
* Start a test server with custom command
|
|
3252
|
-
*/
|
|
3253
|
-
static async start(options) {
|
|
3254
|
-
const project = options.project ?? "default";
|
|
3255
|
-
const maxAttempts = 3;
|
|
3256
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
3257
|
-
const { port, release, releaseSocket } = await reservePort(project, options.port);
|
|
3258
|
-
const server = new _TestServer(options, port, release, releaseSocket);
|
|
3259
|
-
try {
|
|
3260
|
-
await server.startProcess();
|
|
3261
|
-
return server;
|
|
3262
|
-
} catch (error) {
|
|
3263
|
-
try {
|
|
3264
|
-
await server.stop();
|
|
3265
|
-
} catch (cleanupError) {
|
|
3266
|
-
if (options.debug || DEBUG_SERVER) {
|
|
3267
|
-
const msg = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
|
|
3268
|
-
console.warn(`[TestServer] Cleanup failed after startup error: ${msg}`);
|
|
3269
|
-
}
|
|
3270
|
-
}
|
|
3271
|
-
const isEADDRINUSE = error instanceof Error && (error.message.includes("EADDRINUSE") || server.getLogs().some((l) => l.includes("EADDRINUSE")));
|
|
3272
|
-
if (isEADDRINUSE && attempt < maxAttempts) {
|
|
3273
|
-
const delayMs = attempt * 500;
|
|
3274
|
-
if (options.debug || DEBUG_SERVER) {
|
|
3275
|
-
console.warn(
|
|
3276
|
-
`[TestServer] EADDRINUSE on port ${port}, retrying in ${delayMs}ms (attempt ${attempt}/${maxAttempts})`
|
|
3277
|
-
);
|
|
3278
|
-
}
|
|
3279
|
-
await sleep2(delayMs);
|
|
3280
|
-
continue;
|
|
3281
|
-
}
|
|
3282
|
-
throw error;
|
|
3283
|
-
}
|
|
3284
|
-
}
|
|
3285
|
-
throw new Error(`[TestServer] Failed to start after ${maxAttempts} attempts`);
|
|
3286
|
-
}
|
|
3287
|
-
/**
|
|
3288
|
-
* Start an Nx project as test server
|
|
3289
|
-
*/
|
|
3290
|
-
static async startNx(project, options = {}) {
|
|
3291
|
-
if (!/^[\w-]+$/.test(project)) {
|
|
3292
|
-
throw new Error(
|
|
3293
|
-
`Invalid project name: ${project}. Must contain only alphanumeric, underscore, and hyphen characters.`
|
|
3294
|
-
);
|
|
3295
|
-
}
|
|
3296
|
-
const maxAttempts = 3;
|
|
3297
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
3298
|
-
const { port, release, releaseSocket } = await reservePort(project, options.port);
|
|
3299
|
-
const serverOptions = {
|
|
3300
|
-
...options,
|
|
3301
|
-
port,
|
|
3302
|
-
project,
|
|
3303
|
-
command: `npx nx serve ${project} --port ${port}`,
|
|
3304
|
-
cwd: options.cwd ?? process.cwd()
|
|
3305
|
-
};
|
|
3306
|
-
const server = new _TestServer(serverOptions, port, release, releaseSocket);
|
|
3307
|
-
try {
|
|
3308
|
-
await server.startProcess();
|
|
3309
|
-
return server;
|
|
3310
|
-
} catch (error) {
|
|
3311
|
-
try {
|
|
3312
|
-
await server.stop();
|
|
3313
|
-
} catch (cleanupError) {
|
|
3314
|
-
if (options.debug || DEBUG_SERVER) {
|
|
3315
|
-
const msg = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
|
|
3316
|
-
console.warn(`[TestServer] Cleanup failed after startup error: ${msg}`);
|
|
3317
|
-
}
|
|
3318
|
-
}
|
|
3319
|
-
const isEADDRINUSE = error instanceof Error && (error.message.includes("EADDRINUSE") || server.getLogs().some((l) => l.includes("EADDRINUSE")));
|
|
3320
|
-
if (isEADDRINUSE && attempt < maxAttempts) {
|
|
3321
|
-
const delayMs = attempt * 500;
|
|
3322
|
-
if (options.debug || DEBUG_SERVER) {
|
|
3323
|
-
console.warn(
|
|
3324
|
-
`[TestServer] EADDRINUSE on port ${port}, retrying in ${delayMs}ms (attempt ${attempt}/${maxAttempts})`
|
|
3325
|
-
);
|
|
3326
|
-
}
|
|
3327
|
-
await sleep2(delayMs);
|
|
3328
|
-
continue;
|
|
3329
|
-
}
|
|
3330
|
-
throw error;
|
|
3331
|
-
}
|
|
3332
|
-
}
|
|
3333
|
-
throw new Error(`[TestServer] Failed to start after ${maxAttempts} attempts`);
|
|
3334
|
-
}
|
|
3335
|
-
/**
|
|
3336
|
-
* Create a test server connected to an already running server
|
|
3337
|
-
*/
|
|
3338
|
-
static connect(baseUrl) {
|
|
3339
|
-
const url = new URL(baseUrl);
|
|
3340
|
-
const port = parseInt(url.port, 10) || (url.protocol === "https:" ? 443 : 80);
|
|
3341
|
-
const server = new _TestServer(
|
|
3342
|
-
{
|
|
3343
|
-
command: "",
|
|
3344
|
-
port
|
|
3345
|
-
},
|
|
3346
|
-
port
|
|
3347
|
-
);
|
|
3348
|
-
server._info = {
|
|
3349
|
-
baseUrl: baseUrl.replace(/\/$/, ""),
|
|
3350
|
-
port
|
|
3351
|
-
};
|
|
3352
|
-
return server;
|
|
3353
|
-
}
|
|
3354
|
-
/**
|
|
3355
|
-
* Get server information
|
|
3356
|
-
*/
|
|
3357
|
-
get info() {
|
|
3358
|
-
return { ...this._info };
|
|
3359
|
-
}
|
|
3360
|
-
/**
|
|
3361
|
-
* Stop the test server
|
|
3362
|
-
*/
|
|
3363
|
-
async stop() {
|
|
3364
|
-
try {
|
|
3365
|
-
await this.stopProcess();
|
|
3366
|
-
} finally {
|
|
3367
|
-
if (this.portRelease) {
|
|
3368
|
-
await this.portRelease();
|
|
3369
|
-
this.portRelease = null;
|
|
3370
|
-
}
|
|
3371
|
-
this.portReleaseSocket = null;
|
|
3372
|
-
}
|
|
3373
|
-
}
|
|
3374
|
-
async stopProcess() {
|
|
3375
|
-
if (this.process) {
|
|
3376
|
-
this.log("Stopping server...");
|
|
3377
|
-
const serverPid = this._info.pid !== this._info.shellPid ? this._info.pid : void 0;
|
|
3378
|
-
if (this.process.exitCode !== null || this.process.signalCode !== null) {
|
|
3379
|
-
this.log(`Server already exited (code: ${this.process.exitCode}, signal: ${this.process.signalCode})`);
|
|
3380
|
-
this.process = null;
|
|
3381
|
-
if (serverPid !== void 0) signalIfRunning(serverPid, "SIGTERM");
|
|
3382
|
-
await this.waitForServerGone(serverPid);
|
|
3383
|
-
return;
|
|
3384
|
-
}
|
|
3385
|
-
const pid = this.process.pid;
|
|
3386
|
-
try {
|
|
3387
|
-
if (pid !== void 0) {
|
|
3388
|
-
process.kill(-pid, "SIGTERM");
|
|
3389
|
-
} else {
|
|
3390
|
-
this.process.kill("SIGTERM");
|
|
3391
|
-
}
|
|
3392
|
-
} catch {
|
|
3393
|
-
this.process.kill("SIGTERM");
|
|
3394
|
-
}
|
|
3395
|
-
const exitPromise = new Promise((resolve) => {
|
|
3396
|
-
if (this.process) {
|
|
3397
|
-
this.process.once("exit", () => resolve());
|
|
3398
|
-
} else {
|
|
3399
|
-
resolve();
|
|
3400
|
-
}
|
|
3401
|
-
});
|
|
3402
|
-
const killTimeout = setTimeout(() => {
|
|
3403
|
-
if (this.process) {
|
|
3404
|
-
this.log("Force killing server after timeout...");
|
|
3405
|
-
const killPid = this.process.pid;
|
|
3406
|
-
try {
|
|
3407
|
-
if (killPid !== void 0) {
|
|
3408
|
-
process.kill(-killPid, "SIGKILL");
|
|
3409
|
-
} else {
|
|
3410
|
-
this.process.kill("SIGKILL");
|
|
3411
|
-
}
|
|
3412
|
-
} catch {
|
|
3413
|
-
this.process.kill("SIGKILL");
|
|
3414
|
-
}
|
|
3415
|
-
}
|
|
3416
|
-
}, 5e3);
|
|
3417
|
-
await exitPromise;
|
|
3418
|
-
clearTimeout(killTimeout);
|
|
3419
|
-
this.process = null;
|
|
3420
|
-
await this.waitForServerGone(serverPid);
|
|
3421
|
-
this.log("Server stopped");
|
|
3422
|
-
}
|
|
3423
|
-
}
|
|
3424
|
-
/**
|
|
3425
|
-
* Wait until the server process has exited and its port refuses connections. A server that
|
|
3426
|
-
* drains on SIGTERM can keep listening after the shell it was started through has exited.
|
|
3427
|
-
*/
|
|
3428
|
-
async waitForServerGone(serverPid) {
|
|
3429
|
-
const deadline = Date.now() + SERVER_EXIT_TIMEOUT_MS;
|
|
3430
|
-
while (Date.now() < deadline) {
|
|
3431
|
-
const serverRunning = serverPid !== void 0 && pidIsAlive(serverPid);
|
|
3432
|
-
if (!serverRunning && !await acceptsConnections(this._info.port)) return;
|
|
3433
|
-
await sleep2(100);
|
|
3434
|
-
}
|
|
3435
|
-
if (serverPid !== void 0 && pidIsAlive(serverPid)) {
|
|
3436
|
-
this.log("Force killing server process after timeout...");
|
|
3437
|
-
signalIfRunning(serverPid, "SIGKILL");
|
|
3438
|
-
}
|
|
3439
|
-
}
|
|
3440
|
-
/**
|
|
3441
|
-
* Wait for server to be ready
|
|
3442
|
-
*/
|
|
3443
|
-
async waitForReady(timeout) {
|
|
3444
|
-
const timeoutMs = timeout ?? this.options.startupTimeout;
|
|
3445
|
-
const deadline = Date.now() + timeoutMs;
|
|
3446
|
-
const checkInterval = 100;
|
|
3447
|
-
while (Date.now() < deadline) {
|
|
3448
|
-
try {
|
|
3449
|
-
const response = await fetch(`${this._info.baseUrl}${this.options.healthCheckPath}`, {
|
|
3450
|
-
method: "GET",
|
|
3451
|
-
signal: AbortSignal.timeout(1e3)
|
|
3452
|
-
});
|
|
3453
|
-
if (response.ok || response.status === 404) {
|
|
3454
|
-
this.log("Server is ready");
|
|
3455
|
-
return;
|
|
3456
|
-
}
|
|
3457
|
-
} catch {
|
|
3458
|
-
}
|
|
3459
|
-
await sleep2(checkInterval);
|
|
3460
|
-
}
|
|
3461
|
-
throw new Error(`Server did not become ready within ${timeoutMs}ms`);
|
|
3462
|
-
}
|
|
3463
|
-
/**
|
|
3464
|
-
* Restart the server
|
|
3465
|
-
*/
|
|
3466
|
-
async restart() {
|
|
3467
|
-
await this.stopProcess();
|
|
3468
|
-
await this.startProcess();
|
|
3469
|
-
}
|
|
3470
|
-
/**
|
|
3471
|
-
* Get captured server logs
|
|
3472
|
-
*/
|
|
3473
|
-
getLogs() {
|
|
3474
|
-
return [...this.logs];
|
|
3475
|
-
}
|
|
3476
|
-
/**
|
|
3477
|
-
* Clear captured logs
|
|
3478
|
-
*/
|
|
3479
|
-
clearLogs() {
|
|
3480
|
-
this.logs = [];
|
|
3481
|
-
}
|
|
3482
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
3483
|
-
// PRIVATE METHODS
|
|
3484
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
3485
|
-
async startProcess() {
|
|
3486
|
-
if (!this.options.command) {
|
|
3487
|
-
await this.waitForReady();
|
|
3488
|
-
return;
|
|
3489
|
-
}
|
|
3490
|
-
this.log(`Starting server: ${this.options.command}`);
|
|
3491
|
-
const env = {
|
|
3492
|
-
...process.env,
|
|
3493
|
-
...this.options.env,
|
|
3494
|
-
PORT: String(this.options.port)
|
|
3495
|
-
};
|
|
3496
|
-
const runtimeEnv = withWorkspaceProtocolFallback(env, this.options.cwd);
|
|
3497
|
-
if (this.portReleaseSocket) {
|
|
3498
|
-
await this.portReleaseSocket();
|
|
3499
|
-
this.portReleaseSocket = null;
|
|
3500
|
-
await sleep2(300);
|
|
3501
|
-
}
|
|
3502
|
-
this.process = spawn(this.options.command, [], {
|
|
3503
|
-
cwd: this.options.cwd,
|
|
3504
|
-
env: runtimeEnv,
|
|
3505
|
-
shell: true,
|
|
3506
|
-
detached: true,
|
|
3507
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
3508
|
-
});
|
|
3509
|
-
this._info.pid = this.process.pid;
|
|
3510
|
-
this._info.shellPid = this.process.pid;
|
|
3511
|
-
let processExited = false;
|
|
3512
|
-
let exitCode = null;
|
|
3513
|
-
let exitError = null;
|
|
3514
|
-
this.process.stdout?.on("data", (data) => {
|
|
3515
|
-
const text = data.toString();
|
|
3516
|
-
this.logs.push(text);
|
|
3517
|
-
if (this.options.debug) {
|
|
3518
|
-
console.log("[SERVER]", text);
|
|
3519
|
-
}
|
|
3520
|
-
});
|
|
3521
|
-
this.process.stderr?.on("data", (data) => {
|
|
3522
|
-
const text = data.toString();
|
|
3523
|
-
this.logs.push(`[ERROR] ${text}`);
|
|
3524
|
-
if (this.options.debug) {
|
|
3525
|
-
console.error("[SERVER ERROR]", text);
|
|
3526
|
-
}
|
|
3527
|
-
});
|
|
3528
|
-
this.process.on("error", (err) => {
|
|
3529
|
-
this.logs.push(`[SPAWN ERROR] ${err.message}`);
|
|
3530
|
-
exitError = err;
|
|
3531
|
-
if (this.options.debug) {
|
|
3532
|
-
console.error("[SERVER SPAWN ERROR]", err);
|
|
3533
|
-
}
|
|
3534
|
-
});
|
|
3535
|
-
this.process.once("exit", (code) => {
|
|
3536
|
-
processExited = true;
|
|
3537
|
-
exitCode = code;
|
|
3538
|
-
this.log(`Server process exited with code ${code}`);
|
|
3539
|
-
});
|
|
3540
|
-
try {
|
|
3541
|
-
await this.waitForReadyWithExitDetection(() => {
|
|
3542
|
-
if (exitError) {
|
|
3543
|
-
return { exited: true, error: exitError };
|
|
3544
|
-
}
|
|
3545
|
-
if (processExited) {
|
|
3546
|
-
const allLogs = this.logs.join("\n");
|
|
3547
|
-
const errorLogs = this.logs.filter((l) => l.includes("[ERROR]") || l.toLowerCase().includes("error")).join("\n");
|
|
3548
|
-
return {
|
|
3549
|
-
exited: true,
|
|
3550
|
-
error: new ServerStartError(
|
|
3551
|
-
`Server process exited unexpectedly with code ${exitCode}.
|
|
4
|
+
var __require=(x=>typeof require!=="undefined"?require:typeof Proxy!=="undefined"?new Proxy(x,{get:(a,b)=>(typeof require!=="undefined"?require:a)[b]}):x)(function(x){if(typeof require!=="undefined")return require.apply(this,arguments);throw Error('Dynamic require of "'+x+'" is not supported')});import{exportJWK,generateKeyPair,SignJWT}from"jose";var TestTokenFactory=class{issuer;audience;privateKey=null;publicKey=null;jwk=null;keyId;hmacSecret;constructor(options={}){this.issuer=options.issuer??"https://test.frontmcp.local";this.audience=options.audience??"frontmcp-test";this.keyId=`test-key-${Date.now()}`;this.hmacSecret=options.hmacSecret?new TextEncoder().encode(options.hmacSecret):null}get isHmac(){return this.hmacSecret!==null}get alg(){return this.isHmac?"HS256":"RS256"}signingKey(){if(this.hmacSecret)return this.hmacSecret;if(!this.privateKey){throw new Error("Private key not initialized")}return this.privateKey}async ensureKeys(){if(this.isHmac)return;if(this.privateKey&&this.publicKey)return;const{publicKey,privateKey}=await generateKeyPair("RS256",{extractable:true});this.privateKey=privateKey;this.publicKey=publicKey;this.jwk=await exportJWK(publicKey);this.jwk.kid=this.keyId;this.jwk.use="sig";this.jwk.alg="RS256"}async createTestToken(options){await this.ensureKeys();const nowMs=Date.now();const iat=Math.floor(nowMs/1e3);const exp=options.exp??3600;const payload={iss:options.iss??this.issuer,sub:options.sub,aud:options.aud??this.audience,iat,exp:Math.ceil(nowMs/1e3)+exp,scope:options.scopes?.join(" "),...options.claims};const header=this.isHmac?{alg:this.alg,typ:"JWT"}:{alg:this.alg,kid:this.keyId};const token=await new SignJWT(payload).setProtectedHeader(header).sign(this.signingKey());return token}async createAdminToken(sub="admin-001"){return this.createTestToken({sub,scopes:["admin:*","read","write","delete"],claims:{email:"admin@test.local",name:"Test Admin",role:"admin"}})}async createUserToken(sub="user-001",scopes2=["read","write"]){return this.createTestToken({sub,scopes:scopes2,claims:{email:"user@test.local",name:"Test User",role:"user"}})}async createAnonymousToken(expiresIn){return this.createTestToken({sub:`anon:${Date.now()}`,scopes:["anonymous"],exp:expiresIn,claims:{name:"Anonymous",role:"anonymous"}})}async createExpiredToken(options){await this.ensureKeys();const now=Math.floor(Date.now()/1e3);const payload={iss:this.issuer,sub:options.sub,aud:this.audience,iat:now-7200,exp:now-3600};const header=this.isHmac?{alg:this.alg,typ:"JWT"}:{alg:this.alg,kid:this.keyId};const token=await new SignJWT(payload).setProtectedHeader(header).sign(this.signingKey());return token}createTokenWithInvalidSignature(options){const now=Math.floor(Date.now()/1e3);const headerObj=this.isHmac?{alg:this.alg,typ:"JWT"}:{alg:this.alg,kid:this.keyId};const header=Buffer.from(JSON.stringify(headerObj)).toString("base64url");const payload=Buffer.from(JSON.stringify({iss:this.issuer,sub:options.sub,aud:this.audience,iat:now,exp:now+3600})).toString("base64url");const signature=Buffer.from("invalid-signature-"+Date.now()).toString("base64url");return`${header}.${payload}.${signature}`}async getPublicJwks(){if(this.isHmac){throw new Error("getPublicJwks() is not available in HS256 mode (gateway tokens use a shared secret)")}await this.ensureKeys();if(!this.jwk){throw new Error("JWK not initialized")}return{keys:[this.jwk]}}getIssuer(){return this.issuer}getAudience(){return this.audience}};var DefaultMockRegistry=class{mocks=[];add(mock){const entry={definition:mock,callCount:0,calls:[],remainingUses:mock.times??Infinity};this.mocks.push(entry);return{remove:()=>{const index=this.mocks.indexOf(entry);if(index!==-1){this.mocks.splice(index,1)}},callCount:()=>entry.callCount,calls:()=>[...entry.calls]}}clear(){this.mocks=[]}getAll(){return this.mocks.map(e=>e.definition)}match(request){for(const entry of this.mocks){if(entry.remainingUses<=0)continue;const{definition}=entry;if(definition.method!==request.method)continue;if(definition.params!==void 0){const params=request.params??{};if(typeof definition.params==="function"){if(!definition.params(params))continue}else{if(!this.paramsMatch(definition.params,params))continue}}entry.callCount++;entry.calls.push(request);entry.remainingUses--;return definition}return void 0}paramsMatch(expected,actual){for(const[key,value]of Object.entries(expected)){if(!(key in actual))return false;const actualValue=actual[key];if(Array.isArray(value)){if(!Array.isArray(actualValue))return false;if(value.length!==actualValue.length)return false;for(let i=0;i<value.length;i++){const expectedItem=value[i];const actualItem=actualValue[i];if(typeof expectedItem==="object"&&expectedItem!==null){if(typeof actualItem!=="object"||actualItem===null)return false;if(!this.paramsMatch(expectedItem,actualItem)){return false}}else if(actualItem!==expectedItem){return false}}}else if(typeof value==="object"&&value!==null){if(typeof actualValue!=="object"||actualValue===null)return false;if(!this.paramsMatch(value,actualValue)){return false}}else if(actualValue!==value){return false}}return true}};var mockResponse={success(result,id=1){return{jsonrpc:"2.0",id,result}},error(code,message,data,id=1){return{jsonrpc:"2.0",id,error:{code,message,data}}},toolResult(content,id=1){return{jsonrpc:"2.0",id,result:{content}}},toolsList(tools,id=1){return{jsonrpc:"2.0",id,result:{tools}}},resourcesList(resources,id=1){return{jsonrpc:"2.0",id,result:{resources}}},resourceRead(contents,id=1){return{jsonrpc:"2.0",id,result:{contents}}},errors:{methodNotFound:(method,id=1)=>mockResponse.error(-32601,`Method not found: ${method}`,void 0,id),invalidParams:(message,id=1)=>mockResponse.error(-32602,message,void 0,id),internalError:(message,id=1)=>mockResponse.error(-32603,message,void 0,id),resourceNotFound:(uri,id=1)=>mockResponse.error(-32002,`Resource not found: ${uri}`,{uri},id),toolNotFound:(name,id=1)=>mockResponse.error(-32601,`Tool not found: ${name}`,{name},id),unauthorized:(id=1)=>mockResponse.error(-32001,"Unauthorized",void 0,id),forbidden:(id=1)=>mockResponse.error(-32003,"Forbidden",void 0,id)}};var DefaultInterceptorChain=class{request=[];response=[];mocks;constructor(){this.mocks=new DefaultMockRegistry}addRequestInterceptor(interceptor){this.request.push(interceptor);return()=>{const index=this.request.indexOf(interceptor);if(index!==-1){this.request.splice(index,1)}}}addResponseInterceptor(interceptor){this.response.push(interceptor);return()=>{const index=this.response.indexOf(interceptor);if(index!==-1){this.response.splice(index,1)}}}async processRequest(request,meta){let currentRequest=request;const mockDef=this.mocks.match(request);if(mockDef){if(mockDef.delay&&mockDef.delay>0){await sleep(mockDef.delay)}let mockResponse2;if(typeof mockDef.response==="function"){mockResponse2=await mockDef.response(request)}else{mockResponse2=mockDef.response}return{type:"mock",response:{...mockResponse2,id:request.id??mockResponse2.id}}}for(const interceptor of this.request){const ctx={request:currentRequest,meta};const result=await interceptor(ctx);switch(result.action){case"passthrough":break;case"modify":currentRequest=result.request;break;case"mock":return{type:"mock",response:{...result.response,id:request.id??result.response.id}};case"error":return{type:"error",error:result.error}}}return{type:"continue",request:currentRequest}}async processResponse(request,response,durationMs){let currentResponse=response;for(const interceptor of this.response){const ctx={request,response:currentResponse,durationMs};const result=await interceptor(ctx);switch(result.action){case"passthrough":break;case"modify":currentResponse=result.response;break}}return currentResponse}clear(){this.request=[];this.response=[];this.mocks.clear()}};function sleep(ms){return new Promise(resolve=>setTimeout(resolve,ms))}function errorName(err){if(typeof err!=="object"||err===null)return void 0;const name=err.name;return typeof name==="string"?name:void 0}function isAbortError(err){return errorName(err)==="AbortError"}function errorMessage(err,fallback="Unknown error"){if(typeof err==="string"&&err)return err;if(typeof err==="object"&&err!==null){const message=err.message;if(typeof message==="string"&&message)return message}return fallback}var interceptedErrors=new WeakSet;function markInterceptedError(err){interceptedErrors.add(err);return err}function isInterceptedError(err){return typeof err==="object"&&err!==null&&interceptedErrors.has(err)}var DEFAULT_TIMEOUT=3e4;var SseTransport=class{baseUrl;entryPath;timeout;authHeaders;publicMode;debug;clientInfo;notificationHandler;interceptors;elicitationHandler;state="disconnected";authToken;sessionId;messageEndpoint;stream;pending=new Map;connectionCount=0;reconnectCount=0;lastRequestHeaders={};constructor(config){this.baseUrl=config.baseUrl.replace(/\/$/,"");this.entryPath=config.entryPath;this.timeout=config.timeout??DEFAULT_TIMEOUT;this.authHeaders=config.auth?.headers??{};this.authToken=config.auth?.token;this.publicMode=config.publicMode??false;this.debug=config.debug??false;this.clientInfo=config.clientInfo;this.notificationHandler=config.notificationHandler;this.interceptors=config.interceptors;this.elicitationHandler=config.elicitationHandler}async connect(){this.state="connecting";this.connectionCount++;try{if(!this.publicMode&&!this.authToken){await this.requestAnonymousToken()}await this.openStream();this.state="connected";this.log(`Connected; messages go to ${this.messageEndpoint}`)}catch(error){this.state="error";this.stream?.abort();this.stream=void 0;throw error}}async request(message){this.ensureConnected();const startTime=Date.now();if(this.interceptors){const intercepted=await this.interceptors.processRequest(message,{timestamp:new Date,transport:"sse",sessionId:this.sessionId});switch(intercepted.type){case"mock":return await this.interceptors.processResponse(message,intercepted.response,Date.now()-startTime);case"error":throw markInterceptedError(intercepted.error);case"continue":message=intercepted.request;break}}let response;if(message.id===void 0){await this.post(JSON.stringify(message));response={jsonrpc:"2.0",id:null,result:void 0}}else{response=await this.postAndAwait(message.id,JSON.stringify(message))}if(this.interceptors){response=await this.interceptors.processResponse(message,response,Date.now()-startTime)}return response}async notify(message){this.ensureConnected();const result=await this.post(JSON.stringify(message));if(!result.ok)this.log(`HTTP ${result.status} on notification: ${result.body}`)}async sendRaw(data){this.ensureConnected();let id;try{const parsed=JSON.parse(data);if(typeof parsed.id==="string"||typeof parsed.id==="number")id=parsed.id}catch{}if(id!==void 0)return this.postAndAwait(id,data);const result=await this.post(data);if(!result.ok){return{jsonrpc:"2.0",id:null,error:{code:-32700,message:"Parse error",data:result.body}}}return{jsonrpc:"2.0",id:null,result:void 0}}async close(){this.closeStream("Transport closed");this.state="disconnected";this.sessionId=void 0;this.messageEndpoint=void 0;this.log("SSE transport closed")}isConnected(){return this.state==="connected"}getState(){return this.state}getSessionId(){return this.sessionId}setAuthToken(token){this.authToken=token}setTimeout(ms){this.timeout=ms}getMessageEndpoint(){return this.messageEndpoint}getConnectionCount(){return this.connectionCount}getReconnectCount(){return this.reconnectCount}getLastRequestHeaders(){return{...this.lastRequestHeaders}}setInterceptors(interceptors2){this.interceptors=interceptors2}getInterceptors(){return this.interceptors}setElicitationHandler(handler){this.elicitationHandler=handler}async simulateDisconnect(){this.closeStream("Simulated disconnect");this.state="disconnected";this.sessionId=void 0;this.messageEndpoint=void 0}async waitForReconnect(timeoutMs){this.reconnectCount++;let timer;try{await Promise.race([this.connect(),new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error("Timeout waiting for reconnection")),timeoutMs)})])}finally{clearTimeout(timer)}}async openStream(){const controller=new AbortController;this.stream=controller;const url=this.sseUrl();const{"Content-Type":_contentType,...headers}=this.buildHeaders();this.log(`GET ${url}`);const connectTimer=setTimeout(()=>controller.abort(),this.timeout);let response;try{response=await fetch(url,{method:"GET",headers:{...headers,Accept:"text/event-stream"},signal:controller.signal})}catch(error){clearTimeout(connectTimer);throw new Error(isAbortError(error)?`SSE connection to ${url} timed out after ${this.timeout}ms`:`SSE connection to ${url} failed: ${errorMessage(error)}`,{cause:error})}const isEventStream=response.headers.get("content-type")?.includes("text/event-stream")??false;if(!response.ok||!response.body||!isEventStream){clearTimeout(connectTimer);const body=await response.text().catch(()=>"");throw new Error(`SSE connection to ${url} failed: HTTP ${response.status} ${response.statusText}${body?` \u2014 ${body}`:""}. The legacy HTTP+SSE transport needs a server that serves it (@FrontMcp({ transport: { protocol: { legacy: true } } })).`)}const endpoint=new Promise((resolve,reject)=>{const reader=response.body?.getReader();if(!reader){reject(new Error(`SSE connection to ${url} has no body`));return}void this.readStream(controller,reader,resolve,reject)});try{const data=await endpoint;const messageUrl=new URL(data,url);this.messageEndpoint=messageUrl.toString();this.sessionId=messageUrl.searchParams.get("sessionId")??void 0}finally{clearTimeout(connectTimer)}}async readStream(controller,reader,onEndpoint,onEndpointError){const decoder=new TextDecoder;let buffer="";let endpointSeen=false;try{for(let chunk=await reader.read();!chunk.done;chunk=await reader.read()){const{completeEvents,remainder}=splitSSEEvents(buffer+decoder.decode(chunk.value,{stream:true}));buffer=remainder;for(const text of completeEvents){const event=parseSSEEvent(text);if(!event)continue;if(event.type==="endpoint"){if(!endpointSeen){endpointSeen=true;onEndpoint(event.data)}continue}await this.handleMessage(event.data)}}}catch(error){if(!controller.signal.aborted)this.log("SSE stream failed:",error)}finally{reader.releaseLock()}if(!endpointSeen){onEndpointError(new Error(controller.signal.aborted?`SSE stream at ${this.sseUrl()} sent no endpoint event within ${this.timeout}ms`:`SSE stream at ${this.sseUrl()} ended before the server sent its endpoint`))}if(this.stream===controller){this.stream=void 0;this.rejectPending("SSE stream closed by the server");if(this.state==="connected")this.state="disconnected"}}async handleMessage(data){let message;try{message=JSON.parse(data)}catch{this.log("Ignoring SSE event that is not JSON:",data);return}if(!message||typeof message!=="object")return;if(typeof message.method==="string"){const request=message;if(request.id===void 0||request.id===null){this.notificationHandler?.(request);return}await this.answerServerRequest(request);return}if(message.id!==void 0&&message.id!==null){const pending=this.pending.get(message.id);if(pending){clearTimeout(pending.timer);this.pending.delete(message.id);pending.resolve(message)}}}async answerServerRequest(request){const id=request.id;let reply;if(request.method==="elicitation/create"){reply={jsonrpc:"2.0",id,result:await this.elicit(request.params)}}else if(request.method==="ping"){reply={jsonrpc:"2.0",id,result:{}}}else{reply={jsonrpc:"2.0",id,error:{code:-32601,message:`Method not found: ${request.method}`}}}const result=await this.post(JSON.stringify(reply));if(!result.ok)this.log(`HTTP ${result.status} answering ${request.method}: ${result.body}`)}async elicit(params){if(!this.elicitationHandler)return{action:"decline"};try{return await this.elicitationHandler(params)}catch(error){this.log("Elicitation handler error:",error);return{action:"cancel"}}}closeStream(reason){const controller=this.stream;this.stream=void 0;controller?.abort();this.rejectPending(reason)}rejectPending(reason){for(const[id,pending]of this.pending){clearTimeout(pending.timer);pending.resolve({jsonrpc:"2.0",id,error:{code:-32e3,message:reason}})}this.pending.clear()}async postAndAwait(id,body){const answer=new Promise(resolve=>{const timer=setTimeout(()=>{this.pending.delete(id);resolve({jsonrpc:"2.0",id,error:{code:-32e3,message:`Request timeout after ${this.timeout}ms`}})},this.timeout);this.pending.set(id,{resolve,timer})});const result=await this.post(body);if(!result.ok){const pending=this.pending.get(id);if(pending){clearTimeout(pending.timer);this.pending.delete(id)}return{jsonrpc:"2.0",id,error:{code:-32e3,message:`HTTP ${result.status}: ${result.statusText}`,data:result.body}}}return answer}async post(body){const endpoint=this.messageEndpoint;if(!endpoint)throw new Error("SSE transport has no message endpoint. Call connect() first.");const headers=this.buildHeaders();this.lastRequestHeaders=headers;this.log(`POST ${endpoint}`,body);const controller=new AbortController;const timer=setTimeout(()=>controller.abort(),this.timeout);try{const response=await fetch(endpoint,{method:"POST",headers,body,signal:controller.signal});const text=await response.text();return{ok:response.ok,status:response.status,statusText:response.statusText,body:text}}catch(error){if(isAbortError(error)){return{ok:false,status:0,statusText:`timeout after ${this.timeout}ms`,body:""}}throw error}finally{clearTimeout(timer)}}async requestAnonymousToken(){const url=new URL(this.baseUrl);url.pathname=`${url.pathname.replace(/\/+$/,"")}/oauth/token`;url.search="";const tokenUrl=url.toString();try{const response=await fetch(tokenUrl,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({grant_type:"anonymous",client_id:crypto.randomUUID(),resource:this.baseUrl})});if(!response.ok){this.log(`No anonymous token (HTTP ${response.status})`);return}const token=await response.json();if(token.access_token)this.authToken=token.access_token}catch(error){this.log("Error requesting anonymous token:",error)}}sseUrl(){const url=new URL(this.baseUrl);const entry=(this.entryPath??"").replace(/^\/+|\/+$/g,"");const basePath=url.pathname.replace(/\/+$/,"");url.pathname=entry?`${basePath}/${entry}/sse`:`${basePath}/sse`;return url.toString()}buildHeaders(){const headers={"Content-Type":"application/json",Accept:"application/json, text/event-stream"};if(this.clientInfo)headers["User-Agent"]=`${this.clientInfo.name}/${this.clientInfo.version}`;if(this.authToken)headers["Authorization"]=`Bearer ${this.authToken}`;if(this.sessionId)headers["mcp-session-id"]=this.sessionId;Object.assign(headers,this.authHeaders);return headers}ensureConnected(){if(this.state!=="connected"){throw new Error("Transport not connected. Call connect() first.")}}log(message,data){if(this.debug){console.log(`[SSE] ${message}`,data??"")}}};function splitSSEEvents(buffer){const endsWithCarriageReturn=buffer.endsWith("\r");const normalized=(endsWithCarriageReturn?buffer.slice(0,-1):buffer).replace(/\r\n?/g,"\n");const completeEvents=normalized.split("\n\n");const remainder=completeEvents.pop()??"";return{completeEvents,remainder:endsWithCarriageReturn?`${remainder}\r`:remainder}}function parseSSEEvent(text){let type="message";const data=[];for(const line of text.split("\n")){if(line.startsWith(":"))continue;const colon=line.indexOf(":");const field=colon===-1?line:line.slice(0,colon);const value=colon===-1?"":line.slice(colon+1).replace(/^ /,"");if(field==="event")type=value;else if(field==="data")data.push(value)}return data.length>0?{type,data:data.join("\n")}:void 0}var DEFAULT_TIMEOUT2=3e4;var NOTIFICATION_STREAM_REOPEN_DELAY_MS=250;var StreamableHttpTransport=class{config;state="disconnected";sessionId;authToken;connectionCount=0;reconnectCount=0;lastRequestHeaders={};interceptors;publicMode;elicitationHandler;notificationHandler;notificationStream;notificationProtocolVersion="";notificationStreamReopenTimer;constructor(config){this.config={baseUrl:config.baseUrl.replace(/\/$/,""),entryPath:config.entryPath,timeout:config.timeout??DEFAULT_TIMEOUT2,auth:config.auth??{},publicMode:config.publicMode??false,debug:config.debug??false,interceptors:config.interceptors,clientInfo:config.clientInfo};this.authToken=config.auth?.token;this.interceptors=config.interceptors;this.publicMode=config.publicMode??false;this.elicitationHandler=config.elicitationHandler;this.notificationHandler=config.notificationHandler}async connect(){this.state="connecting";this.connectionCount++;try{if(this.publicMode){this.log("Public mode: connecting without authentication");this.state="connected";return}if(!this.authToken){await this.requestAnonymousToken()}this.state="connected";this.log("Connected to StreamableHTTP transport")}catch(error){this.state="error";throw error}}async requestAnonymousToken(){const clientId=crypto.randomUUID();const tokenUrl=`${this.config.baseUrl}/oauth/token`;this.log(`Requesting anonymous token from ${tokenUrl}`);try{const response=await fetch(tokenUrl,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({grant_type:"anonymous",client_id:clientId,resource:this.config.baseUrl})});if(!response.ok){const errorText=await response.text();this.log(`Failed to get anonymous token: ${response.status} ${errorText}`);return}const tokenResponse=await response.json();if(tokenResponse.access_token){this.authToken=tokenResponse.access_token;this.log("Anonymous token acquired successfully")}}catch(error){this.log(`Error requesting anonymous token: ${error}`)}}async request(message){this.ensureConnected();const startTime=Date.now();if(this.interceptors){const interceptResult=await this.interceptors.processRequest(message,{timestamp:new Date,transport:"streamable-http",sessionId:this.sessionId});switch(interceptResult.type){case"mock":{const mockResponse2=await this.interceptors.processResponse(message,interceptResult.response,Date.now()-startTime);return mockResponse2}case"error":throw markInterceptedError(interceptResult.error);case"continue":message=interceptResult.request;break}}const headers=this.buildHeaders();this.lastRequestHeaders=headers;const url=this.mcpUrl();this.log(`POST ${url}`,message);const controller=new AbortController;const timeoutId=setTimeout(()=>controller.abort(),this.config.timeout);try{const response=await fetch(url,{method:"POST",headers,body:JSON.stringify(message),signal:controller.signal});const newSessionId=response.headers.get("mcp-session-id");if(newSessionId){this.sessionId=newSessionId}let jsonResponse;if(!response.ok){const errorText=await response.text();this.log(`HTTP Error ${response.status}: ${errorText}`);jsonResponse={jsonrpc:"2.0",id:message.id??null,error:{code:-32e3,message:`HTTP ${response.status}: ${response.statusText}`,data:errorText}}}else{const contentType=response.headers.get("content-type")??"";if(contentType.includes("text/event-stream")){jsonResponse=await this.handleSSEResponseWithElicitation(response,message)}else{const text=await response.text();this.log("Response:",text);if(!text.trim()){jsonResponse={jsonrpc:"2.0",id:message.id??null,result:void 0}}else{jsonResponse=JSON.parse(text)}}}if(this.interceptors){jsonResponse=await this.interceptors.processResponse(message,jsonResponse,Date.now()-startTime)}clearTimeout(timeoutId);return jsonResponse}catch(error){clearTimeout(timeoutId);if(isAbortError(error)){return{jsonrpc:"2.0",id:message.id??null,error:{code:-32e3,message:`Request timeout after ${this.config.timeout}ms`}}}throw error}}async notify(message){this.ensureConnected();const headers=this.buildHeaders();this.lastRequestHeaders=headers;const url=this.mcpUrl();this.log(`POST ${url} (notification)`,message);const controller=new AbortController;const timeoutId=setTimeout(()=>controller.abort(),this.config.timeout);try{const response=await fetch(url,{method:"POST",headers,body:JSON.stringify(message),signal:controller.signal});clearTimeout(timeoutId);const newSessionId=response.headers.get("mcp-session-id");if(newSessionId){this.sessionId=newSessionId}if(!response.ok){const errorText=await response.text();this.log(`HTTP Error ${response.status} on notification: ${errorText}`)}}catch(error){clearTimeout(timeoutId);if(!isAbortError(error)){throw error}}}async sendRaw(data){this.ensureConnected();const headers=this.buildHeaders();this.lastRequestHeaders=headers;const url=this.mcpUrl();this.log(`POST ${url} (raw)`,data);const controller=new AbortController;const timeoutId=setTimeout(()=>controller.abort(),this.config.timeout);try{const response=await fetch(url,{method:"POST",headers,body:data,signal:controller.signal});clearTimeout(timeoutId);const text=await response.text();if(!text.trim()){return{jsonrpc:"2.0",id:null,error:{code:-32700,message:"Parse error"}}}return JSON.parse(text)}catch(error){clearTimeout(timeoutId);return{jsonrpc:"2.0",id:null,error:{code:-32700,message:"Parse error",data:errorMessage(error)}}}}async openNotificationStream(protocolVersion){this.notificationProtocolVersion=protocolVersion;await this.connectNotificationStream()}async close(){this.closeNotificationStream();this.state="disconnected";this.sessionId=void 0;this.log("StreamableHTTP transport closed")}isConnected(){return this.state==="connected"}getState(){return this.state}getSessionId(){return this.sessionId}setAuthToken(token){this.authToken=token}setTimeout(ms){this.config.timeout=ms}setInterceptors(interceptors2){this.interceptors=interceptors2}getInterceptors(){return this.interceptors}setElicitationHandler(handler){this.elicitationHandler=handler}getConnectionCount(){return this.connectionCount}getReconnectCount(){return this.reconnectCount}getLastRequestHeaders(){return{...this.lastRequestHeaders}}async simulateDisconnect(){this.closeNotificationStream();this.state="disconnected";this.sessionId=void 0}async waitForReconnect(timeoutMs){const deadline=Date.now()+timeoutMs;this.reconnectCount++;await this.connect();while(Date.now()<deadline){if(this.state==="connected"){return}await new Promise(r=>setTimeout(r,50))}throw new Error("Timeout waiting for reconnection")}closeNotificationStream(){clearTimeout(this.notificationStreamReopenTimer);this.notificationStream?.abort();this.notificationStream=void 0}async connectNotificationStream(){if(!this.sessionId||this.notificationStream)return;const controller=new AbortController;this.notificationStream=controller;const{"Content-Type":_contentType,...headers}=this.buildHeaders();const established=fetch(this.mcpUrl(),{method:"GET",headers:{...headers,Accept:"text/event-stream","MCP-Protocol-Version":this.notificationProtocolVersion},signal:controller.signal}).then(async response=>{const isEventStream=response.headers.get("content-type")?.includes("text/event-stream")??false;if(!response.ok||!response.body||!isEventStream){await response.body?.cancel();this.log(`Server offers no notification stream (HTTP ${response.status})`);this.releaseNotificationStream(controller);return}void this.readNotificationStream(controller,response.body.getReader())},error=>{this.log("Failed to open the notification stream:",error);this.releaseNotificationStream(controller)});let establishTimer;const establishDeadline=new Promise(resolve=>{establishTimer=setTimeout(resolve,this.config.timeout)});await Promise.race([established,establishDeadline]);clearTimeout(establishTimer)}releaseNotificationStream(controller){if(this.notificationStream===controller)this.notificationStream=void 0}async readNotificationStream(controller,reader){const decoder=new TextDecoder;let buffer="";try{for(let chunk=await reader.read();!chunk.done;chunk=await reader.read()){const{completeEvents,remainder}=splitSSEEvents2(buffer+decoder.decode(chunk.value,{stream:true}));buffer=remainder;for(const eventText of completeEvents){for(const event of this.parseSSEEvents(eventText,void 0).events){await this.handleSSEEvent(event)}}}}catch(error){this.log("Notification stream ended:",error)}finally{reader.releaseLock()}if(controller.signal.aborted||this.notificationStream!==controller)return;this.releaseNotificationStream(controller);this.notificationStreamReopenTimer=setTimeout(()=>void this.connectNotificationStream(),NOTIFICATION_STREAM_REOPEN_DELAY_MS)}async handleSSEResponseWithElicitation(response,originalRequest){this.log("handleSSEResponseWithElicitation: starting",{requestId:originalRequest.id});const reader=response.body?.getReader();if(!reader){this.log("handleSSEResponseWithElicitation: no response body");return{jsonrpc:"2.0",id:originalRequest.id??null,error:{code:-32e3,message:"No response body"}}}const decoder=new TextDecoder;let buffer="";let finalResponse=null;let sseSessionId;try{let readCount=0;while(true){readCount++;this.log(`handleSSEResponseWithElicitation: reading chunk ${readCount}`);const{done,value}=await reader.read();this.log(`handleSSEResponseWithElicitation: read result`,{done,valueLength:value?.length});if(done){if(buffer.trim()){const parsed=this.parseSSEEvents(buffer,originalRequest.id);for(const event of parsed.events){const handled=await this.handleSSEEvent(event);if(handled.isFinal){finalResponse=handled.response}}if(parsed.sessionId&&!sseSessionId){sseSessionId=parsed.sessionId}}break}buffer+=decoder.decode(value,{stream:true});const eventEndPattern=/\n\n/g;let lastEventEnd=0;let match;while((match=eventEndPattern.exec(buffer))!==null){const eventText=buffer.slice(lastEventEnd,match.index);lastEventEnd=match.index+2;if(eventText.trim()){const parsed=this.parseSSEEvents(eventText,originalRequest.id);for(const event of parsed.events){const handled=await this.handleSSEEvent(event);if(handled.isFinal){finalResponse=handled.response}}if(parsed.sessionId&&!sseSessionId){sseSessionId=parsed.sessionId}}}buffer=buffer.slice(lastEventEnd)}}finally{reader.releaseLock()}if(sseSessionId&&!this.sessionId){this.sessionId=sseSessionId;this.log("Session ID from SSE:",this.sessionId)}if(finalResponse){return finalResponse}return{jsonrpc:"2.0",id:originalRequest.id??null,error:{code:-32e3,message:"No final response received in SSE stream"}}}parseSSEEvents(text,_requestId){const lines=text.split("\n");const events=[];let currentEvent={type:"message",data:[]};let sessionId;for(const line of lines){if(line.startsWith("event: ")){currentEvent.type=line.slice(7)}else if(line.startsWith("data: ")){currentEvent.data.push(line.slice(6))}else if(line==="data:"){currentEvent.data.push("")}else if(line.startsWith("id: ")){const idValue=line.slice(4);currentEvent.id=idValue;const colonIndex=idValue.lastIndexOf(":");if(colonIndex>0){sessionId=idValue.substring(0,colonIndex)}else{sessionId=idValue}}else if(line===""&¤tEvent.data.length>0){events.push({type:currentEvent.type,data:currentEvent.data.join("\n"),id:currentEvent.id});currentEvent={type:"message",data:[]}}}if(currentEvent.data.length>0){events.push({type:currentEvent.type,data:currentEvent.data.join("\n"),id:currentEvent.id})}return{events,sessionId}}async handleSSEEvent(event){this.log("SSE Event:",{type:event.type,data:event.data.slice(0,200)});try{const parsed=JSON.parse(event.data);if("method"in parsed&&parsed.method==="elicitation/create"){await this.handleElicitationRequest(parsed);return{isFinal:false,response:{jsonrpc:"2.0",id:null,result:void 0}}}if("method"in parsed&&parsed.id===void 0){this.notificationHandler?.(parsed);return{isFinal:false,response:{jsonrpc:"2.0",id:null,result:void 0}}}if("result"in parsed||"error"in parsed){return{isFinal:true,response:parsed}}return{isFinal:false,response:{jsonrpc:"2.0",id:null,result:void 0}}}catch{this.log("Failed to parse SSE event data:",event.data);return{isFinal:false,response:{jsonrpc:"2.0",id:null,result:void 0}}}}async handleElicitationRequest(request){const params=request.params;this.log("Elicitation request received:",{mode:params?.mode,message:params?.message?.slice(0,100)});const requestId=request.id;if(requestId===void 0||requestId===null){this.log("Elicitation request has no ID, cannot respond");return}if(!this.elicitationHandler){this.log("No elicitation handler registered, sending error");await this.sendElicitationResponse(requestId,{action:"decline"});return}try{const response=await this.elicitationHandler(params);this.log("Elicitation handler response:",response);await this.sendElicitationResponse(requestId,response)}catch(error){this.log("Elicitation handler error:",error);await this.sendElicitationResponse(requestId,{action:"cancel"})}}async sendElicitationResponse(requestId,response){const headers=this.buildHeaders();const url=this.mcpUrl();const rpcResponse={jsonrpc:"2.0",id:requestId,result:response};this.log("Sending elicitation response:",rpcResponse);try{const fetchResponse=await fetch(url,{method:"POST",headers,body:JSON.stringify(rpcResponse)});if(!fetchResponse.ok){this.log(`Elicitation response HTTP error: ${fetchResponse.status}`)}}catch(error){this.log("Failed to send elicitation response:",error)}}mcpUrl(){const url=new URL(this.config.baseUrl);const entry=(this.config.entryPath??"").replace(/^\/+|\/+$/g,"");const basePath=url.pathname.replace(/\/+$/,"");url.pathname=entry?`${basePath}/${entry}`:`${basePath}/`;return url.toString()}buildHeaders(){const headers={"Content-Type":"application/json",Accept:"application/json, text/event-stream"};if(this.config.clientInfo){headers["User-Agent"]=`${this.config.clientInfo.name}/${this.config.clientInfo.version}`}if(this.authToken){headers["Authorization"]=`Bearer ${this.authToken}`}if(this.sessionId){headers["mcp-session-id"]=this.sessionId}if(this.config.auth.headers){Object.assign(headers,this.config.auth.headers)}return headers}ensureConnected(){if(this.state!=="connected"){throw new Error("Transport not connected. Call connect() first.")}}log(message,data){if(this.config.debug){console.log(`[StreamableHTTP] ${message}`,data??"")}}parseSSEResponseWithSession(text,requestId){const lines=text.split("\n");const dataLines=[];let sseSessionId;for(const line of lines){if(line.startsWith("data: ")){dataLines.push(line.slice(6))}else if(line==="data:"){dataLines.push("")}else if(line.startsWith("id: ")){const idValue=line.slice(4);const colonIndex=idValue.lastIndexOf(":");if(colonIndex>0){sseSessionId=idValue.substring(0,colonIndex)}else{sseSessionId=idValue}}}if(dataLines.length>0){const jsonData=dataLines.join("\n");try{return{response:JSON.parse(jsonData),sseSessionId}}catch{this.log("Failed to parse SSE data as JSON:",jsonData)}}return{response:{jsonrpc:"2.0",id:requestId??null,error:{code:-32700,message:"Failed to parse SSE response",data:text}},sseSessionId}}};function splitSSEEvents2(buffer){const endsWithCarriageReturn=buffer.endsWith("\r");const normalized=(endsWithCarriageReturn?buffer.slice(0,-1):buffer).replace(/\r\n?/g,"\n");const completeEvents=normalized.split("\n\n");const remainder=completeEvents.pop()??"";return{completeEvents,remainder:endsWithCarriageReturn?`${remainder}\r`:remainder}}var MCP_APPS_EXTENSION_KEY="io.modelcontextprotocol/ui";function getPlatformClientInfo(platform){switch(platform){case"openai":return{name:"ChatGPT",version:"1.0"};case"ext-apps":return{name:"mcp-ext-apps",version:"1.0"};case"claude":return{name:"claude-desktop",version:"1.0"};case"cursor":return{name:"cursor",version:"1.0"};case"continue":return{name:"continue",version:"1.0"};case"cody":return{name:"cody",version:"1.0"};case"gemini":return{name:"gemini",version:"1.0"};case"generic-mcp":return{name:"generic-mcp-client",version:"1.0"};case"unknown":default:return{name:"mcp-test-client",version:"1.0"}}}function getPlatformCapabilities(platform){const baseCapabilities={sampling:{},elicitation:{form:{}}};if(platform==="ext-apps"){return{...baseCapabilities,experimental:{[MCP_APPS_EXTENSION_KEY]:{mimeTypes:["text/html;profile=mcp-app"]}}}}return baseCapabilities}var McpTestClientBuilder=class{config;constructor(config){this.config={...config}}withAuth(auth){this.config.auth={...this.config.auth,...auth};return this}withToken(token){this.config.auth={...this.config.auth,token};return this}withHeaders(headers){this.config.auth={...this.config.auth,headers:{...this.config.auth?.headers,...headers}};return this}withTransport(transport){this.config.transport=transport;return this}withTimeout(timeoutMs){this.config.timeout=timeoutMs;return this}withDebug(enabled=true){this.config.debug=enabled;return this}withPublicMode(enabled=true){this.config.publicMode=enabled;return this}withProtocolVersion(version){this.config.protocolVersion=version;return this}withClientInfo(info){this.config.clientInfo=info;return this}withPlatform(platform){this.config.platform=platform;this.config.clientInfo=getPlatformClientInfo(platform);this.config.capabilities=getPlatformCapabilities(platform);return this}withCapabilities(capabilities){this.config.capabilities=capabilities;return this}withQueryParams(params){this.config.queryParams={...this.config.queryParams,...params};return this}build(){return new McpTestClient(this.config)}async buildAndConnect(){const client=this.build();await client.connect();return client}};var DEFAULT_TIMEOUT3=3e4;var DEFAULT_PROTOCOL_VERSION="2025-06-18";var UNSUPPORTED_PROTOCOL_VERSIONS=["2026-07-28"];var DEFAULT_CLIENT_INFO={name:"@frontmcp/testing",version:"0.4.0"};var MAX_LIST_PAGES=1e3;function isAuthFailure(error){return typeof error?.message==="string"&&/^HTTP (401|403)\b/.test(error.message)}function describeInitializeError(error){const message=error?.message??"Unknown error";const data=error?.data;const detail=typeof data==="string"?data.trim():"";return detail?`${message} - ${detail.slice(0,500)}`:message}function extractEntryPaths(data){let payload=data;if(typeof payload==="string"){try{payload=JSON.parse(payload)}catch{return void 0}}if(!payload||typeof payload!=="object")return void 0;const paths=payload.entryPaths;if(!Array.isArray(paths))return void 0;const strings=paths.filter(p=>typeof p==="string");return strings.length>0?strings:void 0}var McpTestClient=class{config;transport=null;initResult=null;requestIdCounter=0;_lastRequestId=0;_sessionId;triedDiscoveredEntryPath=false;_sessionInfo=null;_authState={isAnonymous:true,scopes:[]};_logs=[];_traces=[];_notifications=[];_progressUpdates=[];collectingProgress=false;progressTokenCounter=0;_interceptors;_elicitationHandler;constructor(config){if(config.protocolVersion&&UNSUPPORTED_PROTOCOL_VERSIONS.includes(config.protocolVersion)){throw new Error(`McpTestClient does not support protocol version ${config.protocolVersion}: it has no initialize handshake. Use ${DEFAULT_PROTOCOL_VERSION} or earlier.`)}this.config={baseUrl:config.baseUrl,entryPath:config.entryPath,transport:config.transport??"streamable-http",auth:config.auth??{},publicMode:config.publicMode??false,timeout:config.timeout??DEFAULT_TIMEOUT3,debug:config.debug??false,protocolVersion:config.protocolVersion??DEFAULT_PROTOCOL_VERSION,clientInfo:config.clientInfo??DEFAULT_CLIENT_INFO,platform:config.platform,capabilities:config.capabilities,queryParams:config.queryParams};if(config.auth?.token){this._authState={isAnonymous:false,token:config.auth.token,scopes:this.parseScopesFromToken(config.auth.token),user:this.parseUserFromToken(config.auth.token)}}this._interceptors=new DefaultInterceptorChain}static create(config){return new McpTestClientBuilder(config)}async connect(){this.log("debug",`Connecting to ${this.config.baseUrl}...`);this.triedDiscoveredEntryPath=false;this.initResult=null;this.transport=this.createTransport();await this.transport.connect();const maxRetries=3;const retryDelayMs=500;let lastError;let reportedEntryPaths;let attemptsMade=0;for(let attempt=1;attempt<=maxRetries;attempt++){attemptsMade=attempt;const initResponse=await this.initialize();if(initResponse.success&&initResponse.data){this.initResult=initResponse.data;break}lastError=describeInitializeError(initResponse.error);reportedEntryPaths=extractEntryPaths(initResponse.error?.data)??reportedEntryPaths;if(reportedEntryPaths?.length===1&&!this.triedDiscoveredEntryPath){this.triedDiscoveredEntryPath=true;const discovered=reportedEntryPaths[0];this.log("info",`Server serves MCP at ${discovered}; reconnecting there (configured: ${this.mcpPathLabel()})`);this.config.entryPath=discovered;await this.transport.close();this.transport=this.createTransport();await this.transport.connect();continue}if(isAuthFailure(initResponse.error))break;if(attempt<maxRetries){this.log("debug",`MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);await new Promise(resolve=>setTimeout(resolve,retryDelayMs))}}if(!this.initResult){const served=reportedEntryPaths?.length?` The server reports it serves MCP at ${reportedEntryPaths.join(", ")}; set \`entryPath\` in test.use() (or \`http.entryPath\` on the server) to match.`:"";throw new Error(`Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${attemptsMade} attempt${attemptsMade===1?"":"s"}: ${lastError}.${served}`)}this._sessionId=this.transport.getSessionId();this._sessionInfo={id:this._sessionId??`session-${Date.now()}`,createdAt:new Date,lastActivityAt:new Date,requestCount:1};await this.transport.notify({jsonrpc:"2.0",method:"notifications/initialized"});await this.transport.openNotificationStream?.(this.initResult.protocolVersion);this.log("info",`Connected to ${this.initResult.serverInfo?.name??"MCP Server"}`);return this.initResult}async disconnect(){if(this.transport){await this.transport.close();this.transport=null}this.initResult=null;this.log("info","Disconnected from MCP server")}async reconnect(options){await this.disconnect();if(options?.sessionId&&this.transport){this._sessionId=options.sessionId}await this.connect()}isConnected(){return this.transport?.isConnected()??false}get sessionId(){return this._sessionId??""}get session(){const info=this._sessionInfo??{id:"",createdAt:new Date,lastActivityAt:new Date,requestCount:0};return{...info,expire:async()=>{this._sessionId=void 0;this._sessionInfo=null}}}get auth(){return this._authState}async authenticate(token){if(typeof token!=="string"||token.trim()===""){throw new Error("authenticate() requires a non-empty token")}const previousAuthState=this._authState;const previousConfigAuth=this.config.auth;this.config.auth={...previousConfigAuth,token};this._authState={isAnonymous:false,token,scopes:this.parseScopesFromToken(token),user:this.parseUserFromToken(token)};const previousTransport=this.transport;if(!previousTransport){this.log("debug","Authentication updated (not connected yet)");return}const previousInit=this.initResult;const previousSessionId=this._sessionId;const previousSessionInfo=this._sessionInfo;try{await this.connect()}catch(err){await this.transport?.close().catch(()=>void 0);this.transport=previousTransport;this.initResult=previousInit;this._sessionId=previousSessionId;this._sessionInfo=previousSessionInfo;this.config.auth=previousConfigAuth;this._authState=previousAuthState;throw err}await previousTransport.close().catch(()=>void 0);this.log("debug","Authentication updated")}get serverInfo(){return{name:this.initResult?.serverInfo?.name??"",version:this.initResult?.serverInfo?.version??""}}get protocolVersion(){return this.initResult?.protocolVersion??""}get instructions(){return this.initResult?.instructions??""}get capabilities(){return this.initResult?.capabilities??{}}hasCapability(name){return!!this.capabilities[name]}tools={list:async()=>this.listAllPages("tools/list","tools",cursor=>this.listTools(cursor),page=>page.tools),call:async(name,args,options={})=>{const response=await this.callTool(name,args,this.progressTokenFor(options));return this.wrapToolResult(response)}};resources={list:async()=>this.listAllPages("resources/list","resources",cursor=>this.listResources(cursor),page=>page.resources),listTemplates:async()=>this.listAllPages("resources/templates/list","resource templates",cursor=>this.listResourceTemplates(cursor),page=>page.resourceTemplates),read:async uri=>{const response=await this.readResource(uri);return this.wrapResourceContent(response)},subscribe:async _uri=>{this.log("warn","Resource subscription not yet implemented")},unsubscribe:async _uri=>{this.log("warn","Resource unsubscription not yet implemented")}};prompts={list:async()=>this.listAllPages("prompts/list","prompts",cursor=>this.listPrompts(cursor),page=>page.prompts),get:async(name,args)=>{const response=await this.getPrompt(name,args);return this.wrapPromptResult(response)}};raw={request:async message=>{const transport=this.getConnectedTransport();const start=Date.now();const response=await transport.request(message);this.traceRequest(message.method,message.params,message.id,response,Date.now()-start);return response},notify:async message=>{const transport=this.getConnectedTransport();await transport.notify(message)},sendRaw:async data=>{const transport=this.getConnectedTransport();return transport.sendRaw(data)}};get lastRequestId(){return this._lastRequestId}get transport_info(){return{type:this.config.transport,isConnected:()=>this.transport?.isConnected()??false,messageEndpoint:this.transport?.getMessageEndpoint?.(),connectionCount:this.transport?.getConnectionCount?.()??0,reconnectCount:this.transport?.getReconnectCount?.()??0,lastRequestHeaders:this.transport?.getLastRequestHeaders?.()??{},simulateDisconnect:async()=>{await this.transport?.simulateDisconnect?.()},waitForReconnect:async timeoutMs=>{await this.transport?.waitForReconnect?.(timeoutMs)}}}get transport_(){return this.transport_info}notifications={collect:()=>{return new NotificationCollector(this._notifications)},collectProgress:()=>{this.collectingProgress=true;return new ProgressCollector(this._progressUpdates)},send:async(method,params)=>{await this.raw.notify({jsonrpc:"2.0",method,params})}};onElicitation(handler){this._elicitationHandler=handler;if(this.transport?.setElicitationHandler){this.transport.setElicitationHandler(handler)}this.log("debug","Elicitation handler registered")}clearElicitationHandler(){this._elicitationHandler=void 0;if(this.transport?.setElicitationHandler){this.transport.setElicitationHandler(void 0)}this.log("debug","Elicitation handler cleared")}logs={all:()=>[...this._logs],filter:level=>this._logs.filter(l=>l.level===level),search:text=>this._logs.filter(l=>l.message.includes(text)),last:()=>this._logs[this._logs.length-1],clear:()=>{this._logs=[]}};trace={all:()=>[...this._traces],last:()=>this._traces[this._traces.length-1],clear:()=>{this._traces=[]}};mock={add:mock=>{return this._interceptors.mocks.add(mock)},tool:(name,result,options)=>{return this._interceptors.mocks.add({method:"tools/call",params:{name},response:mockResponse.toolResult([{type:"text",text:typeof result==="string"?result:JSON.stringify(result)}]),times:options?.times,delay:options?.delay})},toolError:(name,code,message,options)=>{return this._interceptors.mocks.add({method:"tools/call",params:{name},response:mockResponse.error(code,message),times:options?.times})},resource:(uri,content,options)=>{const contentObj=typeof content==="string"?{uri,text:content}:{uri,...content};return this._interceptors.mocks.add({method:"resources/read",params:{uri},response:mockResponse.resourceRead([contentObj]),times:options?.times,delay:options?.delay})},resourceError:(uri,options)=>{return this._interceptors.mocks.add({method:"resources/read",params:{uri},response:mockResponse.errors.resourceNotFound(uri),times:options?.times})},toolsList:(tools,options)=>{return this._interceptors.mocks.add({method:"tools/list",response:mockResponse.toolsList(tools),times:options?.times})},resourcesList:(resources,options)=>{return this._interceptors.mocks.add({method:"resources/list",response:mockResponse.resourcesList(resources),times:options?.times})},clear:()=>{this._interceptors.mocks.clear()},all:()=>{return this._interceptors.mocks.getAll()}};intercept={request:interceptor=>{return this._interceptors.addRequestInterceptor(interceptor)},response:interceptor=>{return this._interceptors.addResponseInterceptor(interceptor)},delay:ms=>{return this._interceptors.addRequestInterceptor(async()=>{await new Promise(r=>setTimeout(r,ms));return{action:"passthrough"}})},failMethod:(method,error)=>{return this._interceptors.addRequestInterceptor(ctx=>{if(ctx.request.method===method){return{action:"error",error:new Error(error??`Intercepted: ${method}`)}}return{action:"passthrough"}})},clear:()=>{this._interceptors.request=[];this._interceptors.response=[]},clearAll:()=>{this._interceptors.clear()}};setTimeout(ms){this.config.timeout=ms;if(this.transport){this.transport.setTimeout(ms)}}async initialize(){const capabilities=this.config.capabilities??{sampling:{},elicitation:{form:{}}};return this.request("initialize",{protocolVersion:this.config.protocolVersion,capabilities,clientInfo:this.config.clientInfo})}async listTools(cursor){return this.request("tools/list",cursor?{cursor}:{})}async listAllPages(method,label,fetchPage,itemsOf){const items=[];const seenCursors=new Set;let cursor;for(let page=0;page<MAX_LIST_PAGES;page++){const response=await fetchPage(cursor);if(!response.success||!response.data){throw new Error(`Failed to list ${label}: ${response.error?.message}`)}items.push(...itemsOf(response.data)??[]);const next=response.data.nextCursor;if(!next)return items;if(seenCursors.has(next)){throw new Error(`Failed to list ${label}: ${method} returned the cursor "${next}" twice`)}seenCursors.add(next);cursor=next}throw new Error(`Failed to list ${label}: ${method} did not finish within ${MAX_LIST_PAGES} pages`)}async callTool(name,args,progressToken){return this.request("tools/call",{name,arguments:args??{},...progressToken!==void 0&&{_meta:{progressToken}}})}progressTokenFor(options){if(options.progressToken!==void 0)return options.progressToken;return this.collectingProgress?`progress-${++this.progressTokenCounter}`:void 0}recordNotification=notification=>{const timestamp=new Date;this._notifications.push({method:notification.method,params:notification.params,timestamp});if(notification.method!=="notifications/progress")return;const{progress,total,progressToken}=notification.params??{};this._progressUpdates.push({progress:Number(progress),total:typeof total==="number"?total:void 0,progressToken:typeof progressToken==="string"||typeof progressToken==="number"?progressToken:void 0,timestamp})};async listResources(cursor){return this.request("resources/list",cursor?{cursor}:{})}async listResourceTemplates(cursor){return this.request("resources/templates/list",cursor?{cursor}:{})}async readResource(uri){return this.request("resources/read",{uri})}async listPrompts(cursor){return this.request("prompts/list",cursor?{cursor}:{})}async getPrompt(name,args){return this.request("prompts/get",{name,arguments:args??{}})}mcpPathLabel(){const entry=(this.config.entryPath??"").replace(/^\/+|\/+$/g,"");return entry?`/${entry}`:"/"}createTransport(){let baseUrl=this.config.baseUrl;if(this.config.queryParams&&Object.keys(this.config.queryParams).length>0){const url=new URL(baseUrl);Object.entries(this.config.queryParams).forEach(([key,value])=>{url.searchParams.set(key,String(value))});baseUrl=url.toString()}switch(this.config.transport){case"streamable-http":return new StreamableHttpTransport({baseUrl,entryPath:this.config.entryPath,timeout:this.config.timeout,auth:this.config.auth,publicMode:this.config.publicMode,debug:this.config.debug,interceptors:this._interceptors,clientInfo:this.config.clientInfo,elicitationHandler:this._elicitationHandler,notificationHandler:this.recordNotification});case"sse":return new SseTransport({baseUrl,entryPath:this.config.entryPath,timeout:this.config.timeout,auth:this.config.auth,publicMode:this.config.publicMode,debug:this.config.debug,interceptors:this._interceptors,clientInfo:this.config.clientInfo,elicitationHandler:this._elicitationHandler,notificationHandler:this.recordNotification});default:throw new Error(`Unknown transport type: ${this.config.transport}`)}}async request(method,params){const transport=this.getConnectedTransport();const id=++this.requestIdCounter;this._lastRequestId=id;const start=Date.now();try{const response=await transport.request({jsonrpc:"2.0",id,method,params});const durationMs=Date.now()-start;this.updateSessionActivity();if("error"in response&&response.error){const error=response.error;this.traceRequest(method,params,id,response,durationMs);return{success:false,error,durationMs,requestId:id}}this.traceRequest(method,params,id,response,durationMs);return{success:true,data:response.result,durationMs,requestId:id}}catch(err){if(isInterceptedError(err))throw err;const durationMs=Date.now()-start;const error={code:-32603,message:errorMessage(err)};return{success:false,error,durationMs,requestId:id}}}getConnectedTransport(){if(!this.transport||!this.transport.isConnected()){throw new Error("Not connected to MCP server. Call connect() first.")}return this.transport}updateSessionActivity(){if(this._sessionInfo){this._sessionInfo.lastActivityAt=new Date;this._sessionInfo.requestCount++}}wrapToolResult(response){const raw=response.data??{content:[]};const isError=!response.success||raw.isError===true;const meta=raw._meta;const hasUI=meta?.["ui/html"]!==void 0||meta?.["ui/component"]!==void 0;const structuredContent=raw["structuredContent"];return{raw,isSuccess:!isError,isError,error:response.error,durationMs:response.durationMs,json(){if(hasUI&&structuredContent!==void 0){return structuredContent}const textContent=raw.content?.find(c=>c.type==="text");if(textContent&&"text"in textContent){return JSON.parse(textContent.text)}throw new Error("No text content to parse as JSON")},text(){const textContent=raw.content?.find(c=>c.type==="text");if(textContent&&"text"in textContent){return textContent.text}return void 0},hasTextContent(){return raw.content?.some(c=>c.type==="text")??false},hasImageContent(){return raw.content?.some(c=>c.type==="image")??false},hasResourceContent(){return raw.content?.some(c=>c.type==="resource")??false},hasToolUI(){return hasUI}}}wrapResourceContent(response){const raw=response.data??{contents:[]};const isError=!response.success;const firstContent=raw.contents?.[0];return{raw,isSuccess:!isError,isError,error:response.error,durationMs:response.durationMs,json(){if(firstContent&&"text"in firstContent){return JSON.parse(firstContent.text)}throw new Error("No text content to parse as JSON")},text(){if(firstContent&&"text"in firstContent){return firstContent.text}return void 0},mimeType(){return firstContent?.mimeType},hasMimeType(type){return firstContent?.mimeType===type}}}wrapPromptResult(response){const raw=response.data??{messages:[]};const isError=!response.success;return{raw,isSuccess:!isError,isError,error:response.error,durationMs:response.durationMs,messages:raw.messages??[],description:raw.description}}log(level,message,data){const entry={level,message,timestamp:new Date,data};this._logs.push(entry);if(this.config.debug){console.log(`[${level.toUpperCase()}] ${message}`,data??"")}}traceRequest(method,params,id,response,durationMs){this._traces.push({request:{method,params,id},response:{result:"result"in response?response.result:void 0,error:"error"in response?response.error:void 0},durationMs,timestamp:new Date})}parseScopesFromToken(token){try{const payload=this.decodeJwtPayload(token);if(!payload)return[];const scope=payload["scope"];const scopes2=payload["scopes"];if(typeof scope==="string"){return scope.split(" ")}if(Array.isArray(scopes2)){return scopes2}return[]}catch{return[]}}parseUserFromToken(token){try{const payload=this.decodeJwtPayload(token);const sub=payload?.["sub"];if(!sub||typeof sub!=="string")return void 0;return{sub,email:payload["email"],name:payload["name"]}}catch{return void 0}}decodeJwtPayload(token){try{const parts=token.split(".");if(parts.length!==3)return null;const payload=Buffer.from(parts[1],"base64url").toString("utf-8");return JSON.parse(payload)}catch{return null}}};var NotificationCollector=class{constructor(notifications){this.notifications=notifications}notifications;get received(){return[...this.notifications]}has(method){return this.notifications.some(n=>n.method===method)}async waitFor(method,timeoutMs){const deadline=Date.now()+timeoutMs;while(Date.now()<deadline){const found=this.notifications.find(n=>n.method===method);if(found)return found;await new Promise(r=>setTimeout(r,50))}throw new Error(`Timeout waiting for notification: ${method}`)}};var ProgressCollector=class{constructor(updates){this.updates=updates}updates;get all(){return[...this.updates]}async waitForComplete(timeoutMs){const deadline=Date.now()+timeoutMs;while(Date.now()<deadline){const last=this.updates[this.updates.length-1];if(last&&last.total!==void 0&&last.progress>=last.total){return}await new Promise(r=>setTimeout(r,50))}throw new Error("Timeout waiting for progress to complete")}};import{execFile,spawn}from"child_process";import{connect}from"net";import{sha256Hex}from"@frontmcp/utils";var TestClientError=class extends Error{constructor(message){super(message);this.name="TestClientError";Object.setPrototypeOf(this,new.target.prototype)}};var ServerStartError=class extends TestClientError{constructor(message,cause){super(message);this.cause=cause;this.name="ServerStartError";Object.setPrototypeOf(this,new.target.prototype)}cause};import{createServer}from"net";var E2E_PORT_RANGES={"demo-e2e-public":{start:5e4,size:10},"demo-e2e-cache":{start:50010,size:10},"demo-e2e-config":{start:50020,size:10},"demo-e2e-direct":{start:50030,size:10},"demo-e2e-errors":{start:50040,size:10},"demo-e2e-hooks":{start:50050,size:10},"demo-e2e-multiapp":{start:50060,size:10},"demo-e2e-notifications":{start:50070,size:10},"demo-e2e-providers":{start:50080,size:10},"demo-e2e-standalone":{start:50090,size:10},"demo-e2e-orchestrated":{start:50100,size:10},"demo-e2e-transparent":{start:50110,size:10},"demo-e2e-cimd":{start:50120,size:10},"demo-e2e-remote-proxy":{start:50130,size:10},"demo-e2e-skills":{start:50200,size:10},"demo-e2e-remote":{start:50210,size:10},"demo-e2e-openapi":{start:50220,size:10},"demo-e2e-ui":{start:50230,size:10},"demo-e2e-codecall":{start:50240,size:10},"demo-e2e-remember":{start:50250,size:10},"demo-e2e-elicitation":{start:50260,size:10},"demo-e2e-agents":{start:50270,size:10},"demo-e2e-transport-recreation":{start:50280,size:10},"demo-e2e-jobs":{start:50290,size:10},"demo-e2e-redis":{start:50300,size:10},"demo-e2e-serverless":{start:50310,size:10},"demo-e2e-uipack":{start:50320,size:10},"demo-e2e-agent-adapters":{start:50330,size:10},"demo-e2e-guard":{start:50340,size:10},"demo-e2e-cli-exec":{start:50350,size:10},"demo-e2e-env-awareness":{start:50360,size:10},"demo-e2e-esm":{start:50370,size:10},"demo-e2e-feature-flags":{start:50380,size:10},"demo-e2e-resource-providers":{start:50390,size:10},"esm-package-server":{start:50400,size:10},"esm-package-server-hot-reload":{start:50410,size:10},"esm-package-server-cli":{start:50420,size:10},"demo-e2e-unix-socket":{start:50430,size:10},"demo-e2e-distributed":{start:50440,size:20},"demo-e2e-protocol-20260728":{start:50460,size:20},"demo-e2e-testing":{start:50480,size:20},"mock-oauth":{start:50900,size:10},"mock-api":{start:50910,size:10},"mock-cimd":{start:50920,size:10},default:{start:51e3,size:100}};var reservedPorts=new Map;var lockedPorts=new Set;var LOCK_MAX_AGE_MS=10*60*1e3;function lockDir(){try{const os=__require("node:os");const path=__require("node:path");return path.join(os.tmpdir(),"frontmcp-testing-ports")}catch{return void 0}}function pidIsAlive(pid){try{process.kill(pid,0);return true}catch(error){return error.code==="EPERM"}}function acquirePortLock(port){if(lockedPorts.has(port))return false;let fs;let file;try{fs=__require("node:fs");const dir=lockDir();if(!dir)return true;fs.mkdirSync(dir,{recursive:true});file=__require("node:path").join(dir,`${port}.lock`)}catch{return true}const payload=JSON.stringify({pid:process.pid,at:Date.now()});for(let attempt=0;attempt<2;attempt++){try{fs.writeFileSync(file,payload,{flag:"wx"});lockedPorts.add(port);return true}catch(error){if(error.code!=="EEXIST")return true;try{const held=JSON.parse(fs.readFileSync(file,"utf8"));const fresh=typeof held.at==="number"&&Date.now()-held.at<LOCK_MAX_AGE_MS;if(fresh&&typeof held.pid==="number"&&pidIsAlive(held.pid))return false;fs.unlinkSync(file)}catch{}}}return false}function releasePortLock(port){if(!lockedPorts.delete(port))return;try{const fs=__require("node:fs");const dir=lockDir();if(dir)fs.unlinkSync(__require("node:path").join(dir,`${port}.lock`))}catch{}}var projectPortIndex=new Map;function getPortRange(project){const key=project;if(key in E2E_PORT_RANGES){return E2E_PORT_RANGES[key]}return E2E_PORT_RANGES.default}async function reservePort(project,preferredPort){const range=getPortRange(project);const handle=port=>({port,releaseSocket:async()=>{await releasePort(port)},release:async()=>{await releasePort(port);releasePortLock(port)}});const wantsSpecificPort=preferredPort!==void 0&&Number.isInteger(preferredPort)&&preferredPort>0;if(wantsSpecificPort){const reservation=await tryReservePort(preferredPort,project);if(reservation){return handle(preferredPort)}console.warn(`[PortRegistry] Preferred port ${preferredPort} not available for ${project}, allocating from range`)}let index=projectPortIndex.get(project)??0;const maxAttempts=range.size;for(let attempt=0;attempt<maxAttempts;attempt++){const port=range.start+index%range.size;index=(index+1)%range.size;if(reservedPorts.has(port)||lockedPorts.has(port)){continue}const reservation=await tryReservePort(port,project);if(reservation){projectPortIndex.set(project,index);return handle(port)}}for(let port=51e3;port<52e3;port++){if(reservedPorts.has(port)||lockedPorts.has(port))continue;if(await tryReservePort(port,project)){return handle(port)}}throw new Error(`[PortRegistry] Could not reserve a port for ${project}. Range: ${range.start}-${range.start+range.size-1}. Currently reserved: ${Array.from(reservedPorts.keys()).join(", ")}`)}async function tryReservePort(port,project){if(!acquirePortLock(port)){return false}return new Promise(resolve=>{const server=createServer();server.once("error",()=>{releasePortLock(port);resolve(false)});server.listen(port,()=>{reservedPorts.set(port,{port,project,holder:server,reservedAt:Date.now()});resolve(true)})})}async function releasePort(port){const reservation=reservedPorts.get(port);if(!reservation){return}return new Promise(resolve=>{reservation.holder.close(()=>{reservedPorts.delete(port);resolve()})})}var SERVER_EXIT_TIMEOUT_MS=12e3;var DEBUG_SERVER=process.env["DEBUG_SERVER"]==="1"||process.env["DEBUG"]==="1";var TestServer=class _TestServer{process=null;options;_info;logs=[];portRelease=null;portReleaseSocket=null;constructor(options,port,portRelease,portReleaseSocket){this.options={port,project:options.project,command:options.command??"",cwd:options.cwd??process.cwd(),env:options.env??{},startupTimeout:options.startupTimeout??3e4,healthCheckPath:options.healthCheckPath??"/health",debug:options.debug??DEBUG_SERVER};this.portRelease=portRelease??null;this.portReleaseSocket=portReleaseSocket??portRelease??null;this._info={baseUrl:`http://localhost:${port}`,port}}static async start(options){const project=options.project??"default";const maxAttempts=3;for(let attempt=1;attempt<=maxAttempts;attempt++){const{port,release,releaseSocket}=await reservePort(project,options.port);const server=new _TestServer(options,port,release,releaseSocket);try{await server.startProcess();return server}catch(error){try{await server.stop()}catch(cleanupError){if(options.debug||DEBUG_SERVER){const msg=cleanupError instanceof Error?cleanupError.message:String(cleanupError);console.warn(`[TestServer] Cleanup failed after startup error: ${msg}`)}}const isEADDRINUSE=error instanceof Error&&(error.message.includes("EADDRINUSE")||server.getLogs().some(l=>l.includes("EADDRINUSE")));if(isEADDRINUSE&&attempt<maxAttempts){const delayMs=attempt*500;if(options.debug||DEBUG_SERVER){console.warn(`[TestServer] EADDRINUSE on port ${port}, retrying in ${delayMs}ms (attempt ${attempt}/${maxAttempts})`)}await sleep2(delayMs);continue}throw error}}throw new Error(`[TestServer] Failed to start after ${maxAttempts} attempts`)}static async startNx(project,options={}){if(!/^[\w-]+$/.test(project)){throw new Error(`Invalid project name: ${project}. Must contain only alphanumeric, underscore, and hyphen characters.`)}const maxAttempts=3;for(let attempt=1;attempt<=maxAttempts;attempt++){const{port,release,releaseSocket}=await reservePort(project,options.port);const serverOptions={...options,port,project,command:`npx nx serve ${project} --port ${port}`,cwd:options.cwd??process.cwd()};const server=new _TestServer(serverOptions,port,release,releaseSocket);try{await server.startProcess();return server}catch(error){try{await server.stop()}catch(cleanupError){if(options.debug||DEBUG_SERVER){const msg=cleanupError instanceof Error?cleanupError.message:String(cleanupError);console.warn(`[TestServer] Cleanup failed after startup error: ${msg}`)}}const isEADDRINUSE=error instanceof Error&&(error.message.includes("EADDRINUSE")||server.getLogs().some(l=>l.includes("EADDRINUSE")));if(isEADDRINUSE&&attempt<maxAttempts){const delayMs=attempt*500;if(options.debug||DEBUG_SERVER){console.warn(`[TestServer] EADDRINUSE on port ${port}, retrying in ${delayMs}ms (attempt ${attempt}/${maxAttempts})`)}await sleep2(delayMs);continue}throw error}}throw new Error(`[TestServer] Failed to start after ${maxAttempts} attempts`)}static connect(baseUrl){const url=new URL(baseUrl);const port=parseInt(url.port,10)||(url.protocol==="https:"?443:80);const server=new _TestServer({command:"",port},port);server._info={baseUrl:baseUrl.replace(/\/$/,""),port};return server}get info(){return{...this._info}}async stop(){try{await this.stopProcess()}finally{if(this.portRelease){await this.portRelease();this.portRelease=null}this.portReleaseSocket=null}}async stopProcess(){if(this.process){this.log("Stopping server...");const serverPid=this._info.pid!==this._info.shellPid?this._info.pid:void 0;if(this.process.exitCode!==null||this.process.signalCode!==null){this.log(`Server already exited (code: ${this.process.exitCode}, signal: ${this.process.signalCode})`);this.process=null;if(serverPid!==void 0)signalIfRunning(serverPid,"SIGTERM");await this.waitForServerGone(serverPid);return}const pid=this.process.pid;try{if(pid!==void 0){process.kill(-pid,"SIGTERM")}else{this.process.kill("SIGTERM")}}catch{this.process.kill("SIGTERM")}const exitPromise=new Promise(resolve=>{if(this.process){this.process.once("exit",()=>resolve())}else{resolve()}});const killTimeout=setTimeout(()=>{if(this.process){this.log("Force killing server after timeout...");const killPid=this.process.pid;try{if(killPid!==void 0){process.kill(-killPid,"SIGKILL")}else{this.process.kill("SIGKILL")}}catch{this.process.kill("SIGKILL")}}},5e3);await exitPromise;clearTimeout(killTimeout);this.process=null;await this.waitForServerGone(serverPid);this.log("Server stopped")}}async waitForServerGone(serverPid){const deadline=Date.now()+SERVER_EXIT_TIMEOUT_MS;while(Date.now()<deadline){const serverRunning=serverPid!==void 0&&pidIsAlive(serverPid);if(!serverRunning&&!await acceptsConnections(this._info.port))return;await sleep2(100)}if(serverPid!==void 0&&pidIsAlive(serverPid)){this.log("Force killing server process after timeout...");signalIfRunning(serverPid,"SIGKILL")}}async waitForReady(timeout){const timeoutMs=timeout??this.options.startupTimeout;const deadline=Date.now()+timeoutMs;const checkInterval=100;while(Date.now()<deadline){try{const response=await fetch(`${this._info.baseUrl}${this.options.healthCheckPath}`,{method:"GET",signal:AbortSignal.timeout(1e3)});if(response.ok||response.status===404){this.log("Server is ready");return}}catch{}await sleep2(checkInterval)}throw new Error(`Server did not become ready within ${timeoutMs}ms`)}async restart(){await this.stopProcess();await this.startProcess()}getLogs(){return[...this.logs]}clearLogs(){this.logs=[]}async startProcess(){if(!this.options.command){await this.waitForReady();return}this.log(`Starting server: ${this.options.command}`);const env={...process.env,...this.options.env,PORT:String(this.options.port)};const runtimeEnv=withWorkspaceProtocolFallback(env,this.options.cwd);if(this.portReleaseSocket){await this.portReleaseSocket();this.portReleaseSocket=null;await sleep2(300)}this.process=spawn(this.options.command,[],{cwd:this.options.cwd,env:runtimeEnv,shell:true,detached:true,stdio:["pipe","pipe","pipe"]});this._info.pid=this.process.pid;this._info.shellPid=this.process.pid;let processExited=false;let exitCode=null;let exitError=null;this.process.stdout?.on("data",data=>{const text=data.toString();this.logs.push(text);if(this.options.debug){console.log("[SERVER]",text)}});this.process.stderr?.on("data",data=>{const text=data.toString();this.logs.push(`[ERROR] ${text}`);if(this.options.debug){console.error("[SERVER ERROR]",text)}});this.process.on("error",err=>{this.logs.push(`[SPAWN ERROR] ${err.message}`);exitError=err;if(this.options.debug){console.error("[SERVER SPAWN ERROR]",err)}});this.process.once("exit",code=>{processExited=true;exitCode=code;this.log(`Server process exited with code ${code}`)});try{await this.waitForReadyWithExitDetection(()=>{if(exitError){return{exited:true,error:exitError}}if(processExited){const allLogs=this.logs.join("\n");const errorLogs=this.logs.filter(l=>l.includes("[ERROR]")||l.toLowerCase().includes("error")).join("\n");return{exited:true,error:new ServerStartError(`Server process exited unexpectedly with code ${exitCode}.
|
|
3552
5
|
|
|
3553
6
|
Command: ${this.options.command}
|
|
3554
7
|
CWD: ${this.options.cwd}
|
|
3555
8
|
Port: ${this.options.port}
|
|
3556
9
|
|
|
3557
10
|
=== Error Logs ===
|
|
3558
|
-
${errorLogs
|
|
11
|
+
${errorLogs||"No error logs captured"}
|
|
3559
12
|
|
|
3560
13
|
=== Full Logs ===
|
|
3561
|
-
${allLogs
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
return { exited: false };
|
|
3566
|
-
});
|
|
3567
|
-
} catch (error) {
|
|
3568
|
-
this.printLogsOnFailure("Server startup failed");
|
|
3569
|
-
throw error;
|
|
3570
|
-
}
|
|
3571
|
-
}
|
|
3572
|
-
/**
|
|
3573
|
-
* Print server logs on failure for debugging
|
|
3574
|
-
*/
|
|
3575
|
-
printLogsOnFailure(context) {
|
|
3576
|
-
const allLogs = this.logs.join("\n");
|
|
3577
|
-
if (allLogs) {
|
|
3578
|
-
console.error(`
|
|
3579
|
-
[TestServer] ${context}`);
|
|
3580
|
-
console.error(`[TestServer] Command: ${this.options.command}`);
|
|
3581
|
-
console.error(`[TestServer] Port: ${this.options.port}`);
|
|
3582
|
-
console.error(`[TestServer] CWD: ${this.options.cwd}`);
|
|
3583
|
-
console.error(`[TestServer] === Server Logs ===
|
|
3584
|
-
${allLogs}`);
|
|
3585
|
-
console.error(`[TestServer] === End Logs ===
|
|
3586
|
-
`);
|
|
3587
|
-
}
|
|
3588
|
-
}
|
|
3589
|
-
/**
|
|
3590
|
-
* Wait for server to be ready, but also detect early process exit
|
|
3591
|
-
*/
|
|
3592
|
-
async waitForReadyWithExitDetection(checkExit) {
|
|
3593
|
-
const timeoutMs = this.options.startupTimeout;
|
|
3594
|
-
const deadline = Date.now() + timeoutMs;
|
|
3595
|
-
const checkInterval = 100;
|
|
3596
|
-
let lastHealthCheckError = null;
|
|
3597
|
-
let healthCheckAttempts = 0;
|
|
3598
|
-
this.log(`Waiting for server to be ready (timeout: ${timeoutMs}ms)...`);
|
|
3599
|
-
while (Date.now() < deadline) {
|
|
3600
|
-
const exitStatus = checkExit();
|
|
3601
|
-
if (exitStatus.exited) {
|
|
3602
|
-
throw exitStatus.error ?? new ServerStartError("Server process exited unexpectedly");
|
|
3603
|
-
}
|
|
3604
|
-
healthCheckAttempts++;
|
|
3605
|
-
try {
|
|
3606
|
-
const healthUrl = `${this._info.baseUrl}${this.options.healthCheckPath}`;
|
|
3607
|
-
const response = await fetch(healthUrl, {
|
|
3608
|
-
method: "GET",
|
|
3609
|
-
signal: AbortSignal.timeout(1e3)
|
|
3610
|
-
});
|
|
3611
|
-
if (response.ok || response.status === 404) {
|
|
3612
|
-
this.log(`Server is ready after ${healthCheckAttempts} health check attempts`);
|
|
3613
|
-
const listeningPid = await findListeningPid(this.options.port);
|
|
3614
|
-
if (listeningPid !== void 0) this._info.pid = listeningPid;
|
|
3615
|
-
return;
|
|
3616
|
-
}
|
|
3617
|
-
lastHealthCheckError = `HTTP ${response.status}: ${response.statusText}`;
|
|
3618
|
-
} catch (err) {
|
|
3619
|
-
lastHealthCheckError = err instanceof Error ? err.message : String(err);
|
|
3620
|
-
}
|
|
3621
|
-
const elapsed = Date.now() - (deadline - timeoutMs);
|
|
3622
|
-
if (elapsed > 0 && elapsed % 5e3 < checkInterval) {
|
|
3623
|
-
this.log(
|
|
3624
|
-
`Still waiting for server... (${Math.round(elapsed / 1e3)}s elapsed, last error: ${lastHealthCheckError})`
|
|
3625
|
-
);
|
|
3626
|
-
}
|
|
3627
|
-
await sleep2(checkInterval);
|
|
3628
|
-
}
|
|
3629
|
-
const finalExitStatus = checkExit();
|
|
3630
|
-
if (finalExitStatus.exited) {
|
|
3631
|
-
throw finalExitStatus.error ?? new ServerStartError("Server process exited unexpectedly");
|
|
3632
|
-
}
|
|
3633
|
-
const allLogs = this.logs.join("\n");
|
|
3634
|
-
throw new ServerStartError(
|
|
3635
|
-
`Server did not become ready within ${timeoutMs}ms.
|
|
14
|
+
${allLogs||"No logs captured"}`)}}return{exited:false}})}catch(error){this.printLogsOnFailure("Server startup failed");throw error}}printLogsOnFailure(context){const allLogs=this.logs.join("\n");if(allLogs){console.error(`
|
|
15
|
+
[TestServer] ${context}`);console.error(`[TestServer] Command: ${this.options.command}`);console.error(`[TestServer] Port: ${this.options.port}`);console.error(`[TestServer] CWD: ${this.options.cwd}`);console.error(`[TestServer] === Server Logs ===
|
|
16
|
+
${allLogs}`);console.error(`[TestServer] === End Logs ===
|
|
17
|
+
`)}}async waitForReadyWithExitDetection(checkExit){const timeoutMs=this.options.startupTimeout;const deadline=Date.now()+timeoutMs;const checkInterval=100;let lastHealthCheckError=null;let healthCheckAttempts=0;this.log(`Waiting for server to be ready (timeout: ${timeoutMs}ms)...`);while(Date.now()<deadline){const exitStatus=checkExit();if(exitStatus.exited){throw exitStatus.error??new ServerStartError("Server process exited unexpectedly")}healthCheckAttempts++;try{const healthUrl=`${this._info.baseUrl}${this.options.healthCheckPath}`;const response=await fetch(healthUrl,{method:"GET",signal:AbortSignal.timeout(1e3)});if(response.ok||response.status===404){this.log(`Server is ready after ${healthCheckAttempts} health check attempts`);const listeningPid=await findListeningPid(this.options.port);if(listeningPid!==void 0)this._info.pid=listeningPid;return}lastHealthCheckError=`HTTP ${response.status}: ${response.statusText}`}catch(err){lastHealthCheckError=err instanceof Error?err.message:String(err)}const elapsed=Date.now()-(deadline-timeoutMs);if(elapsed>0&&elapsed%5e3<checkInterval){this.log(`Still waiting for server... (${Math.round(elapsed/1e3)}s elapsed, last error: ${lastHealthCheckError})`)}await sleep2(checkInterval)}const finalExitStatus=checkExit();if(finalExitStatus.exited){throw finalExitStatus.error??new ServerStartError("Server process exited unexpectedly")}const allLogs=this.logs.join("\n");throw new ServerStartError(`Server did not become ready within ${timeoutMs}ms.
|
|
3636
18
|
|
|
3637
19
|
Command: ${this.options.command}
|
|
3638
20
|
CWD: ${this.options.cwd}
|
|
3639
21
|
Port: ${this.options.port}
|
|
3640
22
|
Health check URL: ${this._info.baseUrl}${this.options.healthCheckPath}
|
|
3641
23
|
Health check attempts: ${healthCheckAttempts}
|
|
3642
|
-
Last health check error: ${lastHealthCheckError
|
|
24
|
+
Last health check error: ${lastHealthCheckError??"none"}
|
|
3643
25
|
|
|
3644
26
|
=== Server Logs ===
|
|
3645
|
-
${allLogs
|
|
3646
|
-
|
|
3647
|
-
TIP: Set DEBUG_SERVER=1 or DEBUG=1 environment variable for verbose output`
|
|
3648
|
-
);
|
|
3649
|
-
}
|
|
3650
|
-
log(message) {
|
|
3651
|
-
if (this.options.debug) {
|
|
3652
|
-
console.log(`[TestServer] ${message}`);
|
|
3653
|
-
}
|
|
3654
|
-
}
|
|
3655
|
-
};
|
|
3656
|
-
function sleep2(ms) {
|
|
3657
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3658
|
-
}
|
|
3659
|
-
function signalIfRunning(pid, signal) {
|
|
3660
|
-
try {
|
|
3661
|
-
process.kill(pid, signal);
|
|
3662
|
-
} catch (error) {
|
|
3663
|
-
if (error.code !== "ESRCH") throw error;
|
|
3664
|
-
}
|
|
3665
|
-
}
|
|
3666
|
-
function acceptsConnections(port) {
|
|
3667
|
-
return new Promise((resolve) => {
|
|
3668
|
-
const socket = connect(port, "127.0.0.1");
|
|
3669
|
-
socket.once("connect", () => {
|
|
3670
|
-
socket.destroy();
|
|
3671
|
-
resolve(true);
|
|
3672
|
-
});
|
|
3673
|
-
socket.once("error", () => resolve(false));
|
|
3674
|
-
});
|
|
3675
|
-
}
|
|
3676
|
-
function findListeningPid(port) {
|
|
3677
|
-
if (process.platform === "win32") return Promise.resolve(void 0);
|
|
3678
|
-
return new Promise((resolve) => {
|
|
3679
|
-
execFile("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], { timeout: 2e3 }, (error, stdout) => {
|
|
3680
|
-
if (error) {
|
|
3681
|
-
resolve(void 0);
|
|
3682
|
-
return;
|
|
3683
|
-
}
|
|
3684
|
-
const pid = parseInt(String(stdout).split("\n")[0] ?? "", 10);
|
|
3685
|
-
resolve(Number.isInteger(pid) && pid > 0 ? pid : void 0);
|
|
3686
|
-
});
|
|
3687
|
-
});
|
|
3688
|
-
}
|
|
3689
|
-
function withWorkspaceProtocolFallback(env, cwd) {
|
|
3690
|
-
if (findInstalledProtocolPackageDir(cwd)) {
|
|
3691
|
-
return env;
|
|
3692
|
-
}
|
|
3693
|
-
try {
|
|
3694
|
-
const workspacePackageDir = findWorkspaceProtocolDir(cwd);
|
|
3695
|
-
if (!workspacePackageDir) {
|
|
3696
|
-
return env;
|
|
3697
|
-
}
|
|
3698
|
-
ensureWorkspaceProtocolLink(cwd, workspacePackageDir);
|
|
3699
|
-
if (findInstalledProtocolPackageDir(cwd)) {
|
|
3700
|
-
return env;
|
|
3701
|
-
}
|
|
3702
|
-
return withProtocolNodePathAlias(env, cwd, workspacePackageDir);
|
|
3703
|
-
} catch (err) {
|
|
3704
|
-
if (DEBUG_SERVER) {
|
|
3705
|
-
console.error(
|
|
3706
|
-
`[TestServer] Workspace protocol fallback failed: ${err instanceof Error ? err.message : String(err)}`
|
|
3707
|
-
);
|
|
3708
|
-
}
|
|
3709
|
-
return env;
|
|
3710
|
-
}
|
|
3711
|
-
}
|
|
3712
|
-
function ensureWorkspaceProtocolLink(cwd, workspacePackageDir) {
|
|
3713
|
-
const fs = __require("node:fs");
|
|
3714
|
-
const path = __require("node:path");
|
|
3715
|
-
const nodeModulesDir = findWorkspaceNodeModulesDir(cwd);
|
|
3716
|
-
if (!nodeModulesDir) {
|
|
3717
|
-
return;
|
|
3718
|
-
}
|
|
3719
|
-
const scopeDir = path.join(nodeModulesDir, "@frontmcp");
|
|
3720
|
-
const aliasPackageDir = path.join(scopeDir, "protocol");
|
|
3721
|
-
if (fs.existsSync(aliasPackageDir)) {
|
|
3722
|
-
return;
|
|
3723
|
-
}
|
|
3724
|
-
fs.mkdirSync(scopeDir, { recursive: true });
|
|
3725
|
-
try {
|
|
3726
|
-
fs.symlinkSync(workspacePackageDir, aliasPackageDir, process.platform === "win32" ? "junction" : "dir");
|
|
3727
|
-
} catch (error) {
|
|
3728
|
-
const err = error;
|
|
3729
|
-
if (err.code !== "EEXIST") {
|
|
3730
|
-
throw error;
|
|
3731
|
-
}
|
|
3732
|
-
}
|
|
3733
|
-
}
|
|
3734
|
-
function withProtocolNodePathAlias(env, cwd, workspacePackageDir) {
|
|
3735
|
-
const fs = __require("node:fs");
|
|
3736
|
-
const os = __require("node:os");
|
|
3737
|
-
const path = __require("node:path");
|
|
3738
|
-
const aliasRoot = path.join(os.tmpdir(), "frontmcp-test-node-path", sha256Hex(cwd).slice(0, 12));
|
|
3739
|
-
const scopeDir = path.join(aliasRoot, "@frontmcp");
|
|
3740
|
-
const aliasPackageDir = path.join(scopeDir, "protocol");
|
|
3741
|
-
fs.mkdirSync(scopeDir, { recursive: true });
|
|
3742
|
-
try {
|
|
3743
|
-
if (!fs.existsSync(aliasPackageDir)) {
|
|
3744
|
-
fs.symlinkSync(workspacePackageDir, aliasPackageDir, process.platform === "win32" ? "junction" : "dir");
|
|
3745
|
-
}
|
|
3746
|
-
} catch (error) {
|
|
3747
|
-
const err = error;
|
|
3748
|
-
if (err.code !== "EEXIST") throw error;
|
|
3749
|
-
}
|
|
3750
|
-
const existingNodePath = env["NODE_PATH"];
|
|
3751
|
-
const nodePathEntries = [aliasRoot, ...existingNodePath ? existingNodePath.split(path.delimiter) : []].filter(
|
|
3752
|
-
Boolean
|
|
3753
|
-
);
|
|
3754
|
-
return {
|
|
3755
|
-
...env,
|
|
3756
|
-
NODE_PATH: [...new Set(nodePathEntries)].join(path.delimiter)
|
|
3757
|
-
};
|
|
3758
|
-
}
|
|
3759
|
-
function findUp(startDir, testFn) {
|
|
3760
|
-
const path = __require("node:path");
|
|
3761
|
-
let currentDir = startDir;
|
|
3762
|
-
while (true) {
|
|
3763
|
-
const result = testFn(currentDir);
|
|
3764
|
-
if (result !== void 0) return result;
|
|
3765
|
-
const parentDir = path.dirname(currentDir);
|
|
3766
|
-
if (parentDir === currentDir) return void 0;
|
|
3767
|
-
currentDir = parentDir;
|
|
3768
|
-
}
|
|
3769
|
-
}
|
|
3770
|
-
function findWorkspaceProtocolDir(startDir) {
|
|
3771
|
-
const fs = __require("node:fs");
|
|
3772
|
-
const path = __require("node:path");
|
|
3773
|
-
return findUp(startDir, (dir) => {
|
|
3774
|
-
const candidate = path.join(dir, "libs", "protocol");
|
|
3775
|
-
return fs.existsSync(path.join(candidate, "dist", "index.js")) ? candidate : void 0;
|
|
3776
|
-
});
|
|
3777
|
-
}
|
|
3778
|
-
function findInstalledProtocolPackageDir(startDir) {
|
|
3779
|
-
const fs = __require("node:fs");
|
|
3780
|
-
const path = __require("node:path");
|
|
3781
|
-
return findUp(startDir, (dir) => {
|
|
3782
|
-
const candidate = path.join(dir, "node_modules", "@frontmcp", "protocol");
|
|
3783
|
-
return fs.existsSync(path.join(candidate, "package.json")) ? candidate : void 0;
|
|
3784
|
-
});
|
|
3785
|
-
}
|
|
3786
|
-
function findWorkspaceNodeModulesDir(startDir) {
|
|
3787
|
-
const fs = __require("node:fs");
|
|
3788
|
-
const path = __require("node:path");
|
|
3789
|
-
return findUp(startDir, (dir) => {
|
|
3790
|
-
const candidate = path.join(dir, "node_modules");
|
|
3791
|
-
return fs.existsSync(candidate) ? candidate : void 0;
|
|
3792
|
-
});
|
|
3793
|
-
}
|
|
3794
|
-
|
|
3795
|
-
// libs/testing/src/fixtures/gateway-token-binding.ts
|
|
3796
|
-
import { trimSlashes, trimTrailing } from "@frontmcp/utils";
|
|
3797
|
-
function gatewayTokenBinding(serverUrl, entryPath) {
|
|
3798
|
-
const base = trimTrailing(serverUrl, "/");
|
|
3799
|
-
const prefix = trimSlashes(entryPath ?? "");
|
|
3800
|
-
const mcpUrl = prefix ? `${base}/${prefix}` : base;
|
|
3801
|
-
return { issuer: mcpUrl, audience: mcpUrl };
|
|
3802
|
-
}
|
|
27
|
+
${allLogs||"No logs captured"}
|
|
3803
28
|
|
|
3804
|
-
|
|
3805
|
-
var jestApi;
|
|
3806
|
-
function jestGlobals() {
|
|
3807
|
-
if (jestApi) return jestApi;
|
|
3808
|
-
try {
|
|
3809
|
-
const api = __require("@jest/globals");
|
|
3810
|
-
jestApi = {
|
|
3811
|
-
describe: api["describe"],
|
|
3812
|
-
beforeAll: api["beforeAll"],
|
|
3813
|
-
beforeEach: api["beforeEach"],
|
|
3814
|
-
afterEach: api["afterEach"],
|
|
3815
|
-
afterAll: api["afterAll"],
|
|
3816
|
-
it: api["it"]
|
|
3817
|
-
};
|
|
3818
|
-
return jestApi;
|
|
3819
|
-
} catch (error) {
|
|
3820
|
-
throw new Error(
|
|
3821
|
-
`@frontmcp/testing fixtures ("test", "test.use", ...) can only be used inside a Jest test file: ${error instanceof Error ? error.message : String(error)}`,
|
|
3822
|
-
{ cause: error }
|
|
3823
|
-
);
|
|
3824
|
-
}
|
|
3825
|
-
}
|
|
3826
|
-
function newScope(parent) {
|
|
3827
|
-
return {
|
|
3828
|
-
skipped: parent?.skipped ?? false,
|
|
3829
|
-
reason: parent?.reason,
|
|
3830
|
-
config: {},
|
|
3831
|
-
envs: [],
|
|
3832
|
-
configured: false,
|
|
3833
|
-
cleanupRegistered: false,
|
|
3834
|
-
beforeEachHooks: [],
|
|
3835
|
-
afterEachHooks: []
|
|
3836
|
-
};
|
|
3837
|
-
}
|
|
3838
|
-
var scopes = [newScope()];
|
|
3839
|
-
function currentScope() {
|
|
3840
|
-
return scopes[scopes.length - 1];
|
|
3841
|
-
}
|
|
3842
|
-
var serverEntries = /* @__PURE__ */ new Map();
|
|
3843
|
-
function resolveConfig(chain) {
|
|
3844
|
-
const merged = {};
|
|
3845
|
-
for (const scope of chain) {
|
|
3846
|
-
Object.assign(merged, scope.config);
|
|
3847
|
-
for (const env of scope.envs) merged.env = { ...merged.env, ...env };
|
|
3848
|
-
}
|
|
3849
|
-
return merged;
|
|
3850
|
-
}
|
|
3851
|
-
function ownerScopeOf(chain) {
|
|
3852
|
-
for (let i = chain.length - 1; i > 0; i--) {
|
|
3853
|
-
if (chain[i].configured) return chain[i];
|
|
3854
|
-
}
|
|
3855
|
-
return chain[0];
|
|
3856
|
-
}
|
|
3857
|
-
function serverKey(config) {
|
|
3858
|
-
const env = Object.fromEntries(Object.entries(config.env ?? {}).sort(([a], [b]) => a.localeCompare(b)));
|
|
3859
|
-
return JSON.stringify({
|
|
3860
|
-
server: config.server,
|
|
3861
|
-
baseUrl: config.baseUrl,
|
|
3862
|
-
project: config.project,
|
|
3863
|
-
port: config.port,
|
|
3864
|
-
entryPath: config.entryPath,
|
|
3865
|
-
startupTimeout: config.startupTimeout,
|
|
3866
|
-
logLevel: config.logLevel,
|
|
3867
|
-
auth: config.auth,
|
|
3868
|
-
env
|
|
3869
|
-
});
|
|
3870
|
-
}
|
|
3871
|
-
function serverEnv(config) {
|
|
3872
|
-
const extra = {};
|
|
3873
|
-
if (config.logLevel) extra["FRONTMCP_LOG_LEVEL"] = config.logLevel;
|
|
3874
|
-
if (config.auth?.mode) extra["FRONTMCP_TEST_AUTH_MODE"] = config.auth.mode;
|
|
3875
|
-
if (config.auth?.type) extra["FRONTMCP_TEST_AUTH_TYPE"] = config.auth.type;
|
|
3876
|
-
if (Object.keys(extra).length === 0 && !config.env) return void 0;
|
|
3877
|
-
return { ...extra, ...config.env };
|
|
3878
|
-
}
|
|
3879
|
-
async function startServerEntry(config, owner) {
|
|
3880
|
-
let server;
|
|
3881
|
-
let startedByUs = false;
|
|
3882
|
-
if (config.baseUrl && !config.server) {
|
|
3883
|
-
server = TestServer.connect(config.baseUrl);
|
|
3884
|
-
} else if (config.server) {
|
|
3885
|
-
const serverCommand = resolveServerCommand(config.server);
|
|
3886
|
-
const isDebug = config.logLevel === "debug" || process.env["DEBUG"] === "1" || process.env["DEBUG_SERVER"] === "1";
|
|
3887
|
-
if (isDebug) {
|
|
3888
|
-
console.log(`[TestFixture] Starting server: ${serverCommand}`);
|
|
3889
|
-
}
|
|
3890
|
-
try {
|
|
3891
|
-
server = await TestServer.start({
|
|
3892
|
-
project: config.project,
|
|
3893
|
-
port: config.port,
|
|
3894
|
-
command: serverCommand,
|
|
3895
|
-
env: serverEnv(config),
|
|
3896
|
-
startupTimeout: config.startupTimeout ?? 3e4,
|
|
3897
|
-
debug: isDebug
|
|
3898
|
-
});
|
|
3899
|
-
startedByUs = true;
|
|
3900
|
-
if (isDebug) {
|
|
3901
|
-
console.log(`[TestFixture] Server started at ${server.info.baseUrl}`);
|
|
3902
|
-
}
|
|
3903
|
-
} catch (error) {
|
|
3904
|
-
const errMsg = error instanceof Error ? error.message : String(error);
|
|
3905
|
-
throw new Error(
|
|
3906
|
-
`Failed to start test server.
|
|
29
|
+
TIP: Set DEBUG_SERVER=1 or DEBUG=1 environment variable for verbose output`)}log(message){if(this.options.debug){console.log(`[TestServer] ${message}`)}}};function sleep2(ms){return new Promise(resolve=>setTimeout(resolve,ms))}function signalIfRunning(pid,signal){try{process.kill(pid,signal)}catch(error){if(error.code!=="ESRCH")throw error}}function acceptsConnections(port){return new Promise(resolve=>{const socket=connect(port,"127.0.0.1");socket.once("connect",()=>{socket.destroy();resolve(true)});socket.once("error",()=>resolve(false))})}function findListeningPid(port){if(process.platform==="win32")return Promise.resolve(void 0);return new Promise(resolve=>{execFile("lsof",["-nP",`-iTCP:${port}`,"-sTCP:LISTEN","-t"],{timeout:2e3},(error,stdout)=>{if(error){resolve(void 0);return}const pid=parseInt(String(stdout).split("\n")[0]??"",10);resolve(Number.isInteger(pid)&&pid>0?pid:void 0)})})}function withWorkspaceProtocolFallback(env,cwd){if(findInstalledProtocolPackageDir(cwd)){return env}try{const workspacePackageDir=findWorkspaceProtocolDir(cwd);if(!workspacePackageDir){return env}ensureWorkspaceProtocolLink(cwd,workspacePackageDir);if(findInstalledProtocolPackageDir(cwd)){return env}return withProtocolNodePathAlias(env,cwd,workspacePackageDir)}catch(err){if(DEBUG_SERVER){console.error(`[TestServer] Workspace protocol fallback failed: ${err instanceof Error?err.message:String(err)}`)}return env}}function ensureWorkspaceProtocolLink(cwd,workspacePackageDir){const fs=__require("node:fs");const path=__require("node:path");const nodeModulesDir=findWorkspaceNodeModulesDir(cwd);if(!nodeModulesDir){return}const scopeDir=path.join(nodeModulesDir,"@frontmcp");const aliasPackageDir=path.join(scopeDir,"protocol");if(fs.existsSync(aliasPackageDir)){return}fs.mkdirSync(scopeDir,{recursive:true});try{fs.symlinkSync(workspacePackageDir,aliasPackageDir,process.platform==="win32"?"junction":"dir")}catch(error){const err=error;if(err.code!=="EEXIST"){throw error}}}function withProtocolNodePathAlias(env,cwd,workspacePackageDir){const fs=__require("node:fs");const os=__require("node:os");const path=__require("node:path");const aliasRoot=path.join(os.tmpdir(),"frontmcp-test-node-path",sha256Hex(cwd).slice(0,12));const scopeDir=path.join(aliasRoot,"@frontmcp");const aliasPackageDir=path.join(scopeDir,"protocol");fs.mkdirSync(scopeDir,{recursive:true});try{if(!fs.existsSync(aliasPackageDir)){fs.symlinkSync(workspacePackageDir,aliasPackageDir,process.platform==="win32"?"junction":"dir")}}catch(error){const err=error;if(err.code!=="EEXIST")throw error}const existingNodePath=env["NODE_PATH"];const nodePathEntries=[aliasRoot,...existingNodePath?existingNodePath.split(path.delimiter):[]].filter(Boolean);return{...env,NODE_PATH:[...new Set(nodePathEntries)].join(path.delimiter)}}function findUp(startDir,testFn){const path=__require("node:path");let currentDir=startDir;while(true){const result=testFn(currentDir);if(result!==void 0)return result;const parentDir=path.dirname(currentDir);if(parentDir===currentDir)return void 0;currentDir=parentDir}}function findWorkspaceProtocolDir(startDir){const fs=__require("node:fs");const path=__require("node:path");return findUp(startDir,dir=>{const candidate=path.join(dir,"libs","protocol");return fs.existsSync(path.join(candidate,"dist","index.js"))?candidate:void 0})}function findInstalledProtocolPackageDir(startDir){const fs=__require("node:fs");const path=__require("node:path");return findUp(startDir,dir=>{const candidate=path.join(dir,"node_modules","@frontmcp","protocol");return fs.existsSync(path.join(candidate,"package.json"))?candidate:void 0})}function findWorkspaceNodeModulesDir(startDir){const fs=__require("node:fs");const path=__require("node:path");return findUp(startDir,dir=>{const candidate=path.join(dir,"node_modules");return fs.existsSync(candidate)?candidate:void 0})}import{trimSlashes,trimTrailing}from"@frontmcp/utils";function gatewayTokenBinding(serverUrl,entryPath){const base=trimTrailing(serverUrl,"/");const prefix=trimSlashes(entryPath??"");const mcpUrl=prefix?`${base}/${prefix}`:base;return{issuer:mcpUrl,audience:mcpUrl}}var jestApi;function jestGlobals(){if(jestApi)return jestApi;try{const api=__require("@jest/globals");jestApi={describe:api["describe"],beforeAll:api["beforeAll"],beforeEach:api["beforeEach"],afterEach:api["afterEach"],afterAll:api["afterAll"],it:api["it"]};return jestApi}catch(error){throw new Error(`@frontmcp/testing fixtures ("test", "test.use", ...) can only be used inside a Jest test file: ${error instanceof Error?error.message:String(error)}`,{cause:error})}}function newScope(parent){return{skipped:parent?.skipped??false,reason:parent?.reason,config:{},envs:[],configured:false,cleanupRegistered:false,beforeEachHooks:[],afterEachHooks:[]}}var scopes=[newScope()];function currentScope(){return scopes[scopes.length-1]}var serverEntries=new Map;function resolveConfig(chain){const merged={};for(const scope of chain){Object.assign(merged,scope.config);for(const env of scope.envs)merged.env={...merged.env,...env}}return merged}function ownerScopeOf(chain){for(let i=chain.length-1;i>0;i--){if(chain[i].configured)return chain[i]}return chain[0]}function serverKey(config){const env=Object.fromEntries(Object.entries(config.env??{}).sort(([a],[b])=>a.localeCompare(b)));return JSON.stringify({server:config.server,baseUrl:config.baseUrl,project:config.project,port:config.port,entryPath:config.entryPath,startupTimeout:config.startupTimeout,logLevel:config.logLevel,auth:config.auth,env})}function serverEnv(config){const extra={};if(config.logLevel)extra["FRONTMCP_LOG_LEVEL"]=config.logLevel;if(config.auth?.mode)extra["FRONTMCP_TEST_AUTH_MODE"]=config.auth.mode;if(config.auth?.type)extra["FRONTMCP_TEST_AUTH_TYPE"]=config.auth.type;if(Object.keys(extra).length===0&&!config.env)return void 0;return{...extra,...config.env}}async function startServerEntry(config,owner){let server;let startedByUs=false;if(config.baseUrl&&!config.server){server=TestServer.connect(config.baseUrl)}else if(config.server){const serverCommand=resolveServerCommand(config.server);const isDebug=config.logLevel==="debug"||process.env["DEBUG"]==="1"||process.env["DEBUG_SERVER"]==="1";if(isDebug){console.log(`[TestFixture] Starting server: ${serverCommand}`)}try{server=await TestServer.start({project:config.project,port:config.port,command:serverCommand,env:serverEnv(config),startupTimeout:config.startupTimeout??3e4,debug:isDebug});startedByUs=true;if(isDebug){console.log(`[TestFixture] Server started at ${server.info.baseUrl}`)}}catch(error){const errMsg=error instanceof Error?error.message:String(error);throw new Error(`Failed to start test server.
|
|
3907
30
|
|
|
3908
31
|
Server entry: ${config.server}
|
|
3909
|
-
Project: ${config.project
|
|
32
|
+
Project: ${config.project??"default"}
|
|
3910
33
|
Command: ${serverCommand}
|
|
3911
34
|
|
|
3912
|
-
Error: ${errMsg}`,
|
|
3913
|
-
{ cause: error }
|
|
3914
|
-
);
|
|
3915
|
-
}
|
|
3916
|
-
} else {
|
|
3917
|
-
throw new Error('test.use() requires either "server" (entry file path) or "baseUrl" (for external server) option');
|
|
3918
|
-
}
|
|
3919
|
-
const sharedSecret = config.env?.["JWT_SECRET"];
|
|
3920
|
-
const binding = sharedSecret ? gatewayTokenBinding(config.baseUrl ?? server.info.baseUrl, config.entryPath) : void 0;
|
|
3921
|
-
const tokenFactory = new TestTokenFactory(sharedSecret ? { hmacSecret: sharedSecret, ...binding } : {});
|
|
3922
|
-
return { server, startedByUs, tokenFactory, owner };
|
|
3923
|
-
}
|
|
3924
|
-
function acquireServer(chain) {
|
|
3925
|
-
const config = resolveConfig(chain);
|
|
3926
|
-
const key = serverKey(config);
|
|
3927
|
-
let entry = serverEntries.get(key);
|
|
3928
|
-
if (!entry) {
|
|
3929
|
-
entry = startServerEntry(config, ownerScopeOf(chain));
|
|
3930
|
-
serverEntries.set(key, entry);
|
|
3931
|
-
entry.catch(() => serverEntries.delete(key));
|
|
3932
|
-
}
|
|
3933
|
-
return entry;
|
|
3934
|
-
}
|
|
3935
|
-
async function stopServers(owner) {
|
|
3936
|
-
for (const [key, pending] of Array.from(serverEntries)) {
|
|
3937
|
-
let entry;
|
|
3938
|
-
try {
|
|
3939
|
-
entry = await pending;
|
|
3940
|
-
} catch {
|
|
3941
|
-
serverEntries.delete(key);
|
|
3942
|
-
continue;
|
|
3943
|
-
}
|
|
3944
|
-
if (owner && entry.owner !== owner) continue;
|
|
3945
|
-
serverEntries.delete(key);
|
|
3946
|
-
if (entry.startedByUs) {
|
|
3947
|
-
await entry.server.stop();
|
|
3948
|
-
}
|
|
3949
|
-
}
|
|
3950
|
-
}
|
|
3951
|
-
var fixtureContexts = /* @__PURE__ */ new WeakMap();
|
|
3952
|
-
async function buildFixtures(chain) {
|
|
3953
|
-
const config = resolveConfig(chain);
|
|
3954
|
-
const entry = await acquireServer(chain);
|
|
3955
|
-
const { server, tokenFactory } = entry;
|
|
3956
|
-
const publicMode = config.publicMode ?? config.auth?.mode === "public";
|
|
3957
|
-
const clientConfig = { ...config, publicMode };
|
|
3958
|
-
const clientInstance = McpTestClient.create({
|
|
3959
|
-
baseUrl: resolveClientBaseUrl(server, clientConfig),
|
|
3960
|
-
entryPath: clientConfig.entryPath,
|
|
3961
|
-
transport: clientConfig.transport ?? "streamable-http",
|
|
3962
|
-
publicMode
|
|
3963
|
-
}).build();
|
|
3964
|
-
try {
|
|
3965
|
-
await clientInstance.connect();
|
|
3966
|
-
} catch (err) {
|
|
3967
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
3968
|
-
if (/\b401\b|unauthor/i.test(message)) {
|
|
3969
|
-
console.warn(
|
|
3970
|
-
`[TestFixture] MCP client could not connect anonymously \u2014 the server requires authentication (${message}). Tests using only the 'server'/'auth' fixtures are unaffected; tests using the 'mcp' fixture must supply a token.`
|
|
3971
|
-
);
|
|
3972
|
-
} else {
|
|
3973
|
-
throw err;
|
|
3974
|
-
}
|
|
3975
|
-
}
|
|
3976
|
-
const context = { extraClients: [] };
|
|
3977
|
-
const fixtures = {
|
|
3978
|
-
mcp: clientInstance,
|
|
3979
|
-
auth: createAuthFixture(tokenFactory),
|
|
3980
|
-
server: createServerFixture(server, clientConfig, clientInstance, context)
|
|
3981
|
-
};
|
|
3982
|
-
fixtureContexts.set(fixtures, context);
|
|
3983
|
-
return fixtures;
|
|
3984
|
-
}
|
|
3985
|
-
async function createTestFixtures() {
|
|
3986
|
-
return buildFixtures(scopes.slice());
|
|
3987
|
-
}
|
|
3988
|
-
async function initializeSharedResources() {
|
|
3989
|
-
await acquireServer(scopes.slice());
|
|
3990
|
-
}
|
|
3991
|
-
async function cleanupTestFixtures(fixtures, testFailed = false) {
|
|
3992
|
-
if (testFailed) {
|
|
3993
|
-
const logs = fixtures.server.getLogs();
|
|
3994
|
-
if (logs.length > 0) {
|
|
3995
|
-
console.error("\n[TestFixture] === Server Logs (test failed) ===");
|
|
3996
|
-
const recentLogs = logs.slice(-50);
|
|
3997
|
-
if (logs.length > 50) {
|
|
3998
|
-
console.error(`[TestFixture] (showing last 50 of ${logs.length} log entries)`);
|
|
3999
|
-
}
|
|
4000
|
-
console.error(recentLogs.join("\n"));
|
|
4001
|
-
console.error("[TestFixture] === End Server Logs ===\n");
|
|
4002
|
-
}
|
|
4003
|
-
}
|
|
4004
|
-
const extras = fixtureContexts.get(fixtures)?.extraClients ?? [];
|
|
4005
|
-
for (const client of extras) {
|
|
4006
|
-
if (client.isConnected()) {
|
|
4007
|
-
await client.disconnect().catch(() => void 0);
|
|
4008
|
-
}
|
|
4009
|
-
}
|
|
4010
|
-
if (fixtures.mcp.isConnected()) {
|
|
4011
|
-
await fixtures.mcp.disconnect();
|
|
4012
|
-
}
|
|
4013
|
-
}
|
|
4014
|
-
async function cleanupSharedResources() {
|
|
4015
|
-
await stopServers();
|
|
4016
|
-
}
|
|
4017
|
-
function resolveClientBaseUrl(server, config) {
|
|
4018
|
-
return config.baseUrl ?? server.info.baseUrl;
|
|
4019
|
-
}
|
|
4020
|
-
function createAuthFixture(factory) {
|
|
4021
|
-
const users = {
|
|
4022
|
-
admin: {
|
|
4023
|
-
sub: "admin-001",
|
|
4024
|
-
scopes: ["admin:*", "read", "write", "delete"],
|
|
4025
|
-
email: "admin@test.local",
|
|
4026
|
-
name: "Test Admin"
|
|
4027
|
-
},
|
|
4028
|
-
user: {
|
|
4029
|
-
sub: "user-001",
|
|
4030
|
-
scopes: ["read", "write"],
|
|
4031
|
-
email: "user@test.local",
|
|
4032
|
-
name: "Test User"
|
|
4033
|
-
},
|
|
4034
|
-
readOnly: {
|
|
4035
|
-
sub: "readonly-001",
|
|
4036
|
-
scopes: ["read"],
|
|
4037
|
-
email: "readonly@test.local",
|
|
4038
|
-
name: "Read Only User"
|
|
4039
|
-
}
|
|
4040
|
-
};
|
|
4041
|
-
return {
|
|
4042
|
-
createToken: (opts) => factory.createTestToken({
|
|
4043
|
-
sub: opts.sub,
|
|
4044
|
-
scopes: opts.scopes,
|
|
4045
|
-
claims: {
|
|
4046
|
-
email: opts.email,
|
|
4047
|
-
name: opts.name,
|
|
4048
|
-
...opts.claims
|
|
4049
|
-
},
|
|
4050
|
-
exp: opts.expiresIn
|
|
4051
|
-
}),
|
|
4052
|
-
createExpiredToken: (opts) => factory.createExpiredToken(opts),
|
|
4053
|
-
createInvalidToken: (opts) => factory.createTokenWithInvalidSignature(opts),
|
|
4054
|
-
users: {
|
|
4055
|
-
admin: users["admin"],
|
|
4056
|
-
user: users["user"],
|
|
4057
|
-
readOnly: users["readOnly"]
|
|
4058
|
-
},
|
|
4059
|
-
getJwks: () => factory.getPublicJwks(),
|
|
4060
|
-
getIssuer: () => factory.getIssuer(),
|
|
4061
|
-
getAudience: () => factory.getAudience()
|
|
4062
|
-
};
|
|
4063
|
-
}
|
|
4064
|
-
function createServerFixture(server, config, mcp, context) {
|
|
4065
|
-
return {
|
|
4066
|
-
// Live view: `pid` is filled in once the server is listening and follows restarts
|
|
4067
|
-
get info() {
|
|
4068
|
-
return server.info;
|
|
4069
|
-
},
|
|
4070
|
-
createClient: async (opts) => {
|
|
4071
|
-
const client = await McpTestClient.create({
|
|
4072
|
-
baseUrl: resolveClientBaseUrl(server, config),
|
|
4073
|
-
entryPath: opts?.entryPath ?? config.entryPath,
|
|
4074
|
-
transport: opts?.transport ?? "streamable-http",
|
|
4075
|
-
auth: opts?.token ? { token: opts.token } : void 0,
|
|
4076
|
-
clientInfo: opts?.clientInfo,
|
|
4077
|
-
publicMode: config.publicMode
|
|
4078
|
-
}).buildAndConnect();
|
|
4079
|
-
context.extraClients.push(client);
|
|
4080
|
-
return client;
|
|
4081
|
-
},
|
|
4082
|
-
createClientBuilder: () => {
|
|
4083
|
-
const builder = new McpTestClientBuilder({
|
|
4084
|
-
baseUrl: resolveClientBaseUrl(server, config),
|
|
4085
|
-
entryPath: config.entryPath,
|
|
4086
|
-
publicMode: config.publicMode
|
|
4087
|
-
});
|
|
4088
|
-
return builder;
|
|
4089
|
-
},
|
|
4090
|
-
restart: async () => {
|
|
4091
|
-
const reconnect = [mcp, ...context.extraClients].filter((client) => client.isConnected());
|
|
4092
|
-
await server.restart();
|
|
4093
|
-
for (const client of reconnect) {
|
|
4094
|
-
await client.reconnect();
|
|
4095
|
-
}
|
|
4096
|
-
},
|
|
4097
|
-
getLogs: () => server.getLogs(),
|
|
4098
|
-
clearLogs: () => server.clearLogs()
|
|
4099
|
-
};
|
|
4100
|
-
}
|
|
4101
|
-
function resolveServerCommand(server) {
|
|
4102
|
-
if (server.includes(" ")) {
|
|
4103
|
-
return server;
|
|
4104
|
-
}
|
|
4105
|
-
return `npx tsx ${server}`;
|
|
4106
|
-
}
|
|
4107
|
-
function runWithFixtures(fn, chain) {
|
|
4108
|
-
return async (...extra) => {
|
|
4109
|
-
const fixtures = await buildFixtures(chain);
|
|
4110
|
-
let failure;
|
|
4111
|
-
try {
|
|
4112
|
-
for (const scope of chain) {
|
|
4113
|
-
for (const hook of scope.beforeEachHooks) await hook(fixtures);
|
|
4114
|
-
}
|
|
4115
|
-
await fn(fixtures, ...extra);
|
|
4116
|
-
} catch (error) {
|
|
4117
|
-
failure = { error };
|
|
4118
|
-
}
|
|
4119
|
-
try {
|
|
4120
|
-
await runAfterEachHooks(chain, fixtures);
|
|
4121
|
-
} catch (error) {
|
|
4122
|
-
failure ??= { error };
|
|
4123
|
-
}
|
|
4124
|
-
await cleanupTestFixtures(fixtures, failure !== void 0);
|
|
4125
|
-
if (failure) throw failure.error;
|
|
4126
|
-
};
|
|
4127
|
-
}
|
|
4128
|
-
async function runAfterEachHooks(chain, fixtures) {
|
|
4129
|
-
let firstError;
|
|
4130
|
-
let failed = false;
|
|
4131
|
-
for (let i = chain.length - 1; i >= 0; i--) {
|
|
4132
|
-
for (const hook of chain[i].afterEachHooks) {
|
|
4133
|
-
try {
|
|
4134
|
-
await hook(fixtures);
|
|
4135
|
-
} catch (error) {
|
|
4136
|
-
if (!failed) firstError = error;
|
|
4137
|
-
failed = true;
|
|
4138
|
-
}
|
|
4139
|
-
}
|
|
4140
|
-
}
|
|
4141
|
-
if (failed) throw firstError;
|
|
4142
|
-
}
|
|
4143
|
-
function skipTitle(name, scope) {
|
|
4144
|
-
return scope.reason ? `${name} (skipped: ${scope.reason})` : name;
|
|
4145
|
-
}
|
|
4146
|
-
function testWithFixtures(name, fn) {
|
|
4147
|
-
const chain = scopes.slice();
|
|
4148
|
-
const scope = currentScope();
|
|
4149
|
-
if (scope.skipped) {
|
|
4150
|
-
jestGlobals().it.skip(skipTitle(name, scope), runWithFixtures(fn, chain));
|
|
4151
|
-
return;
|
|
4152
|
-
}
|
|
4153
|
-
jestGlobals().it(name, runWithFixtures(fn, chain));
|
|
4154
|
-
}
|
|
4155
|
-
function use(config) {
|
|
4156
|
-
const scope = currentScope();
|
|
4157
|
-
const { env, ...rest } = config;
|
|
4158
|
-
scope.config = { ...scope.config, ...rest };
|
|
4159
|
-
if (env) scope.envs.push(env);
|
|
4160
|
-
scope.configured = true;
|
|
4161
|
-
if (!scope.cleanupRegistered) {
|
|
4162
|
-
scope.cleanupRegistered = true;
|
|
4163
|
-
jestGlobals().afterAll(async () => {
|
|
4164
|
-
await stopServers(scope === scopes[0] ? void 0 : scope);
|
|
4165
|
-
});
|
|
4166
|
-
}
|
|
4167
|
-
}
|
|
4168
|
-
function skip(nameOrCondition, fnOrReason) {
|
|
4169
|
-
if (typeof nameOrCondition === "boolean") {
|
|
4170
|
-
if (nameOrCondition) {
|
|
4171
|
-
const scope = currentScope();
|
|
4172
|
-
scope.skipped = true;
|
|
4173
|
-
scope.reason = typeof fnOrReason === "string" ? fnOrReason : void 0;
|
|
4174
|
-
}
|
|
4175
|
-
return;
|
|
4176
|
-
}
|
|
4177
|
-
if (typeof fnOrReason !== "function") {
|
|
4178
|
-
throw new TypeError(
|
|
4179
|
-
`test.skip expects either (name: string, fn) or (condition: boolean, reason?: string); received (${typeof nameOrCondition}, ${typeof fnOrReason}).`
|
|
4180
|
-
);
|
|
4181
|
-
}
|
|
4182
|
-
jestGlobals().it.skip(nameOrCondition, runWithFixtures(fnOrReason, scopes.slice()));
|
|
4183
|
-
}
|
|
4184
|
-
function only(name, fn) {
|
|
4185
|
-
const scope = currentScope();
|
|
4186
|
-
if (scope.skipped) {
|
|
4187
|
-
jestGlobals().it.skip(skipTitle(name, scope), runWithFixtures(fn, scopes.slice()));
|
|
4188
|
-
return;
|
|
4189
|
-
}
|
|
4190
|
-
jestGlobals().it.only(name, runWithFixtures(fn, scopes.slice()));
|
|
4191
|
-
}
|
|
4192
|
-
function todo(name) {
|
|
4193
|
-
jestGlobals().it.todo(name);
|
|
4194
|
-
}
|
|
4195
|
-
function each(table, ...tagged) {
|
|
4196
|
-
const chain = scopes.slice();
|
|
4197
|
-
const register = jestGlobals().it.each(table, ...tagged);
|
|
4198
|
-
return (name, fn) => {
|
|
4199
|
-
const scope = currentScope();
|
|
4200
|
-
if (scope.skipped) {
|
|
4201
|
-
const skipRegister = jestGlobals().it.skip.each(table, ...tagged);
|
|
4202
|
-
skipRegister(skipTitle(name, scope), runWithFixtures(fn, chain));
|
|
4203
|
-
return;
|
|
4204
|
-
}
|
|
4205
|
-
register(name, runWithFixtures(fn, chain));
|
|
4206
|
-
};
|
|
4207
|
-
}
|
|
4208
|
-
function withScope(fn) {
|
|
4209
|
-
return (...args) => {
|
|
4210
|
-
scopes.push(newScope(currentScope()));
|
|
4211
|
-
try {
|
|
4212
|
-
fn(...args);
|
|
4213
|
-
} finally {
|
|
4214
|
-
scopes.pop();
|
|
4215
|
-
}
|
|
4216
|
-
};
|
|
4217
|
-
}
|
|
4218
|
-
function scopedRegister(pick) {
|
|
4219
|
-
return (name, fn, timeout) => {
|
|
4220
|
-
pick()(name, withScope(fn), timeout);
|
|
4221
|
-
};
|
|
4222
|
-
}
|
|
4223
|
-
function scopedEach(pick) {
|
|
4224
|
-
return (...eachArgs) => (name, fn, timeout) => {
|
|
4225
|
-
const register = pick()(...eachArgs);
|
|
4226
|
-
register(name, withScope(fn), timeout);
|
|
4227
|
-
};
|
|
4228
|
-
}
|
|
4229
|
-
var describeWithScope = Object.assign(
|
|
4230
|
-
scopedRegister(() => jestGlobals().describe),
|
|
4231
|
-
{
|
|
4232
|
-
// `describe.skip` still evaluates its callback during collection, so it needs a scope too
|
|
4233
|
-
only: Object.assign(
|
|
4234
|
-
scopedRegister(() => jestGlobals().describe.only),
|
|
4235
|
-
{
|
|
4236
|
-
each: scopedEach(() => jestGlobals().describe.only.each)
|
|
4237
|
-
}
|
|
4238
|
-
),
|
|
4239
|
-
skip: Object.assign(
|
|
4240
|
-
scopedRegister(() => jestGlobals().describe.skip),
|
|
4241
|
-
{
|
|
4242
|
-
each: scopedEach(() => jestGlobals().describe.skip.each)
|
|
4243
|
-
}
|
|
4244
|
-
),
|
|
4245
|
-
each: scopedEach(() => jestGlobals().describe.each)
|
|
4246
|
-
}
|
|
4247
|
-
);
|
|
4248
|
-
function eachHook(kind) {
|
|
4249
|
-
return (fn, timeout) => {
|
|
4250
|
-
if (typeof fn === "function" && fn.length > 0) {
|
|
4251
|
-
const scope = currentScope();
|
|
4252
|
-
(kind === "beforeEach" ? scope.beforeEachHooks : scope.afterEachHooks).push(fn);
|
|
4253
|
-
return;
|
|
4254
|
-
}
|
|
4255
|
-
jestGlobals()[kind](fn, timeout);
|
|
4256
|
-
};
|
|
4257
|
-
}
|
|
4258
|
-
var test = testWithFixtures;
|
|
4259
|
-
test.use = use;
|
|
4260
|
-
test.describe = describeWithScope;
|
|
4261
|
-
test.beforeAll = ((...args) => jestGlobals().beforeAll(...args));
|
|
4262
|
-
test.beforeEach = eachHook("beforeEach");
|
|
4263
|
-
test.afterEach = eachHook("afterEach");
|
|
4264
|
-
test.afterAll = ((...args) => jestGlobals().afterAll(...args));
|
|
4265
|
-
test.skip = skip;
|
|
4266
|
-
test.only = only;
|
|
4267
|
-
test.todo = todo;
|
|
4268
|
-
test.each = each;
|
|
4269
|
-
export {
|
|
4270
|
-
cleanupSharedResources,
|
|
4271
|
-
cleanupTestFixtures,
|
|
4272
|
-
createTestFixtures,
|
|
4273
|
-
initializeSharedResources,
|
|
4274
|
-
test
|
|
4275
|
-
};
|
|
35
|
+
Error: ${errMsg}`,{cause:error})}}else{throw new Error('test.use() requires either "server" (entry file path) or "baseUrl" (for external server) option')}const sharedSecret=config.env?.["JWT_SECRET"];const binding=sharedSecret?gatewayTokenBinding(config.baseUrl??server.info.baseUrl,config.entryPath):void 0;const tokenFactory=new TestTokenFactory(sharedSecret?{hmacSecret:sharedSecret,...binding}:{});return{server,startedByUs,tokenFactory,owner}}function acquireServer(chain){const config=resolveConfig(chain);const key=serverKey(config);let entry=serverEntries.get(key);if(!entry){entry=startServerEntry(config,ownerScopeOf(chain));serverEntries.set(key,entry);entry.catch(()=>serverEntries.delete(key))}return entry}async function stopServers(owner){for(const[key,pending]of Array.from(serverEntries)){let entry;try{entry=await pending}catch{serverEntries.delete(key);continue}if(owner&&entry.owner!==owner)continue;serverEntries.delete(key);if(entry.startedByUs){await entry.server.stop()}}}var fixtureContexts=new WeakMap;async function buildFixtures(chain){const config=resolveConfig(chain);const entry=await acquireServer(chain);const{server,tokenFactory}=entry;const publicMode=config.publicMode??config.auth?.mode==="public";const clientConfig={...config,publicMode};const clientInstance=McpTestClient.create({baseUrl:resolveClientBaseUrl(server,clientConfig),entryPath:clientConfig.entryPath,transport:clientConfig.transport??"streamable-http",publicMode}).build();try{await clientInstance.connect()}catch(err){const message=err instanceof Error?err.message:String(err);if(/\b401\b|unauthor/i.test(message)){console.warn(`[TestFixture] MCP client could not connect anonymously \u2014 the server requires authentication (${message}). Tests using only the 'server'/'auth' fixtures are unaffected; tests using the 'mcp' fixture must supply a token.`)}else{throw err}}const context={extraClients:[]};const fixtures={mcp:clientInstance,auth:createAuthFixture(tokenFactory),server:createServerFixture(server,clientConfig,clientInstance,context)};fixtureContexts.set(fixtures,context);return fixtures}async function createTestFixtures(){return buildFixtures(scopes.slice())}async function initializeSharedResources(){await acquireServer(scopes.slice())}async function cleanupTestFixtures(fixtures,testFailed=false){if(testFailed){const logs=fixtures.server.getLogs();if(logs.length>0){console.error("\n[TestFixture] === Server Logs (test failed) ===");const recentLogs=logs.slice(-50);if(logs.length>50){console.error(`[TestFixture] (showing last 50 of ${logs.length} log entries)`)}console.error(recentLogs.join("\n"));console.error("[TestFixture] === End Server Logs ===\n")}}const extras=fixtureContexts.get(fixtures)?.extraClients??[];for(const client of extras){if(client.isConnected()){await client.disconnect().catch(()=>void 0)}}if(fixtures.mcp.isConnected()){await fixtures.mcp.disconnect()}}async function cleanupSharedResources(){await stopServers()}function resolveClientBaseUrl(server,config){return config.baseUrl??server.info.baseUrl}function createAuthFixture(factory){const users={admin:{sub:"admin-001",scopes:["admin:*","read","write","delete"],email:"admin@test.local",name:"Test Admin"},user:{sub:"user-001",scopes:["read","write"],email:"user@test.local",name:"Test User"},readOnly:{sub:"readonly-001",scopes:["read"],email:"readonly@test.local",name:"Read Only User"}};return{createToken:opts=>factory.createTestToken({sub:opts.sub,scopes:opts.scopes,claims:{email:opts.email,name:opts.name,...opts.claims},exp:opts.expiresIn}),createExpiredToken:opts=>factory.createExpiredToken(opts),createInvalidToken:opts=>factory.createTokenWithInvalidSignature(opts),users:{admin:users["admin"],user:users["user"],readOnly:users["readOnly"]},getJwks:()=>factory.getPublicJwks(),getIssuer:()=>factory.getIssuer(),getAudience:()=>factory.getAudience()}}function createServerFixture(server,config,mcp,context){return{get info(){return server.info},createClient:async opts=>{const client=await McpTestClient.create({baseUrl:resolveClientBaseUrl(server,config),entryPath:opts?.entryPath??config.entryPath,transport:opts?.transport??"streamable-http",auth:opts?.token?{token:opts.token}:void 0,clientInfo:opts?.clientInfo,publicMode:config.publicMode}).buildAndConnect();context.extraClients.push(client);return client},createClientBuilder:()=>{const builder=new McpTestClientBuilder({baseUrl:resolveClientBaseUrl(server,config),entryPath:config.entryPath,publicMode:config.publicMode});return builder},restart:async()=>{const reconnect=[mcp,...context.extraClients].filter(client=>client.isConnected());await server.restart();for(const client of reconnect){await client.reconnect()}},getLogs:()=>server.getLogs(),clearLogs:()=>server.clearLogs()}}function resolveServerCommand(server){if(server.includes(" ")){return server}return`npx tsx ${server}`}function runWithFixtures(fn,chain){return async(...extra)=>{const fixtures=await buildFixtures(chain);let failure;try{for(const scope of chain){for(const hook of scope.beforeEachHooks)await hook(fixtures)}await fn(fixtures,...extra)}catch(error){failure={error}}try{await runAfterEachHooks(chain,fixtures)}catch(error){failure??={error}}await cleanupTestFixtures(fixtures,failure!==void 0);if(failure)throw failure.error}}async function runAfterEachHooks(chain,fixtures){let firstError;let failed=false;for(let i=chain.length-1;i>=0;i--){for(const hook of chain[i].afterEachHooks){try{await hook(fixtures)}catch(error){if(!failed)firstError=error;failed=true}}}if(failed)throw firstError}function skipTitle(name,scope){return scope.reason?`${name} (skipped: ${scope.reason})`:name}function testWithFixtures(name,fn){const chain=scopes.slice();const scope=currentScope();if(scope.skipped){jestGlobals().it.skip(skipTitle(name,scope),runWithFixtures(fn,chain));return}jestGlobals().it(name,runWithFixtures(fn,chain))}function use(config){const scope=currentScope();const{env,...rest}=config;scope.config={...scope.config,...rest};if(env)scope.envs.push(env);scope.configured=true;if(!scope.cleanupRegistered){scope.cleanupRegistered=true;jestGlobals().afterAll(async()=>{await stopServers(scope===scopes[0]?void 0:scope)})}}function skip(nameOrCondition,fnOrReason){if(typeof nameOrCondition==="boolean"){if(nameOrCondition){const scope=currentScope();scope.skipped=true;scope.reason=typeof fnOrReason==="string"?fnOrReason:void 0}return}if(typeof fnOrReason!=="function"){throw new TypeError(`test.skip expects either (name: string, fn) or (condition: boolean, reason?: string); received (${typeof nameOrCondition}, ${typeof fnOrReason}).`)}jestGlobals().it.skip(nameOrCondition,runWithFixtures(fnOrReason,scopes.slice()))}function only(name,fn){const scope=currentScope();if(scope.skipped){jestGlobals().it.skip(skipTitle(name,scope),runWithFixtures(fn,scopes.slice()));return}jestGlobals().it.only(name,runWithFixtures(fn,scopes.slice()))}function todo(name){jestGlobals().it.todo(name)}function each(table,...tagged){const chain=scopes.slice();const register=jestGlobals().it.each(table,...tagged);return(name,fn)=>{const scope=currentScope();if(scope.skipped){const skipRegister=jestGlobals().it.skip.each(table,...tagged);skipRegister(skipTitle(name,scope),runWithFixtures(fn,chain));return}register(name,runWithFixtures(fn,chain))}}function withScope(fn){return(...args)=>{scopes.push(newScope(currentScope()));try{fn(...args)}finally{scopes.pop()}}}function scopedRegister(pick){return(name,fn,timeout)=>{pick()(name,withScope(fn),timeout)}}function scopedEach(pick){return(...eachArgs)=>(name,fn,timeout)=>{const register=pick()(...eachArgs);register(name,withScope(fn),timeout)}}var describeWithScope=Object.assign(scopedRegister(()=>jestGlobals().describe),{only:Object.assign(scopedRegister(()=>jestGlobals().describe.only),{each:scopedEach(()=>jestGlobals().describe.only.each)}),skip:Object.assign(scopedRegister(()=>jestGlobals().describe.skip),{each:scopedEach(()=>jestGlobals().describe.skip.each)}),each:scopedEach(()=>jestGlobals().describe.each)});function eachHook(kind){return(fn,timeout)=>{if(typeof fn==="function"&&fn.length>0){const scope=currentScope();(kind==="beforeEach"?scope.beforeEachHooks:scope.afterEachHooks).push(fn);return}jestGlobals()[kind](fn,timeout)}}var test=testWithFixtures;test.use=use;test.describe=describeWithScope;test.beforeAll=((...args)=>jestGlobals().beforeAll(...args));test.beforeEach=eachHook("beforeEach");test.afterEach=eachHook("afterEach");test.afterAll=((...args)=>jestGlobals().afterAll(...args));test.skip=skip;test.only=only;test.todo=todo;test.each=each;export{cleanupSharedResources,cleanupTestFixtures,createTestFixtures,initializeSharedResources,test};
|