@frontmcp/testing 1.7.2 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/mcp-test-client.d.ts +6 -2
- package/client/mcp-test-client.d.ts.map +1 -1
- package/client/mcp-test-client.types.d.ts +9 -2
- package/client/mcp-test-client.types.d.ts.map +1 -1
- package/esm/fixtures/index.mjs +545 -454
- package/esm/index.mjs +635 -544
- package/esm/package.json +6 -6
- package/esm/perf/index.mjs +474 -421
- package/fixtures/fixture-types.d.ts +34 -3
- package/fixtures/fixture-types.d.ts.map +1 -1
- package/fixtures/index.js +545 -454
- package/fixtures/test-fixture.d.ts.map +1 -1
- package/index.js +635 -544
- package/package.json +6 -6
- package/perf/index.js +474 -421
- package/transport/streamable-http.transport.d.ts +8 -2
- package/transport/streamable-http.transport.d.ts.map +1 -1
- package/transport/transport.interface.d.ts +8 -1
- package/transport/transport.interface.d.ts.map +1 -1
package/esm/fixtures/index.mjs
CHANGED
|
@@ -199,233 +199,277 @@ var TestTokenFactory = class {
|
|
|
199
199
|
}
|
|
200
200
|
};
|
|
201
201
|
|
|
202
|
-
// libs/testing/src/
|
|
203
|
-
var
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
name: "mcp-ext-apps",
|
|
214
|
-
version: "1.0"
|
|
215
|
-
};
|
|
216
|
-
case "claude":
|
|
217
|
-
return {
|
|
218
|
-
name: "claude-desktop",
|
|
219
|
-
version: "1.0"
|
|
220
|
-
};
|
|
221
|
-
case "cursor":
|
|
222
|
-
return {
|
|
223
|
-
name: "cursor",
|
|
224
|
-
version: "1.0"
|
|
225
|
-
};
|
|
226
|
-
case "continue":
|
|
227
|
-
return {
|
|
228
|
-
name: "continue",
|
|
229
|
-
version: "1.0"
|
|
230
|
-
};
|
|
231
|
-
case "cody":
|
|
232
|
-
return {
|
|
233
|
-
name: "cody",
|
|
234
|
-
version: "1.0"
|
|
235
|
-
};
|
|
236
|
-
case "gemini":
|
|
237
|
-
return {
|
|
238
|
-
name: "gemini",
|
|
239
|
-
version: "1.0"
|
|
240
|
-
};
|
|
241
|
-
case "generic-mcp":
|
|
242
|
-
return {
|
|
243
|
-
name: "generic-mcp-client",
|
|
244
|
-
version: "1.0"
|
|
245
|
-
};
|
|
246
|
-
case "unknown":
|
|
247
|
-
default:
|
|
248
|
-
return {
|
|
249
|
-
name: "mcp-test-client",
|
|
250
|
-
version: "1.0"
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
function getPlatformCapabilities(platform) {
|
|
255
|
-
const baseCapabilities = {
|
|
256
|
-
sampling: {},
|
|
257
|
-
// Include elicitation.form by default for testing elicitation workflows
|
|
258
|
-
// Note: MCP SDK expects form to be an object, not boolean
|
|
259
|
-
elicitation: {
|
|
260
|
-
form: {}
|
|
261
|
-
}
|
|
262
|
-
};
|
|
263
|
-
if (platform === "ext-apps") {
|
|
202
|
+
// libs/testing/src/interceptor/mock-registry.ts
|
|
203
|
+
var DefaultMockRegistry = class {
|
|
204
|
+
mocks = [];
|
|
205
|
+
add(mock) {
|
|
206
|
+
const entry = {
|
|
207
|
+
definition: mock,
|
|
208
|
+
callCount: 0,
|
|
209
|
+
calls: [],
|
|
210
|
+
remainingUses: mock.times ?? Infinity
|
|
211
|
+
};
|
|
212
|
+
this.mocks.push(entry);
|
|
264
213
|
return {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
214
|
+
remove: () => {
|
|
215
|
+
const index = this.mocks.indexOf(entry);
|
|
216
|
+
if (index !== -1) {
|
|
217
|
+
this.mocks.splice(index, 1);
|
|
269
218
|
}
|
|
270
|
-
}
|
|
219
|
+
},
|
|
220
|
+
callCount: () => entry.callCount,
|
|
221
|
+
calls: () => [...entry.calls]
|
|
271
222
|
};
|
|
272
223
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
// libs/testing/src/client/mcp-test-client.builder.ts
|
|
277
|
-
var McpTestClientBuilder = class {
|
|
278
|
-
config;
|
|
279
|
-
constructor(config) {
|
|
280
|
-
this.config = { ...config };
|
|
224
|
+
clear() {
|
|
225
|
+
this.mocks = [];
|
|
281
226
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
227
|
+
getAll() {
|
|
228
|
+
return this.mocks.map((e) => e.definition);
|
|
229
|
+
}
|
|
230
|
+
match(request) {
|
|
231
|
+
for (const entry of this.mocks) {
|
|
232
|
+
if (entry.remainingUses <= 0) continue;
|
|
233
|
+
const { definition } = entry;
|
|
234
|
+
if (definition.method !== request.method) continue;
|
|
235
|
+
if (definition.params !== void 0) {
|
|
236
|
+
const params = request.params ?? {};
|
|
237
|
+
if (typeof definition.params === "function") {
|
|
238
|
+
if (!definition.params(params)) continue;
|
|
239
|
+
} else {
|
|
240
|
+
if (!this.paramsMatch(definition.params, params)) continue;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
entry.callCount++;
|
|
244
|
+
entry.calls.push(request);
|
|
245
|
+
entry.remainingUses--;
|
|
246
|
+
return definition;
|
|
247
|
+
}
|
|
248
|
+
return void 0;
|
|
288
249
|
}
|
|
289
250
|
/**
|
|
290
|
-
*
|
|
251
|
+
* Check if request params match the mock params definition
|
|
291
252
|
*/
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
253
|
+
paramsMatch(expected, actual) {
|
|
254
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
255
|
+
if (!(key in actual)) return false;
|
|
256
|
+
const actualValue = actual[key];
|
|
257
|
+
if (Array.isArray(value)) {
|
|
258
|
+
if (!Array.isArray(actualValue)) return false;
|
|
259
|
+
if (value.length !== actualValue.length) return false;
|
|
260
|
+
for (let i = 0; i < value.length; i++) {
|
|
261
|
+
const expectedItem = value[i];
|
|
262
|
+
const actualItem = actualValue[i];
|
|
263
|
+
if (typeof expectedItem === "object" && expectedItem !== null) {
|
|
264
|
+
if (typeof actualItem !== "object" || actualItem === null) return false;
|
|
265
|
+
if (!this.paramsMatch(expectedItem, actualItem)) {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
} else if (actualItem !== expectedItem) {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
} else if (typeof value === "object" && value !== null) {
|
|
273
|
+
if (typeof actualValue !== "object" || actualValue === null) return false;
|
|
274
|
+
if (!this.paramsMatch(value, actualValue)) {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
} else if (actualValue !== value) {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return true;
|
|
295
282
|
}
|
|
283
|
+
};
|
|
284
|
+
var mockResponse = {
|
|
296
285
|
/**
|
|
297
|
-
*
|
|
286
|
+
* Create a successful JSON-RPC response
|
|
298
287
|
*/
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
288
|
+
success(result, id = 1) {
|
|
289
|
+
return {
|
|
290
|
+
jsonrpc: "2.0",
|
|
291
|
+
id,
|
|
292
|
+
result
|
|
303
293
|
};
|
|
304
|
-
|
|
305
|
-
}
|
|
294
|
+
},
|
|
306
295
|
/**
|
|
307
|
-
*
|
|
296
|
+
* Create an error JSON-RPC response
|
|
308
297
|
*/
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
298
|
+
error(code, message, data, id = 1) {
|
|
299
|
+
return {
|
|
300
|
+
jsonrpc: "2.0",
|
|
301
|
+
id,
|
|
302
|
+
error: { code, message, data }
|
|
303
|
+
};
|
|
304
|
+
},
|
|
313
305
|
/**
|
|
314
|
-
*
|
|
306
|
+
* Create a tool result response
|
|
315
307
|
*/
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
308
|
+
toolResult(content, id = 1) {
|
|
309
|
+
return {
|
|
310
|
+
jsonrpc: "2.0",
|
|
311
|
+
id,
|
|
312
|
+
result: { content }
|
|
313
|
+
};
|
|
314
|
+
},
|
|
320
315
|
/**
|
|
321
|
-
*
|
|
316
|
+
* Create a tools/list response
|
|
322
317
|
*/
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
318
|
+
toolsList(tools, id = 1) {
|
|
319
|
+
return {
|
|
320
|
+
jsonrpc: "2.0",
|
|
321
|
+
id,
|
|
322
|
+
result: { tools }
|
|
323
|
+
};
|
|
324
|
+
},
|
|
327
325
|
/**
|
|
328
|
-
*
|
|
329
|
-
* When true, no Authorization header is sent and anonymous token is not requested.
|
|
330
|
-
* Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
|
|
326
|
+
* Create a resources/list response
|
|
331
327
|
*/
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
328
|
+
resourcesList(resources, id = 1) {
|
|
329
|
+
return {
|
|
330
|
+
jsonrpc: "2.0",
|
|
331
|
+
id,
|
|
332
|
+
result: { resources }
|
|
333
|
+
};
|
|
334
|
+
},
|
|
336
335
|
/**
|
|
337
|
-
*
|
|
336
|
+
* Create a resources/read response
|
|
338
337
|
*/
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
338
|
+
resourceRead(contents, id = 1) {
|
|
339
|
+
return {
|
|
340
|
+
jsonrpc: "2.0",
|
|
341
|
+
id,
|
|
342
|
+
result: { contents }
|
|
343
|
+
};
|
|
344
|
+
},
|
|
343
345
|
/**
|
|
344
|
-
*
|
|
346
|
+
* Common MCP errors
|
|
345
347
|
*/
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
348
|
+
errors: {
|
|
349
|
+
methodNotFound: (method, id = 1) => mockResponse.error(-32601, `Method not found: ${method}`, void 0, id),
|
|
350
|
+
invalidParams: (message, id = 1) => mockResponse.error(-32602, message, void 0, id),
|
|
351
|
+
internalError: (message, id = 1) => mockResponse.error(-32603, message, void 0, id),
|
|
352
|
+
resourceNotFound: (uri, id = 1) => mockResponse.error(-32002, `Resource not found: ${uri}`, { uri }, id),
|
|
353
|
+
toolNotFound: (name, id = 1) => mockResponse.error(-32601, `Tool not found: ${name}`, { name }, id),
|
|
354
|
+
unauthorized: (id = 1) => mockResponse.error(-32001, "Unauthorized", void 0, id),
|
|
355
|
+
forbidden: (id = 1) => mockResponse.error(-32003, "Forbidden", void 0, id)
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
// libs/testing/src/interceptor/interceptor-chain.ts
|
|
360
|
+
var DefaultInterceptorChain = class {
|
|
361
|
+
request = [];
|
|
362
|
+
response = [];
|
|
363
|
+
mocks;
|
|
364
|
+
constructor() {
|
|
365
|
+
this.mocks = new DefaultMockRegistry();
|
|
349
366
|
}
|
|
350
367
|
/**
|
|
351
|
-
*
|
|
352
|
-
* Automatically configures clientInfo and capabilities for platform detection.
|
|
353
|
-
*
|
|
354
|
-
* Platform-specific behavior:
|
|
355
|
-
* - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
|
|
356
|
-
* - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
|
|
357
|
-
* - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
|
|
358
|
-
* - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
|
|
359
|
-
* - Other platforms follow similar patterns
|
|
360
|
-
*
|
|
361
|
-
* @example
|
|
362
|
-
* ```typescript
|
|
363
|
-
* const client = await McpTestClient.create({ baseUrl })
|
|
364
|
-
* .withPlatform('openai')
|
|
365
|
-
* .buildAndConnect();
|
|
366
|
-
*
|
|
367
|
-
* // ext-apps automatically sets the io.modelcontextprotocol/ui capability
|
|
368
|
-
* const extAppsClient = await McpTestClient.create({ baseUrl })
|
|
369
|
-
* .withPlatform('ext-apps')
|
|
370
|
-
* .buildAndConnect();
|
|
371
|
-
* ```
|
|
368
|
+
* Add a request interceptor
|
|
372
369
|
*/
|
|
373
|
-
|
|
374
|
-
this.
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
370
|
+
addRequestInterceptor(interceptor) {
|
|
371
|
+
this.request.push(interceptor);
|
|
372
|
+
return () => {
|
|
373
|
+
const index = this.request.indexOf(interceptor);
|
|
374
|
+
if (index !== -1) {
|
|
375
|
+
this.request.splice(index, 1);
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
378
|
}
|
|
379
379
|
/**
|
|
380
|
-
*
|
|
381
|
-
* Use this for fine-grained control over capabilities sent during initialization.
|
|
382
|
-
*
|
|
383
|
-
* @example
|
|
384
|
-
* ```typescript
|
|
385
|
-
* const client = await McpTestClient.create({ baseUrl })
|
|
386
|
-
* .withCapabilities({
|
|
387
|
-
* sampling: {},
|
|
388
|
-
* experimental: {
|
|
389
|
-
* 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
|
|
390
|
-
* }
|
|
391
|
-
* })
|
|
392
|
-
* .buildAndConnect();
|
|
393
|
-
* ```
|
|
380
|
+
* Add a response interceptor
|
|
394
381
|
*/
|
|
395
|
-
|
|
396
|
-
this.
|
|
397
|
-
return
|
|
382
|
+
addResponseInterceptor(interceptor) {
|
|
383
|
+
this.response.push(interceptor);
|
|
384
|
+
return () => {
|
|
385
|
+
const index = this.response.indexOf(interceptor);
|
|
386
|
+
if (index !== -1) {
|
|
387
|
+
this.response.splice(index, 1);
|
|
388
|
+
}
|
|
389
|
+
};
|
|
398
390
|
}
|
|
399
391
|
/**
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
* const client = await McpTestClient.create({ baseUrl })
|
|
406
|
-
* .withQueryParams({ mode: 'skills_only' })
|
|
407
|
-
* .buildAndConnect();
|
|
408
|
-
* ```
|
|
392
|
+
* Process a request through the interceptor chain
|
|
393
|
+
* Returns either:
|
|
394
|
+
* - { type: 'continue', request } - continue with (possibly modified) request
|
|
395
|
+
* - { type: 'mock', response } - return mock response immediately
|
|
396
|
+
* - { type: 'error', error } - throw error
|
|
409
397
|
*/
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
398
|
+
async processRequest(request, meta) {
|
|
399
|
+
let currentRequest = request;
|
|
400
|
+
const mockDef = this.mocks.match(request);
|
|
401
|
+
if (mockDef) {
|
|
402
|
+
if (mockDef.delay && mockDef.delay > 0) {
|
|
403
|
+
await sleep(mockDef.delay);
|
|
404
|
+
}
|
|
405
|
+
let mockResponse2;
|
|
406
|
+
if (typeof mockDef.response === "function") {
|
|
407
|
+
mockResponse2 = await mockDef.response(request);
|
|
408
|
+
} else {
|
|
409
|
+
mockResponse2 = mockDef.response;
|
|
410
|
+
}
|
|
411
|
+
return {
|
|
412
|
+
type: "mock",
|
|
413
|
+
response: { ...mockResponse2, id: request.id ?? mockResponse2.id }
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
for (const interceptor of this.request) {
|
|
417
|
+
const ctx = {
|
|
418
|
+
request: currentRequest,
|
|
419
|
+
meta
|
|
420
|
+
};
|
|
421
|
+
const result = await interceptor(ctx);
|
|
422
|
+
switch (result.action) {
|
|
423
|
+
case "passthrough":
|
|
424
|
+
break;
|
|
425
|
+
case "modify":
|
|
426
|
+
currentRequest = result.request;
|
|
427
|
+
break;
|
|
428
|
+
case "mock":
|
|
429
|
+
return {
|
|
430
|
+
type: "mock",
|
|
431
|
+
response: { ...result.response, id: request.id ?? result.response.id }
|
|
432
|
+
};
|
|
433
|
+
case "error":
|
|
434
|
+
return { type: "error", error: result.error };
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return { type: "continue", request: currentRequest };
|
|
413
438
|
}
|
|
414
439
|
/**
|
|
415
|
-
*
|
|
440
|
+
* Process a response through the interceptor chain
|
|
416
441
|
*/
|
|
417
|
-
|
|
418
|
-
|
|
442
|
+
async processResponse(request, response, durationMs) {
|
|
443
|
+
let currentResponse = response;
|
|
444
|
+
for (const interceptor of this.response) {
|
|
445
|
+
const ctx = {
|
|
446
|
+
request,
|
|
447
|
+
response: currentResponse,
|
|
448
|
+
durationMs
|
|
449
|
+
};
|
|
450
|
+
const result = await interceptor(ctx);
|
|
451
|
+
switch (result.action) {
|
|
452
|
+
case "passthrough":
|
|
453
|
+
break;
|
|
454
|
+
case "modify":
|
|
455
|
+
currentResponse = result.response;
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return currentResponse;
|
|
419
460
|
}
|
|
420
461
|
/**
|
|
421
|
-
*
|
|
462
|
+
* Clear all interceptors and mocks
|
|
422
463
|
*/
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
464
|
+
clear() {
|
|
465
|
+
this.request = [];
|
|
466
|
+
this.response = [];
|
|
467
|
+
this.mocks.clear();
|
|
427
468
|
}
|
|
428
469
|
};
|
|
470
|
+
function sleep(ms) {
|
|
471
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
472
|
+
}
|
|
429
473
|
|
|
430
474
|
// libs/testing/src/transport/streamable-http.transport.ts
|
|
431
475
|
var DEFAULT_TIMEOUT = 3e4;
|
|
@@ -444,6 +488,7 @@ var StreamableHttpTransport = class {
|
|
|
444
488
|
this.config = {
|
|
445
489
|
baseUrl: config.baseUrl.replace(/\/$/, ""),
|
|
446
490
|
// Remove trailing slash
|
|
491
|
+
entryPath: config.entryPath,
|
|
447
492
|
timeout: config.timeout ?? DEFAULT_TIMEOUT,
|
|
448
493
|
auth: config.auth ?? {},
|
|
449
494
|
publicMode: config.publicMode ?? false,
|
|
@@ -536,7 +581,7 @@ var StreamableHttpTransport = class {
|
|
|
536
581
|
}
|
|
537
582
|
const headers = this.buildHeaders();
|
|
538
583
|
this.lastRequestHeaders = headers;
|
|
539
|
-
const url =
|
|
584
|
+
const url = this.mcpUrl();
|
|
540
585
|
this.log(`POST ${url}`, message);
|
|
541
586
|
const controller = new AbortController();
|
|
542
587
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
@@ -606,7 +651,7 @@ var StreamableHttpTransport = class {
|
|
|
606
651
|
this.ensureConnected();
|
|
607
652
|
const headers = this.buildHeaders();
|
|
608
653
|
this.lastRequestHeaders = headers;
|
|
609
|
-
const url =
|
|
654
|
+
const url = this.mcpUrl();
|
|
610
655
|
this.log(`POST ${url} (notification)`, message);
|
|
611
656
|
const controller = new AbortController();
|
|
612
657
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
@@ -637,7 +682,7 @@ var StreamableHttpTransport = class {
|
|
|
637
682
|
this.ensureConnected();
|
|
638
683
|
const headers = this.buildHeaders();
|
|
639
684
|
this.lastRequestHeaders = headers;
|
|
640
|
-
const url =
|
|
685
|
+
const url = this.mcpUrl();
|
|
641
686
|
this.log(`POST ${url} (raw)`, data);
|
|
642
687
|
const controller = new AbortController();
|
|
643
688
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
@@ -920,7 +965,7 @@ var StreamableHttpTransport = class {
|
|
|
920
965
|
*/
|
|
921
966
|
async sendElicitationResponse(requestId, response) {
|
|
922
967
|
const headers = this.buildHeaders();
|
|
923
|
-
const url =
|
|
968
|
+
const url = this.mcpUrl();
|
|
924
969
|
const rpcResponse = {
|
|
925
970
|
jsonrpc: "2.0",
|
|
926
971
|
id: requestId,
|
|
@@ -940,6 +985,18 @@ var StreamableHttpTransport = class {
|
|
|
940
985
|
this.log("Failed to send elicitation response:", error);
|
|
941
986
|
}
|
|
942
987
|
}
|
|
988
|
+
/**
|
|
989
|
+
* The MCP endpoint URL. `entryPath` is where the server mounts MCP; the
|
|
990
|
+
* trailing slash is kept because both hosts normalize it and the original
|
|
991
|
+
* behaviour (`${baseUrl}/`) relied on it (issue #543).
|
|
992
|
+
*/
|
|
993
|
+
mcpUrl() {
|
|
994
|
+
const url = new URL(this.config.baseUrl);
|
|
995
|
+
const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
|
|
996
|
+
const basePath = url.pathname.replace(/\/+$/, "");
|
|
997
|
+
url.pathname = entry ? `${basePath}/${entry}` : `${basePath}/`;
|
|
998
|
+
return url.toString();
|
|
999
|
+
}
|
|
943
1000
|
buildHeaders() {
|
|
944
1001
|
const headers = {
|
|
945
1002
|
"Content-Type": "application/json",
|
|
@@ -1027,277 +1084,233 @@ var StreamableHttpTransport = class {
|
|
|
1027
1084
|
}
|
|
1028
1085
|
};
|
|
1029
1086
|
|
|
1030
|
-
// libs/testing/src/
|
|
1031
|
-
var
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1087
|
+
// libs/testing/src/platform/platform-client-info.ts
|
|
1088
|
+
var MCP_APPS_EXTENSION_KEY = "io.modelcontextprotocol/ui";
|
|
1089
|
+
function getPlatformClientInfo(platform) {
|
|
1090
|
+
switch (platform) {
|
|
1091
|
+
case "openai":
|
|
1092
|
+
return {
|
|
1093
|
+
name: "ChatGPT",
|
|
1094
|
+
version: "1.0"
|
|
1095
|
+
};
|
|
1096
|
+
case "ext-apps":
|
|
1097
|
+
return {
|
|
1098
|
+
name: "mcp-ext-apps",
|
|
1099
|
+
version: "1.0"
|
|
1100
|
+
};
|
|
1101
|
+
case "claude":
|
|
1102
|
+
return {
|
|
1103
|
+
name: "claude-desktop",
|
|
1104
|
+
version: "1.0"
|
|
1105
|
+
};
|
|
1106
|
+
case "cursor":
|
|
1107
|
+
return {
|
|
1108
|
+
name: "cursor",
|
|
1109
|
+
version: "1.0"
|
|
1110
|
+
};
|
|
1111
|
+
case "continue":
|
|
1112
|
+
return {
|
|
1113
|
+
name: "continue",
|
|
1114
|
+
version: "1.0"
|
|
1115
|
+
};
|
|
1116
|
+
case "cody":
|
|
1117
|
+
return {
|
|
1118
|
+
name: "cody",
|
|
1119
|
+
version: "1.0"
|
|
1120
|
+
};
|
|
1121
|
+
case "gemini":
|
|
1122
|
+
return {
|
|
1123
|
+
name: "gemini",
|
|
1124
|
+
version: "1.0"
|
|
1125
|
+
};
|
|
1126
|
+
case "generic-mcp":
|
|
1127
|
+
return {
|
|
1128
|
+
name: "generic-mcp-client",
|
|
1129
|
+
version: "1.0"
|
|
1130
|
+
};
|
|
1131
|
+
case "unknown":
|
|
1132
|
+
default:
|
|
1133
|
+
return {
|
|
1134
|
+
name: "mcp-test-client",
|
|
1135
|
+
version: "1.0"
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
function getPlatformCapabilities(platform) {
|
|
1140
|
+
const baseCapabilities = {
|
|
1141
|
+
sampling: {},
|
|
1142
|
+
// Include elicitation.form by default for testing elicitation workflows
|
|
1143
|
+
// Note: MCP SDK expects form to be an object, not boolean
|
|
1144
|
+
elicitation: {
|
|
1145
|
+
form: {}
|
|
1146
|
+
}
|
|
1147
|
+
};
|
|
1148
|
+
if (platform === "ext-apps") {
|
|
1041
1149
|
return {
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1150
|
+
...baseCapabilities,
|
|
1151
|
+
experimental: {
|
|
1152
|
+
[MCP_APPS_EXTENSION_KEY]: {
|
|
1153
|
+
mimeTypes: ["text/html;profile=mcp-app"]
|
|
1046
1154
|
}
|
|
1047
|
-
}
|
|
1048
|
-
callCount: () => entry.callCount,
|
|
1049
|
-
calls: () => [...entry.calls]
|
|
1155
|
+
}
|
|
1050
1156
|
};
|
|
1051
1157
|
}
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
if (entry.remainingUses <= 0) continue;
|
|
1061
|
-
const { definition } = entry;
|
|
1062
|
-
if (definition.method !== request.method) continue;
|
|
1063
|
-
if (definition.params !== void 0) {
|
|
1064
|
-
const params = request.params ?? {};
|
|
1065
|
-
if (typeof definition.params === "function") {
|
|
1066
|
-
if (!definition.params(params)) continue;
|
|
1067
|
-
} else {
|
|
1068
|
-
if (!this.paramsMatch(definition.params, params)) continue;
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
entry.callCount++;
|
|
1072
|
-
entry.calls.push(request);
|
|
1073
|
-
entry.remainingUses--;
|
|
1074
|
-
return definition;
|
|
1075
|
-
}
|
|
1076
|
-
return void 0;
|
|
1158
|
+
return baseCapabilities;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// libs/testing/src/client/mcp-test-client.builder.ts
|
|
1162
|
+
var McpTestClientBuilder = class {
|
|
1163
|
+
config;
|
|
1164
|
+
constructor(config) {
|
|
1165
|
+
this.config = { ...config };
|
|
1077
1166
|
}
|
|
1078
1167
|
/**
|
|
1079
|
-
*
|
|
1168
|
+
* Set the authentication configuration
|
|
1080
1169
|
*/
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
const actualValue = actual[key];
|
|
1085
|
-
if (Array.isArray(value)) {
|
|
1086
|
-
if (!Array.isArray(actualValue)) return false;
|
|
1087
|
-
if (value.length !== actualValue.length) return false;
|
|
1088
|
-
for (let i = 0; i < value.length; i++) {
|
|
1089
|
-
const expectedItem = value[i];
|
|
1090
|
-
const actualItem = actualValue[i];
|
|
1091
|
-
if (typeof expectedItem === "object" && expectedItem !== null) {
|
|
1092
|
-
if (typeof actualItem !== "object" || actualItem === null) return false;
|
|
1093
|
-
if (!this.paramsMatch(expectedItem, actualItem)) {
|
|
1094
|
-
return false;
|
|
1095
|
-
}
|
|
1096
|
-
} else if (actualItem !== expectedItem) {
|
|
1097
|
-
return false;
|
|
1098
|
-
}
|
|
1099
|
-
}
|
|
1100
|
-
} else if (typeof value === "object" && value !== null) {
|
|
1101
|
-
if (typeof actualValue !== "object" || actualValue === null) return false;
|
|
1102
|
-
if (!this.paramsMatch(value, actualValue)) {
|
|
1103
|
-
return false;
|
|
1104
|
-
}
|
|
1105
|
-
} else if (actualValue !== value) {
|
|
1106
|
-
return false;
|
|
1107
|
-
}
|
|
1108
|
-
}
|
|
1109
|
-
return true;
|
|
1170
|
+
withAuth(auth) {
|
|
1171
|
+
this.config.auth = { ...this.config.auth, ...auth };
|
|
1172
|
+
return this;
|
|
1110
1173
|
}
|
|
1111
|
-
};
|
|
1112
|
-
var mockResponse = {
|
|
1113
1174
|
/**
|
|
1114
|
-
*
|
|
1175
|
+
* Set the bearer token for authentication
|
|
1115
1176
|
*/
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
result
|
|
1121
|
-
};
|
|
1122
|
-
},
|
|
1177
|
+
withToken(token) {
|
|
1178
|
+
this.config.auth = { ...this.config.auth, token };
|
|
1179
|
+
return this;
|
|
1180
|
+
}
|
|
1123
1181
|
/**
|
|
1124
|
-
*
|
|
1182
|
+
* Add custom headers to all requests
|
|
1125
1183
|
*/
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
error: { code, message, data }
|
|
1184
|
+
withHeaders(headers) {
|
|
1185
|
+
this.config.auth = {
|
|
1186
|
+
...this.config.auth,
|
|
1187
|
+
headers: { ...this.config.auth?.headers, ...headers }
|
|
1131
1188
|
};
|
|
1132
|
-
|
|
1189
|
+
return this;
|
|
1190
|
+
}
|
|
1133
1191
|
/**
|
|
1134
|
-
*
|
|
1192
|
+
* Set the transport type
|
|
1135
1193
|
*/
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
result: { content }
|
|
1141
|
-
};
|
|
1142
|
-
},
|
|
1194
|
+
withTransport(transport) {
|
|
1195
|
+
this.config.transport = transport;
|
|
1196
|
+
return this;
|
|
1197
|
+
}
|
|
1143
1198
|
/**
|
|
1144
|
-
*
|
|
1199
|
+
* Set the request timeout in milliseconds
|
|
1145
1200
|
*/
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
result: { tools }
|
|
1151
|
-
};
|
|
1152
|
-
},
|
|
1201
|
+
withTimeout(timeoutMs) {
|
|
1202
|
+
this.config.timeout = timeoutMs;
|
|
1203
|
+
return this;
|
|
1204
|
+
}
|
|
1153
1205
|
/**
|
|
1154
|
-
*
|
|
1155
|
-
*/
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
result: { resources }
|
|
1161
|
-
};
|
|
1162
|
-
},
|
|
1206
|
+
* Enable debug logging
|
|
1207
|
+
*/
|
|
1208
|
+
withDebug(enabled = true) {
|
|
1209
|
+
this.config.debug = enabled;
|
|
1210
|
+
return this;
|
|
1211
|
+
}
|
|
1163
1212
|
/**
|
|
1164
|
-
*
|
|
1213
|
+
* Enable public mode - skip authentication entirely.
|
|
1214
|
+
* When true, no Authorization header is sent and anonymous token is not requested.
|
|
1215
|
+
* Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
|
|
1165
1216
|
*/
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
result: { contents }
|
|
1171
|
-
};
|
|
1172
|
-
},
|
|
1217
|
+
withPublicMode(enabled = true) {
|
|
1218
|
+
this.config.publicMode = enabled;
|
|
1219
|
+
return this;
|
|
1220
|
+
}
|
|
1173
1221
|
/**
|
|
1174
|
-
*
|
|
1222
|
+
* Set the MCP protocol version to request
|
|
1175
1223
|
*/
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
internalError: (message, id = 1) => mockResponse.error(-32603, message, void 0, id),
|
|
1180
|
-
resourceNotFound: (uri, id = 1) => mockResponse.error(-32002, `Resource not found: ${uri}`, { uri }, id),
|
|
1181
|
-
toolNotFound: (name, id = 1) => mockResponse.error(-32601, `Tool not found: ${name}`, { name }, id),
|
|
1182
|
-
unauthorized: (id = 1) => mockResponse.error(-32001, "Unauthorized", void 0, id),
|
|
1183
|
-
forbidden: (id = 1) => mockResponse.error(-32003, "Forbidden", void 0, id)
|
|
1224
|
+
withProtocolVersion(version) {
|
|
1225
|
+
this.config.protocolVersion = version;
|
|
1226
|
+
return this;
|
|
1184
1227
|
}
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
mocks;
|
|
1192
|
-
constructor() {
|
|
1193
|
-
this.mocks = new DefaultMockRegistry();
|
|
1228
|
+
/**
|
|
1229
|
+
* Set the client info sent during initialization
|
|
1230
|
+
*/
|
|
1231
|
+
withClientInfo(info) {
|
|
1232
|
+
this.config.clientInfo = info;
|
|
1233
|
+
return this;
|
|
1194
1234
|
}
|
|
1195
1235
|
/**
|
|
1196
|
-
*
|
|
1236
|
+
* Set the platform type for testing platform-specific meta keys.
|
|
1237
|
+
* Automatically configures clientInfo and capabilities for platform detection.
|
|
1238
|
+
*
|
|
1239
|
+
* Platform-specific behavior:
|
|
1240
|
+
* - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
|
|
1241
|
+
* - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
|
|
1242
|
+
* - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
|
|
1243
|
+
* - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
|
|
1244
|
+
* - Other platforms follow similar patterns
|
|
1245
|
+
*
|
|
1246
|
+
* @example
|
|
1247
|
+
* ```typescript
|
|
1248
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
1249
|
+
* .withPlatform('openai')
|
|
1250
|
+
* .buildAndConnect();
|
|
1251
|
+
*
|
|
1252
|
+
* // ext-apps automatically sets the io.modelcontextprotocol/ui capability
|
|
1253
|
+
* const extAppsClient = await McpTestClient.create({ baseUrl })
|
|
1254
|
+
* .withPlatform('ext-apps')
|
|
1255
|
+
* .buildAndConnect();
|
|
1256
|
+
* ```
|
|
1197
1257
|
*/
|
|
1198
|
-
|
|
1199
|
-
this.
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
this.request.splice(index, 1);
|
|
1204
|
-
}
|
|
1205
|
-
};
|
|
1258
|
+
withPlatform(platform) {
|
|
1259
|
+
this.config.platform = platform;
|
|
1260
|
+
this.config.clientInfo = getPlatformClientInfo(platform);
|
|
1261
|
+
this.config.capabilities = getPlatformCapabilities(platform);
|
|
1262
|
+
return this;
|
|
1206
1263
|
}
|
|
1207
1264
|
/**
|
|
1208
|
-
*
|
|
1265
|
+
* Set custom client capabilities for MCP initialization.
|
|
1266
|
+
* Use this for fine-grained control over capabilities sent during initialization.
|
|
1267
|
+
*
|
|
1268
|
+
* @example
|
|
1269
|
+
* ```typescript
|
|
1270
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
1271
|
+
* .withCapabilities({
|
|
1272
|
+
* sampling: {},
|
|
1273
|
+
* experimental: {
|
|
1274
|
+
* 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
|
|
1275
|
+
* }
|
|
1276
|
+
* })
|
|
1277
|
+
* .buildAndConnect();
|
|
1278
|
+
* ```
|
|
1209
1279
|
*/
|
|
1210
|
-
|
|
1211
|
-
this.
|
|
1212
|
-
return
|
|
1213
|
-
const index = this.response.indexOf(interceptor);
|
|
1214
|
-
if (index !== -1) {
|
|
1215
|
-
this.response.splice(index, 1);
|
|
1216
|
-
}
|
|
1217
|
-
};
|
|
1280
|
+
withCapabilities(capabilities) {
|
|
1281
|
+
this.config.capabilities = capabilities;
|
|
1282
|
+
return this;
|
|
1218
1283
|
}
|
|
1219
1284
|
/**
|
|
1220
|
-
*
|
|
1221
|
-
*
|
|
1222
|
-
*
|
|
1223
|
-
*
|
|
1224
|
-
*
|
|
1285
|
+
* Set query parameters to append to the connection URL.
|
|
1286
|
+
* Useful for testing mode switches like `?mode=skills_only`.
|
|
1287
|
+
*
|
|
1288
|
+
* @example
|
|
1289
|
+
* ```typescript
|
|
1290
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
1291
|
+
* .withQueryParams({ mode: 'skills_only' })
|
|
1292
|
+
* .buildAndConnect();
|
|
1293
|
+
* ```
|
|
1225
1294
|
*/
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
if (mockDef) {
|
|
1230
|
-
if (mockDef.delay && mockDef.delay > 0) {
|
|
1231
|
-
await sleep(mockDef.delay);
|
|
1232
|
-
}
|
|
1233
|
-
let mockResponse2;
|
|
1234
|
-
if (typeof mockDef.response === "function") {
|
|
1235
|
-
mockResponse2 = await mockDef.response(request);
|
|
1236
|
-
} else {
|
|
1237
|
-
mockResponse2 = mockDef.response;
|
|
1238
|
-
}
|
|
1239
|
-
return {
|
|
1240
|
-
type: "mock",
|
|
1241
|
-
response: { ...mockResponse2, id: request.id ?? mockResponse2.id }
|
|
1242
|
-
};
|
|
1243
|
-
}
|
|
1244
|
-
for (const interceptor of this.request) {
|
|
1245
|
-
const ctx = {
|
|
1246
|
-
request: currentRequest,
|
|
1247
|
-
meta
|
|
1248
|
-
};
|
|
1249
|
-
const result = await interceptor(ctx);
|
|
1250
|
-
switch (result.action) {
|
|
1251
|
-
case "passthrough":
|
|
1252
|
-
break;
|
|
1253
|
-
case "modify":
|
|
1254
|
-
currentRequest = result.request;
|
|
1255
|
-
break;
|
|
1256
|
-
case "mock":
|
|
1257
|
-
return {
|
|
1258
|
-
type: "mock",
|
|
1259
|
-
response: { ...result.response, id: request.id ?? result.response.id }
|
|
1260
|
-
};
|
|
1261
|
-
case "error":
|
|
1262
|
-
return { type: "error", error: result.error };
|
|
1263
|
-
}
|
|
1264
|
-
}
|
|
1265
|
-
return { type: "continue", request: currentRequest };
|
|
1295
|
+
withQueryParams(params) {
|
|
1296
|
+
this.config.queryParams = { ...this.config.queryParams, ...params };
|
|
1297
|
+
return this;
|
|
1266
1298
|
}
|
|
1267
1299
|
/**
|
|
1268
|
-
*
|
|
1300
|
+
* Build the McpTestClient instance (does not connect)
|
|
1269
1301
|
*/
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
for (const interceptor of this.response) {
|
|
1273
|
-
const ctx = {
|
|
1274
|
-
request,
|
|
1275
|
-
response: currentResponse,
|
|
1276
|
-
durationMs
|
|
1277
|
-
};
|
|
1278
|
-
const result = await interceptor(ctx);
|
|
1279
|
-
switch (result.action) {
|
|
1280
|
-
case "passthrough":
|
|
1281
|
-
break;
|
|
1282
|
-
case "modify":
|
|
1283
|
-
currentResponse = result.response;
|
|
1284
|
-
break;
|
|
1285
|
-
}
|
|
1286
|
-
}
|
|
1287
|
-
return currentResponse;
|
|
1302
|
+
build() {
|
|
1303
|
+
return new McpTestClient(this.config);
|
|
1288
1304
|
}
|
|
1289
1305
|
/**
|
|
1290
|
-
*
|
|
1306
|
+
* Build the McpTestClient and connect to the server
|
|
1291
1307
|
*/
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1308
|
+
async buildAndConnect() {
|
|
1309
|
+
const client = this.build();
|
|
1310
|
+
await client.connect();
|
|
1311
|
+
return client;
|
|
1296
1312
|
}
|
|
1297
1313
|
};
|
|
1298
|
-
function sleep(ms) {
|
|
1299
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1300
|
-
}
|
|
1301
1314
|
|
|
1302
1315
|
// libs/testing/src/client/mcp-test-client.ts
|
|
1303
1316
|
var DEFAULT_TIMEOUT2 = 3e4;
|
|
@@ -1306,6 +1319,21 @@ var DEFAULT_CLIENT_INFO = {
|
|
|
1306
1319
|
name: "@frontmcp/testing",
|
|
1307
1320
|
version: "0.4.0"
|
|
1308
1321
|
};
|
|
1322
|
+
function extractEntryPaths(data) {
|
|
1323
|
+
let payload = data;
|
|
1324
|
+
if (typeof payload === "string") {
|
|
1325
|
+
try {
|
|
1326
|
+
payload = JSON.parse(payload);
|
|
1327
|
+
} catch {
|
|
1328
|
+
return void 0;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
if (!payload || typeof payload !== "object") return void 0;
|
|
1332
|
+
const paths = payload.entryPaths;
|
|
1333
|
+
if (!Array.isArray(paths)) return void 0;
|
|
1334
|
+
const strings = paths.filter((p) => typeof p === "string");
|
|
1335
|
+
return strings.length > 0 ? strings : void 0;
|
|
1336
|
+
}
|
|
1309
1337
|
var McpTestClient = class {
|
|
1310
1338
|
// Platform, capabilities, and queryParams are optional - only set when needed
|
|
1311
1339
|
config;
|
|
@@ -1314,6 +1342,8 @@ var McpTestClient = class {
|
|
|
1314
1342
|
requestIdCounter = 0;
|
|
1315
1343
|
_lastRequestId = 0;
|
|
1316
1344
|
_sessionId;
|
|
1345
|
+
/** #543 — discovery is attempted at most once per connect. */
|
|
1346
|
+
triedDiscoveredEntryPath = false;
|
|
1317
1347
|
_sessionInfo = null;
|
|
1318
1348
|
_authState = { isAnonymous: true, scopes: [] };
|
|
1319
1349
|
// Logging and tracing
|
|
@@ -1331,6 +1361,7 @@ var McpTestClient = class {
|
|
|
1331
1361
|
constructor(config) {
|
|
1332
1362
|
this.config = {
|
|
1333
1363
|
baseUrl: config.baseUrl,
|
|
1364
|
+
entryPath: config.entryPath,
|
|
1334
1365
|
transport: config.transport ?? "streamable-http",
|
|
1335
1366
|
auth: config.auth ?? {},
|
|
1336
1367
|
publicMode: config.publicMode ?? false,
|
|
@@ -1366,11 +1397,13 @@ var McpTestClient = class {
|
|
|
1366
1397
|
*/
|
|
1367
1398
|
async connect() {
|
|
1368
1399
|
this.log("debug", `Connecting to ${this.config.baseUrl}...`);
|
|
1400
|
+
this.triedDiscoveredEntryPath = false;
|
|
1369
1401
|
this.transport = this.createTransport();
|
|
1370
1402
|
await this.transport.connect();
|
|
1371
1403
|
const maxRetries = 3;
|
|
1372
1404
|
const retryDelayMs = 500;
|
|
1373
1405
|
let lastError;
|
|
1406
|
+
let reportedEntryPaths;
|
|
1374
1407
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
1375
1408
|
const initResponse = await this.initialize();
|
|
1376
1409
|
if (initResponse.success && initResponse.data) {
|
|
@@ -1378,13 +1411,27 @@ var McpTestClient = class {
|
|
|
1378
1411
|
break;
|
|
1379
1412
|
}
|
|
1380
1413
|
lastError = initResponse.error?.message ?? "Unknown error";
|
|
1414
|
+
reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
|
|
1415
|
+
if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
|
|
1416
|
+
this.triedDiscoveredEntryPath = true;
|
|
1417
|
+
const discovered = reportedEntryPaths[0];
|
|
1418
|
+
this.log("info", `Server serves MCP at ${discovered}; reconnecting there (configured: ${this.mcpPathLabel()})`);
|
|
1419
|
+
this.config.entryPath = discovered;
|
|
1420
|
+
await this.transport.close();
|
|
1421
|
+
this.transport = this.createTransport();
|
|
1422
|
+
await this.transport.connect();
|
|
1423
|
+
continue;
|
|
1424
|
+
}
|
|
1381
1425
|
if (attempt < maxRetries) {
|
|
1382
1426
|
this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
|
|
1383
1427
|
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
1384
1428
|
}
|
|
1385
1429
|
}
|
|
1386
1430
|
if (!this.initResult) {
|
|
1387
|
-
|
|
1431
|
+
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.` : "";
|
|
1432
|
+
throw new Error(
|
|
1433
|
+
`Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${maxRetries} attempts: ${lastError}.${served}`
|
|
1434
|
+
);
|
|
1388
1435
|
}
|
|
1389
1436
|
this._sessionId = this.transport.getSessionId();
|
|
1390
1437
|
this._sessionInfo = {
|
|
@@ -1982,6 +2029,11 @@ var McpTestClient = class {
|
|
|
1982
2029
|
// ═══════════════════════════════════════════════════════════════════
|
|
1983
2030
|
// PRIVATE: TRANSPORT & REQUEST HELPERS
|
|
1984
2031
|
// ═══════════════════════════════════════════════════════════════════
|
|
2032
|
+
/** The MCP path this client is currently pointed at, for log and error text. */
|
|
2033
|
+
mcpPathLabel() {
|
|
2034
|
+
const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
|
|
2035
|
+
return entry ? `/${entry}` : "/";
|
|
2036
|
+
}
|
|
1985
2037
|
createTransport() {
|
|
1986
2038
|
let baseUrl = this.config.baseUrl;
|
|
1987
2039
|
if (this.config.queryParams && Object.keys(this.config.queryParams).length > 0) {
|
|
@@ -1995,6 +2047,7 @@ var McpTestClient = class {
|
|
|
1995
2047
|
case "streamable-http":
|
|
1996
2048
|
return new StreamableHttpTransport({
|
|
1997
2049
|
baseUrl,
|
|
2050
|
+
entryPath: this.config.entryPath,
|
|
1998
2051
|
timeout: this.config.timeout,
|
|
1999
2052
|
auth: this.config.auth,
|
|
2000
2053
|
publicMode: this.config.publicMode,
|
|
@@ -2986,13 +3039,17 @@ var currentConfig = {};
|
|
|
2986
3039
|
var serverInstance = null;
|
|
2987
3040
|
var tokenFactory = null;
|
|
2988
3041
|
var serverStartedByUs = false;
|
|
3042
|
+
var skipScopes = [{ skipped: false }];
|
|
3043
|
+
function currentSkipScope() {
|
|
3044
|
+
return skipScopes[skipScopes.length - 1];
|
|
3045
|
+
}
|
|
2989
3046
|
async function initializeSharedResources() {
|
|
2990
3047
|
if (!tokenFactory) {
|
|
2991
3048
|
const sharedSecret = currentConfig.env?.["JWT_SECRET"];
|
|
2992
3049
|
tokenFactory = new TestTokenFactory(sharedSecret ? { hmacSecret: sharedSecret } : {});
|
|
2993
3050
|
}
|
|
2994
3051
|
if (!serverInstance) {
|
|
2995
|
-
if (currentConfig.baseUrl) {
|
|
3052
|
+
if (currentConfig.baseUrl && !currentConfig.server) {
|
|
2996
3053
|
serverInstance = TestServer.connect(currentConfig.baseUrl);
|
|
2997
3054
|
serverStartedByUs = false;
|
|
2998
3055
|
} else if (currentConfig.server) {
|
|
@@ -3042,7 +3099,8 @@ async function createTestFixtures() {
|
|
|
3042
3099
|
throw new Error("Token factory not initialized");
|
|
3043
3100
|
}
|
|
3044
3101
|
const clientInstance = McpTestClient.create({
|
|
3045
|
-
baseUrl: serverInstance
|
|
3102
|
+
baseUrl: resolveClientBaseUrl(serverInstance),
|
|
3103
|
+
entryPath: currentConfig.entryPath,
|
|
3046
3104
|
transport: currentConfig.transport ?? "streamable-http",
|
|
3047
3105
|
publicMode: currentConfig.publicMode
|
|
3048
3106
|
}).build();
|
|
@@ -3091,6 +3149,9 @@ async function cleanupSharedResources() {
|
|
|
3091
3149
|
tokenFactory = null;
|
|
3092
3150
|
serverStartedByUs = false;
|
|
3093
3151
|
}
|
|
3152
|
+
function resolveClientBaseUrl(server) {
|
|
3153
|
+
return currentConfig.baseUrl ?? server.info.baseUrl;
|
|
3154
|
+
}
|
|
3094
3155
|
function createAuthFixture(factory) {
|
|
3095
3156
|
const users = {
|
|
3096
3157
|
admin: {
|
|
@@ -3140,7 +3201,8 @@ function createServerFixture(server) {
|
|
|
3140
3201
|
info: server.info,
|
|
3141
3202
|
createClient: async (opts) => {
|
|
3142
3203
|
return McpTestClient.create({
|
|
3143
|
-
baseUrl: server
|
|
3204
|
+
baseUrl: resolveClientBaseUrl(server),
|
|
3205
|
+
entryPath: opts?.entryPath ?? currentConfig.entryPath,
|
|
3144
3206
|
transport: opts?.transport ?? "streamable-http",
|
|
3145
3207
|
auth: opts?.token ? { token: opts.token } : void 0,
|
|
3146
3208
|
clientInfo: opts?.clientInfo,
|
|
@@ -3149,7 +3211,8 @@ function createServerFixture(server) {
|
|
|
3149
3211
|
},
|
|
3150
3212
|
createClientBuilder: () => {
|
|
3151
3213
|
const builder = new McpTestClientBuilder({
|
|
3152
|
-
baseUrl: server
|
|
3214
|
+
baseUrl: resolveClientBaseUrl(server),
|
|
3215
|
+
entryPath: currentConfig.entryPath,
|
|
3153
3216
|
publicMode: currentConfig.publicMode
|
|
3154
3217
|
});
|
|
3155
3218
|
return builder;
|
|
@@ -3165,8 +3228,8 @@ function resolveServerCommand(server) {
|
|
|
3165
3228
|
}
|
|
3166
3229
|
return `npx tsx ${server}`;
|
|
3167
3230
|
}
|
|
3168
|
-
function
|
|
3169
|
-
|
|
3231
|
+
function runWithFixtures(fn) {
|
|
3232
|
+
return async () => {
|
|
3170
3233
|
const fixtures = await createTestFixtures();
|
|
3171
3234
|
let testFailed = false;
|
|
3172
3235
|
try {
|
|
@@ -3177,7 +3240,15 @@ function testWithFixtures(name, fn) {
|
|
|
3177
3240
|
} finally {
|
|
3178
3241
|
await cleanupTestFixtures(fixtures, testFailed);
|
|
3179
3242
|
}
|
|
3180
|
-
}
|
|
3243
|
+
};
|
|
3244
|
+
}
|
|
3245
|
+
function testWithFixtures(name, fn) {
|
|
3246
|
+
const scope = currentSkipScope();
|
|
3247
|
+
if (scope.skipped) {
|
|
3248
|
+
it.skip(scope.reason ? `${name} (skipped: ${scope.reason})` : name, runWithFixtures(fn));
|
|
3249
|
+
return;
|
|
3250
|
+
}
|
|
3251
|
+
it(name, runWithFixtures(fn));
|
|
3181
3252
|
}
|
|
3182
3253
|
function use(config) {
|
|
3183
3254
|
currentConfig = { ...currentConfig, ...config };
|
|
@@ -3185,40 +3256,60 @@ function use(config) {
|
|
|
3185
3256
|
await cleanupSharedResources();
|
|
3186
3257
|
});
|
|
3187
3258
|
}
|
|
3188
|
-
function skip(
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
} catch (error) {
|
|
3195
|
-
testFailed = true;
|
|
3196
|
-
throw error;
|
|
3197
|
-
} finally {
|
|
3198
|
-
await cleanupTestFixtures(fixtures, testFailed);
|
|
3259
|
+
function skip(nameOrCondition, fnOrReason) {
|
|
3260
|
+
if (typeof nameOrCondition === "boolean") {
|
|
3261
|
+
if (nameOrCondition) {
|
|
3262
|
+
const scope = currentSkipScope();
|
|
3263
|
+
scope.skipped = true;
|
|
3264
|
+
scope.reason = typeof fnOrReason === "string" ? fnOrReason : void 0;
|
|
3199
3265
|
}
|
|
3200
|
-
|
|
3266
|
+
return;
|
|
3267
|
+
}
|
|
3268
|
+
if (typeof fnOrReason !== "function") {
|
|
3269
|
+
throw new TypeError(
|
|
3270
|
+
`test.skip expects either (name: string, fn) or (condition: boolean, reason?: string); received (${typeof nameOrCondition}, ${typeof fnOrReason}).`
|
|
3271
|
+
);
|
|
3272
|
+
}
|
|
3273
|
+
it.skip(nameOrCondition, runWithFixtures(fnOrReason));
|
|
3201
3274
|
}
|
|
3202
3275
|
function only(name, fn) {
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
testFailed = true;
|
|
3210
|
-
throw error;
|
|
3211
|
-
} finally {
|
|
3212
|
-
await cleanupTestFixtures(fixtures, testFailed);
|
|
3213
|
-
}
|
|
3214
|
-
});
|
|
3276
|
+
const scope = currentSkipScope();
|
|
3277
|
+
if (scope.skipped) {
|
|
3278
|
+
it.skip(scope.reason ? `${name} (skipped: ${scope.reason})` : name, runWithFixtures(fn));
|
|
3279
|
+
return;
|
|
3280
|
+
}
|
|
3281
|
+
it.only(name, runWithFixtures(fn));
|
|
3215
3282
|
}
|
|
3216
3283
|
function todo(name) {
|
|
3217
3284
|
it.todo(name);
|
|
3218
3285
|
}
|
|
3286
|
+
function withSkipScope(register) {
|
|
3287
|
+
return (name, fn) => {
|
|
3288
|
+
register(name, () => {
|
|
3289
|
+
const parent = currentSkipScope();
|
|
3290
|
+
skipScopes.push({ skipped: parent.skipped, reason: parent.reason });
|
|
3291
|
+
try {
|
|
3292
|
+
fn();
|
|
3293
|
+
} finally {
|
|
3294
|
+
skipScopes.pop();
|
|
3295
|
+
}
|
|
3296
|
+
});
|
|
3297
|
+
};
|
|
3298
|
+
}
|
|
3299
|
+
function eachWithSkipScope(each) {
|
|
3300
|
+
return ((...eachArgs) => {
|
|
3301
|
+
const register = each(...eachArgs);
|
|
3302
|
+
return withSkipScope(register);
|
|
3303
|
+
});
|
|
3304
|
+
}
|
|
3305
|
+
var describeWithSkipScope = Object.assign(withSkipScope(describe), describe, {
|
|
3306
|
+
only: Object.assign(withSkipScope(describe.only), { each: eachWithSkipScope(describe.only.each) }),
|
|
3307
|
+
skip: Object.assign(withSkipScope(describe.skip), { each: eachWithSkipScope(describe.skip.each) }),
|
|
3308
|
+
each: eachWithSkipScope(describe.each)
|
|
3309
|
+
});
|
|
3219
3310
|
var test = testWithFixtures;
|
|
3220
3311
|
test.use = use;
|
|
3221
|
-
test.describe =
|
|
3312
|
+
test.describe = describeWithSkipScope;
|
|
3222
3313
|
test.beforeAll = beforeAll;
|
|
3223
3314
|
test.beforeEach = beforeEach;
|
|
3224
3315
|
test.afterEach = afterEach;
|