@frontmcp/testing 1.7.2 → 1.8.1
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/index.d.ts +1 -1
- package/client/index.d.ts.map +1 -1
- package/client/mcp-test-client.builder.d.ts +2 -1
- package/client/mcp-test-client.builder.d.ts.map +1 -1
- package/client/mcp-test-client.d.ts +11 -3
- package/client/mcp-test-client.d.ts.map +1 -1
- package/client/mcp-test-client.types.d.ts +17 -3
- package/client/mcp-test-client.types.d.ts.map +1 -1
- package/esm/fixtures/index.mjs +670 -458
- package/esm/index.mjs +780 -569
- package/esm/matchers/index.mjs +5 -6
- package/esm/package.json +6 -6
- package/esm/perf/index.mjs +600 -426
- package/esm/setup.mjs +5 -6
- package/fixtures/fixture-types.d.ts +34 -3
- package/fixtures/fixture-types.d.ts.map +1 -1
- package/fixtures/index.js +670 -458
- package/fixtures/test-fixture.d.ts.map +1 -1
- package/index.d.ts +1 -1
- package/index.d.ts.map +1 -1
- package/index.js +780 -569
- package/matchers/index.js +5 -6
- package/matchers/matcher-types.d.ts +3 -2
- package/matchers/matcher-types.d.ts.map +1 -1
- package/matchers/mcp-matchers.d.ts +1 -1
- package/matchers/mcp-matchers.d.ts.map +1 -1
- package/package.json +6 -6
- package/perf/index.js +600 -426
- package/setup.js +5 -6
- package/transport/index.d.ts +1 -1
- package/transport/index.d.ts.map +1 -1
- package/transport/streamable-http.transport.d.ts +18 -2
- package/transport/streamable-http.transport.d.ts.map +1 -1
- package/transport/transport.interface.d.ts +17 -1
- package/transport/transport.interface.d.ts.map +1 -1
package/index.js
CHANGED
|
@@ -124,255 +124,345 @@ __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
|
|
|
374
463
|
// libs/testing/src/transport/streamable-http.transport.ts
|
|
375
464
|
var DEFAULT_TIMEOUT = 3e4;
|
|
465
|
+
var NOTIFICATION_STREAM_REOPEN_DELAY_MS = 250;
|
|
376
466
|
var StreamableHttpTransport = class {
|
|
377
467
|
config;
|
|
378
468
|
state = "disconnected";
|
|
@@ -384,10 +474,15 @@ var StreamableHttpTransport = class {
|
|
|
384
474
|
interceptors;
|
|
385
475
|
publicMode;
|
|
386
476
|
elicitationHandler;
|
|
477
|
+
notificationHandler;
|
|
478
|
+
notificationStream;
|
|
479
|
+
notificationProtocolVersion = "";
|
|
480
|
+
notificationStreamReopenTimer;
|
|
387
481
|
constructor(config) {
|
|
388
482
|
this.config = {
|
|
389
483
|
baseUrl: config.baseUrl.replace(/\/$/, ""),
|
|
390
484
|
// Remove trailing slash
|
|
485
|
+
entryPath: config.entryPath,
|
|
391
486
|
timeout: config.timeout ?? DEFAULT_TIMEOUT,
|
|
392
487
|
auth: config.auth ?? {},
|
|
393
488
|
publicMode: config.publicMode ?? false,
|
|
@@ -399,6 +494,7 @@ var StreamableHttpTransport = class {
|
|
|
399
494
|
this.interceptors = config.interceptors;
|
|
400
495
|
this.publicMode = config.publicMode ?? false;
|
|
401
496
|
this.elicitationHandler = config.elicitationHandler;
|
|
497
|
+
this.notificationHandler = config.notificationHandler;
|
|
402
498
|
}
|
|
403
499
|
async connect() {
|
|
404
500
|
this.state = "connecting";
|
|
@@ -480,7 +576,7 @@ var StreamableHttpTransport = class {
|
|
|
480
576
|
}
|
|
481
577
|
const headers = this.buildHeaders();
|
|
482
578
|
this.lastRequestHeaders = headers;
|
|
483
|
-
const url =
|
|
579
|
+
const url = this.mcpUrl();
|
|
484
580
|
this.log(`POST ${url}`, message);
|
|
485
581
|
const controller = new AbortController();
|
|
486
582
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
@@ -550,7 +646,7 @@ var StreamableHttpTransport = class {
|
|
|
550
646
|
this.ensureConnected();
|
|
551
647
|
const headers = this.buildHeaders();
|
|
552
648
|
this.lastRequestHeaders = headers;
|
|
553
|
-
const url =
|
|
649
|
+
const url = this.mcpUrl();
|
|
554
650
|
this.log(`POST ${url} (notification)`, message);
|
|
555
651
|
const controller = new AbortController();
|
|
556
652
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
@@ -581,7 +677,7 @@ var StreamableHttpTransport = class {
|
|
|
581
677
|
this.ensureConnected();
|
|
582
678
|
const headers = this.buildHeaders();
|
|
583
679
|
this.lastRequestHeaders = headers;
|
|
584
|
-
const url =
|
|
680
|
+
const url = this.mcpUrl();
|
|
585
681
|
this.log(`POST ${url} (raw)`, data);
|
|
586
682
|
const controller = new AbortController();
|
|
587
683
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
@@ -618,7 +714,12 @@ var StreamableHttpTransport = class {
|
|
|
618
714
|
};
|
|
619
715
|
}
|
|
620
716
|
}
|
|
717
|
+
async openNotificationStream(protocolVersion) {
|
|
718
|
+
this.notificationProtocolVersion = protocolVersion;
|
|
719
|
+
await this.connectNotificationStream();
|
|
720
|
+
}
|
|
621
721
|
async close() {
|
|
722
|
+
this.closeNotificationStream();
|
|
622
723
|
this.state = "disconnected";
|
|
623
724
|
this.sessionId = void 0;
|
|
624
725
|
this.log("StreamableHTTP transport closed");
|
|
@@ -657,6 +758,7 @@ var StreamableHttpTransport = class {
|
|
|
657
758
|
return { ...this.lastRequestHeaders };
|
|
658
759
|
}
|
|
659
760
|
async simulateDisconnect() {
|
|
761
|
+
this.closeNotificationStream();
|
|
660
762
|
this.state = "disconnected";
|
|
661
763
|
this.sessionId = void 0;
|
|
662
764
|
}
|
|
@@ -675,23 +777,89 @@ var StreamableHttpTransport = class {
|
|
|
675
777
|
// ═══════════════════════════════════════════════════════════════════
|
|
676
778
|
// PRIVATE HELPERS
|
|
677
779
|
// ═══════════════════════════════════════════════════════════════════
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
async
|
|
685
|
-
this.
|
|
686
|
-
const
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
780
|
+
closeNotificationStream() {
|
|
781
|
+
clearTimeout(this.notificationStreamReopenTimer);
|
|
782
|
+
this.notificationStream?.abort();
|
|
783
|
+
this.notificationStream = void 0;
|
|
784
|
+
}
|
|
785
|
+
/** Opens the stream without holding up the caller past the request timeout; close() aborts it at any point. */
|
|
786
|
+
async connectNotificationStream() {
|
|
787
|
+
if (!this.sessionId || this.notificationStream) return;
|
|
788
|
+
const controller = new AbortController();
|
|
789
|
+
this.notificationStream = controller;
|
|
790
|
+
const { "Content-Type": _contentType, ...headers } = this.buildHeaders();
|
|
791
|
+
const established = fetch(this.mcpUrl(), {
|
|
792
|
+
method: "GET",
|
|
793
|
+
headers: { ...headers, Accept: "text/event-stream", "MCP-Protocol-Version": this.notificationProtocolVersion },
|
|
794
|
+
signal: controller.signal
|
|
795
|
+
}).then(
|
|
796
|
+
async (response) => {
|
|
797
|
+
const isEventStream = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
|
|
798
|
+
if (!response.ok || !response.body || !isEventStream) {
|
|
799
|
+
await response.body?.cancel();
|
|
800
|
+
this.log(`Server offers no notification stream (HTTP ${response.status})`);
|
|
801
|
+
this.releaseNotificationStream(controller);
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
void this.readNotificationStream(controller, response.body.getReader());
|
|
805
|
+
},
|
|
806
|
+
(error) => {
|
|
807
|
+
this.log("Failed to open the notification stream:", error);
|
|
808
|
+
this.releaseNotificationStream(controller);
|
|
809
|
+
}
|
|
810
|
+
);
|
|
811
|
+
let establishTimer;
|
|
812
|
+
const establishDeadline = new Promise((resolve) => {
|
|
813
|
+
establishTimer = setTimeout(resolve, this.config.timeout);
|
|
814
|
+
});
|
|
815
|
+
await Promise.race([established, establishDeadline]);
|
|
816
|
+
clearTimeout(establishTimer);
|
|
817
|
+
}
|
|
818
|
+
releaseNotificationStream(controller) {
|
|
819
|
+
if (this.notificationStream === controller) this.notificationStream = void 0;
|
|
820
|
+
}
|
|
821
|
+
async readNotificationStream(controller, reader) {
|
|
822
|
+
const decoder = new TextDecoder();
|
|
823
|
+
let buffer = "";
|
|
824
|
+
try {
|
|
825
|
+
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
|
|
826
|
+
const { completeEvents, remainder } = splitSSEEvents(buffer + decoder.decode(chunk.value, { stream: true }));
|
|
827
|
+
buffer = remainder;
|
|
828
|
+
for (const eventText of completeEvents) {
|
|
829
|
+
for (const event of this.parseSSEEvents(eventText, void 0).events) {
|
|
830
|
+
await this.handleSSEEvent(event);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
} catch (error) {
|
|
835
|
+
this.log("Notification stream ended:", error);
|
|
836
|
+
} finally {
|
|
837
|
+
reader.releaseLock();
|
|
838
|
+
}
|
|
839
|
+
if (controller.signal.aborted || this.notificationStream !== controller) return;
|
|
840
|
+
this.releaseNotificationStream(controller);
|
|
841
|
+
this.notificationStreamReopenTimer = setTimeout(
|
|
842
|
+
() => void this.connectNotificationStream(),
|
|
843
|
+
NOTIFICATION_STREAM_REOPEN_DELAY_MS
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Handle SSE response with elicitation support.
|
|
848
|
+
*
|
|
849
|
+
* Streams the SSE response, detects elicitation/create requests, and handles them
|
|
850
|
+
* by calling the registered handler and sending the response back to the server.
|
|
851
|
+
*/
|
|
852
|
+
async handleSSEResponseWithElicitation(response, originalRequest) {
|
|
853
|
+
this.log("handleSSEResponseWithElicitation: starting", { requestId: originalRequest.id });
|
|
854
|
+
const reader = response.body?.getReader();
|
|
855
|
+
if (!reader) {
|
|
856
|
+
this.log("handleSSEResponseWithElicitation: no response body");
|
|
857
|
+
return {
|
|
858
|
+
jsonrpc: "2.0",
|
|
859
|
+
id: originalRequest.id ?? null,
|
|
860
|
+
error: { code: -32e3, message: "No response body" }
|
|
861
|
+
};
|
|
862
|
+
}
|
|
695
863
|
const decoder = new TextDecoder();
|
|
696
864
|
let buffer = "";
|
|
697
865
|
let finalResponse = null;
|
|
@@ -812,6 +980,13 @@ var StreamableHttpTransport = class {
|
|
|
812
980
|
response: { jsonrpc: "2.0", id: null, result: void 0 }
|
|
813
981
|
};
|
|
814
982
|
}
|
|
983
|
+
if ("method" in parsed && parsed.id === void 0) {
|
|
984
|
+
this.notificationHandler?.(parsed);
|
|
985
|
+
return {
|
|
986
|
+
isFinal: false,
|
|
987
|
+
response: { jsonrpc: "2.0", id: null, result: void 0 }
|
|
988
|
+
};
|
|
989
|
+
}
|
|
815
990
|
if ("result" in parsed || "error" in parsed) {
|
|
816
991
|
return { isFinal: true, response: parsed };
|
|
817
992
|
}
|
|
@@ -864,7 +1039,7 @@ var StreamableHttpTransport = class {
|
|
|
864
1039
|
*/
|
|
865
1040
|
async sendElicitationResponse(requestId, response) {
|
|
866
1041
|
const headers = this.buildHeaders();
|
|
867
|
-
const url =
|
|
1042
|
+
const url = this.mcpUrl();
|
|
868
1043
|
const rpcResponse = {
|
|
869
1044
|
jsonrpc: "2.0",
|
|
870
1045
|
id: requestId,
|
|
@@ -884,6 +1059,18 @@ var StreamableHttpTransport = class {
|
|
|
884
1059
|
this.log("Failed to send elicitation response:", error);
|
|
885
1060
|
}
|
|
886
1061
|
}
|
|
1062
|
+
/**
|
|
1063
|
+
* The MCP endpoint URL. `entryPath` is where the server mounts MCP; the
|
|
1064
|
+
* trailing slash is kept because both hosts normalize it and the original
|
|
1065
|
+
* behaviour (`${baseUrl}/`) relied on it (issue #543).
|
|
1066
|
+
*/
|
|
1067
|
+
mcpUrl() {
|
|
1068
|
+
const url = new URL(this.config.baseUrl);
|
|
1069
|
+
const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
|
|
1070
|
+
const basePath = url.pathname.replace(/\/+$/, "");
|
|
1071
|
+
url.pathname = entry ? `${basePath}/${entry}` : `${basePath}/`;
|
|
1072
|
+
return url.toString();
|
|
1073
|
+
}
|
|
887
1074
|
buildHeaders() {
|
|
888
1075
|
const headers = {
|
|
889
1076
|
"Content-Type": "application/json",
|
|
@@ -970,350 +1157,285 @@ var StreamableHttpTransport = class {
|
|
|
970
1157
|
};
|
|
971
1158
|
}
|
|
972
1159
|
};
|
|
1160
|
+
function splitSSEEvents(buffer) {
|
|
1161
|
+
const endsWithCarriageReturn = buffer.endsWith("\r");
|
|
1162
|
+
const normalized = (endsWithCarriageReturn ? buffer.slice(0, -1) : buffer).replace(/\r\n?/g, "\n");
|
|
1163
|
+
const completeEvents = normalized.split("\n\n");
|
|
1164
|
+
const remainder = completeEvents.pop() ?? "";
|
|
1165
|
+
return { completeEvents, remainder: endsWithCarriageReturn ? `${remainder}\r` : remainder };
|
|
1166
|
+
}
|
|
973
1167
|
|
|
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
|
-
|
|
1168
|
+
// libs/testing/src/platform/platform-client-info.ts
|
|
1169
|
+
var MCP_APPS_EXTENSION_KEY = "io.modelcontextprotocol/ui";
|
|
1170
|
+
function getPlatformClientInfo(platform) {
|
|
1171
|
+
switch (platform) {
|
|
1172
|
+
case "openai":
|
|
1173
|
+
return {
|
|
1174
|
+
name: "ChatGPT",
|
|
1175
|
+
version: "1.0"
|
|
1176
|
+
};
|
|
1177
|
+
case "ext-apps":
|
|
1178
|
+
return {
|
|
1179
|
+
name: "mcp-ext-apps",
|
|
1180
|
+
version: "1.0"
|
|
1181
|
+
};
|
|
1182
|
+
case "claude":
|
|
1183
|
+
return {
|
|
1184
|
+
name: "claude-desktop",
|
|
1185
|
+
version: "1.0"
|
|
1186
|
+
};
|
|
1187
|
+
case "cursor":
|
|
1188
|
+
return {
|
|
1189
|
+
name: "cursor",
|
|
1190
|
+
version: "1.0"
|
|
1191
|
+
};
|
|
1192
|
+
case "continue":
|
|
1193
|
+
return {
|
|
1194
|
+
name: "continue",
|
|
1195
|
+
version: "1.0"
|
|
1196
|
+
};
|
|
1197
|
+
case "cody":
|
|
1198
|
+
return {
|
|
1199
|
+
name: "cody",
|
|
1200
|
+
version: "1.0"
|
|
1201
|
+
};
|
|
1202
|
+
case "gemini":
|
|
1203
|
+
return {
|
|
1204
|
+
name: "gemini",
|
|
1205
|
+
version: "1.0"
|
|
1206
|
+
};
|
|
1207
|
+
case "generic-mcp":
|
|
1208
|
+
return {
|
|
1209
|
+
name: "generic-mcp-client",
|
|
1210
|
+
version: "1.0"
|
|
1211
|
+
};
|
|
1212
|
+
case "unknown":
|
|
1213
|
+
default:
|
|
1214
|
+
return {
|
|
1215
|
+
name: "mcp-test-client",
|
|
1216
|
+
version: "1.0"
|
|
1217
|
+
};
|
|
1001
1218
|
}
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1219
|
+
}
|
|
1220
|
+
function buildUserAgent(clientInfo) {
|
|
1221
|
+
return `${clientInfo.name}/${clientInfo.version}`;
|
|
1222
|
+
}
|
|
1223
|
+
function getPlatformUserAgent(platform) {
|
|
1224
|
+
return buildUserAgent(getPlatformClientInfo(platform));
|
|
1225
|
+
}
|
|
1226
|
+
var PLATFORM_DETECTION_PATTERNS = {
|
|
1227
|
+
openai: /chatgpt/i,
|
|
1228
|
+
"ext-apps": /mcp-ext-apps/i,
|
|
1229
|
+
// Note: Actual detection uses capabilities
|
|
1230
|
+
claude: /claude|claude-desktop/i,
|
|
1231
|
+
cursor: /cursor/i,
|
|
1232
|
+
continue: /continue/i,
|
|
1233
|
+
cody: /cody/i,
|
|
1234
|
+
gemini: /gemini/i,
|
|
1235
|
+
"generic-mcp": /generic-mcp/i,
|
|
1236
|
+
unknown: /.*/
|
|
1237
|
+
// Matches anything (fallback)
|
|
1238
|
+
};
|
|
1239
|
+
function getPlatformCapabilities(platform) {
|
|
1240
|
+
const baseCapabilities = {
|
|
1241
|
+
sampling: {},
|
|
1242
|
+
// Include elicitation.form by default for testing elicitation workflows
|
|
1243
|
+
// Note: MCP SDK expects form to be an object, not boolean
|
|
1244
|
+
elicitation: {
|
|
1245
|
+
form: {}
|
|
1019
1246
|
}
|
|
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;
|
|
1247
|
+
};
|
|
1248
|
+
if (platform === "ext-apps") {
|
|
1249
|
+
return {
|
|
1250
|
+
...baseCapabilities,
|
|
1251
|
+
experimental: {
|
|
1252
|
+
[MCP_APPS_EXTENSION_KEY]: {
|
|
1253
|
+
mimeTypes: ["text/html;profile=mcp-app"]
|
|
1048
1254
|
}
|
|
1049
|
-
} else if (actualValue !== value) {
|
|
1050
|
-
return false;
|
|
1051
1255
|
}
|
|
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
|
-
};
|
|
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
1256
|
};
|
|
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
1257
|
}
|
|
1129
|
-
|
|
1258
|
+
return baseCapabilities;
|
|
1259
|
+
}
|
|
1130
1260
|
|
|
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
|
-
};
|
|
1261
|
+
// libs/testing/src/client/mcp-test-client.builder.ts
|
|
1262
|
+
var McpTestClientBuilder = class {
|
|
1263
|
+
config;
|
|
1264
|
+
constructor(config) {
|
|
1265
|
+
this.config = { ...config };
|
|
1150
1266
|
}
|
|
1151
1267
|
/**
|
|
1152
|
-
*
|
|
1268
|
+
* Set the authentication configuration
|
|
1153
1269
|
*/
|
|
1154
|
-
|
|
1155
|
-
this.
|
|
1156
|
-
return
|
|
1157
|
-
const index = this.response.indexOf(interceptor);
|
|
1158
|
-
if (index !== -1) {
|
|
1159
|
-
this.response.splice(index, 1);
|
|
1160
|
-
}
|
|
1161
|
-
};
|
|
1270
|
+
withAuth(auth) {
|
|
1271
|
+
this.config.auth = { ...this.config.auth, ...auth };
|
|
1272
|
+
return this;
|
|
1162
1273
|
}
|
|
1163
1274
|
/**
|
|
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
|
|
1275
|
+
* Set the bearer token for authentication
|
|
1169
1276
|
*/
|
|
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 };
|
|
1277
|
+
withToken(token) {
|
|
1278
|
+
this.config.auth = { ...this.config.auth, token };
|
|
1279
|
+
return this;
|
|
1210
1280
|
}
|
|
1211
1281
|
/**
|
|
1212
|
-
*
|
|
1282
|
+
* Add custom headers to all requests
|
|
1213
1283
|
*/
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
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;
|
|
1284
|
+
withHeaders(headers) {
|
|
1285
|
+
this.config.auth = {
|
|
1286
|
+
...this.config.auth,
|
|
1287
|
+
headers: { ...this.config.auth?.headers, ...headers }
|
|
1288
|
+
};
|
|
1289
|
+
return this;
|
|
1232
1290
|
}
|
|
1233
1291
|
/**
|
|
1234
|
-
*
|
|
1292
|
+
* Set the transport type
|
|
1235
1293
|
*/
|
|
1236
|
-
|
|
1237
|
-
this.
|
|
1238
|
-
this
|
|
1239
|
-
this.mocks.clear();
|
|
1294
|
+
withTransport(transport) {
|
|
1295
|
+
this.config.transport = transport;
|
|
1296
|
+
return this;
|
|
1240
1297
|
}
|
|
1241
|
-
};
|
|
1242
|
-
function sleep(ms) {
|
|
1243
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1244
|
-
}
|
|
1245
|
-
var interceptors = {
|
|
1246
1298
|
/**
|
|
1247
|
-
*
|
|
1299
|
+
* Set the request timeout in milliseconds
|
|
1248
1300
|
*/
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
};
|
|
1254
|
-
},
|
|
1301
|
+
withTimeout(timeoutMs) {
|
|
1302
|
+
this.config.timeout = timeoutMs;
|
|
1303
|
+
return this;
|
|
1304
|
+
}
|
|
1255
1305
|
/**
|
|
1256
|
-
*
|
|
1306
|
+
* Enable debug logging
|
|
1257
1307
|
*/
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
};
|
|
1263
|
-
},
|
|
1308
|
+
withDebug(enabled = true) {
|
|
1309
|
+
this.config.debug = enabled;
|
|
1310
|
+
return this;
|
|
1311
|
+
}
|
|
1264
1312
|
/**
|
|
1265
|
-
*
|
|
1313
|
+
* Enable public mode - skip authentication entirely.
|
|
1314
|
+
* When true, no Authorization header is sent and anonymous token is not requested.
|
|
1315
|
+
* Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
|
|
1266
1316
|
*/
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
return { action: "error", error: err };
|
|
1272
|
-
}
|
|
1273
|
-
return { action: "passthrough" };
|
|
1274
|
-
};
|
|
1275
|
-
},
|
|
1317
|
+
withPublicMode(enabled = true) {
|
|
1318
|
+
this.config.publicMode = enabled;
|
|
1319
|
+
return this;
|
|
1320
|
+
}
|
|
1276
1321
|
/**
|
|
1277
|
-
*
|
|
1322
|
+
* Set the MCP protocol version to request (default '2025-06-18').
|
|
1323
|
+
* '2026-07-28' has no initialize handshake, so `build()` throws for it.
|
|
1278
1324
|
*/
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
}
|
|
1284
|
-
return { action: "passthrough" };
|
|
1285
|
-
};
|
|
1286
|
-
},
|
|
1325
|
+
withProtocolVersion(version) {
|
|
1326
|
+
this.config.protocolVersion = version;
|
|
1327
|
+
return this;
|
|
1328
|
+
}
|
|
1287
1329
|
/**
|
|
1288
|
-
*
|
|
1330
|
+
* Set the client info sent during initialization
|
|
1289
1331
|
*/
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
return { action: "passthrough" };
|
|
1295
|
-
};
|
|
1296
|
-
},
|
|
1332
|
+
withClientInfo(info) {
|
|
1333
|
+
this.config.clientInfo = info;
|
|
1334
|
+
return this;
|
|
1335
|
+
}
|
|
1297
1336
|
/**
|
|
1298
|
-
*
|
|
1337
|
+
* Set the platform type for testing platform-specific meta keys.
|
|
1338
|
+
* Automatically configures clientInfo and capabilities for platform detection.
|
|
1339
|
+
*
|
|
1340
|
+
* Platform-specific behavior:
|
|
1341
|
+
* - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
|
|
1342
|
+
* - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
|
|
1343
|
+
* - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
|
|
1344
|
+
* - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
|
|
1345
|
+
* - Other platforms follow similar patterns
|
|
1346
|
+
*
|
|
1347
|
+
* @example
|
|
1348
|
+
* ```typescript
|
|
1349
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
1350
|
+
* .withPlatform('openai')
|
|
1351
|
+
* .buildAndConnect();
|
|
1352
|
+
*
|
|
1353
|
+
* // ext-apps automatically sets the io.modelcontextprotocol/ui capability
|
|
1354
|
+
* const extAppsClient = await McpTestClient.create({ baseUrl })
|
|
1355
|
+
* .withPlatform('ext-apps')
|
|
1356
|
+
* .buildAndConnect();
|
|
1357
|
+
* ```
|
|
1299
1358
|
*/
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1359
|
+
withPlatform(platform) {
|
|
1360
|
+
this.config.platform = platform;
|
|
1361
|
+
this.config.clientInfo = getPlatformClientInfo(platform);
|
|
1362
|
+
this.config.capabilities = getPlatformCapabilities(platform);
|
|
1363
|
+
return this;
|
|
1364
|
+
}
|
|
1365
|
+
/**
|
|
1366
|
+
* Set custom client capabilities for MCP initialization.
|
|
1367
|
+
* Use this for fine-grained control over capabilities sent during initialization.
|
|
1368
|
+
*
|
|
1369
|
+
* @example
|
|
1370
|
+
* ```typescript
|
|
1371
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
1372
|
+
* .withCapabilities({
|
|
1373
|
+
* sampling: {},
|
|
1374
|
+
* experimental: {
|
|
1375
|
+
* 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
|
|
1376
|
+
* }
|
|
1377
|
+
* })
|
|
1378
|
+
* .buildAndConnect();
|
|
1379
|
+
* ```
|
|
1380
|
+
*/
|
|
1381
|
+
withCapabilities(capabilities) {
|
|
1382
|
+
this.config.capabilities = capabilities;
|
|
1383
|
+
return this;
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* Set query parameters to append to the connection URL.
|
|
1387
|
+
* Useful for testing mode switches like `?mode=skills_only`.
|
|
1388
|
+
*
|
|
1389
|
+
* @example
|
|
1390
|
+
* ```typescript
|
|
1391
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
1392
|
+
* .withQueryParams({ mode: 'skills_only' })
|
|
1393
|
+
* .buildAndConnect();
|
|
1394
|
+
* ```
|
|
1395
|
+
*/
|
|
1396
|
+
withQueryParams(params) {
|
|
1397
|
+
this.config.queryParams = { ...this.config.queryParams, ...params };
|
|
1398
|
+
return this;
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* Build the McpTestClient instance (does not connect)
|
|
1402
|
+
*/
|
|
1403
|
+
build() {
|
|
1404
|
+
return new McpTestClient(this.config);
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* Build the McpTestClient and connect to the server
|
|
1408
|
+
*/
|
|
1409
|
+
async buildAndConnect() {
|
|
1410
|
+
const client = this.build();
|
|
1411
|
+
await client.connect();
|
|
1412
|
+
return client;
|
|
1307
1413
|
}
|
|
1308
1414
|
};
|
|
1309
1415
|
|
|
1310
1416
|
// libs/testing/src/client/mcp-test-client.ts
|
|
1311
1417
|
var DEFAULT_TIMEOUT2 = 3e4;
|
|
1312
1418
|
var DEFAULT_PROTOCOL_VERSION = "2025-06-18";
|
|
1419
|
+
var UNSUPPORTED_PROTOCOL_VERSIONS = ["2026-07-28"];
|
|
1313
1420
|
var DEFAULT_CLIENT_INFO = {
|
|
1314
1421
|
name: "@frontmcp/testing",
|
|
1315
1422
|
version: "0.4.0"
|
|
1316
1423
|
};
|
|
1424
|
+
function extractEntryPaths(data) {
|
|
1425
|
+
let payload = data;
|
|
1426
|
+
if (typeof payload === "string") {
|
|
1427
|
+
try {
|
|
1428
|
+
payload = JSON.parse(payload);
|
|
1429
|
+
} catch {
|
|
1430
|
+
return void 0;
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
if (!payload || typeof payload !== "object") return void 0;
|
|
1434
|
+
const paths = payload.entryPaths;
|
|
1435
|
+
if (!Array.isArray(paths)) return void 0;
|
|
1436
|
+
const strings = paths.filter((p) => typeof p === "string");
|
|
1437
|
+
return strings.length > 0 ? strings : void 0;
|
|
1438
|
+
}
|
|
1317
1439
|
var McpTestClient = class {
|
|
1318
1440
|
// Platform, capabilities, and queryParams are optional - only set when needed
|
|
1319
1441
|
config;
|
|
@@ -1322,6 +1444,8 @@ var McpTestClient = class {
|
|
|
1322
1444
|
requestIdCounter = 0;
|
|
1323
1445
|
_lastRequestId = 0;
|
|
1324
1446
|
_sessionId;
|
|
1447
|
+
/** #543 — discovery is attempted at most once per connect. */
|
|
1448
|
+
triedDiscoveredEntryPath = false;
|
|
1325
1449
|
_sessionInfo = null;
|
|
1326
1450
|
_authState = { isAnonymous: true, scopes: [] };
|
|
1327
1451
|
// Logging and tracing
|
|
@@ -1329,6 +1453,8 @@ var McpTestClient = class {
|
|
|
1329
1453
|
_traces = [];
|
|
1330
1454
|
_notifications = [];
|
|
1331
1455
|
_progressUpdates = [];
|
|
1456
|
+
collectingProgress = false;
|
|
1457
|
+
progressTokenCounter = 0;
|
|
1332
1458
|
// Interceptor chain
|
|
1333
1459
|
_interceptors;
|
|
1334
1460
|
// Elicitation handler for server→client elicit requests
|
|
@@ -1337,8 +1463,14 @@ var McpTestClient = class {
|
|
|
1337
1463
|
// CONSTRUCTOR & FACTORY
|
|
1338
1464
|
// ═══════════════════════════════════════════════════════════════════
|
|
1339
1465
|
constructor(config) {
|
|
1466
|
+
if (config.protocolVersion && UNSUPPORTED_PROTOCOL_VERSIONS.includes(config.protocolVersion)) {
|
|
1467
|
+
throw new Error(
|
|
1468
|
+
`McpTestClient does not support protocol version ${config.protocolVersion}: it has no initialize handshake. Use ${DEFAULT_PROTOCOL_VERSION} or earlier.`
|
|
1469
|
+
);
|
|
1470
|
+
}
|
|
1340
1471
|
this.config = {
|
|
1341
1472
|
baseUrl: config.baseUrl,
|
|
1473
|
+
entryPath: config.entryPath,
|
|
1342
1474
|
transport: config.transport ?? "streamable-http",
|
|
1343
1475
|
auth: config.auth ?? {},
|
|
1344
1476
|
publicMode: config.publicMode ?? false,
|
|
@@ -1374,11 +1506,13 @@ var McpTestClient = class {
|
|
|
1374
1506
|
*/
|
|
1375
1507
|
async connect() {
|
|
1376
1508
|
this.log("debug", `Connecting to ${this.config.baseUrl}...`);
|
|
1509
|
+
this.triedDiscoveredEntryPath = false;
|
|
1377
1510
|
this.transport = this.createTransport();
|
|
1378
1511
|
await this.transport.connect();
|
|
1379
1512
|
const maxRetries = 3;
|
|
1380
1513
|
const retryDelayMs = 500;
|
|
1381
1514
|
let lastError;
|
|
1515
|
+
let reportedEntryPaths;
|
|
1382
1516
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
1383
1517
|
const initResponse = await this.initialize();
|
|
1384
1518
|
if (initResponse.success && initResponse.data) {
|
|
@@ -1386,13 +1520,27 @@ var McpTestClient = class {
|
|
|
1386
1520
|
break;
|
|
1387
1521
|
}
|
|
1388
1522
|
lastError = initResponse.error?.message ?? "Unknown error";
|
|
1523
|
+
reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
|
|
1524
|
+
if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
|
|
1525
|
+
this.triedDiscoveredEntryPath = true;
|
|
1526
|
+
const discovered = reportedEntryPaths[0];
|
|
1527
|
+
this.log("info", `Server serves MCP at ${discovered}; reconnecting there (configured: ${this.mcpPathLabel()})`);
|
|
1528
|
+
this.config.entryPath = discovered;
|
|
1529
|
+
await this.transport.close();
|
|
1530
|
+
this.transport = this.createTransport();
|
|
1531
|
+
await this.transport.connect();
|
|
1532
|
+
continue;
|
|
1533
|
+
}
|
|
1389
1534
|
if (attempt < maxRetries) {
|
|
1390
1535
|
this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
|
|
1391
1536
|
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
1392
1537
|
}
|
|
1393
1538
|
}
|
|
1394
1539
|
if (!this.initResult) {
|
|
1395
|
-
|
|
1540
|
+
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.` : "";
|
|
1541
|
+
throw new Error(
|
|
1542
|
+
`Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${maxRetries} attempts: ${lastError}.${served}`
|
|
1543
|
+
);
|
|
1396
1544
|
}
|
|
1397
1545
|
this._sessionId = this.transport.getSessionId();
|
|
1398
1546
|
this._sessionInfo = {
|
|
@@ -1405,6 +1553,7 @@ var McpTestClient = class {
|
|
|
1405
1553
|
jsonrpc: "2.0",
|
|
1406
1554
|
method: "notifications/initialized"
|
|
1407
1555
|
});
|
|
1556
|
+
await this.transport.openNotificationStream?.(this.initResult.protocolVersion);
|
|
1408
1557
|
this.log("info", `Connected to ${this.initResult.serverInfo?.name ?? "MCP Server"}`);
|
|
1409
1558
|
return this.initResult;
|
|
1410
1559
|
}
|
|
@@ -1515,8 +1664,8 @@ var McpTestClient = class {
|
|
|
1515
1664
|
/**
|
|
1516
1665
|
* Call a tool by name with arguments
|
|
1517
1666
|
*/
|
|
1518
|
-
call: async (name, args) => {
|
|
1519
|
-
const response = await this.callTool(name, args);
|
|
1667
|
+
call: async (name, args, options = {}) => {
|
|
1668
|
+
const response = await this.callTool(name, args, this.progressTokenFor(options));
|
|
1520
1669
|
return this.wrapToolResult(response);
|
|
1521
1670
|
}
|
|
1522
1671
|
};
|
|
@@ -1658,6 +1807,7 @@ var McpTestClient = class {
|
|
|
1658
1807
|
* Collect progress notifications specifically
|
|
1659
1808
|
*/
|
|
1660
1809
|
collectProgress: () => {
|
|
1810
|
+
this.collectingProgress = true;
|
|
1661
1811
|
return new ProgressCollector(this._progressUpdates);
|
|
1662
1812
|
},
|
|
1663
1813
|
/**
|
|
@@ -1963,12 +2113,29 @@ var McpTestClient = class {
|
|
|
1963
2113
|
async listTools() {
|
|
1964
2114
|
return this.request("tools/list", {});
|
|
1965
2115
|
}
|
|
1966
|
-
async callTool(name, args) {
|
|
2116
|
+
async callTool(name, args, progressToken) {
|
|
1967
2117
|
return this.request("tools/call", {
|
|
1968
2118
|
name,
|
|
1969
|
-
arguments: args ?? {}
|
|
2119
|
+
arguments: args ?? {},
|
|
2120
|
+
...progressToken !== void 0 && { _meta: { progressToken } }
|
|
1970
2121
|
});
|
|
1971
2122
|
}
|
|
2123
|
+
progressTokenFor(options) {
|
|
2124
|
+
if (options.progressToken !== void 0) return options.progressToken;
|
|
2125
|
+
return this.collectingProgress ? `progress-${++this.progressTokenCounter}` : void 0;
|
|
2126
|
+
}
|
|
2127
|
+
recordNotification = (notification) => {
|
|
2128
|
+
const timestamp = /* @__PURE__ */ new Date();
|
|
2129
|
+
this._notifications.push({ method: notification.method, params: notification.params, timestamp });
|
|
2130
|
+
if (notification.method !== "notifications/progress") return;
|
|
2131
|
+
const { progress, total, progressToken } = notification.params ?? {};
|
|
2132
|
+
this._progressUpdates.push({
|
|
2133
|
+
progress: Number(progress),
|
|
2134
|
+
total: typeof total === "number" ? total : void 0,
|
|
2135
|
+
progressToken: typeof progressToken === "string" || typeof progressToken === "number" ? progressToken : void 0,
|
|
2136
|
+
timestamp
|
|
2137
|
+
});
|
|
2138
|
+
};
|
|
1972
2139
|
async listResources() {
|
|
1973
2140
|
return this.request("resources/list", {});
|
|
1974
2141
|
}
|
|
@@ -1990,6 +2157,11 @@ var McpTestClient = class {
|
|
|
1990
2157
|
// ═══════════════════════════════════════════════════════════════════
|
|
1991
2158
|
// PRIVATE: TRANSPORT & REQUEST HELPERS
|
|
1992
2159
|
// ═══════════════════════════════════════════════════════════════════
|
|
2160
|
+
/** The MCP path this client is currently pointed at, for log and error text. */
|
|
2161
|
+
mcpPathLabel() {
|
|
2162
|
+
const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
|
|
2163
|
+
return entry ? `/${entry}` : "/";
|
|
2164
|
+
}
|
|
1993
2165
|
createTransport() {
|
|
1994
2166
|
let baseUrl = this.config.baseUrl;
|
|
1995
2167
|
if (this.config.queryParams && Object.keys(this.config.queryParams).length > 0) {
|
|
@@ -2003,13 +2175,15 @@ var McpTestClient = class {
|
|
|
2003
2175
|
case "streamable-http":
|
|
2004
2176
|
return new StreamableHttpTransport({
|
|
2005
2177
|
baseUrl,
|
|
2178
|
+
entryPath: this.config.entryPath,
|
|
2006
2179
|
timeout: this.config.timeout,
|
|
2007
2180
|
auth: this.config.auth,
|
|
2008
2181
|
publicMode: this.config.publicMode,
|
|
2009
2182
|
debug: this.config.debug,
|
|
2010
2183
|
interceptors: this._interceptors,
|
|
2011
2184
|
clientInfo: this.config.clientInfo,
|
|
2012
|
-
elicitationHandler: this._elicitationHandler
|
|
2185
|
+
elicitationHandler: this._elicitationHandler,
|
|
2186
|
+
notificationHandler: this.recordNotification
|
|
2013
2187
|
});
|
|
2014
2188
|
case "sse":
|
|
2015
2189
|
throw new Error("SSE transport not yet implemented");
|
|
@@ -4882,13 +5056,17 @@ var currentConfig = {};
|
|
|
4882
5056
|
var serverInstance = null;
|
|
4883
5057
|
var tokenFactory = null;
|
|
4884
5058
|
var serverStartedByUs = false;
|
|
5059
|
+
var skipScopes = [{ skipped: false }];
|
|
5060
|
+
function currentSkipScope() {
|
|
5061
|
+
return skipScopes[skipScopes.length - 1];
|
|
5062
|
+
}
|
|
4885
5063
|
async function initializeSharedResources() {
|
|
4886
5064
|
if (!tokenFactory) {
|
|
4887
5065
|
const sharedSecret = currentConfig.env?.["JWT_SECRET"];
|
|
4888
5066
|
tokenFactory = new TestTokenFactory(sharedSecret ? { hmacSecret: sharedSecret } : {});
|
|
4889
5067
|
}
|
|
4890
5068
|
if (!serverInstance) {
|
|
4891
|
-
if (currentConfig.baseUrl) {
|
|
5069
|
+
if (currentConfig.baseUrl && !currentConfig.server) {
|
|
4892
5070
|
serverInstance = TestServer.connect(currentConfig.baseUrl);
|
|
4893
5071
|
serverStartedByUs = false;
|
|
4894
5072
|
} else if (currentConfig.server) {
|
|
@@ -4938,7 +5116,8 @@ async function createTestFixtures() {
|
|
|
4938
5116
|
throw new Error("Token factory not initialized");
|
|
4939
5117
|
}
|
|
4940
5118
|
const clientInstance = McpTestClient.create({
|
|
4941
|
-
baseUrl: serverInstance
|
|
5119
|
+
baseUrl: resolveClientBaseUrl(serverInstance),
|
|
5120
|
+
entryPath: currentConfig.entryPath,
|
|
4942
5121
|
transport: currentConfig.transport ?? "streamable-http",
|
|
4943
5122
|
publicMode: currentConfig.publicMode
|
|
4944
5123
|
}).build();
|
|
@@ -4987,6 +5166,9 @@ async function cleanupSharedResources() {
|
|
|
4987
5166
|
tokenFactory = null;
|
|
4988
5167
|
serverStartedByUs = false;
|
|
4989
5168
|
}
|
|
5169
|
+
function resolveClientBaseUrl(server) {
|
|
5170
|
+
return currentConfig.baseUrl ?? server.info.baseUrl;
|
|
5171
|
+
}
|
|
4990
5172
|
function createAuthFixture(factory) {
|
|
4991
5173
|
const users = {
|
|
4992
5174
|
admin: {
|
|
@@ -5036,7 +5218,8 @@ function createServerFixture(server) {
|
|
|
5036
5218
|
info: server.info,
|
|
5037
5219
|
createClient: async (opts) => {
|
|
5038
5220
|
return McpTestClient.create({
|
|
5039
|
-
baseUrl: server
|
|
5221
|
+
baseUrl: resolveClientBaseUrl(server),
|
|
5222
|
+
entryPath: opts?.entryPath ?? currentConfig.entryPath,
|
|
5040
5223
|
transport: opts?.transport ?? "streamable-http",
|
|
5041
5224
|
auth: opts?.token ? { token: opts.token } : void 0,
|
|
5042
5225
|
clientInfo: opts?.clientInfo,
|
|
@@ -5045,7 +5228,8 @@ function createServerFixture(server) {
|
|
|
5045
5228
|
},
|
|
5046
5229
|
createClientBuilder: () => {
|
|
5047
5230
|
const builder = new McpTestClientBuilder({
|
|
5048
|
-
baseUrl: server
|
|
5231
|
+
baseUrl: resolveClientBaseUrl(server),
|
|
5232
|
+
entryPath: currentConfig.entryPath,
|
|
5049
5233
|
publicMode: currentConfig.publicMode
|
|
5050
5234
|
});
|
|
5051
5235
|
return builder;
|
|
@@ -5061,8 +5245,8 @@ function resolveServerCommand(server) {
|
|
|
5061
5245
|
}
|
|
5062
5246
|
return `npx tsx ${server}`;
|
|
5063
5247
|
}
|
|
5064
|
-
function
|
|
5065
|
-
|
|
5248
|
+
function runWithFixtures(fn) {
|
|
5249
|
+
return async () => {
|
|
5066
5250
|
const fixtures = await createTestFixtures();
|
|
5067
5251
|
let testFailed = false;
|
|
5068
5252
|
try {
|
|
@@ -5073,7 +5257,15 @@ function testWithFixtures(name, fn) {
|
|
|
5073
5257
|
} finally {
|
|
5074
5258
|
await cleanupTestFixtures(fixtures, testFailed);
|
|
5075
5259
|
}
|
|
5076
|
-
}
|
|
5260
|
+
};
|
|
5261
|
+
}
|
|
5262
|
+
function testWithFixtures(name, fn) {
|
|
5263
|
+
const scope = currentSkipScope();
|
|
5264
|
+
if (scope.skipped) {
|
|
5265
|
+
it2.skip(scope.reason ? `${name} (skipped: ${scope.reason})` : name, runWithFixtures(fn));
|
|
5266
|
+
return;
|
|
5267
|
+
}
|
|
5268
|
+
it2(name, runWithFixtures(fn));
|
|
5077
5269
|
}
|
|
5078
5270
|
function use(config) {
|
|
5079
5271
|
currentConfig = { ...currentConfig, ...config };
|
|
@@ -5081,40 +5273,60 @@ function use(config) {
|
|
|
5081
5273
|
await cleanupSharedResources();
|
|
5082
5274
|
});
|
|
5083
5275
|
}
|
|
5084
|
-
function skip(
|
|
5085
|
-
|
|
5086
|
-
|
|
5087
|
-
|
|
5088
|
-
|
|
5089
|
-
|
|
5090
|
-
} catch (error) {
|
|
5091
|
-
testFailed = true;
|
|
5092
|
-
throw error;
|
|
5093
|
-
} finally {
|
|
5094
|
-
await cleanupTestFixtures(fixtures, testFailed);
|
|
5276
|
+
function skip(nameOrCondition, fnOrReason) {
|
|
5277
|
+
if (typeof nameOrCondition === "boolean") {
|
|
5278
|
+
if (nameOrCondition) {
|
|
5279
|
+
const scope = currentSkipScope();
|
|
5280
|
+
scope.skipped = true;
|
|
5281
|
+
scope.reason = typeof fnOrReason === "string" ? fnOrReason : void 0;
|
|
5095
5282
|
}
|
|
5096
|
-
|
|
5283
|
+
return;
|
|
5284
|
+
}
|
|
5285
|
+
if (typeof fnOrReason !== "function") {
|
|
5286
|
+
throw new TypeError(
|
|
5287
|
+
`test.skip expects either (name: string, fn) or (condition: boolean, reason?: string); received (${typeof nameOrCondition}, ${typeof fnOrReason}).`
|
|
5288
|
+
);
|
|
5289
|
+
}
|
|
5290
|
+
it2.skip(nameOrCondition, runWithFixtures(fnOrReason));
|
|
5097
5291
|
}
|
|
5098
5292
|
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
|
-
});
|
|
5293
|
+
const scope = currentSkipScope();
|
|
5294
|
+
if (scope.skipped) {
|
|
5295
|
+
it2.skip(scope.reason ? `${name} (skipped: ${scope.reason})` : name, runWithFixtures(fn));
|
|
5296
|
+
return;
|
|
5297
|
+
}
|
|
5298
|
+
it2.only(name, runWithFixtures(fn));
|
|
5111
5299
|
}
|
|
5112
5300
|
function todo(name) {
|
|
5113
5301
|
it2.todo(name);
|
|
5114
5302
|
}
|
|
5303
|
+
function withSkipScope(register) {
|
|
5304
|
+
return (name, fn) => {
|
|
5305
|
+
register(name, () => {
|
|
5306
|
+
const parent = currentSkipScope();
|
|
5307
|
+
skipScopes.push({ skipped: parent.skipped, reason: parent.reason });
|
|
5308
|
+
try {
|
|
5309
|
+
fn();
|
|
5310
|
+
} finally {
|
|
5311
|
+
skipScopes.pop();
|
|
5312
|
+
}
|
|
5313
|
+
});
|
|
5314
|
+
};
|
|
5315
|
+
}
|
|
5316
|
+
function eachWithSkipScope(each) {
|
|
5317
|
+
return ((...eachArgs) => {
|
|
5318
|
+
const register = each(...eachArgs);
|
|
5319
|
+
return withSkipScope(register);
|
|
5320
|
+
});
|
|
5321
|
+
}
|
|
5322
|
+
var describeWithSkipScope = Object.assign(withSkipScope(describe2), describe2, {
|
|
5323
|
+
only: Object.assign(withSkipScope(describe2.only), { each: eachWithSkipScope(describe2.only.each) }),
|
|
5324
|
+
skip: Object.assign(withSkipScope(describe2.skip), { each: eachWithSkipScope(describe2.skip.each) }),
|
|
5325
|
+
each: eachWithSkipScope(describe2.each)
|
|
5326
|
+
});
|
|
5115
5327
|
var test = testWithFixtures;
|
|
5116
5328
|
test.use = use;
|
|
5117
|
-
test.describe =
|
|
5329
|
+
test.describe = describeWithSkipScope;
|
|
5118
5330
|
test.beforeAll = beforeAll2;
|
|
5119
5331
|
test.beforeEach = beforeEach2;
|
|
5120
5332
|
test.afterEach = afterEach2;
|
|
@@ -5507,18 +5719,17 @@ var toBeError = function(received, expectedCode) {
|
|
|
5507
5719
|
message: () => `Expected a result wrapper object with isError property`
|
|
5508
5720
|
};
|
|
5509
5721
|
}
|
|
5510
|
-
|
|
5511
|
-
|
|
5512
|
-
|
|
5513
|
-
}
|
|
5722
|
+
const receivedCode = typeof expectedCode === "string" ? result.raw?._meta?.["code"] : result.error?.code;
|
|
5723
|
+
const codeMatches = expectedCode === void 0 || receivedCode === expectedCode;
|
|
5724
|
+
const pass = result.isError && codeMatches;
|
|
5514
5725
|
return {
|
|
5515
5726
|
pass,
|
|
5516
5727
|
message: () => {
|
|
5517
5728
|
if (!result.isError) {
|
|
5518
5729
|
return "Expected result to be an error, but it was successful";
|
|
5519
5730
|
}
|
|
5520
|
-
if (
|
|
5521
|
-
return `Expected error code ${expectedCode}, but got ${
|
|
5731
|
+
if (!codeMatches) {
|
|
5732
|
+
return `Expected error code ${expectedCode}, but got ${String(receivedCode)}`;
|
|
5522
5733
|
}
|
|
5523
5734
|
return "Expected result not to be an error";
|
|
5524
5735
|
}
|