@frontmcp/testing 1.7.1 → 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/fixtures/index.js
CHANGED
|
@@ -215,233 +215,277 @@ var TestTokenFactory = class {
|
|
|
215
215
|
}
|
|
216
216
|
};
|
|
217
217
|
|
|
218
|
-
// libs/testing/src/
|
|
219
|
-
var
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
name: "mcp-ext-apps",
|
|
230
|
-
version: "1.0"
|
|
231
|
-
};
|
|
232
|
-
case "claude":
|
|
233
|
-
return {
|
|
234
|
-
name: "claude-desktop",
|
|
235
|
-
version: "1.0"
|
|
236
|
-
};
|
|
237
|
-
case "cursor":
|
|
238
|
-
return {
|
|
239
|
-
name: "cursor",
|
|
240
|
-
version: "1.0"
|
|
241
|
-
};
|
|
242
|
-
case "continue":
|
|
243
|
-
return {
|
|
244
|
-
name: "continue",
|
|
245
|
-
version: "1.0"
|
|
246
|
-
};
|
|
247
|
-
case "cody":
|
|
248
|
-
return {
|
|
249
|
-
name: "cody",
|
|
250
|
-
version: "1.0"
|
|
251
|
-
};
|
|
252
|
-
case "gemini":
|
|
253
|
-
return {
|
|
254
|
-
name: "gemini",
|
|
255
|
-
version: "1.0"
|
|
256
|
-
};
|
|
257
|
-
case "generic-mcp":
|
|
258
|
-
return {
|
|
259
|
-
name: "generic-mcp-client",
|
|
260
|
-
version: "1.0"
|
|
261
|
-
};
|
|
262
|
-
case "unknown":
|
|
263
|
-
default:
|
|
264
|
-
return {
|
|
265
|
-
name: "mcp-test-client",
|
|
266
|
-
version: "1.0"
|
|
267
|
-
};
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
function getPlatformCapabilities(platform) {
|
|
271
|
-
const baseCapabilities = {
|
|
272
|
-
sampling: {},
|
|
273
|
-
// Include elicitation.form by default for testing elicitation workflows
|
|
274
|
-
// Note: MCP SDK expects form to be an object, not boolean
|
|
275
|
-
elicitation: {
|
|
276
|
-
form: {}
|
|
277
|
-
}
|
|
278
|
-
};
|
|
279
|
-
if (platform === "ext-apps") {
|
|
218
|
+
// libs/testing/src/interceptor/mock-registry.ts
|
|
219
|
+
var DefaultMockRegistry = class {
|
|
220
|
+
mocks = [];
|
|
221
|
+
add(mock) {
|
|
222
|
+
const entry = {
|
|
223
|
+
definition: mock,
|
|
224
|
+
callCount: 0,
|
|
225
|
+
calls: [],
|
|
226
|
+
remainingUses: mock.times ?? Infinity
|
|
227
|
+
};
|
|
228
|
+
this.mocks.push(entry);
|
|
280
229
|
return {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
230
|
+
remove: () => {
|
|
231
|
+
const index = this.mocks.indexOf(entry);
|
|
232
|
+
if (index !== -1) {
|
|
233
|
+
this.mocks.splice(index, 1);
|
|
285
234
|
}
|
|
286
|
-
}
|
|
235
|
+
},
|
|
236
|
+
callCount: () => entry.callCount,
|
|
237
|
+
calls: () => [...entry.calls]
|
|
287
238
|
};
|
|
288
239
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
// libs/testing/src/client/mcp-test-client.builder.ts
|
|
293
|
-
var McpTestClientBuilder = class {
|
|
294
|
-
config;
|
|
295
|
-
constructor(config) {
|
|
296
|
-
this.config = { ...config };
|
|
240
|
+
clear() {
|
|
241
|
+
this.mocks = [];
|
|
297
242
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
243
|
+
getAll() {
|
|
244
|
+
return this.mocks.map((e) => e.definition);
|
|
245
|
+
}
|
|
246
|
+
match(request) {
|
|
247
|
+
for (const entry of this.mocks) {
|
|
248
|
+
if (entry.remainingUses <= 0) continue;
|
|
249
|
+
const { definition } = entry;
|
|
250
|
+
if (definition.method !== request.method) continue;
|
|
251
|
+
if (definition.params !== void 0) {
|
|
252
|
+
const params = request.params ?? {};
|
|
253
|
+
if (typeof definition.params === "function") {
|
|
254
|
+
if (!definition.params(params)) continue;
|
|
255
|
+
} else {
|
|
256
|
+
if (!this.paramsMatch(definition.params, params)) continue;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
entry.callCount++;
|
|
260
|
+
entry.calls.push(request);
|
|
261
|
+
entry.remainingUses--;
|
|
262
|
+
return definition;
|
|
263
|
+
}
|
|
264
|
+
return void 0;
|
|
304
265
|
}
|
|
305
266
|
/**
|
|
306
|
-
*
|
|
267
|
+
* Check if request params match the mock params definition
|
|
307
268
|
*/
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
269
|
+
paramsMatch(expected, actual) {
|
|
270
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
271
|
+
if (!(key in actual)) return false;
|
|
272
|
+
const actualValue = actual[key];
|
|
273
|
+
if (Array.isArray(value)) {
|
|
274
|
+
if (!Array.isArray(actualValue)) return false;
|
|
275
|
+
if (value.length !== actualValue.length) return false;
|
|
276
|
+
for (let i = 0; i < value.length; i++) {
|
|
277
|
+
const expectedItem = value[i];
|
|
278
|
+
const actualItem = actualValue[i];
|
|
279
|
+
if (typeof expectedItem === "object" && expectedItem !== null) {
|
|
280
|
+
if (typeof actualItem !== "object" || actualItem === null) return false;
|
|
281
|
+
if (!this.paramsMatch(expectedItem, actualItem)) {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
} else if (actualItem !== expectedItem) {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
} else if (typeof value === "object" && value !== null) {
|
|
289
|
+
if (typeof actualValue !== "object" || actualValue === null) return false;
|
|
290
|
+
if (!this.paramsMatch(value, actualValue)) {
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
} else if (actualValue !== value) {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return true;
|
|
311
298
|
}
|
|
299
|
+
};
|
|
300
|
+
var mockResponse = {
|
|
312
301
|
/**
|
|
313
|
-
*
|
|
302
|
+
* Create a successful JSON-RPC response
|
|
314
303
|
*/
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
304
|
+
success(result, id = 1) {
|
|
305
|
+
return {
|
|
306
|
+
jsonrpc: "2.0",
|
|
307
|
+
id,
|
|
308
|
+
result
|
|
319
309
|
};
|
|
320
|
-
|
|
321
|
-
}
|
|
310
|
+
},
|
|
322
311
|
/**
|
|
323
|
-
*
|
|
312
|
+
* Create an error JSON-RPC response
|
|
324
313
|
*/
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
314
|
+
error(code, message, data, id = 1) {
|
|
315
|
+
return {
|
|
316
|
+
jsonrpc: "2.0",
|
|
317
|
+
id,
|
|
318
|
+
error: { code, message, data }
|
|
319
|
+
};
|
|
320
|
+
},
|
|
329
321
|
/**
|
|
330
|
-
*
|
|
322
|
+
* Create a tool result response
|
|
331
323
|
*/
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
324
|
+
toolResult(content, id = 1) {
|
|
325
|
+
return {
|
|
326
|
+
jsonrpc: "2.0",
|
|
327
|
+
id,
|
|
328
|
+
result: { content }
|
|
329
|
+
};
|
|
330
|
+
},
|
|
336
331
|
/**
|
|
337
|
-
*
|
|
332
|
+
* Create a tools/list response
|
|
338
333
|
*/
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
334
|
+
toolsList(tools, id = 1) {
|
|
335
|
+
return {
|
|
336
|
+
jsonrpc: "2.0",
|
|
337
|
+
id,
|
|
338
|
+
result: { tools }
|
|
339
|
+
};
|
|
340
|
+
},
|
|
343
341
|
/**
|
|
344
|
-
*
|
|
345
|
-
* When true, no Authorization header is sent and anonymous token is not requested.
|
|
346
|
-
* Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
|
|
342
|
+
* Create a resources/list response
|
|
347
343
|
*/
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
344
|
+
resourcesList(resources, id = 1) {
|
|
345
|
+
return {
|
|
346
|
+
jsonrpc: "2.0",
|
|
347
|
+
id,
|
|
348
|
+
result: { resources }
|
|
349
|
+
};
|
|
350
|
+
},
|
|
352
351
|
/**
|
|
353
|
-
*
|
|
352
|
+
* Create a resources/read response
|
|
354
353
|
*/
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
354
|
+
resourceRead(contents, id = 1) {
|
|
355
|
+
return {
|
|
356
|
+
jsonrpc: "2.0",
|
|
357
|
+
id,
|
|
358
|
+
result: { contents }
|
|
359
|
+
};
|
|
360
|
+
},
|
|
359
361
|
/**
|
|
360
|
-
*
|
|
362
|
+
* Common MCP errors
|
|
361
363
|
*/
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
364
|
+
errors: {
|
|
365
|
+
methodNotFound: (method, id = 1) => mockResponse.error(-32601, `Method not found: ${method}`, void 0, id),
|
|
366
|
+
invalidParams: (message, id = 1) => mockResponse.error(-32602, message, void 0, id),
|
|
367
|
+
internalError: (message, id = 1) => mockResponse.error(-32603, message, void 0, id),
|
|
368
|
+
resourceNotFound: (uri, id = 1) => mockResponse.error(-32002, `Resource not found: ${uri}`, { uri }, id),
|
|
369
|
+
toolNotFound: (name, id = 1) => mockResponse.error(-32601, `Tool not found: ${name}`, { name }, id),
|
|
370
|
+
unauthorized: (id = 1) => mockResponse.error(-32001, "Unauthorized", void 0, id),
|
|
371
|
+
forbidden: (id = 1) => mockResponse.error(-32003, "Forbidden", void 0, id)
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
// libs/testing/src/interceptor/interceptor-chain.ts
|
|
376
|
+
var DefaultInterceptorChain = class {
|
|
377
|
+
request = [];
|
|
378
|
+
response = [];
|
|
379
|
+
mocks;
|
|
380
|
+
constructor() {
|
|
381
|
+
this.mocks = new DefaultMockRegistry();
|
|
365
382
|
}
|
|
366
383
|
/**
|
|
367
|
-
*
|
|
368
|
-
* Automatically configures clientInfo and capabilities for platform detection.
|
|
369
|
-
*
|
|
370
|
-
* Platform-specific behavior:
|
|
371
|
-
* - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
|
|
372
|
-
* - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
|
|
373
|
-
* - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
|
|
374
|
-
* - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
|
|
375
|
-
* - Other platforms follow similar patterns
|
|
376
|
-
*
|
|
377
|
-
* @example
|
|
378
|
-
* ```typescript
|
|
379
|
-
* const client = await McpTestClient.create({ baseUrl })
|
|
380
|
-
* .withPlatform('openai')
|
|
381
|
-
* .buildAndConnect();
|
|
382
|
-
*
|
|
383
|
-
* // ext-apps automatically sets the io.modelcontextprotocol/ui capability
|
|
384
|
-
* const extAppsClient = await McpTestClient.create({ baseUrl })
|
|
385
|
-
* .withPlatform('ext-apps')
|
|
386
|
-
* .buildAndConnect();
|
|
387
|
-
* ```
|
|
384
|
+
* Add a request interceptor
|
|
388
385
|
*/
|
|
389
|
-
|
|
390
|
-
this.
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
386
|
+
addRequestInterceptor(interceptor) {
|
|
387
|
+
this.request.push(interceptor);
|
|
388
|
+
return () => {
|
|
389
|
+
const index = this.request.indexOf(interceptor);
|
|
390
|
+
if (index !== -1) {
|
|
391
|
+
this.request.splice(index, 1);
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
394
|
}
|
|
395
395
|
/**
|
|
396
|
-
*
|
|
397
|
-
* Use this for fine-grained control over capabilities sent during initialization.
|
|
398
|
-
*
|
|
399
|
-
* @example
|
|
400
|
-
* ```typescript
|
|
401
|
-
* const client = await McpTestClient.create({ baseUrl })
|
|
402
|
-
* .withCapabilities({
|
|
403
|
-
* sampling: {},
|
|
404
|
-
* experimental: {
|
|
405
|
-
* 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
|
|
406
|
-
* }
|
|
407
|
-
* })
|
|
408
|
-
* .buildAndConnect();
|
|
409
|
-
* ```
|
|
396
|
+
* Add a response interceptor
|
|
410
397
|
*/
|
|
411
|
-
|
|
412
|
-
this.
|
|
413
|
-
return
|
|
398
|
+
addResponseInterceptor(interceptor) {
|
|
399
|
+
this.response.push(interceptor);
|
|
400
|
+
return () => {
|
|
401
|
+
const index = this.response.indexOf(interceptor);
|
|
402
|
+
if (index !== -1) {
|
|
403
|
+
this.response.splice(index, 1);
|
|
404
|
+
}
|
|
405
|
+
};
|
|
414
406
|
}
|
|
415
407
|
/**
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
* const client = await McpTestClient.create({ baseUrl })
|
|
422
|
-
* .withQueryParams({ mode: 'skills_only' })
|
|
423
|
-
* .buildAndConnect();
|
|
424
|
-
* ```
|
|
408
|
+
* Process a request through the interceptor chain
|
|
409
|
+
* Returns either:
|
|
410
|
+
* - { type: 'continue', request } - continue with (possibly modified) request
|
|
411
|
+
* - { type: 'mock', response } - return mock response immediately
|
|
412
|
+
* - { type: 'error', error } - throw error
|
|
425
413
|
*/
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
414
|
+
async processRequest(request, meta) {
|
|
415
|
+
let currentRequest = request;
|
|
416
|
+
const mockDef = this.mocks.match(request);
|
|
417
|
+
if (mockDef) {
|
|
418
|
+
if (mockDef.delay && mockDef.delay > 0) {
|
|
419
|
+
await sleep(mockDef.delay);
|
|
420
|
+
}
|
|
421
|
+
let mockResponse2;
|
|
422
|
+
if (typeof mockDef.response === "function") {
|
|
423
|
+
mockResponse2 = await mockDef.response(request);
|
|
424
|
+
} else {
|
|
425
|
+
mockResponse2 = mockDef.response;
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
type: "mock",
|
|
429
|
+
response: { ...mockResponse2, id: request.id ?? mockResponse2.id }
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
for (const interceptor of this.request) {
|
|
433
|
+
const ctx = {
|
|
434
|
+
request: currentRequest,
|
|
435
|
+
meta
|
|
436
|
+
};
|
|
437
|
+
const result = await interceptor(ctx);
|
|
438
|
+
switch (result.action) {
|
|
439
|
+
case "passthrough":
|
|
440
|
+
break;
|
|
441
|
+
case "modify":
|
|
442
|
+
currentRequest = result.request;
|
|
443
|
+
break;
|
|
444
|
+
case "mock":
|
|
445
|
+
return {
|
|
446
|
+
type: "mock",
|
|
447
|
+
response: { ...result.response, id: request.id ?? result.response.id }
|
|
448
|
+
};
|
|
449
|
+
case "error":
|
|
450
|
+
return { type: "error", error: result.error };
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return { type: "continue", request: currentRequest };
|
|
429
454
|
}
|
|
430
455
|
/**
|
|
431
|
-
*
|
|
456
|
+
* Process a response through the interceptor chain
|
|
432
457
|
*/
|
|
433
|
-
|
|
434
|
-
|
|
458
|
+
async processResponse(request, response, durationMs) {
|
|
459
|
+
let currentResponse = response;
|
|
460
|
+
for (const interceptor of this.response) {
|
|
461
|
+
const ctx = {
|
|
462
|
+
request,
|
|
463
|
+
response: currentResponse,
|
|
464
|
+
durationMs
|
|
465
|
+
};
|
|
466
|
+
const result = await interceptor(ctx);
|
|
467
|
+
switch (result.action) {
|
|
468
|
+
case "passthrough":
|
|
469
|
+
break;
|
|
470
|
+
case "modify":
|
|
471
|
+
currentResponse = result.response;
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return currentResponse;
|
|
435
476
|
}
|
|
436
477
|
/**
|
|
437
|
-
*
|
|
478
|
+
* Clear all interceptors and mocks
|
|
438
479
|
*/
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
480
|
+
clear() {
|
|
481
|
+
this.request = [];
|
|
482
|
+
this.response = [];
|
|
483
|
+
this.mocks.clear();
|
|
443
484
|
}
|
|
444
485
|
};
|
|
486
|
+
function sleep(ms) {
|
|
487
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
488
|
+
}
|
|
445
489
|
|
|
446
490
|
// libs/testing/src/transport/streamable-http.transport.ts
|
|
447
491
|
var DEFAULT_TIMEOUT = 3e4;
|
|
@@ -460,6 +504,7 @@ var StreamableHttpTransport = class {
|
|
|
460
504
|
this.config = {
|
|
461
505
|
baseUrl: config.baseUrl.replace(/\/$/, ""),
|
|
462
506
|
// Remove trailing slash
|
|
507
|
+
entryPath: config.entryPath,
|
|
463
508
|
timeout: config.timeout ?? DEFAULT_TIMEOUT,
|
|
464
509
|
auth: config.auth ?? {},
|
|
465
510
|
publicMode: config.publicMode ?? false,
|
|
@@ -552,7 +597,7 @@ var StreamableHttpTransport = class {
|
|
|
552
597
|
}
|
|
553
598
|
const headers = this.buildHeaders();
|
|
554
599
|
this.lastRequestHeaders = headers;
|
|
555
|
-
const url =
|
|
600
|
+
const url = this.mcpUrl();
|
|
556
601
|
this.log(`POST ${url}`, message);
|
|
557
602
|
const controller = new AbortController();
|
|
558
603
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
@@ -622,7 +667,7 @@ var StreamableHttpTransport = class {
|
|
|
622
667
|
this.ensureConnected();
|
|
623
668
|
const headers = this.buildHeaders();
|
|
624
669
|
this.lastRequestHeaders = headers;
|
|
625
|
-
const url =
|
|
670
|
+
const url = this.mcpUrl();
|
|
626
671
|
this.log(`POST ${url} (notification)`, message);
|
|
627
672
|
const controller = new AbortController();
|
|
628
673
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
@@ -653,7 +698,7 @@ var StreamableHttpTransport = class {
|
|
|
653
698
|
this.ensureConnected();
|
|
654
699
|
const headers = this.buildHeaders();
|
|
655
700
|
this.lastRequestHeaders = headers;
|
|
656
|
-
const url =
|
|
701
|
+
const url = this.mcpUrl();
|
|
657
702
|
this.log(`POST ${url} (raw)`, data);
|
|
658
703
|
const controller = new AbortController();
|
|
659
704
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
@@ -936,7 +981,7 @@ var StreamableHttpTransport = class {
|
|
|
936
981
|
*/
|
|
937
982
|
async sendElicitationResponse(requestId, response) {
|
|
938
983
|
const headers = this.buildHeaders();
|
|
939
|
-
const url =
|
|
984
|
+
const url = this.mcpUrl();
|
|
940
985
|
const rpcResponse = {
|
|
941
986
|
jsonrpc: "2.0",
|
|
942
987
|
id: requestId,
|
|
@@ -956,6 +1001,18 @@ var StreamableHttpTransport = class {
|
|
|
956
1001
|
this.log("Failed to send elicitation response:", error);
|
|
957
1002
|
}
|
|
958
1003
|
}
|
|
1004
|
+
/**
|
|
1005
|
+
* The MCP endpoint URL. `entryPath` is where the server mounts MCP; the
|
|
1006
|
+
* trailing slash is kept because both hosts normalize it and the original
|
|
1007
|
+
* behaviour (`${baseUrl}/`) relied on it (issue #543).
|
|
1008
|
+
*/
|
|
1009
|
+
mcpUrl() {
|
|
1010
|
+
const url = new URL(this.config.baseUrl);
|
|
1011
|
+
const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
|
|
1012
|
+
const basePath = url.pathname.replace(/\/+$/, "");
|
|
1013
|
+
url.pathname = entry ? `${basePath}/${entry}` : `${basePath}/`;
|
|
1014
|
+
return url.toString();
|
|
1015
|
+
}
|
|
959
1016
|
buildHeaders() {
|
|
960
1017
|
const headers = {
|
|
961
1018
|
"Content-Type": "application/json",
|
|
@@ -1043,277 +1100,233 @@ var StreamableHttpTransport = class {
|
|
|
1043
1100
|
}
|
|
1044
1101
|
};
|
|
1045
1102
|
|
|
1046
|
-
// libs/testing/src/
|
|
1047
|
-
var
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1103
|
+
// libs/testing/src/platform/platform-client-info.ts
|
|
1104
|
+
var MCP_APPS_EXTENSION_KEY = "io.modelcontextprotocol/ui";
|
|
1105
|
+
function getPlatformClientInfo(platform) {
|
|
1106
|
+
switch (platform) {
|
|
1107
|
+
case "openai":
|
|
1108
|
+
return {
|
|
1109
|
+
name: "ChatGPT",
|
|
1110
|
+
version: "1.0"
|
|
1111
|
+
};
|
|
1112
|
+
case "ext-apps":
|
|
1113
|
+
return {
|
|
1114
|
+
name: "mcp-ext-apps",
|
|
1115
|
+
version: "1.0"
|
|
1116
|
+
};
|
|
1117
|
+
case "claude":
|
|
1118
|
+
return {
|
|
1119
|
+
name: "claude-desktop",
|
|
1120
|
+
version: "1.0"
|
|
1121
|
+
};
|
|
1122
|
+
case "cursor":
|
|
1123
|
+
return {
|
|
1124
|
+
name: "cursor",
|
|
1125
|
+
version: "1.0"
|
|
1126
|
+
};
|
|
1127
|
+
case "continue":
|
|
1128
|
+
return {
|
|
1129
|
+
name: "continue",
|
|
1130
|
+
version: "1.0"
|
|
1131
|
+
};
|
|
1132
|
+
case "cody":
|
|
1133
|
+
return {
|
|
1134
|
+
name: "cody",
|
|
1135
|
+
version: "1.0"
|
|
1136
|
+
};
|
|
1137
|
+
case "gemini":
|
|
1138
|
+
return {
|
|
1139
|
+
name: "gemini",
|
|
1140
|
+
version: "1.0"
|
|
1141
|
+
};
|
|
1142
|
+
case "generic-mcp":
|
|
1143
|
+
return {
|
|
1144
|
+
name: "generic-mcp-client",
|
|
1145
|
+
version: "1.0"
|
|
1146
|
+
};
|
|
1147
|
+
case "unknown":
|
|
1148
|
+
default:
|
|
1149
|
+
return {
|
|
1150
|
+
name: "mcp-test-client",
|
|
1151
|
+
version: "1.0"
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
function getPlatformCapabilities(platform) {
|
|
1156
|
+
const baseCapabilities = {
|
|
1157
|
+
sampling: {},
|
|
1158
|
+
// Include elicitation.form by default for testing elicitation workflows
|
|
1159
|
+
// Note: MCP SDK expects form to be an object, not boolean
|
|
1160
|
+
elicitation: {
|
|
1161
|
+
form: {}
|
|
1162
|
+
}
|
|
1163
|
+
};
|
|
1164
|
+
if (platform === "ext-apps") {
|
|
1057
1165
|
return {
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1166
|
+
...baseCapabilities,
|
|
1167
|
+
experimental: {
|
|
1168
|
+
[MCP_APPS_EXTENSION_KEY]: {
|
|
1169
|
+
mimeTypes: ["text/html;profile=mcp-app"]
|
|
1062
1170
|
}
|
|
1063
|
-
}
|
|
1064
|
-
callCount: () => entry.callCount,
|
|
1065
|
-
calls: () => [...entry.calls]
|
|
1171
|
+
}
|
|
1066
1172
|
};
|
|
1067
1173
|
}
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
if (entry.remainingUses <= 0) continue;
|
|
1077
|
-
const { definition } = entry;
|
|
1078
|
-
if (definition.method !== request.method) continue;
|
|
1079
|
-
if (definition.params !== void 0) {
|
|
1080
|
-
const params = request.params ?? {};
|
|
1081
|
-
if (typeof definition.params === "function") {
|
|
1082
|
-
if (!definition.params(params)) continue;
|
|
1083
|
-
} else {
|
|
1084
|
-
if (!this.paramsMatch(definition.params, params)) continue;
|
|
1085
|
-
}
|
|
1086
|
-
}
|
|
1087
|
-
entry.callCount++;
|
|
1088
|
-
entry.calls.push(request);
|
|
1089
|
-
entry.remainingUses--;
|
|
1090
|
-
return definition;
|
|
1091
|
-
}
|
|
1092
|
-
return void 0;
|
|
1174
|
+
return baseCapabilities;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// libs/testing/src/client/mcp-test-client.builder.ts
|
|
1178
|
+
var McpTestClientBuilder = class {
|
|
1179
|
+
config;
|
|
1180
|
+
constructor(config) {
|
|
1181
|
+
this.config = { ...config };
|
|
1093
1182
|
}
|
|
1094
1183
|
/**
|
|
1095
|
-
*
|
|
1184
|
+
* Set the authentication configuration
|
|
1096
1185
|
*/
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
const actualValue = actual[key];
|
|
1101
|
-
if (Array.isArray(value)) {
|
|
1102
|
-
if (!Array.isArray(actualValue)) return false;
|
|
1103
|
-
if (value.length !== actualValue.length) return false;
|
|
1104
|
-
for (let i = 0; i < value.length; i++) {
|
|
1105
|
-
const expectedItem = value[i];
|
|
1106
|
-
const actualItem = actualValue[i];
|
|
1107
|
-
if (typeof expectedItem === "object" && expectedItem !== null) {
|
|
1108
|
-
if (typeof actualItem !== "object" || actualItem === null) return false;
|
|
1109
|
-
if (!this.paramsMatch(expectedItem, actualItem)) {
|
|
1110
|
-
return false;
|
|
1111
|
-
}
|
|
1112
|
-
} else if (actualItem !== expectedItem) {
|
|
1113
|
-
return false;
|
|
1114
|
-
}
|
|
1115
|
-
}
|
|
1116
|
-
} else if (typeof value === "object" && value !== null) {
|
|
1117
|
-
if (typeof actualValue !== "object" || actualValue === null) return false;
|
|
1118
|
-
if (!this.paramsMatch(value, actualValue)) {
|
|
1119
|
-
return false;
|
|
1120
|
-
}
|
|
1121
|
-
} else if (actualValue !== value) {
|
|
1122
|
-
return false;
|
|
1123
|
-
}
|
|
1124
|
-
}
|
|
1125
|
-
return true;
|
|
1186
|
+
withAuth(auth) {
|
|
1187
|
+
this.config.auth = { ...this.config.auth, ...auth };
|
|
1188
|
+
return this;
|
|
1126
1189
|
}
|
|
1127
|
-
};
|
|
1128
|
-
var mockResponse = {
|
|
1129
1190
|
/**
|
|
1130
|
-
*
|
|
1191
|
+
* Set the bearer token for authentication
|
|
1131
1192
|
*/
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
result
|
|
1137
|
-
};
|
|
1138
|
-
},
|
|
1193
|
+
withToken(token) {
|
|
1194
|
+
this.config.auth = { ...this.config.auth, token };
|
|
1195
|
+
return this;
|
|
1196
|
+
}
|
|
1139
1197
|
/**
|
|
1140
|
-
*
|
|
1198
|
+
* Add custom headers to all requests
|
|
1141
1199
|
*/
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
error: { code, message, data }
|
|
1200
|
+
withHeaders(headers) {
|
|
1201
|
+
this.config.auth = {
|
|
1202
|
+
...this.config.auth,
|
|
1203
|
+
headers: { ...this.config.auth?.headers, ...headers }
|
|
1147
1204
|
};
|
|
1148
|
-
|
|
1205
|
+
return this;
|
|
1206
|
+
}
|
|
1149
1207
|
/**
|
|
1150
|
-
*
|
|
1208
|
+
* Set the transport type
|
|
1151
1209
|
*/
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
result: { content }
|
|
1157
|
-
};
|
|
1158
|
-
},
|
|
1210
|
+
withTransport(transport) {
|
|
1211
|
+
this.config.transport = transport;
|
|
1212
|
+
return this;
|
|
1213
|
+
}
|
|
1159
1214
|
/**
|
|
1160
|
-
*
|
|
1215
|
+
* Set the request timeout in milliseconds
|
|
1161
1216
|
*/
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
result: { tools }
|
|
1167
|
-
};
|
|
1168
|
-
},
|
|
1217
|
+
withTimeout(timeoutMs) {
|
|
1218
|
+
this.config.timeout = timeoutMs;
|
|
1219
|
+
return this;
|
|
1220
|
+
}
|
|
1169
1221
|
/**
|
|
1170
|
-
*
|
|
1171
|
-
*/
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
result: { resources }
|
|
1177
|
-
};
|
|
1178
|
-
},
|
|
1222
|
+
* Enable debug logging
|
|
1223
|
+
*/
|
|
1224
|
+
withDebug(enabled = true) {
|
|
1225
|
+
this.config.debug = enabled;
|
|
1226
|
+
return this;
|
|
1227
|
+
}
|
|
1179
1228
|
/**
|
|
1180
|
-
*
|
|
1229
|
+
* Enable public mode - skip authentication entirely.
|
|
1230
|
+
* When true, no Authorization header is sent and anonymous token is not requested.
|
|
1231
|
+
* Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
|
|
1181
1232
|
*/
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
result: { contents }
|
|
1187
|
-
};
|
|
1188
|
-
},
|
|
1233
|
+
withPublicMode(enabled = true) {
|
|
1234
|
+
this.config.publicMode = enabled;
|
|
1235
|
+
return this;
|
|
1236
|
+
}
|
|
1189
1237
|
/**
|
|
1190
|
-
*
|
|
1238
|
+
* Set the MCP protocol version to request
|
|
1191
1239
|
*/
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
internalError: (message, id = 1) => mockResponse.error(-32603, message, void 0, id),
|
|
1196
|
-
resourceNotFound: (uri, id = 1) => mockResponse.error(-32002, `Resource not found: ${uri}`, { uri }, id),
|
|
1197
|
-
toolNotFound: (name, id = 1) => mockResponse.error(-32601, `Tool not found: ${name}`, { name }, id),
|
|
1198
|
-
unauthorized: (id = 1) => mockResponse.error(-32001, "Unauthorized", void 0, id),
|
|
1199
|
-
forbidden: (id = 1) => mockResponse.error(-32003, "Forbidden", void 0, id)
|
|
1240
|
+
withProtocolVersion(version) {
|
|
1241
|
+
this.config.protocolVersion = version;
|
|
1242
|
+
return this;
|
|
1200
1243
|
}
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
mocks;
|
|
1208
|
-
constructor() {
|
|
1209
|
-
this.mocks = new DefaultMockRegistry();
|
|
1244
|
+
/**
|
|
1245
|
+
* Set the client info sent during initialization
|
|
1246
|
+
*/
|
|
1247
|
+
withClientInfo(info) {
|
|
1248
|
+
this.config.clientInfo = info;
|
|
1249
|
+
return this;
|
|
1210
1250
|
}
|
|
1211
1251
|
/**
|
|
1212
|
-
*
|
|
1252
|
+
* Set the platform type for testing platform-specific meta keys.
|
|
1253
|
+
* Automatically configures clientInfo and capabilities for platform detection.
|
|
1254
|
+
*
|
|
1255
|
+
* Platform-specific behavior:
|
|
1256
|
+
* - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
|
|
1257
|
+
* - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
|
|
1258
|
+
* - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
|
|
1259
|
+
* - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
|
|
1260
|
+
* - Other platforms follow similar patterns
|
|
1261
|
+
*
|
|
1262
|
+
* @example
|
|
1263
|
+
* ```typescript
|
|
1264
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
1265
|
+
* .withPlatform('openai')
|
|
1266
|
+
* .buildAndConnect();
|
|
1267
|
+
*
|
|
1268
|
+
* // ext-apps automatically sets the io.modelcontextprotocol/ui capability
|
|
1269
|
+
* const extAppsClient = await McpTestClient.create({ baseUrl })
|
|
1270
|
+
* .withPlatform('ext-apps')
|
|
1271
|
+
* .buildAndConnect();
|
|
1272
|
+
* ```
|
|
1213
1273
|
*/
|
|
1214
|
-
|
|
1215
|
-
this.
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
this.request.splice(index, 1);
|
|
1220
|
-
}
|
|
1221
|
-
};
|
|
1274
|
+
withPlatform(platform) {
|
|
1275
|
+
this.config.platform = platform;
|
|
1276
|
+
this.config.clientInfo = getPlatformClientInfo(platform);
|
|
1277
|
+
this.config.capabilities = getPlatformCapabilities(platform);
|
|
1278
|
+
return this;
|
|
1222
1279
|
}
|
|
1223
1280
|
/**
|
|
1224
|
-
*
|
|
1281
|
+
* Set custom client capabilities for MCP initialization.
|
|
1282
|
+
* Use this for fine-grained control over capabilities sent during initialization.
|
|
1283
|
+
*
|
|
1284
|
+
* @example
|
|
1285
|
+
* ```typescript
|
|
1286
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
1287
|
+
* .withCapabilities({
|
|
1288
|
+
* sampling: {},
|
|
1289
|
+
* experimental: {
|
|
1290
|
+
* 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
|
|
1291
|
+
* }
|
|
1292
|
+
* })
|
|
1293
|
+
* .buildAndConnect();
|
|
1294
|
+
* ```
|
|
1225
1295
|
*/
|
|
1226
|
-
|
|
1227
|
-
this.
|
|
1228
|
-
return
|
|
1229
|
-
const index = this.response.indexOf(interceptor);
|
|
1230
|
-
if (index !== -1) {
|
|
1231
|
-
this.response.splice(index, 1);
|
|
1232
|
-
}
|
|
1233
|
-
};
|
|
1296
|
+
withCapabilities(capabilities) {
|
|
1297
|
+
this.config.capabilities = capabilities;
|
|
1298
|
+
return this;
|
|
1234
1299
|
}
|
|
1235
1300
|
/**
|
|
1236
|
-
*
|
|
1237
|
-
*
|
|
1238
|
-
*
|
|
1239
|
-
*
|
|
1240
|
-
*
|
|
1301
|
+
* Set query parameters to append to the connection URL.
|
|
1302
|
+
* Useful for testing mode switches like `?mode=skills_only`.
|
|
1303
|
+
*
|
|
1304
|
+
* @example
|
|
1305
|
+
* ```typescript
|
|
1306
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
1307
|
+
* .withQueryParams({ mode: 'skills_only' })
|
|
1308
|
+
* .buildAndConnect();
|
|
1309
|
+
* ```
|
|
1241
1310
|
*/
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
if (mockDef) {
|
|
1246
|
-
if (mockDef.delay && mockDef.delay > 0) {
|
|
1247
|
-
await sleep(mockDef.delay);
|
|
1248
|
-
}
|
|
1249
|
-
let mockResponse2;
|
|
1250
|
-
if (typeof mockDef.response === "function") {
|
|
1251
|
-
mockResponse2 = await mockDef.response(request);
|
|
1252
|
-
} else {
|
|
1253
|
-
mockResponse2 = mockDef.response;
|
|
1254
|
-
}
|
|
1255
|
-
return {
|
|
1256
|
-
type: "mock",
|
|
1257
|
-
response: { ...mockResponse2, id: request.id ?? mockResponse2.id }
|
|
1258
|
-
};
|
|
1259
|
-
}
|
|
1260
|
-
for (const interceptor of this.request) {
|
|
1261
|
-
const ctx = {
|
|
1262
|
-
request: currentRequest,
|
|
1263
|
-
meta
|
|
1264
|
-
};
|
|
1265
|
-
const result = await interceptor(ctx);
|
|
1266
|
-
switch (result.action) {
|
|
1267
|
-
case "passthrough":
|
|
1268
|
-
break;
|
|
1269
|
-
case "modify":
|
|
1270
|
-
currentRequest = result.request;
|
|
1271
|
-
break;
|
|
1272
|
-
case "mock":
|
|
1273
|
-
return {
|
|
1274
|
-
type: "mock",
|
|
1275
|
-
response: { ...result.response, id: request.id ?? result.response.id }
|
|
1276
|
-
};
|
|
1277
|
-
case "error":
|
|
1278
|
-
return { type: "error", error: result.error };
|
|
1279
|
-
}
|
|
1280
|
-
}
|
|
1281
|
-
return { type: "continue", request: currentRequest };
|
|
1311
|
+
withQueryParams(params) {
|
|
1312
|
+
this.config.queryParams = { ...this.config.queryParams, ...params };
|
|
1313
|
+
return this;
|
|
1282
1314
|
}
|
|
1283
1315
|
/**
|
|
1284
|
-
*
|
|
1316
|
+
* Build the McpTestClient instance (does not connect)
|
|
1285
1317
|
*/
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
for (const interceptor of this.response) {
|
|
1289
|
-
const ctx = {
|
|
1290
|
-
request,
|
|
1291
|
-
response: currentResponse,
|
|
1292
|
-
durationMs
|
|
1293
|
-
};
|
|
1294
|
-
const result = await interceptor(ctx);
|
|
1295
|
-
switch (result.action) {
|
|
1296
|
-
case "passthrough":
|
|
1297
|
-
break;
|
|
1298
|
-
case "modify":
|
|
1299
|
-
currentResponse = result.response;
|
|
1300
|
-
break;
|
|
1301
|
-
}
|
|
1302
|
-
}
|
|
1303
|
-
return currentResponse;
|
|
1318
|
+
build() {
|
|
1319
|
+
return new McpTestClient(this.config);
|
|
1304
1320
|
}
|
|
1305
1321
|
/**
|
|
1306
|
-
*
|
|
1322
|
+
* Build the McpTestClient and connect to the server
|
|
1307
1323
|
*/
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1324
|
+
async buildAndConnect() {
|
|
1325
|
+
const client = this.build();
|
|
1326
|
+
await client.connect();
|
|
1327
|
+
return client;
|
|
1312
1328
|
}
|
|
1313
1329
|
};
|
|
1314
|
-
function sleep(ms) {
|
|
1315
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1316
|
-
}
|
|
1317
1330
|
|
|
1318
1331
|
// libs/testing/src/client/mcp-test-client.ts
|
|
1319
1332
|
var DEFAULT_TIMEOUT2 = 3e4;
|
|
@@ -1322,6 +1335,21 @@ var DEFAULT_CLIENT_INFO = {
|
|
|
1322
1335
|
name: "@frontmcp/testing",
|
|
1323
1336
|
version: "0.4.0"
|
|
1324
1337
|
};
|
|
1338
|
+
function extractEntryPaths(data) {
|
|
1339
|
+
let payload = data;
|
|
1340
|
+
if (typeof payload === "string") {
|
|
1341
|
+
try {
|
|
1342
|
+
payload = JSON.parse(payload);
|
|
1343
|
+
} catch {
|
|
1344
|
+
return void 0;
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
if (!payload || typeof payload !== "object") return void 0;
|
|
1348
|
+
const paths = payload.entryPaths;
|
|
1349
|
+
if (!Array.isArray(paths)) return void 0;
|
|
1350
|
+
const strings = paths.filter((p) => typeof p === "string");
|
|
1351
|
+
return strings.length > 0 ? strings : void 0;
|
|
1352
|
+
}
|
|
1325
1353
|
var McpTestClient = class {
|
|
1326
1354
|
// Platform, capabilities, and queryParams are optional - only set when needed
|
|
1327
1355
|
config;
|
|
@@ -1330,6 +1358,8 @@ var McpTestClient = class {
|
|
|
1330
1358
|
requestIdCounter = 0;
|
|
1331
1359
|
_lastRequestId = 0;
|
|
1332
1360
|
_sessionId;
|
|
1361
|
+
/** #543 — discovery is attempted at most once per connect. */
|
|
1362
|
+
triedDiscoveredEntryPath = false;
|
|
1333
1363
|
_sessionInfo = null;
|
|
1334
1364
|
_authState = { isAnonymous: true, scopes: [] };
|
|
1335
1365
|
// Logging and tracing
|
|
@@ -1347,6 +1377,7 @@ var McpTestClient = class {
|
|
|
1347
1377
|
constructor(config) {
|
|
1348
1378
|
this.config = {
|
|
1349
1379
|
baseUrl: config.baseUrl,
|
|
1380
|
+
entryPath: config.entryPath,
|
|
1350
1381
|
transport: config.transport ?? "streamable-http",
|
|
1351
1382
|
auth: config.auth ?? {},
|
|
1352
1383
|
publicMode: config.publicMode ?? false,
|
|
@@ -1382,11 +1413,13 @@ var McpTestClient = class {
|
|
|
1382
1413
|
*/
|
|
1383
1414
|
async connect() {
|
|
1384
1415
|
this.log("debug", `Connecting to ${this.config.baseUrl}...`);
|
|
1416
|
+
this.triedDiscoveredEntryPath = false;
|
|
1385
1417
|
this.transport = this.createTransport();
|
|
1386
1418
|
await this.transport.connect();
|
|
1387
1419
|
const maxRetries = 3;
|
|
1388
1420
|
const retryDelayMs = 500;
|
|
1389
1421
|
let lastError;
|
|
1422
|
+
let reportedEntryPaths;
|
|
1390
1423
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
1391
1424
|
const initResponse = await this.initialize();
|
|
1392
1425
|
if (initResponse.success && initResponse.data) {
|
|
@@ -1394,13 +1427,27 @@ var McpTestClient = class {
|
|
|
1394
1427
|
break;
|
|
1395
1428
|
}
|
|
1396
1429
|
lastError = initResponse.error?.message ?? "Unknown error";
|
|
1430
|
+
reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
|
|
1431
|
+
if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
|
|
1432
|
+
this.triedDiscoveredEntryPath = true;
|
|
1433
|
+
const discovered = reportedEntryPaths[0];
|
|
1434
|
+
this.log("info", `Server serves MCP at ${discovered}; reconnecting there (configured: ${this.mcpPathLabel()})`);
|
|
1435
|
+
this.config.entryPath = discovered;
|
|
1436
|
+
await this.transport.close();
|
|
1437
|
+
this.transport = this.createTransport();
|
|
1438
|
+
await this.transport.connect();
|
|
1439
|
+
continue;
|
|
1440
|
+
}
|
|
1397
1441
|
if (attempt < maxRetries) {
|
|
1398
1442
|
this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
|
|
1399
1443
|
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
1400
1444
|
}
|
|
1401
1445
|
}
|
|
1402
1446
|
if (!this.initResult) {
|
|
1403
|
-
|
|
1447
|
+
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.` : "";
|
|
1448
|
+
throw new Error(
|
|
1449
|
+
`Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${maxRetries} attempts: ${lastError}.${served}`
|
|
1450
|
+
);
|
|
1404
1451
|
}
|
|
1405
1452
|
this._sessionId = this.transport.getSessionId();
|
|
1406
1453
|
this._sessionInfo = {
|
|
@@ -1998,6 +2045,11 @@ var McpTestClient = class {
|
|
|
1998
2045
|
// ═══════════════════════════════════════════════════════════════════
|
|
1999
2046
|
// PRIVATE: TRANSPORT & REQUEST HELPERS
|
|
2000
2047
|
// ═══════════════════════════════════════════════════════════════════
|
|
2048
|
+
/** The MCP path this client is currently pointed at, for log and error text. */
|
|
2049
|
+
mcpPathLabel() {
|
|
2050
|
+
const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
|
|
2051
|
+
return entry ? `/${entry}` : "/";
|
|
2052
|
+
}
|
|
2001
2053
|
createTransport() {
|
|
2002
2054
|
let baseUrl = this.config.baseUrl;
|
|
2003
2055
|
if (this.config.queryParams && Object.keys(this.config.queryParams).length > 0) {
|
|
@@ -2011,6 +2063,7 @@ var McpTestClient = class {
|
|
|
2011
2063
|
case "streamable-http":
|
|
2012
2064
|
return new StreamableHttpTransport({
|
|
2013
2065
|
baseUrl,
|
|
2066
|
+
entryPath: this.config.entryPath,
|
|
2014
2067
|
timeout: this.config.timeout,
|
|
2015
2068
|
auth: this.config.auth,
|
|
2016
2069
|
publicMode: this.config.publicMode,
|
|
@@ -3002,13 +3055,17 @@ var currentConfig = {};
|
|
|
3002
3055
|
var serverInstance = null;
|
|
3003
3056
|
var tokenFactory = null;
|
|
3004
3057
|
var serverStartedByUs = false;
|
|
3058
|
+
var skipScopes = [{ skipped: false }];
|
|
3059
|
+
function currentSkipScope() {
|
|
3060
|
+
return skipScopes[skipScopes.length - 1];
|
|
3061
|
+
}
|
|
3005
3062
|
async function initializeSharedResources() {
|
|
3006
3063
|
if (!tokenFactory) {
|
|
3007
3064
|
const sharedSecret = currentConfig.env?.["JWT_SECRET"];
|
|
3008
3065
|
tokenFactory = new TestTokenFactory(sharedSecret ? { hmacSecret: sharedSecret } : {});
|
|
3009
3066
|
}
|
|
3010
3067
|
if (!serverInstance) {
|
|
3011
|
-
if (currentConfig.baseUrl) {
|
|
3068
|
+
if (currentConfig.baseUrl && !currentConfig.server) {
|
|
3012
3069
|
serverInstance = TestServer.connect(currentConfig.baseUrl);
|
|
3013
3070
|
serverStartedByUs = false;
|
|
3014
3071
|
} else if (currentConfig.server) {
|
|
@@ -3058,7 +3115,8 @@ async function createTestFixtures() {
|
|
|
3058
3115
|
throw new Error("Token factory not initialized");
|
|
3059
3116
|
}
|
|
3060
3117
|
const clientInstance = McpTestClient.create({
|
|
3061
|
-
baseUrl: serverInstance
|
|
3118
|
+
baseUrl: resolveClientBaseUrl(serverInstance),
|
|
3119
|
+
entryPath: currentConfig.entryPath,
|
|
3062
3120
|
transport: currentConfig.transport ?? "streamable-http",
|
|
3063
3121
|
publicMode: currentConfig.publicMode
|
|
3064
3122
|
}).build();
|
|
@@ -3107,6 +3165,9 @@ async function cleanupSharedResources() {
|
|
|
3107
3165
|
tokenFactory = null;
|
|
3108
3166
|
serverStartedByUs = false;
|
|
3109
3167
|
}
|
|
3168
|
+
function resolveClientBaseUrl(server) {
|
|
3169
|
+
return currentConfig.baseUrl ?? server.info.baseUrl;
|
|
3170
|
+
}
|
|
3110
3171
|
function createAuthFixture(factory) {
|
|
3111
3172
|
const users = {
|
|
3112
3173
|
admin: {
|
|
@@ -3156,7 +3217,8 @@ function createServerFixture(server) {
|
|
|
3156
3217
|
info: server.info,
|
|
3157
3218
|
createClient: async (opts) => {
|
|
3158
3219
|
return McpTestClient.create({
|
|
3159
|
-
baseUrl: server
|
|
3220
|
+
baseUrl: resolveClientBaseUrl(server),
|
|
3221
|
+
entryPath: opts?.entryPath ?? currentConfig.entryPath,
|
|
3160
3222
|
transport: opts?.transport ?? "streamable-http",
|
|
3161
3223
|
auth: opts?.token ? { token: opts.token } : void 0,
|
|
3162
3224
|
clientInfo: opts?.clientInfo,
|
|
@@ -3165,7 +3227,8 @@ function createServerFixture(server) {
|
|
|
3165
3227
|
},
|
|
3166
3228
|
createClientBuilder: () => {
|
|
3167
3229
|
const builder = new McpTestClientBuilder({
|
|
3168
|
-
baseUrl: server
|
|
3230
|
+
baseUrl: resolveClientBaseUrl(server),
|
|
3231
|
+
entryPath: currentConfig.entryPath,
|
|
3169
3232
|
publicMode: currentConfig.publicMode
|
|
3170
3233
|
});
|
|
3171
3234
|
return builder;
|
|
@@ -3181,8 +3244,8 @@ function resolveServerCommand(server) {
|
|
|
3181
3244
|
}
|
|
3182
3245
|
return `npx tsx ${server}`;
|
|
3183
3246
|
}
|
|
3184
|
-
function
|
|
3185
|
-
|
|
3247
|
+
function runWithFixtures(fn) {
|
|
3248
|
+
return async () => {
|
|
3186
3249
|
const fixtures = await createTestFixtures();
|
|
3187
3250
|
let testFailed = false;
|
|
3188
3251
|
try {
|
|
@@ -3193,7 +3256,15 @@ function testWithFixtures(name, fn) {
|
|
|
3193
3256
|
} finally {
|
|
3194
3257
|
await cleanupTestFixtures(fixtures, testFailed);
|
|
3195
3258
|
}
|
|
3196
|
-
}
|
|
3259
|
+
};
|
|
3260
|
+
}
|
|
3261
|
+
function testWithFixtures(name, fn) {
|
|
3262
|
+
const scope = currentSkipScope();
|
|
3263
|
+
if (scope.skipped) {
|
|
3264
|
+
it.skip(scope.reason ? `${name} (skipped: ${scope.reason})` : name, runWithFixtures(fn));
|
|
3265
|
+
return;
|
|
3266
|
+
}
|
|
3267
|
+
it(name, runWithFixtures(fn));
|
|
3197
3268
|
}
|
|
3198
3269
|
function use(config) {
|
|
3199
3270
|
currentConfig = { ...currentConfig, ...config };
|
|
@@ -3201,40 +3272,60 @@ function use(config) {
|
|
|
3201
3272
|
await cleanupSharedResources();
|
|
3202
3273
|
});
|
|
3203
3274
|
}
|
|
3204
|
-
function skip(
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
} catch (error) {
|
|
3211
|
-
testFailed = true;
|
|
3212
|
-
throw error;
|
|
3213
|
-
} finally {
|
|
3214
|
-
await cleanupTestFixtures(fixtures, testFailed);
|
|
3275
|
+
function skip(nameOrCondition, fnOrReason) {
|
|
3276
|
+
if (typeof nameOrCondition === "boolean") {
|
|
3277
|
+
if (nameOrCondition) {
|
|
3278
|
+
const scope = currentSkipScope();
|
|
3279
|
+
scope.skipped = true;
|
|
3280
|
+
scope.reason = typeof fnOrReason === "string" ? fnOrReason : void 0;
|
|
3215
3281
|
}
|
|
3216
|
-
|
|
3282
|
+
return;
|
|
3283
|
+
}
|
|
3284
|
+
if (typeof fnOrReason !== "function") {
|
|
3285
|
+
throw new TypeError(
|
|
3286
|
+
`test.skip expects either (name: string, fn) or (condition: boolean, reason?: string); received (${typeof nameOrCondition}, ${typeof fnOrReason}).`
|
|
3287
|
+
);
|
|
3288
|
+
}
|
|
3289
|
+
it.skip(nameOrCondition, runWithFixtures(fnOrReason));
|
|
3217
3290
|
}
|
|
3218
3291
|
function only(name, fn) {
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
testFailed = true;
|
|
3226
|
-
throw error;
|
|
3227
|
-
} finally {
|
|
3228
|
-
await cleanupTestFixtures(fixtures, testFailed);
|
|
3229
|
-
}
|
|
3230
|
-
});
|
|
3292
|
+
const scope = currentSkipScope();
|
|
3293
|
+
if (scope.skipped) {
|
|
3294
|
+
it.skip(scope.reason ? `${name} (skipped: ${scope.reason})` : name, runWithFixtures(fn));
|
|
3295
|
+
return;
|
|
3296
|
+
}
|
|
3297
|
+
it.only(name, runWithFixtures(fn));
|
|
3231
3298
|
}
|
|
3232
3299
|
function todo(name) {
|
|
3233
3300
|
it.todo(name);
|
|
3234
3301
|
}
|
|
3302
|
+
function withSkipScope(register) {
|
|
3303
|
+
return (name, fn) => {
|
|
3304
|
+
register(name, () => {
|
|
3305
|
+
const parent = currentSkipScope();
|
|
3306
|
+
skipScopes.push({ skipped: parent.skipped, reason: parent.reason });
|
|
3307
|
+
try {
|
|
3308
|
+
fn();
|
|
3309
|
+
} finally {
|
|
3310
|
+
skipScopes.pop();
|
|
3311
|
+
}
|
|
3312
|
+
});
|
|
3313
|
+
};
|
|
3314
|
+
}
|
|
3315
|
+
function eachWithSkipScope(each) {
|
|
3316
|
+
return ((...eachArgs) => {
|
|
3317
|
+
const register = each(...eachArgs);
|
|
3318
|
+
return withSkipScope(register);
|
|
3319
|
+
});
|
|
3320
|
+
}
|
|
3321
|
+
var describeWithSkipScope = Object.assign(withSkipScope(describe), describe, {
|
|
3322
|
+
only: Object.assign(withSkipScope(describe.only), { each: eachWithSkipScope(describe.only.each) }),
|
|
3323
|
+
skip: Object.assign(withSkipScope(describe.skip), { each: eachWithSkipScope(describe.skip.each) }),
|
|
3324
|
+
each: eachWithSkipScope(describe.each)
|
|
3325
|
+
});
|
|
3235
3326
|
var test = testWithFixtures;
|
|
3236
3327
|
test.use = use;
|
|
3237
|
-
test.describe =
|
|
3328
|
+
test.describe = describeWithSkipScope;
|
|
3238
3329
|
test.beforeAll = beforeAll;
|
|
3239
3330
|
test.beforeEach = beforeEach;
|
|
3240
3331
|
test.afterEach = afterEach;
|