@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/index.js
CHANGED
|
@@ -124,250 +124,339 @@ __export(index_exports, {
|
|
|
124
124
|
});
|
|
125
125
|
module.exports = __toCommonJS(index_exports);
|
|
126
126
|
|
|
127
|
-
// libs/testing/src/
|
|
128
|
-
var
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
name: "mcp-ext-apps",
|
|
139
|
-
version: "1.0"
|
|
140
|
-
};
|
|
141
|
-
case "claude":
|
|
142
|
-
return {
|
|
143
|
-
name: "claude-desktop",
|
|
144
|
-
version: "1.0"
|
|
145
|
-
};
|
|
146
|
-
case "cursor":
|
|
147
|
-
return {
|
|
148
|
-
name: "cursor",
|
|
149
|
-
version: "1.0"
|
|
150
|
-
};
|
|
151
|
-
case "continue":
|
|
152
|
-
return {
|
|
153
|
-
name: "continue",
|
|
154
|
-
version: "1.0"
|
|
155
|
-
};
|
|
156
|
-
case "cody":
|
|
157
|
-
return {
|
|
158
|
-
name: "cody",
|
|
159
|
-
version: "1.0"
|
|
160
|
-
};
|
|
161
|
-
case "gemini":
|
|
162
|
-
return {
|
|
163
|
-
name: "gemini",
|
|
164
|
-
version: "1.0"
|
|
165
|
-
};
|
|
166
|
-
case "generic-mcp":
|
|
167
|
-
return {
|
|
168
|
-
name: "generic-mcp-client",
|
|
169
|
-
version: "1.0"
|
|
170
|
-
};
|
|
171
|
-
case "unknown":
|
|
172
|
-
default:
|
|
173
|
-
return {
|
|
174
|
-
name: "mcp-test-client",
|
|
175
|
-
version: "1.0"
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
function buildUserAgent(clientInfo) {
|
|
180
|
-
return `${clientInfo.name}/${clientInfo.version}`;
|
|
181
|
-
}
|
|
182
|
-
function getPlatformUserAgent(platform) {
|
|
183
|
-
return buildUserAgent(getPlatformClientInfo(platform));
|
|
184
|
-
}
|
|
185
|
-
var PLATFORM_DETECTION_PATTERNS = {
|
|
186
|
-
openai: /chatgpt/i,
|
|
187
|
-
"ext-apps": /mcp-ext-apps/i,
|
|
188
|
-
// Note: Actual detection uses capabilities
|
|
189
|
-
claude: /claude|claude-desktop/i,
|
|
190
|
-
cursor: /cursor/i,
|
|
191
|
-
continue: /continue/i,
|
|
192
|
-
cody: /cody/i,
|
|
193
|
-
gemini: /gemini/i,
|
|
194
|
-
"generic-mcp": /generic-mcp/i,
|
|
195
|
-
unknown: /.*/
|
|
196
|
-
// Matches anything (fallback)
|
|
197
|
-
};
|
|
198
|
-
function getPlatformCapabilities(platform) {
|
|
199
|
-
const baseCapabilities = {
|
|
200
|
-
sampling: {},
|
|
201
|
-
// Include elicitation.form by default for testing elicitation workflows
|
|
202
|
-
// Note: MCP SDK expects form to be an object, not boolean
|
|
203
|
-
elicitation: {
|
|
204
|
-
form: {}
|
|
205
|
-
}
|
|
206
|
-
};
|
|
207
|
-
if (platform === "ext-apps") {
|
|
127
|
+
// libs/testing/src/interceptor/mock-registry.ts
|
|
128
|
+
var DefaultMockRegistry = class {
|
|
129
|
+
mocks = [];
|
|
130
|
+
add(mock) {
|
|
131
|
+
const entry = {
|
|
132
|
+
definition: mock,
|
|
133
|
+
callCount: 0,
|
|
134
|
+
calls: [],
|
|
135
|
+
remainingUses: mock.times ?? Infinity
|
|
136
|
+
};
|
|
137
|
+
this.mocks.push(entry);
|
|
208
138
|
return {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
139
|
+
remove: () => {
|
|
140
|
+
const index = this.mocks.indexOf(entry);
|
|
141
|
+
if (index !== -1) {
|
|
142
|
+
this.mocks.splice(index, 1);
|
|
213
143
|
}
|
|
214
|
-
}
|
|
144
|
+
},
|
|
145
|
+
callCount: () => entry.callCount,
|
|
146
|
+
calls: () => [...entry.calls]
|
|
215
147
|
};
|
|
216
148
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
this.
|
|
149
|
+
clear() {
|
|
150
|
+
this.mocks = [];
|
|
151
|
+
}
|
|
152
|
+
getAll() {
|
|
153
|
+
return this.mocks.map((e) => e.definition);
|
|
154
|
+
}
|
|
155
|
+
match(request) {
|
|
156
|
+
for (const entry of this.mocks) {
|
|
157
|
+
if (entry.remainingUses <= 0) continue;
|
|
158
|
+
const { definition } = entry;
|
|
159
|
+
if (definition.method !== request.method) continue;
|
|
160
|
+
if (definition.params !== void 0) {
|
|
161
|
+
const params = request.params ?? {};
|
|
162
|
+
if (typeof definition.params === "function") {
|
|
163
|
+
if (!definition.params(params)) continue;
|
|
164
|
+
} else {
|
|
165
|
+
if (!this.paramsMatch(definition.params, params)) continue;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
entry.callCount++;
|
|
169
|
+
entry.calls.push(request);
|
|
170
|
+
entry.remainingUses--;
|
|
171
|
+
return definition;
|
|
172
|
+
}
|
|
173
|
+
return void 0;
|
|
225
174
|
}
|
|
226
175
|
/**
|
|
227
|
-
*
|
|
176
|
+
* Check if request params match the mock params definition
|
|
228
177
|
*/
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
178
|
+
paramsMatch(expected, actual) {
|
|
179
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
180
|
+
if (!(key in actual)) return false;
|
|
181
|
+
const actualValue = actual[key];
|
|
182
|
+
if (Array.isArray(value)) {
|
|
183
|
+
if (!Array.isArray(actualValue)) return false;
|
|
184
|
+
if (value.length !== actualValue.length) return false;
|
|
185
|
+
for (let i = 0; i < value.length; i++) {
|
|
186
|
+
const expectedItem = value[i];
|
|
187
|
+
const actualItem = actualValue[i];
|
|
188
|
+
if (typeof expectedItem === "object" && expectedItem !== null) {
|
|
189
|
+
if (typeof actualItem !== "object" || actualItem === null) return false;
|
|
190
|
+
if (!this.paramsMatch(expectedItem, actualItem)) {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
} else if (actualItem !== expectedItem) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} else if (typeof value === "object" && value !== null) {
|
|
198
|
+
if (typeof actualValue !== "object" || actualValue === null) return false;
|
|
199
|
+
if (!this.paramsMatch(value, actualValue)) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
} else if (actualValue !== value) {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return true;
|
|
232
207
|
}
|
|
208
|
+
};
|
|
209
|
+
var mockResponse = {
|
|
233
210
|
/**
|
|
234
|
-
*
|
|
211
|
+
* Create a successful JSON-RPC response
|
|
235
212
|
*/
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
213
|
+
success(result, id = 1) {
|
|
214
|
+
return {
|
|
215
|
+
jsonrpc: "2.0",
|
|
216
|
+
id,
|
|
217
|
+
result
|
|
218
|
+
};
|
|
219
|
+
},
|
|
240
220
|
/**
|
|
241
|
-
*
|
|
221
|
+
* Create an error JSON-RPC response
|
|
242
222
|
*/
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
223
|
+
error(code, message, data, id = 1) {
|
|
224
|
+
return {
|
|
225
|
+
jsonrpc: "2.0",
|
|
226
|
+
id,
|
|
227
|
+
error: { code, message, data }
|
|
247
228
|
};
|
|
248
|
-
|
|
249
|
-
}
|
|
229
|
+
},
|
|
250
230
|
/**
|
|
251
|
-
*
|
|
231
|
+
* Create a tool result response
|
|
252
232
|
*/
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
233
|
+
toolResult(content, id = 1) {
|
|
234
|
+
return {
|
|
235
|
+
jsonrpc: "2.0",
|
|
236
|
+
id,
|
|
237
|
+
result: { content }
|
|
238
|
+
};
|
|
239
|
+
},
|
|
257
240
|
/**
|
|
258
|
-
*
|
|
241
|
+
* Create a tools/list response
|
|
259
242
|
*/
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
243
|
+
toolsList(tools, id = 1) {
|
|
244
|
+
return {
|
|
245
|
+
jsonrpc: "2.0",
|
|
246
|
+
id,
|
|
247
|
+
result: { tools }
|
|
248
|
+
};
|
|
249
|
+
},
|
|
264
250
|
/**
|
|
265
|
-
*
|
|
251
|
+
* Create a resources/list response
|
|
266
252
|
*/
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
253
|
+
resourcesList(resources, id = 1) {
|
|
254
|
+
return {
|
|
255
|
+
jsonrpc: "2.0",
|
|
256
|
+
id,
|
|
257
|
+
result: { resources }
|
|
258
|
+
};
|
|
259
|
+
},
|
|
271
260
|
/**
|
|
272
|
-
*
|
|
273
|
-
* When true, no Authorization header is sent and anonymous token is not requested.
|
|
274
|
-
* Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
|
|
261
|
+
* Create a resources/read response
|
|
275
262
|
*/
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
263
|
+
resourceRead(contents, id = 1) {
|
|
264
|
+
return {
|
|
265
|
+
jsonrpc: "2.0",
|
|
266
|
+
id,
|
|
267
|
+
result: { contents }
|
|
268
|
+
};
|
|
269
|
+
},
|
|
280
270
|
/**
|
|
281
|
-
*
|
|
271
|
+
* Common MCP errors
|
|
282
272
|
*/
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
273
|
+
errors: {
|
|
274
|
+
methodNotFound: (method, id = 1) => mockResponse.error(-32601, `Method not found: ${method}`, void 0, id),
|
|
275
|
+
invalidParams: (message, id = 1) => mockResponse.error(-32602, message, void 0, id),
|
|
276
|
+
internalError: (message, id = 1) => mockResponse.error(-32603, message, void 0, id),
|
|
277
|
+
resourceNotFound: (uri, id = 1) => mockResponse.error(-32002, `Resource not found: ${uri}`, { uri }, id),
|
|
278
|
+
toolNotFound: (name, id = 1) => mockResponse.error(-32601, `Tool not found: ${name}`, { name }, id),
|
|
279
|
+
unauthorized: (id = 1) => mockResponse.error(-32001, "Unauthorized", void 0, id),
|
|
280
|
+
forbidden: (id = 1) => mockResponse.error(-32003, "Forbidden", void 0, id)
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
// libs/testing/src/interceptor/interceptor-chain.ts
|
|
285
|
+
var DefaultInterceptorChain = class {
|
|
286
|
+
request = [];
|
|
287
|
+
response = [];
|
|
288
|
+
mocks;
|
|
289
|
+
constructor() {
|
|
290
|
+
this.mocks = new DefaultMockRegistry();
|
|
286
291
|
}
|
|
287
292
|
/**
|
|
288
|
-
*
|
|
293
|
+
* Add a request interceptor
|
|
289
294
|
*/
|
|
290
|
-
|
|
291
|
-
this.
|
|
292
|
-
return
|
|
295
|
+
addRequestInterceptor(interceptor) {
|
|
296
|
+
this.request.push(interceptor);
|
|
297
|
+
return () => {
|
|
298
|
+
const index = this.request.indexOf(interceptor);
|
|
299
|
+
if (index !== -1) {
|
|
300
|
+
this.request.splice(index, 1);
|
|
301
|
+
}
|
|
302
|
+
};
|
|
293
303
|
}
|
|
294
304
|
/**
|
|
295
|
-
*
|
|
296
|
-
* Automatically configures clientInfo and capabilities for platform detection.
|
|
297
|
-
*
|
|
298
|
-
* Platform-specific behavior:
|
|
299
|
-
* - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
|
|
300
|
-
* - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
|
|
301
|
-
* - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
|
|
302
|
-
* - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
|
|
303
|
-
* - Other platforms follow similar patterns
|
|
304
|
-
*
|
|
305
|
-
* @example
|
|
306
|
-
* ```typescript
|
|
307
|
-
* const client = await McpTestClient.create({ baseUrl })
|
|
308
|
-
* .withPlatform('openai')
|
|
309
|
-
* .buildAndConnect();
|
|
310
|
-
*
|
|
311
|
-
* // ext-apps automatically sets the io.modelcontextprotocol/ui capability
|
|
312
|
-
* const extAppsClient = await McpTestClient.create({ baseUrl })
|
|
313
|
-
* .withPlatform('ext-apps')
|
|
314
|
-
* .buildAndConnect();
|
|
315
|
-
* ```
|
|
305
|
+
* Add a response interceptor
|
|
316
306
|
*/
|
|
317
|
-
|
|
318
|
-
this.
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
307
|
+
addResponseInterceptor(interceptor) {
|
|
308
|
+
this.response.push(interceptor);
|
|
309
|
+
return () => {
|
|
310
|
+
const index = this.response.indexOf(interceptor);
|
|
311
|
+
if (index !== -1) {
|
|
312
|
+
this.response.splice(index, 1);
|
|
313
|
+
}
|
|
314
|
+
};
|
|
322
315
|
}
|
|
323
316
|
/**
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
* const client = await McpTestClient.create({ baseUrl })
|
|
330
|
-
* .withCapabilities({
|
|
331
|
-
* sampling: {},
|
|
332
|
-
* experimental: {
|
|
333
|
-
* 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
|
|
334
|
-
* }
|
|
335
|
-
* })
|
|
336
|
-
* .buildAndConnect();
|
|
337
|
-
* ```
|
|
317
|
+
* Process a request through the interceptor chain
|
|
318
|
+
* Returns either:
|
|
319
|
+
* - { type: 'continue', request } - continue with (possibly modified) request
|
|
320
|
+
* - { type: 'mock', response } - return mock response immediately
|
|
321
|
+
* - { type: 'error', error } - throw error
|
|
338
322
|
*/
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
323
|
+
async processRequest(request, meta) {
|
|
324
|
+
let currentRequest = request;
|
|
325
|
+
const mockDef = this.mocks.match(request);
|
|
326
|
+
if (mockDef) {
|
|
327
|
+
if (mockDef.delay && mockDef.delay > 0) {
|
|
328
|
+
await sleep(mockDef.delay);
|
|
329
|
+
}
|
|
330
|
+
let mockResponse2;
|
|
331
|
+
if (typeof mockDef.response === "function") {
|
|
332
|
+
mockResponse2 = await mockDef.response(request);
|
|
333
|
+
} else {
|
|
334
|
+
mockResponse2 = mockDef.response;
|
|
335
|
+
}
|
|
336
|
+
return {
|
|
337
|
+
type: "mock",
|
|
338
|
+
response: { ...mockResponse2, id: request.id ?? mockResponse2.id }
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
for (const interceptor of this.request) {
|
|
342
|
+
const ctx = {
|
|
343
|
+
request: currentRequest,
|
|
344
|
+
meta
|
|
345
|
+
};
|
|
346
|
+
const result = await interceptor(ctx);
|
|
347
|
+
switch (result.action) {
|
|
348
|
+
case "passthrough":
|
|
349
|
+
break;
|
|
350
|
+
case "modify":
|
|
351
|
+
currentRequest = result.request;
|
|
352
|
+
break;
|
|
353
|
+
case "mock":
|
|
354
|
+
return {
|
|
355
|
+
type: "mock",
|
|
356
|
+
response: { ...result.response, id: request.id ?? result.response.id }
|
|
357
|
+
};
|
|
358
|
+
case "error":
|
|
359
|
+
return { type: "error", error: result.error };
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return { type: "continue", request: currentRequest };
|
|
342
363
|
}
|
|
343
364
|
/**
|
|
344
|
-
*
|
|
345
|
-
* Useful for testing mode switches like `?mode=skills_only`.
|
|
346
|
-
*
|
|
347
|
-
* @example
|
|
348
|
-
* ```typescript
|
|
349
|
-
* const client = await McpTestClient.create({ baseUrl })
|
|
350
|
-
* .withQueryParams({ mode: 'skills_only' })
|
|
351
|
-
* .buildAndConnect();
|
|
352
|
-
* ```
|
|
365
|
+
* Process a response through the interceptor chain
|
|
353
366
|
*/
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
367
|
+
async processResponse(request, response, durationMs) {
|
|
368
|
+
let currentResponse = response;
|
|
369
|
+
for (const interceptor of this.response) {
|
|
370
|
+
const ctx = {
|
|
371
|
+
request,
|
|
372
|
+
response: currentResponse,
|
|
373
|
+
durationMs
|
|
374
|
+
};
|
|
375
|
+
const result = await interceptor(ctx);
|
|
376
|
+
switch (result.action) {
|
|
377
|
+
case "passthrough":
|
|
378
|
+
break;
|
|
379
|
+
case "modify":
|
|
380
|
+
currentResponse = result.response;
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return currentResponse;
|
|
357
385
|
}
|
|
358
386
|
/**
|
|
359
|
-
*
|
|
387
|
+
* Clear all interceptors and mocks
|
|
360
388
|
*/
|
|
361
|
-
|
|
362
|
-
|
|
389
|
+
clear() {
|
|
390
|
+
this.request = [];
|
|
391
|
+
this.response = [];
|
|
392
|
+
this.mocks.clear();
|
|
363
393
|
}
|
|
394
|
+
};
|
|
395
|
+
function sleep(ms) {
|
|
396
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
397
|
+
}
|
|
398
|
+
var interceptors = {
|
|
364
399
|
/**
|
|
365
|
-
*
|
|
400
|
+
* Create an interceptor that logs all requests
|
|
366
401
|
*/
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
402
|
+
logger(logFn = console.log) {
|
|
403
|
+
return (ctx) => {
|
|
404
|
+
logFn(`[MCP Request] ${ctx.request.method}`, ctx.request.params);
|
|
405
|
+
return { action: "passthrough" };
|
|
406
|
+
};
|
|
407
|
+
},
|
|
408
|
+
/**
|
|
409
|
+
* Create an interceptor that adds latency to all requests
|
|
410
|
+
*/
|
|
411
|
+
delay(ms) {
|
|
412
|
+
return async () => {
|
|
413
|
+
await sleep(ms);
|
|
414
|
+
return { action: "passthrough" };
|
|
415
|
+
};
|
|
416
|
+
},
|
|
417
|
+
/**
|
|
418
|
+
* Create an interceptor that fails requests matching a condition
|
|
419
|
+
*/
|
|
420
|
+
failWhen(condition, error) {
|
|
421
|
+
return (ctx) => {
|
|
422
|
+
if (condition(ctx)) {
|
|
423
|
+
const err = typeof error === "string" ? new Error(error) : error;
|
|
424
|
+
return { action: "error", error: err };
|
|
425
|
+
}
|
|
426
|
+
return { action: "passthrough" };
|
|
427
|
+
};
|
|
428
|
+
},
|
|
429
|
+
/**
|
|
430
|
+
* Create an interceptor that modifies specific methods
|
|
431
|
+
*/
|
|
432
|
+
modifyMethod(method, modifier) {
|
|
433
|
+
return (ctx) => {
|
|
434
|
+
if (ctx.request.method === method) {
|
|
435
|
+
return { action: "modify", request: modifier(ctx.request) };
|
|
436
|
+
}
|
|
437
|
+
return { action: "passthrough" };
|
|
438
|
+
};
|
|
439
|
+
},
|
|
440
|
+
/**
|
|
441
|
+
* Create a response interceptor that logs responses
|
|
442
|
+
*/
|
|
443
|
+
responseLogger(logFn = console.log) {
|
|
444
|
+
return (ctx) => {
|
|
445
|
+
const status = ctx.response.error ? "ERROR" : "OK";
|
|
446
|
+
logFn(`[MCP Response] ${ctx.request.method} ${status} (${ctx.durationMs}ms)`, ctx.response);
|
|
447
|
+
return { action: "passthrough" };
|
|
448
|
+
};
|
|
449
|
+
},
|
|
450
|
+
/**
|
|
451
|
+
* Create a response interceptor that modifies specific responses
|
|
452
|
+
*/
|
|
453
|
+
modifyResponse(method, modifier) {
|
|
454
|
+
return (ctx) => {
|
|
455
|
+
if (ctx.request.method === method) {
|
|
456
|
+
return { action: "modify", response: modifier(ctx.response) };
|
|
457
|
+
}
|
|
458
|
+
return { action: "passthrough" };
|
|
459
|
+
};
|
|
371
460
|
}
|
|
372
461
|
};
|
|
373
462
|
|
|
@@ -388,6 +477,7 @@ var StreamableHttpTransport = class {
|
|
|
388
477
|
this.config = {
|
|
389
478
|
baseUrl: config.baseUrl.replace(/\/$/, ""),
|
|
390
479
|
// Remove trailing slash
|
|
480
|
+
entryPath: config.entryPath,
|
|
391
481
|
timeout: config.timeout ?? DEFAULT_TIMEOUT,
|
|
392
482
|
auth: config.auth ?? {},
|
|
393
483
|
publicMode: config.publicMode ?? false,
|
|
@@ -480,7 +570,7 @@ var StreamableHttpTransport = class {
|
|
|
480
570
|
}
|
|
481
571
|
const headers = this.buildHeaders();
|
|
482
572
|
this.lastRequestHeaders = headers;
|
|
483
|
-
const url =
|
|
573
|
+
const url = this.mcpUrl();
|
|
484
574
|
this.log(`POST ${url}`, message);
|
|
485
575
|
const controller = new AbortController();
|
|
486
576
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
@@ -550,7 +640,7 @@ var StreamableHttpTransport = class {
|
|
|
550
640
|
this.ensureConnected();
|
|
551
641
|
const headers = this.buildHeaders();
|
|
552
642
|
this.lastRequestHeaders = headers;
|
|
553
|
-
const url =
|
|
643
|
+
const url = this.mcpUrl();
|
|
554
644
|
this.log(`POST ${url} (notification)`, message);
|
|
555
645
|
const controller = new AbortController();
|
|
556
646
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
@@ -581,7 +671,7 @@ var StreamableHttpTransport = class {
|
|
|
581
671
|
this.ensureConnected();
|
|
582
672
|
const headers = this.buildHeaders();
|
|
583
673
|
this.lastRequestHeaders = headers;
|
|
584
|
-
const url =
|
|
674
|
+
const url = this.mcpUrl();
|
|
585
675
|
this.log(`POST ${url} (raw)`, data);
|
|
586
676
|
const controller = new AbortController();
|
|
587
677
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
@@ -864,7 +954,7 @@ var StreamableHttpTransport = class {
|
|
|
864
954
|
*/
|
|
865
955
|
async sendElicitationResponse(requestId, response) {
|
|
866
956
|
const headers = this.buildHeaders();
|
|
867
|
-
const url =
|
|
957
|
+
const url = this.mcpUrl();
|
|
868
958
|
const rpcResponse = {
|
|
869
959
|
jsonrpc: "2.0",
|
|
870
960
|
id: requestId,
|
|
@@ -884,6 +974,18 @@ var StreamableHttpTransport = class {
|
|
|
884
974
|
this.log("Failed to send elicitation response:", error);
|
|
885
975
|
}
|
|
886
976
|
}
|
|
977
|
+
/**
|
|
978
|
+
* The MCP endpoint URL. `entryPath` is where the server mounts MCP; the
|
|
979
|
+
* trailing slash is kept because both hosts normalize it and the original
|
|
980
|
+
* behaviour (`${baseUrl}/`) relied on it (issue #543).
|
|
981
|
+
*/
|
|
982
|
+
mcpUrl() {
|
|
983
|
+
const url = new URL(this.config.baseUrl);
|
|
984
|
+
const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
|
|
985
|
+
const basePath = url.pathname.replace(/\/+$/, "");
|
|
986
|
+
url.pathname = entry ? `${basePath}/${entry}` : `${basePath}/`;
|
|
987
|
+
return url.toString();
|
|
988
|
+
}
|
|
887
989
|
buildHeaders() {
|
|
888
990
|
const headers = {
|
|
889
991
|
"Content-Type": "application/json",
|
|
@@ -971,339 +1073,250 @@ var StreamableHttpTransport = class {
|
|
|
971
1073
|
}
|
|
972
1074
|
};
|
|
973
1075
|
|
|
974
|
-
// libs/testing/src/
|
|
975
|
-
var
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1076
|
+
// libs/testing/src/platform/platform-client-info.ts
|
|
1077
|
+
var MCP_APPS_EXTENSION_KEY = "io.modelcontextprotocol/ui";
|
|
1078
|
+
function getPlatformClientInfo(platform) {
|
|
1079
|
+
switch (platform) {
|
|
1080
|
+
case "openai":
|
|
1081
|
+
return {
|
|
1082
|
+
name: "ChatGPT",
|
|
1083
|
+
version: "1.0"
|
|
1084
|
+
};
|
|
1085
|
+
case "ext-apps":
|
|
1086
|
+
return {
|
|
1087
|
+
name: "mcp-ext-apps",
|
|
1088
|
+
version: "1.0"
|
|
1089
|
+
};
|
|
1090
|
+
case "claude":
|
|
1091
|
+
return {
|
|
1092
|
+
name: "claude-desktop",
|
|
1093
|
+
version: "1.0"
|
|
1094
|
+
};
|
|
1095
|
+
case "cursor":
|
|
1096
|
+
return {
|
|
1097
|
+
name: "cursor",
|
|
1098
|
+
version: "1.0"
|
|
1099
|
+
};
|
|
1100
|
+
case "continue":
|
|
1101
|
+
return {
|
|
1102
|
+
name: "continue",
|
|
1103
|
+
version: "1.0"
|
|
1104
|
+
};
|
|
1105
|
+
case "cody":
|
|
1106
|
+
return {
|
|
1107
|
+
name: "cody",
|
|
1108
|
+
version: "1.0"
|
|
1109
|
+
};
|
|
1110
|
+
case "gemini":
|
|
1111
|
+
return {
|
|
1112
|
+
name: "gemini",
|
|
1113
|
+
version: "1.0"
|
|
1114
|
+
};
|
|
1115
|
+
case "generic-mcp":
|
|
1116
|
+
return {
|
|
1117
|
+
name: "generic-mcp-client",
|
|
1118
|
+
version: "1.0"
|
|
1119
|
+
};
|
|
1120
|
+
case "unknown":
|
|
1121
|
+
default:
|
|
1122
|
+
return {
|
|
1123
|
+
name: "mcp-test-client",
|
|
1124
|
+
version: "1.0"
|
|
1125
|
+
};
|
|
1001
1126
|
}
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1127
|
+
}
|
|
1128
|
+
function buildUserAgent(clientInfo) {
|
|
1129
|
+
return `${clientInfo.name}/${clientInfo.version}`;
|
|
1130
|
+
}
|
|
1131
|
+
function getPlatformUserAgent(platform) {
|
|
1132
|
+
return buildUserAgent(getPlatformClientInfo(platform));
|
|
1133
|
+
}
|
|
1134
|
+
var PLATFORM_DETECTION_PATTERNS = {
|
|
1135
|
+
openai: /chatgpt/i,
|
|
1136
|
+
"ext-apps": /mcp-ext-apps/i,
|
|
1137
|
+
// Note: Actual detection uses capabilities
|
|
1138
|
+
claude: /claude|claude-desktop/i,
|
|
1139
|
+
cursor: /cursor/i,
|
|
1140
|
+
continue: /continue/i,
|
|
1141
|
+
cody: /cody/i,
|
|
1142
|
+
gemini: /gemini/i,
|
|
1143
|
+
"generic-mcp": /generic-mcp/i,
|
|
1144
|
+
unknown: /.*/
|
|
1145
|
+
// Matches anything (fallback)
|
|
1146
|
+
};
|
|
1147
|
+
function getPlatformCapabilities(platform) {
|
|
1148
|
+
const baseCapabilities = {
|
|
1149
|
+
sampling: {},
|
|
1150
|
+
// Include elicitation.form by default for testing elicitation workflows
|
|
1151
|
+
// Note: MCP SDK expects form to be an object, not boolean
|
|
1152
|
+
elicitation: {
|
|
1153
|
+
form: {}
|
|
1019
1154
|
}
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
if (!(key in actual)) return false;
|
|
1028
|
-
const actualValue = actual[key];
|
|
1029
|
-
if (Array.isArray(value)) {
|
|
1030
|
-
if (!Array.isArray(actualValue)) return false;
|
|
1031
|
-
if (value.length !== actualValue.length) return false;
|
|
1032
|
-
for (let i = 0; i < value.length; i++) {
|
|
1033
|
-
const expectedItem = value[i];
|
|
1034
|
-
const actualItem = actualValue[i];
|
|
1035
|
-
if (typeof expectedItem === "object" && expectedItem !== null) {
|
|
1036
|
-
if (typeof actualItem !== "object" || actualItem === null) return false;
|
|
1037
|
-
if (!this.paramsMatch(expectedItem, actualItem)) {
|
|
1038
|
-
return false;
|
|
1039
|
-
}
|
|
1040
|
-
} else if (actualItem !== expectedItem) {
|
|
1041
|
-
return false;
|
|
1042
|
-
}
|
|
1043
|
-
}
|
|
1044
|
-
} else if (typeof value === "object" && value !== null) {
|
|
1045
|
-
if (typeof actualValue !== "object" || actualValue === null) return false;
|
|
1046
|
-
if (!this.paramsMatch(value, actualValue)) {
|
|
1047
|
-
return false;
|
|
1155
|
+
};
|
|
1156
|
+
if (platform === "ext-apps") {
|
|
1157
|
+
return {
|
|
1158
|
+
...baseCapabilities,
|
|
1159
|
+
experimental: {
|
|
1160
|
+
[MCP_APPS_EXTENSION_KEY]: {
|
|
1161
|
+
mimeTypes: ["text/html;profile=mcp-app"]
|
|
1048
1162
|
}
|
|
1049
|
-
} else if (actualValue !== value) {
|
|
1050
|
-
return false;
|
|
1051
1163
|
}
|
|
1052
|
-
}
|
|
1053
|
-
return true;
|
|
1054
|
-
}
|
|
1055
|
-
};
|
|
1056
|
-
var mockResponse = {
|
|
1057
|
-
/**
|
|
1058
|
-
* Create a successful JSON-RPC response
|
|
1059
|
-
*/
|
|
1060
|
-
success(result, id = 1) {
|
|
1061
|
-
return {
|
|
1062
|
-
jsonrpc: "2.0",
|
|
1063
|
-
id,
|
|
1064
|
-
result
|
|
1065
|
-
};
|
|
1066
|
-
},
|
|
1067
|
-
/**
|
|
1068
|
-
* Create an error JSON-RPC response
|
|
1069
|
-
*/
|
|
1070
|
-
error(code, message, data, id = 1) {
|
|
1071
|
-
return {
|
|
1072
|
-
jsonrpc: "2.0",
|
|
1073
|
-
id,
|
|
1074
|
-
error: { code, message, data }
|
|
1075
1164
|
};
|
|
1076
|
-
},
|
|
1077
|
-
/**
|
|
1078
|
-
* Create a tool result response
|
|
1079
|
-
*/
|
|
1080
|
-
toolResult(content, id = 1) {
|
|
1081
|
-
return {
|
|
1082
|
-
jsonrpc: "2.0",
|
|
1083
|
-
id,
|
|
1084
|
-
result: { content }
|
|
1085
|
-
};
|
|
1086
|
-
},
|
|
1087
|
-
/**
|
|
1088
|
-
* Create a tools/list response
|
|
1089
|
-
*/
|
|
1090
|
-
toolsList(tools, id = 1) {
|
|
1091
|
-
return {
|
|
1092
|
-
jsonrpc: "2.0",
|
|
1093
|
-
id,
|
|
1094
|
-
result: { tools }
|
|
1095
|
-
};
|
|
1096
|
-
},
|
|
1097
|
-
/**
|
|
1098
|
-
* Create a resources/list response
|
|
1099
|
-
*/
|
|
1100
|
-
resourcesList(resources, id = 1) {
|
|
1101
|
-
return {
|
|
1102
|
-
jsonrpc: "2.0",
|
|
1103
|
-
id,
|
|
1104
|
-
result: { resources }
|
|
1105
|
-
};
|
|
1106
|
-
},
|
|
1107
|
-
/**
|
|
1108
|
-
* Create a resources/read response
|
|
1109
|
-
*/
|
|
1110
|
-
resourceRead(contents, id = 1) {
|
|
1111
|
-
return {
|
|
1112
|
-
jsonrpc: "2.0",
|
|
1113
|
-
id,
|
|
1114
|
-
result: { contents }
|
|
1115
|
-
};
|
|
1116
|
-
},
|
|
1117
|
-
/**
|
|
1118
|
-
* Common MCP errors
|
|
1119
|
-
*/
|
|
1120
|
-
errors: {
|
|
1121
|
-
methodNotFound: (method, id = 1) => mockResponse.error(-32601, `Method not found: ${method}`, void 0, id),
|
|
1122
|
-
invalidParams: (message, id = 1) => mockResponse.error(-32602, message, void 0, id),
|
|
1123
|
-
internalError: (message, id = 1) => mockResponse.error(-32603, message, void 0, id),
|
|
1124
|
-
resourceNotFound: (uri, id = 1) => mockResponse.error(-32002, `Resource not found: ${uri}`, { uri }, id),
|
|
1125
|
-
toolNotFound: (name, id = 1) => mockResponse.error(-32601, `Tool not found: ${name}`, { name }, id),
|
|
1126
|
-
unauthorized: (id = 1) => mockResponse.error(-32001, "Unauthorized", void 0, id),
|
|
1127
|
-
forbidden: (id = 1) => mockResponse.error(-32003, "Forbidden", void 0, id)
|
|
1128
1165
|
}
|
|
1129
|
-
|
|
1166
|
+
return baseCapabilities;
|
|
1167
|
+
}
|
|
1130
1168
|
|
|
1131
|
-
// libs/testing/src/
|
|
1132
|
-
var
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
constructor() {
|
|
1137
|
-
this.mocks = new DefaultMockRegistry();
|
|
1138
|
-
}
|
|
1139
|
-
/**
|
|
1140
|
-
* Add a request interceptor
|
|
1141
|
-
*/
|
|
1142
|
-
addRequestInterceptor(interceptor) {
|
|
1143
|
-
this.request.push(interceptor);
|
|
1144
|
-
return () => {
|
|
1145
|
-
const index = this.request.indexOf(interceptor);
|
|
1146
|
-
if (index !== -1) {
|
|
1147
|
-
this.request.splice(index, 1);
|
|
1148
|
-
}
|
|
1149
|
-
};
|
|
1150
|
-
}
|
|
1151
|
-
/**
|
|
1152
|
-
* Add a response interceptor
|
|
1153
|
-
*/
|
|
1154
|
-
addResponseInterceptor(interceptor) {
|
|
1155
|
-
this.response.push(interceptor);
|
|
1156
|
-
return () => {
|
|
1157
|
-
const index = this.response.indexOf(interceptor);
|
|
1158
|
-
if (index !== -1) {
|
|
1159
|
-
this.response.splice(index, 1);
|
|
1160
|
-
}
|
|
1161
|
-
};
|
|
1169
|
+
// libs/testing/src/client/mcp-test-client.builder.ts
|
|
1170
|
+
var McpTestClientBuilder = class {
|
|
1171
|
+
config;
|
|
1172
|
+
constructor(config) {
|
|
1173
|
+
this.config = { ...config };
|
|
1162
1174
|
}
|
|
1163
1175
|
/**
|
|
1164
|
-
*
|
|
1165
|
-
* Returns either:
|
|
1166
|
-
* - { type: 'continue', request } - continue with (possibly modified) request
|
|
1167
|
-
* - { type: 'mock', response } - return mock response immediately
|
|
1168
|
-
* - { type: 'error', error } - throw error
|
|
1176
|
+
* Set the authentication configuration
|
|
1169
1177
|
*/
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
if (mockDef) {
|
|
1174
|
-
if (mockDef.delay && mockDef.delay > 0) {
|
|
1175
|
-
await sleep(mockDef.delay);
|
|
1176
|
-
}
|
|
1177
|
-
let mockResponse2;
|
|
1178
|
-
if (typeof mockDef.response === "function") {
|
|
1179
|
-
mockResponse2 = await mockDef.response(request);
|
|
1180
|
-
} else {
|
|
1181
|
-
mockResponse2 = mockDef.response;
|
|
1182
|
-
}
|
|
1183
|
-
return {
|
|
1184
|
-
type: "mock",
|
|
1185
|
-
response: { ...mockResponse2, id: request.id ?? mockResponse2.id }
|
|
1186
|
-
};
|
|
1187
|
-
}
|
|
1188
|
-
for (const interceptor of this.request) {
|
|
1189
|
-
const ctx = {
|
|
1190
|
-
request: currentRequest,
|
|
1191
|
-
meta
|
|
1192
|
-
};
|
|
1193
|
-
const result = await interceptor(ctx);
|
|
1194
|
-
switch (result.action) {
|
|
1195
|
-
case "passthrough":
|
|
1196
|
-
break;
|
|
1197
|
-
case "modify":
|
|
1198
|
-
currentRequest = result.request;
|
|
1199
|
-
break;
|
|
1200
|
-
case "mock":
|
|
1201
|
-
return {
|
|
1202
|
-
type: "mock",
|
|
1203
|
-
response: { ...result.response, id: request.id ?? result.response.id }
|
|
1204
|
-
};
|
|
1205
|
-
case "error":
|
|
1206
|
-
return { type: "error", error: result.error };
|
|
1207
|
-
}
|
|
1208
|
-
}
|
|
1209
|
-
return { type: "continue", request: currentRequest };
|
|
1178
|
+
withAuth(auth) {
|
|
1179
|
+
this.config.auth = { ...this.config.auth, ...auth };
|
|
1180
|
+
return this;
|
|
1210
1181
|
}
|
|
1211
1182
|
/**
|
|
1212
|
-
*
|
|
1183
|
+
* Set the bearer token for authentication
|
|
1213
1184
|
*/
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
const ctx = {
|
|
1218
|
-
request,
|
|
1219
|
-
response: currentResponse,
|
|
1220
|
-
durationMs
|
|
1221
|
-
};
|
|
1222
|
-
const result = await interceptor(ctx);
|
|
1223
|
-
switch (result.action) {
|
|
1224
|
-
case "passthrough":
|
|
1225
|
-
break;
|
|
1226
|
-
case "modify":
|
|
1227
|
-
currentResponse = result.response;
|
|
1228
|
-
break;
|
|
1229
|
-
}
|
|
1230
|
-
}
|
|
1231
|
-
return currentResponse;
|
|
1185
|
+
withToken(token) {
|
|
1186
|
+
this.config.auth = { ...this.config.auth, token };
|
|
1187
|
+
return this;
|
|
1232
1188
|
}
|
|
1233
1189
|
/**
|
|
1234
|
-
*
|
|
1190
|
+
* Add custom headers to all requests
|
|
1235
1191
|
*/
|
|
1236
|
-
|
|
1237
|
-
this.
|
|
1238
|
-
|
|
1239
|
-
|
|
1192
|
+
withHeaders(headers) {
|
|
1193
|
+
this.config.auth = {
|
|
1194
|
+
...this.config.auth,
|
|
1195
|
+
headers: { ...this.config.auth?.headers, ...headers }
|
|
1196
|
+
};
|
|
1197
|
+
return this;
|
|
1240
1198
|
}
|
|
1241
|
-
};
|
|
1242
|
-
function sleep(ms) {
|
|
1243
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1244
|
-
}
|
|
1245
|
-
var interceptors = {
|
|
1246
1199
|
/**
|
|
1247
|
-
*
|
|
1200
|
+
* Set the transport type
|
|
1248
1201
|
*/
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
};
|
|
1254
|
-
},
|
|
1202
|
+
withTransport(transport) {
|
|
1203
|
+
this.config.transport = transport;
|
|
1204
|
+
return this;
|
|
1205
|
+
}
|
|
1255
1206
|
/**
|
|
1256
|
-
*
|
|
1207
|
+
* Set the request timeout in milliseconds
|
|
1257
1208
|
*/
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
};
|
|
1263
|
-
},
|
|
1209
|
+
withTimeout(timeoutMs) {
|
|
1210
|
+
this.config.timeout = timeoutMs;
|
|
1211
|
+
return this;
|
|
1212
|
+
}
|
|
1264
1213
|
/**
|
|
1265
|
-
*
|
|
1214
|
+
* Enable debug logging
|
|
1266
1215
|
*/
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
return { action: "error", error: err };
|
|
1272
|
-
}
|
|
1273
|
-
return { action: "passthrough" };
|
|
1274
|
-
};
|
|
1275
|
-
},
|
|
1216
|
+
withDebug(enabled = true) {
|
|
1217
|
+
this.config.debug = enabled;
|
|
1218
|
+
return this;
|
|
1219
|
+
}
|
|
1276
1220
|
/**
|
|
1277
|
-
*
|
|
1221
|
+
* Enable public mode - skip authentication entirely.
|
|
1222
|
+
* When true, no Authorization header is sent and anonymous token is not requested.
|
|
1223
|
+
* Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
|
|
1278
1224
|
*/
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
}
|
|
1284
|
-
return { action: "passthrough" };
|
|
1285
|
-
};
|
|
1286
|
-
},
|
|
1225
|
+
withPublicMode(enabled = true) {
|
|
1226
|
+
this.config.publicMode = enabled;
|
|
1227
|
+
return this;
|
|
1228
|
+
}
|
|
1287
1229
|
/**
|
|
1288
|
-
*
|
|
1230
|
+
* Set the MCP protocol version to request
|
|
1289
1231
|
*/
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
return { action: "passthrough" };
|
|
1295
|
-
};
|
|
1296
|
-
},
|
|
1232
|
+
withProtocolVersion(version) {
|
|
1233
|
+
this.config.protocolVersion = version;
|
|
1234
|
+
return this;
|
|
1235
|
+
}
|
|
1297
1236
|
/**
|
|
1298
|
-
*
|
|
1237
|
+
* Set the client info sent during initialization
|
|
1299
1238
|
*/
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1239
|
+
withClientInfo(info) {
|
|
1240
|
+
this.config.clientInfo = info;
|
|
1241
|
+
return this;
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* Set the platform type for testing platform-specific meta keys.
|
|
1245
|
+
* Automatically configures clientInfo and capabilities for platform detection.
|
|
1246
|
+
*
|
|
1247
|
+
* Platform-specific behavior:
|
|
1248
|
+
* - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
|
|
1249
|
+
* - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
|
|
1250
|
+
* - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
|
|
1251
|
+
* - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
|
|
1252
|
+
* - Other platforms follow similar patterns
|
|
1253
|
+
*
|
|
1254
|
+
* @example
|
|
1255
|
+
* ```typescript
|
|
1256
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
1257
|
+
* .withPlatform('openai')
|
|
1258
|
+
* .buildAndConnect();
|
|
1259
|
+
*
|
|
1260
|
+
* // ext-apps automatically sets the io.modelcontextprotocol/ui capability
|
|
1261
|
+
* const extAppsClient = await McpTestClient.create({ baseUrl })
|
|
1262
|
+
* .withPlatform('ext-apps')
|
|
1263
|
+
* .buildAndConnect();
|
|
1264
|
+
* ```
|
|
1265
|
+
*/
|
|
1266
|
+
withPlatform(platform) {
|
|
1267
|
+
this.config.platform = platform;
|
|
1268
|
+
this.config.clientInfo = getPlatformClientInfo(platform);
|
|
1269
|
+
this.config.capabilities = getPlatformCapabilities(platform);
|
|
1270
|
+
return this;
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* Set custom client capabilities for MCP initialization.
|
|
1274
|
+
* Use this for fine-grained control over capabilities sent during initialization.
|
|
1275
|
+
*
|
|
1276
|
+
* @example
|
|
1277
|
+
* ```typescript
|
|
1278
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
1279
|
+
* .withCapabilities({
|
|
1280
|
+
* sampling: {},
|
|
1281
|
+
* experimental: {
|
|
1282
|
+
* 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
|
|
1283
|
+
* }
|
|
1284
|
+
* })
|
|
1285
|
+
* .buildAndConnect();
|
|
1286
|
+
* ```
|
|
1287
|
+
*/
|
|
1288
|
+
withCapabilities(capabilities) {
|
|
1289
|
+
this.config.capabilities = capabilities;
|
|
1290
|
+
return this;
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* Set query parameters to append to the connection URL.
|
|
1294
|
+
* Useful for testing mode switches like `?mode=skills_only`.
|
|
1295
|
+
*
|
|
1296
|
+
* @example
|
|
1297
|
+
* ```typescript
|
|
1298
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
1299
|
+
* .withQueryParams({ mode: 'skills_only' })
|
|
1300
|
+
* .buildAndConnect();
|
|
1301
|
+
* ```
|
|
1302
|
+
*/
|
|
1303
|
+
withQueryParams(params) {
|
|
1304
|
+
this.config.queryParams = { ...this.config.queryParams, ...params };
|
|
1305
|
+
return this;
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* Build the McpTestClient instance (does not connect)
|
|
1309
|
+
*/
|
|
1310
|
+
build() {
|
|
1311
|
+
return new McpTestClient(this.config);
|
|
1312
|
+
}
|
|
1313
|
+
/**
|
|
1314
|
+
* Build the McpTestClient and connect to the server
|
|
1315
|
+
*/
|
|
1316
|
+
async buildAndConnect() {
|
|
1317
|
+
const client = this.build();
|
|
1318
|
+
await client.connect();
|
|
1319
|
+
return client;
|
|
1307
1320
|
}
|
|
1308
1321
|
};
|
|
1309
1322
|
|
|
@@ -1314,6 +1327,21 @@ var DEFAULT_CLIENT_INFO = {
|
|
|
1314
1327
|
name: "@frontmcp/testing",
|
|
1315
1328
|
version: "0.4.0"
|
|
1316
1329
|
};
|
|
1330
|
+
function extractEntryPaths(data) {
|
|
1331
|
+
let payload = data;
|
|
1332
|
+
if (typeof payload === "string") {
|
|
1333
|
+
try {
|
|
1334
|
+
payload = JSON.parse(payload);
|
|
1335
|
+
} catch {
|
|
1336
|
+
return void 0;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
if (!payload || typeof payload !== "object") return void 0;
|
|
1340
|
+
const paths = payload.entryPaths;
|
|
1341
|
+
if (!Array.isArray(paths)) return void 0;
|
|
1342
|
+
const strings = paths.filter((p) => typeof p === "string");
|
|
1343
|
+
return strings.length > 0 ? strings : void 0;
|
|
1344
|
+
}
|
|
1317
1345
|
var McpTestClient = class {
|
|
1318
1346
|
// Platform, capabilities, and queryParams are optional - only set when needed
|
|
1319
1347
|
config;
|
|
@@ -1322,6 +1350,8 @@ var McpTestClient = class {
|
|
|
1322
1350
|
requestIdCounter = 0;
|
|
1323
1351
|
_lastRequestId = 0;
|
|
1324
1352
|
_sessionId;
|
|
1353
|
+
/** #543 — discovery is attempted at most once per connect. */
|
|
1354
|
+
triedDiscoveredEntryPath = false;
|
|
1325
1355
|
_sessionInfo = null;
|
|
1326
1356
|
_authState = { isAnonymous: true, scopes: [] };
|
|
1327
1357
|
// Logging and tracing
|
|
@@ -1339,6 +1369,7 @@ var McpTestClient = class {
|
|
|
1339
1369
|
constructor(config) {
|
|
1340
1370
|
this.config = {
|
|
1341
1371
|
baseUrl: config.baseUrl,
|
|
1372
|
+
entryPath: config.entryPath,
|
|
1342
1373
|
transport: config.transport ?? "streamable-http",
|
|
1343
1374
|
auth: config.auth ?? {},
|
|
1344
1375
|
publicMode: config.publicMode ?? false,
|
|
@@ -1374,11 +1405,13 @@ var McpTestClient = class {
|
|
|
1374
1405
|
*/
|
|
1375
1406
|
async connect() {
|
|
1376
1407
|
this.log("debug", `Connecting to ${this.config.baseUrl}...`);
|
|
1408
|
+
this.triedDiscoveredEntryPath = false;
|
|
1377
1409
|
this.transport = this.createTransport();
|
|
1378
1410
|
await this.transport.connect();
|
|
1379
1411
|
const maxRetries = 3;
|
|
1380
1412
|
const retryDelayMs = 500;
|
|
1381
1413
|
let lastError;
|
|
1414
|
+
let reportedEntryPaths;
|
|
1382
1415
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
1383
1416
|
const initResponse = await this.initialize();
|
|
1384
1417
|
if (initResponse.success && initResponse.data) {
|
|
@@ -1386,13 +1419,27 @@ var McpTestClient = class {
|
|
|
1386
1419
|
break;
|
|
1387
1420
|
}
|
|
1388
1421
|
lastError = initResponse.error?.message ?? "Unknown error";
|
|
1422
|
+
reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
|
|
1423
|
+
if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
|
|
1424
|
+
this.triedDiscoveredEntryPath = true;
|
|
1425
|
+
const discovered = reportedEntryPaths[0];
|
|
1426
|
+
this.log("info", `Server serves MCP at ${discovered}; reconnecting there (configured: ${this.mcpPathLabel()})`);
|
|
1427
|
+
this.config.entryPath = discovered;
|
|
1428
|
+
await this.transport.close();
|
|
1429
|
+
this.transport = this.createTransport();
|
|
1430
|
+
await this.transport.connect();
|
|
1431
|
+
continue;
|
|
1432
|
+
}
|
|
1389
1433
|
if (attempt < maxRetries) {
|
|
1390
1434
|
this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
|
|
1391
1435
|
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
1392
1436
|
}
|
|
1393
1437
|
}
|
|
1394
1438
|
if (!this.initResult) {
|
|
1395
|
-
|
|
1439
|
+
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.` : "";
|
|
1440
|
+
throw new Error(
|
|
1441
|
+
`Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${maxRetries} attempts: ${lastError}.${served}`
|
|
1442
|
+
);
|
|
1396
1443
|
}
|
|
1397
1444
|
this._sessionId = this.transport.getSessionId();
|
|
1398
1445
|
this._sessionInfo = {
|
|
@@ -1990,6 +2037,11 @@ var McpTestClient = class {
|
|
|
1990
2037
|
// ═══════════════════════════════════════════════════════════════════
|
|
1991
2038
|
// PRIVATE: TRANSPORT & REQUEST HELPERS
|
|
1992
2039
|
// ═══════════════════════════════════════════════════════════════════
|
|
2040
|
+
/** The MCP path this client is currently pointed at, for log and error text. */
|
|
2041
|
+
mcpPathLabel() {
|
|
2042
|
+
const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
|
|
2043
|
+
return entry ? `/${entry}` : "/";
|
|
2044
|
+
}
|
|
1993
2045
|
createTransport() {
|
|
1994
2046
|
let baseUrl = this.config.baseUrl;
|
|
1995
2047
|
if (this.config.queryParams && Object.keys(this.config.queryParams).length > 0) {
|
|
@@ -2003,6 +2055,7 @@ var McpTestClient = class {
|
|
|
2003
2055
|
case "streamable-http":
|
|
2004
2056
|
return new StreamableHttpTransport({
|
|
2005
2057
|
baseUrl,
|
|
2058
|
+
entryPath: this.config.entryPath,
|
|
2006
2059
|
timeout: this.config.timeout,
|
|
2007
2060
|
auth: this.config.auth,
|
|
2008
2061
|
publicMode: this.config.publicMode,
|
|
@@ -4882,13 +4935,17 @@ var currentConfig = {};
|
|
|
4882
4935
|
var serverInstance = null;
|
|
4883
4936
|
var tokenFactory = null;
|
|
4884
4937
|
var serverStartedByUs = false;
|
|
4938
|
+
var skipScopes = [{ skipped: false }];
|
|
4939
|
+
function currentSkipScope() {
|
|
4940
|
+
return skipScopes[skipScopes.length - 1];
|
|
4941
|
+
}
|
|
4885
4942
|
async function initializeSharedResources() {
|
|
4886
4943
|
if (!tokenFactory) {
|
|
4887
4944
|
const sharedSecret = currentConfig.env?.["JWT_SECRET"];
|
|
4888
4945
|
tokenFactory = new TestTokenFactory(sharedSecret ? { hmacSecret: sharedSecret } : {});
|
|
4889
4946
|
}
|
|
4890
4947
|
if (!serverInstance) {
|
|
4891
|
-
if (currentConfig.baseUrl) {
|
|
4948
|
+
if (currentConfig.baseUrl && !currentConfig.server) {
|
|
4892
4949
|
serverInstance = TestServer.connect(currentConfig.baseUrl);
|
|
4893
4950
|
serverStartedByUs = false;
|
|
4894
4951
|
} else if (currentConfig.server) {
|
|
@@ -4938,7 +4995,8 @@ async function createTestFixtures() {
|
|
|
4938
4995
|
throw new Error("Token factory not initialized");
|
|
4939
4996
|
}
|
|
4940
4997
|
const clientInstance = McpTestClient.create({
|
|
4941
|
-
baseUrl: serverInstance
|
|
4998
|
+
baseUrl: resolveClientBaseUrl(serverInstance),
|
|
4999
|
+
entryPath: currentConfig.entryPath,
|
|
4942
5000
|
transport: currentConfig.transport ?? "streamable-http",
|
|
4943
5001
|
publicMode: currentConfig.publicMode
|
|
4944
5002
|
}).build();
|
|
@@ -4987,6 +5045,9 @@ async function cleanupSharedResources() {
|
|
|
4987
5045
|
tokenFactory = null;
|
|
4988
5046
|
serverStartedByUs = false;
|
|
4989
5047
|
}
|
|
5048
|
+
function resolveClientBaseUrl(server) {
|
|
5049
|
+
return currentConfig.baseUrl ?? server.info.baseUrl;
|
|
5050
|
+
}
|
|
4990
5051
|
function createAuthFixture(factory) {
|
|
4991
5052
|
const users = {
|
|
4992
5053
|
admin: {
|
|
@@ -5036,7 +5097,8 @@ function createServerFixture(server) {
|
|
|
5036
5097
|
info: server.info,
|
|
5037
5098
|
createClient: async (opts) => {
|
|
5038
5099
|
return McpTestClient.create({
|
|
5039
|
-
baseUrl: server
|
|
5100
|
+
baseUrl: resolveClientBaseUrl(server),
|
|
5101
|
+
entryPath: opts?.entryPath ?? currentConfig.entryPath,
|
|
5040
5102
|
transport: opts?.transport ?? "streamable-http",
|
|
5041
5103
|
auth: opts?.token ? { token: opts.token } : void 0,
|
|
5042
5104
|
clientInfo: opts?.clientInfo,
|
|
@@ -5045,7 +5107,8 @@ function createServerFixture(server) {
|
|
|
5045
5107
|
},
|
|
5046
5108
|
createClientBuilder: () => {
|
|
5047
5109
|
const builder = new McpTestClientBuilder({
|
|
5048
|
-
baseUrl: server
|
|
5110
|
+
baseUrl: resolveClientBaseUrl(server),
|
|
5111
|
+
entryPath: currentConfig.entryPath,
|
|
5049
5112
|
publicMode: currentConfig.publicMode
|
|
5050
5113
|
});
|
|
5051
5114
|
return builder;
|
|
@@ -5061,8 +5124,8 @@ function resolveServerCommand(server) {
|
|
|
5061
5124
|
}
|
|
5062
5125
|
return `npx tsx ${server}`;
|
|
5063
5126
|
}
|
|
5064
|
-
function
|
|
5065
|
-
|
|
5127
|
+
function runWithFixtures(fn) {
|
|
5128
|
+
return async () => {
|
|
5066
5129
|
const fixtures = await createTestFixtures();
|
|
5067
5130
|
let testFailed = false;
|
|
5068
5131
|
try {
|
|
@@ -5073,7 +5136,15 @@ function testWithFixtures(name, fn) {
|
|
|
5073
5136
|
} finally {
|
|
5074
5137
|
await cleanupTestFixtures(fixtures, testFailed);
|
|
5075
5138
|
}
|
|
5076
|
-
}
|
|
5139
|
+
};
|
|
5140
|
+
}
|
|
5141
|
+
function testWithFixtures(name, fn) {
|
|
5142
|
+
const scope = currentSkipScope();
|
|
5143
|
+
if (scope.skipped) {
|
|
5144
|
+
it2.skip(scope.reason ? `${name} (skipped: ${scope.reason})` : name, runWithFixtures(fn));
|
|
5145
|
+
return;
|
|
5146
|
+
}
|
|
5147
|
+
it2(name, runWithFixtures(fn));
|
|
5077
5148
|
}
|
|
5078
5149
|
function use(config) {
|
|
5079
5150
|
currentConfig = { ...currentConfig, ...config };
|
|
@@ -5081,40 +5152,60 @@ function use(config) {
|
|
|
5081
5152
|
await cleanupSharedResources();
|
|
5082
5153
|
});
|
|
5083
5154
|
}
|
|
5084
|
-
function skip(
|
|
5085
|
-
|
|
5086
|
-
|
|
5087
|
-
|
|
5088
|
-
|
|
5089
|
-
|
|
5090
|
-
} catch (error) {
|
|
5091
|
-
testFailed = true;
|
|
5092
|
-
throw error;
|
|
5093
|
-
} finally {
|
|
5094
|
-
await cleanupTestFixtures(fixtures, testFailed);
|
|
5155
|
+
function skip(nameOrCondition, fnOrReason) {
|
|
5156
|
+
if (typeof nameOrCondition === "boolean") {
|
|
5157
|
+
if (nameOrCondition) {
|
|
5158
|
+
const scope = currentSkipScope();
|
|
5159
|
+
scope.skipped = true;
|
|
5160
|
+
scope.reason = typeof fnOrReason === "string" ? fnOrReason : void 0;
|
|
5095
5161
|
}
|
|
5096
|
-
|
|
5162
|
+
return;
|
|
5163
|
+
}
|
|
5164
|
+
if (typeof fnOrReason !== "function") {
|
|
5165
|
+
throw new TypeError(
|
|
5166
|
+
`test.skip expects either (name: string, fn) or (condition: boolean, reason?: string); received (${typeof nameOrCondition}, ${typeof fnOrReason}).`
|
|
5167
|
+
);
|
|
5168
|
+
}
|
|
5169
|
+
it2.skip(nameOrCondition, runWithFixtures(fnOrReason));
|
|
5097
5170
|
}
|
|
5098
5171
|
function only(name, fn) {
|
|
5099
|
-
|
|
5100
|
-
|
|
5101
|
-
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
5105
|
-
testFailed = true;
|
|
5106
|
-
throw error;
|
|
5107
|
-
} finally {
|
|
5108
|
-
await cleanupTestFixtures(fixtures, testFailed);
|
|
5109
|
-
}
|
|
5110
|
-
});
|
|
5172
|
+
const scope = currentSkipScope();
|
|
5173
|
+
if (scope.skipped) {
|
|
5174
|
+
it2.skip(scope.reason ? `${name} (skipped: ${scope.reason})` : name, runWithFixtures(fn));
|
|
5175
|
+
return;
|
|
5176
|
+
}
|
|
5177
|
+
it2.only(name, runWithFixtures(fn));
|
|
5111
5178
|
}
|
|
5112
5179
|
function todo(name) {
|
|
5113
5180
|
it2.todo(name);
|
|
5114
5181
|
}
|
|
5182
|
+
function withSkipScope(register) {
|
|
5183
|
+
return (name, fn) => {
|
|
5184
|
+
register(name, () => {
|
|
5185
|
+
const parent = currentSkipScope();
|
|
5186
|
+
skipScopes.push({ skipped: parent.skipped, reason: parent.reason });
|
|
5187
|
+
try {
|
|
5188
|
+
fn();
|
|
5189
|
+
} finally {
|
|
5190
|
+
skipScopes.pop();
|
|
5191
|
+
}
|
|
5192
|
+
});
|
|
5193
|
+
};
|
|
5194
|
+
}
|
|
5195
|
+
function eachWithSkipScope(each) {
|
|
5196
|
+
return ((...eachArgs) => {
|
|
5197
|
+
const register = each(...eachArgs);
|
|
5198
|
+
return withSkipScope(register);
|
|
5199
|
+
});
|
|
5200
|
+
}
|
|
5201
|
+
var describeWithSkipScope = Object.assign(withSkipScope(describe2), describe2, {
|
|
5202
|
+
only: Object.assign(withSkipScope(describe2.only), { each: eachWithSkipScope(describe2.only.each) }),
|
|
5203
|
+
skip: Object.assign(withSkipScope(describe2.skip), { each: eachWithSkipScope(describe2.skip.each) }),
|
|
5204
|
+
each: eachWithSkipScope(describe2.each)
|
|
5205
|
+
});
|
|
5115
5206
|
var test = testWithFixtures;
|
|
5116
5207
|
test.use = use;
|
|
5117
|
-
test.describe =
|
|
5208
|
+
test.describe = describeWithSkipScope;
|
|
5118
5209
|
test.beforeAll = beforeAll2;
|
|
5119
5210
|
test.beforeEach = beforeEach2;
|
|
5120
5211
|
test.afterEach = afterEach2;
|