@frontmcp/testing 1.7.2 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/mcp-test-client.d.ts +6 -2
- package/client/mcp-test-client.d.ts.map +1 -1
- package/client/mcp-test-client.types.d.ts +9 -2
- package/client/mcp-test-client.types.d.ts.map +1 -1
- package/esm/fixtures/index.mjs +545 -454
- package/esm/index.mjs +635 -544
- package/esm/package.json +6 -6
- package/esm/perf/index.mjs +474 -421
- package/fixtures/fixture-types.d.ts +34 -3
- package/fixtures/fixture-types.d.ts.map +1 -1
- package/fixtures/index.js +545 -454
- package/fixtures/test-fixture.d.ts.map +1 -1
- package/index.js +635 -544
- package/package.json +6 -6
- package/perf/index.js +474 -421
- package/transport/streamable-http.transport.d.ts +8 -2
- package/transport/streamable-http.transport.d.ts.map +1 -1
- package/transport/transport.interface.d.ts +8 -1
- package/transport/transport.interface.d.ts.map +1 -1
package/esm/index.mjs
CHANGED
|
@@ -5,250 +5,339 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
|
|
|
5
5
|
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
6
6
|
});
|
|
7
7
|
|
|
8
|
-
// libs/testing/src/
|
|
9
|
-
var
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
name: "mcp-ext-apps",
|
|
20
|
-
version: "1.0"
|
|
21
|
-
};
|
|
22
|
-
case "claude":
|
|
23
|
-
return {
|
|
24
|
-
name: "claude-desktop",
|
|
25
|
-
version: "1.0"
|
|
26
|
-
};
|
|
27
|
-
case "cursor":
|
|
28
|
-
return {
|
|
29
|
-
name: "cursor",
|
|
30
|
-
version: "1.0"
|
|
31
|
-
};
|
|
32
|
-
case "continue":
|
|
33
|
-
return {
|
|
34
|
-
name: "continue",
|
|
35
|
-
version: "1.0"
|
|
36
|
-
};
|
|
37
|
-
case "cody":
|
|
38
|
-
return {
|
|
39
|
-
name: "cody",
|
|
40
|
-
version: "1.0"
|
|
41
|
-
};
|
|
42
|
-
case "gemini":
|
|
43
|
-
return {
|
|
44
|
-
name: "gemini",
|
|
45
|
-
version: "1.0"
|
|
46
|
-
};
|
|
47
|
-
case "generic-mcp":
|
|
48
|
-
return {
|
|
49
|
-
name: "generic-mcp-client",
|
|
50
|
-
version: "1.0"
|
|
51
|
-
};
|
|
52
|
-
case "unknown":
|
|
53
|
-
default:
|
|
54
|
-
return {
|
|
55
|
-
name: "mcp-test-client",
|
|
56
|
-
version: "1.0"
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
function buildUserAgent(clientInfo) {
|
|
61
|
-
return `${clientInfo.name}/${clientInfo.version}`;
|
|
62
|
-
}
|
|
63
|
-
function getPlatformUserAgent(platform) {
|
|
64
|
-
return buildUserAgent(getPlatformClientInfo(platform));
|
|
65
|
-
}
|
|
66
|
-
var PLATFORM_DETECTION_PATTERNS = {
|
|
67
|
-
openai: /chatgpt/i,
|
|
68
|
-
"ext-apps": /mcp-ext-apps/i,
|
|
69
|
-
// Note: Actual detection uses capabilities
|
|
70
|
-
claude: /claude|claude-desktop/i,
|
|
71
|
-
cursor: /cursor/i,
|
|
72
|
-
continue: /continue/i,
|
|
73
|
-
cody: /cody/i,
|
|
74
|
-
gemini: /gemini/i,
|
|
75
|
-
"generic-mcp": /generic-mcp/i,
|
|
76
|
-
unknown: /.*/
|
|
77
|
-
// Matches anything (fallback)
|
|
78
|
-
};
|
|
79
|
-
function getPlatformCapabilities(platform) {
|
|
80
|
-
const baseCapabilities = {
|
|
81
|
-
sampling: {},
|
|
82
|
-
// Include elicitation.form by default for testing elicitation workflows
|
|
83
|
-
// Note: MCP SDK expects form to be an object, not boolean
|
|
84
|
-
elicitation: {
|
|
85
|
-
form: {}
|
|
86
|
-
}
|
|
87
|
-
};
|
|
88
|
-
if (platform === "ext-apps") {
|
|
8
|
+
// libs/testing/src/interceptor/mock-registry.ts
|
|
9
|
+
var DefaultMockRegistry = class {
|
|
10
|
+
mocks = [];
|
|
11
|
+
add(mock) {
|
|
12
|
+
const entry = {
|
|
13
|
+
definition: mock,
|
|
14
|
+
callCount: 0,
|
|
15
|
+
calls: [],
|
|
16
|
+
remainingUses: mock.times ?? Infinity
|
|
17
|
+
};
|
|
18
|
+
this.mocks.push(entry);
|
|
89
19
|
return {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
20
|
+
remove: () => {
|
|
21
|
+
const index = this.mocks.indexOf(entry);
|
|
22
|
+
if (index !== -1) {
|
|
23
|
+
this.mocks.splice(index, 1);
|
|
94
24
|
}
|
|
95
|
-
}
|
|
25
|
+
},
|
|
26
|
+
callCount: () => entry.callCount,
|
|
27
|
+
calls: () => [...entry.calls]
|
|
96
28
|
};
|
|
97
29
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
this.
|
|
30
|
+
clear() {
|
|
31
|
+
this.mocks = [];
|
|
32
|
+
}
|
|
33
|
+
getAll() {
|
|
34
|
+
return this.mocks.map((e) => e.definition);
|
|
35
|
+
}
|
|
36
|
+
match(request) {
|
|
37
|
+
for (const entry of this.mocks) {
|
|
38
|
+
if (entry.remainingUses <= 0) continue;
|
|
39
|
+
const { definition } = entry;
|
|
40
|
+
if (definition.method !== request.method) continue;
|
|
41
|
+
if (definition.params !== void 0) {
|
|
42
|
+
const params = request.params ?? {};
|
|
43
|
+
if (typeof definition.params === "function") {
|
|
44
|
+
if (!definition.params(params)) continue;
|
|
45
|
+
} else {
|
|
46
|
+
if (!this.paramsMatch(definition.params, params)) continue;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
entry.callCount++;
|
|
50
|
+
entry.calls.push(request);
|
|
51
|
+
entry.remainingUses--;
|
|
52
|
+
return definition;
|
|
53
|
+
}
|
|
54
|
+
return void 0;
|
|
106
55
|
}
|
|
107
56
|
/**
|
|
108
|
-
*
|
|
57
|
+
* Check if request params match the mock params definition
|
|
109
58
|
*/
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
59
|
+
paramsMatch(expected, actual) {
|
|
60
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
61
|
+
if (!(key in actual)) return false;
|
|
62
|
+
const actualValue = actual[key];
|
|
63
|
+
if (Array.isArray(value)) {
|
|
64
|
+
if (!Array.isArray(actualValue)) return false;
|
|
65
|
+
if (value.length !== actualValue.length) return false;
|
|
66
|
+
for (let i = 0; i < value.length; i++) {
|
|
67
|
+
const expectedItem = value[i];
|
|
68
|
+
const actualItem = actualValue[i];
|
|
69
|
+
if (typeof expectedItem === "object" && expectedItem !== null) {
|
|
70
|
+
if (typeof actualItem !== "object" || actualItem === null) return false;
|
|
71
|
+
if (!this.paramsMatch(expectedItem, actualItem)) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
} else if (actualItem !== expectedItem) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} else if (typeof value === "object" && value !== null) {
|
|
79
|
+
if (typeof actualValue !== "object" || actualValue === null) return false;
|
|
80
|
+
if (!this.paramsMatch(value, actualValue)) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
} else if (actualValue !== value) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
113
88
|
}
|
|
89
|
+
};
|
|
90
|
+
var mockResponse = {
|
|
114
91
|
/**
|
|
115
|
-
*
|
|
92
|
+
* Create a successful JSON-RPC response
|
|
116
93
|
*/
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
94
|
+
success(result, id = 1) {
|
|
95
|
+
return {
|
|
96
|
+
jsonrpc: "2.0",
|
|
97
|
+
id,
|
|
98
|
+
result
|
|
99
|
+
};
|
|
100
|
+
},
|
|
121
101
|
/**
|
|
122
|
-
*
|
|
102
|
+
* Create an error JSON-RPC response
|
|
123
103
|
*/
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
104
|
+
error(code, message, data, id = 1) {
|
|
105
|
+
return {
|
|
106
|
+
jsonrpc: "2.0",
|
|
107
|
+
id,
|
|
108
|
+
error: { code, message, data }
|
|
128
109
|
};
|
|
129
|
-
|
|
130
|
-
}
|
|
110
|
+
},
|
|
131
111
|
/**
|
|
132
|
-
*
|
|
112
|
+
* Create a tool result response
|
|
133
113
|
*/
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
114
|
+
toolResult(content, id = 1) {
|
|
115
|
+
return {
|
|
116
|
+
jsonrpc: "2.0",
|
|
117
|
+
id,
|
|
118
|
+
result: { content }
|
|
119
|
+
};
|
|
120
|
+
},
|
|
138
121
|
/**
|
|
139
|
-
*
|
|
122
|
+
* Create a tools/list response
|
|
140
123
|
*/
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
124
|
+
toolsList(tools, id = 1) {
|
|
125
|
+
return {
|
|
126
|
+
jsonrpc: "2.0",
|
|
127
|
+
id,
|
|
128
|
+
result: { tools }
|
|
129
|
+
};
|
|
130
|
+
},
|
|
145
131
|
/**
|
|
146
|
-
*
|
|
132
|
+
* Create a resources/list response
|
|
147
133
|
*/
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
134
|
+
resourcesList(resources, id = 1) {
|
|
135
|
+
return {
|
|
136
|
+
jsonrpc: "2.0",
|
|
137
|
+
id,
|
|
138
|
+
result: { resources }
|
|
139
|
+
};
|
|
140
|
+
},
|
|
152
141
|
/**
|
|
153
|
-
*
|
|
154
|
-
* When true, no Authorization header is sent and anonymous token is not requested.
|
|
155
|
-
* Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
|
|
142
|
+
* Create a resources/read response
|
|
156
143
|
*/
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
144
|
+
resourceRead(contents, id = 1) {
|
|
145
|
+
return {
|
|
146
|
+
jsonrpc: "2.0",
|
|
147
|
+
id,
|
|
148
|
+
result: { contents }
|
|
149
|
+
};
|
|
150
|
+
},
|
|
161
151
|
/**
|
|
162
|
-
*
|
|
152
|
+
* Common MCP errors
|
|
163
153
|
*/
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
154
|
+
errors: {
|
|
155
|
+
methodNotFound: (method, id = 1) => mockResponse.error(-32601, `Method not found: ${method}`, void 0, id),
|
|
156
|
+
invalidParams: (message, id = 1) => mockResponse.error(-32602, message, void 0, id),
|
|
157
|
+
internalError: (message, id = 1) => mockResponse.error(-32603, message, void 0, id),
|
|
158
|
+
resourceNotFound: (uri, id = 1) => mockResponse.error(-32002, `Resource not found: ${uri}`, { uri }, id),
|
|
159
|
+
toolNotFound: (name, id = 1) => mockResponse.error(-32601, `Tool not found: ${name}`, { name }, id),
|
|
160
|
+
unauthorized: (id = 1) => mockResponse.error(-32001, "Unauthorized", void 0, id),
|
|
161
|
+
forbidden: (id = 1) => mockResponse.error(-32003, "Forbidden", void 0, id)
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// libs/testing/src/interceptor/interceptor-chain.ts
|
|
166
|
+
var DefaultInterceptorChain = class {
|
|
167
|
+
request = [];
|
|
168
|
+
response = [];
|
|
169
|
+
mocks;
|
|
170
|
+
constructor() {
|
|
171
|
+
this.mocks = new DefaultMockRegistry();
|
|
167
172
|
}
|
|
168
173
|
/**
|
|
169
|
-
*
|
|
174
|
+
* Add a request interceptor
|
|
170
175
|
*/
|
|
171
|
-
|
|
172
|
-
this.
|
|
173
|
-
return
|
|
176
|
+
addRequestInterceptor(interceptor) {
|
|
177
|
+
this.request.push(interceptor);
|
|
178
|
+
return () => {
|
|
179
|
+
const index = this.request.indexOf(interceptor);
|
|
180
|
+
if (index !== -1) {
|
|
181
|
+
this.request.splice(index, 1);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
174
184
|
}
|
|
175
185
|
/**
|
|
176
|
-
*
|
|
177
|
-
* Automatically configures clientInfo and capabilities for platform detection.
|
|
178
|
-
*
|
|
179
|
-
* Platform-specific behavior:
|
|
180
|
-
* - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
|
|
181
|
-
* - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
|
|
182
|
-
* - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
|
|
183
|
-
* - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
|
|
184
|
-
* - Other platforms follow similar patterns
|
|
185
|
-
*
|
|
186
|
-
* @example
|
|
187
|
-
* ```typescript
|
|
188
|
-
* const client = await McpTestClient.create({ baseUrl })
|
|
189
|
-
* .withPlatform('openai')
|
|
190
|
-
* .buildAndConnect();
|
|
191
|
-
*
|
|
192
|
-
* // ext-apps automatically sets the io.modelcontextprotocol/ui capability
|
|
193
|
-
* const extAppsClient = await McpTestClient.create({ baseUrl })
|
|
194
|
-
* .withPlatform('ext-apps')
|
|
195
|
-
* .buildAndConnect();
|
|
196
|
-
* ```
|
|
186
|
+
* Add a response interceptor
|
|
197
187
|
*/
|
|
198
|
-
|
|
199
|
-
this.
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
188
|
+
addResponseInterceptor(interceptor) {
|
|
189
|
+
this.response.push(interceptor);
|
|
190
|
+
return () => {
|
|
191
|
+
const index = this.response.indexOf(interceptor);
|
|
192
|
+
if (index !== -1) {
|
|
193
|
+
this.response.splice(index, 1);
|
|
194
|
+
}
|
|
195
|
+
};
|
|
203
196
|
}
|
|
204
197
|
/**
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
* const client = await McpTestClient.create({ baseUrl })
|
|
211
|
-
* .withCapabilities({
|
|
212
|
-
* sampling: {},
|
|
213
|
-
* experimental: {
|
|
214
|
-
* 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
|
|
215
|
-
* }
|
|
216
|
-
* })
|
|
217
|
-
* .buildAndConnect();
|
|
218
|
-
* ```
|
|
198
|
+
* Process a request through the interceptor chain
|
|
199
|
+
* Returns either:
|
|
200
|
+
* - { type: 'continue', request } - continue with (possibly modified) request
|
|
201
|
+
* - { type: 'mock', response } - return mock response immediately
|
|
202
|
+
* - { type: 'error', error } - throw error
|
|
219
203
|
*/
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
204
|
+
async processRequest(request, meta) {
|
|
205
|
+
let currentRequest = request;
|
|
206
|
+
const mockDef = this.mocks.match(request);
|
|
207
|
+
if (mockDef) {
|
|
208
|
+
if (mockDef.delay && mockDef.delay > 0) {
|
|
209
|
+
await sleep(mockDef.delay);
|
|
210
|
+
}
|
|
211
|
+
let mockResponse2;
|
|
212
|
+
if (typeof mockDef.response === "function") {
|
|
213
|
+
mockResponse2 = await mockDef.response(request);
|
|
214
|
+
} else {
|
|
215
|
+
mockResponse2 = mockDef.response;
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
type: "mock",
|
|
219
|
+
response: { ...mockResponse2, id: request.id ?? mockResponse2.id }
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
for (const interceptor of this.request) {
|
|
223
|
+
const ctx = {
|
|
224
|
+
request: currentRequest,
|
|
225
|
+
meta
|
|
226
|
+
};
|
|
227
|
+
const result = await interceptor(ctx);
|
|
228
|
+
switch (result.action) {
|
|
229
|
+
case "passthrough":
|
|
230
|
+
break;
|
|
231
|
+
case "modify":
|
|
232
|
+
currentRequest = result.request;
|
|
233
|
+
break;
|
|
234
|
+
case "mock":
|
|
235
|
+
return {
|
|
236
|
+
type: "mock",
|
|
237
|
+
response: { ...result.response, id: request.id ?? result.response.id }
|
|
238
|
+
};
|
|
239
|
+
case "error":
|
|
240
|
+
return { type: "error", error: result.error };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return { type: "continue", request: currentRequest };
|
|
223
244
|
}
|
|
224
245
|
/**
|
|
225
|
-
*
|
|
226
|
-
* Useful for testing mode switches like `?mode=skills_only`.
|
|
227
|
-
*
|
|
228
|
-
* @example
|
|
229
|
-
* ```typescript
|
|
230
|
-
* const client = await McpTestClient.create({ baseUrl })
|
|
231
|
-
* .withQueryParams({ mode: 'skills_only' })
|
|
232
|
-
* .buildAndConnect();
|
|
233
|
-
* ```
|
|
246
|
+
* Process a response through the interceptor chain
|
|
234
247
|
*/
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
248
|
+
async processResponse(request, response, durationMs) {
|
|
249
|
+
let currentResponse = response;
|
|
250
|
+
for (const interceptor of this.response) {
|
|
251
|
+
const ctx = {
|
|
252
|
+
request,
|
|
253
|
+
response: currentResponse,
|
|
254
|
+
durationMs
|
|
255
|
+
};
|
|
256
|
+
const result = await interceptor(ctx);
|
|
257
|
+
switch (result.action) {
|
|
258
|
+
case "passthrough":
|
|
259
|
+
break;
|
|
260
|
+
case "modify":
|
|
261
|
+
currentResponse = result.response;
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return currentResponse;
|
|
238
266
|
}
|
|
239
267
|
/**
|
|
240
|
-
*
|
|
268
|
+
* Clear all interceptors and mocks
|
|
241
269
|
*/
|
|
242
|
-
|
|
243
|
-
|
|
270
|
+
clear() {
|
|
271
|
+
this.request = [];
|
|
272
|
+
this.response = [];
|
|
273
|
+
this.mocks.clear();
|
|
244
274
|
}
|
|
275
|
+
};
|
|
276
|
+
function sleep(ms) {
|
|
277
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
278
|
+
}
|
|
279
|
+
var interceptors = {
|
|
245
280
|
/**
|
|
246
|
-
*
|
|
281
|
+
* Create an interceptor that logs all requests
|
|
247
282
|
*/
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
283
|
+
logger(logFn = console.log) {
|
|
284
|
+
return (ctx) => {
|
|
285
|
+
logFn(`[MCP Request] ${ctx.request.method}`, ctx.request.params);
|
|
286
|
+
return { action: "passthrough" };
|
|
287
|
+
};
|
|
288
|
+
},
|
|
289
|
+
/**
|
|
290
|
+
* Create an interceptor that adds latency to all requests
|
|
291
|
+
*/
|
|
292
|
+
delay(ms) {
|
|
293
|
+
return async () => {
|
|
294
|
+
await sleep(ms);
|
|
295
|
+
return { action: "passthrough" };
|
|
296
|
+
};
|
|
297
|
+
},
|
|
298
|
+
/**
|
|
299
|
+
* Create an interceptor that fails requests matching a condition
|
|
300
|
+
*/
|
|
301
|
+
failWhen(condition, error) {
|
|
302
|
+
return (ctx) => {
|
|
303
|
+
if (condition(ctx)) {
|
|
304
|
+
const err = typeof error === "string" ? new Error(error) : error;
|
|
305
|
+
return { action: "error", error: err };
|
|
306
|
+
}
|
|
307
|
+
return { action: "passthrough" };
|
|
308
|
+
};
|
|
309
|
+
},
|
|
310
|
+
/**
|
|
311
|
+
* Create an interceptor that modifies specific methods
|
|
312
|
+
*/
|
|
313
|
+
modifyMethod(method, modifier) {
|
|
314
|
+
return (ctx) => {
|
|
315
|
+
if (ctx.request.method === method) {
|
|
316
|
+
return { action: "modify", request: modifier(ctx.request) };
|
|
317
|
+
}
|
|
318
|
+
return { action: "passthrough" };
|
|
319
|
+
};
|
|
320
|
+
},
|
|
321
|
+
/**
|
|
322
|
+
* Create a response interceptor that logs responses
|
|
323
|
+
*/
|
|
324
|
+
responseLogger(logFn = console.log) {
|
|
325
|
+
return (ctx) => {
|
|
326
|
+
const status = ctx.response.error ? "ERROR" : "OK";
|
|
327
|
+
logFn(`[MCP Response] ${ctx.request.method} ${status} (${ctx.durationMs}ms)`, ctx.response);
|
|
328
|
+
return { action: "passthrough" };
|
|
329
|
+
};
|
|
330
|
+
},
|
|
331
|
+
/**
|
|
332
|
+
* Create a response interceptor that modifies specific responses
|
|
333
|
+
*/
|
|
334
|
+
modifyResponse(method, modifier) {
|
|
335
|
+
return (ctx) => {
|
|
336
|
+
if (ctx.request.method === method) {
|
|
337
|
+
return { action: "modify", response: modifier(ctx.response) };
|
|
338
|
+
}
|
|
339
|
+
return { action: "passthrough" };
|
|
340
|
+
};
|
|
252
341
|
}
|
|
253
342
|
};
|
|
254
343
|
|
|
@@ -269,6 +358,7 @@ var StreamableHttpTransport = class {
|
|
|
269
358
|
this.config = {
|
|
270
359
|
baseUrl: config.baseUrl.replace(/\/$/, ""),
|
|
271
360
|
// Remove trailing slash
|
|
361
|
+
entryPath: config.entryPath,
|
|
272
362
|
timeout: config.timeout ?? DEFAULT_TIMEOUT,
|
|
273
363
|
auth: config.auth ?? {},
|
|
274
364
|
publicMode: config.publicMode ?? false,
|
|
@@ -361,7 +451,7 @@ var StreamableHttpTransport = class {
|
|
|
361
451
|
}
|
|
362
452
|
const headers = this.buildHeaders();
|
|
363
453
|
this.lastRequestHeaders = headers;
|
|
364
|
-
const url =
|
|
454
|
+
const url = this.mcpUrl();
|
|
365
455
|
this.log(`POST ${url}`, message);
|
|
366
456
|
const controller = new AbortController();
|
|
367
457
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
@@ -431,7 +521,7 @@ var StreamableHttpTransport = class {
|
|
|
431
521
|
this.ensureConnected();
|
|
432
522
|
const headers = this.buildHeaders();
|
|
433
523
|
this.lastRequestHeaders = headers;
|
|
434
|
-
const url =
|
|
524
|
+
const url = this.mcpUrl();
|
|
435
525
|
this.log(`POST ${url} (notification)`, message);
|
|
436
526
|
const controller = new AbortController();
|
|
437
527
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
@@ -462,7 +552,7 @@ var StreamableHttpTransport = class {
|
|
|
462
552
|
this.ensureConnected();
|
|
463
553
|
const headers = this.buildHeaders();
|
|
464
554
|
this.lastRequestHeaders = headers;
|
|
465
|
-
const url =
|
|
555
|
+
const url = this.mcpUrl();
|
|
466
556
|
this.log(`POST ${url} (raw)`, data);
|
|
467
557
|
const controller = new AbortController();
|
|
468
558
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
@@ -745,7 +835,7 @@ var StreamableHttpTransport = class {
|
|
|
745
835
|
*/
|
|
746
836
|
async sendElicitationResponse(requestId, response) {
|
|
747
837
|
const headers = this.buildHeaders();
|
|
748
|
-
const url =
|
|
838
|
+
const url = this.mcpUrl();
|
|
749
839
|
const rpcResponse = {
|
|
750
840
|
jsonrpc: "2.0",
|
|
751
841
|
id: requestId,
|
|
@@ -765,6 +855,18 @@ var StreamableHttpTransport = class {
|
|
|
765
855
|
this.log("Failed to send elicitation response:", error);
|
|
766
856
|
}
|
|
767
857
|
}
|
|
858
|
+
/**
|
|
859
|
+
* The MCP endpoint URL. `entryPath` is where the server mounts MCP; the
|
|
860
|
+
* trailing slash is kept because both hosts normalize it and the original
|
|
861
|
+
* behaviour (`${baseUrl}/`) relied on it (issue #543).
|
|
862
|
+
*/
|
|
863
|
+
mcpUrl() {
|
|
864
|
+
const url = new URL(this.config.baseUrl);
|
|
865
|
+
const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
|
|
866
|
+
const basePath = url.pathname.replace(/\/+$/, "");
|
|
867
|
+
url.pathname = entry ? `${basePath}/${entry}` : `${basePath}/`;
|
|
868
|
+
return url.toString();
|
|
869
|
+
}
|
|
768
870
|
buildHeaders() {
|
|
769
871
|
const headers = {
|
|
770
872
|
"Content-Type": "application/json",
|
|
@@ -852,339 +954,250 @@ var StreamableHttpTransport = class {
|
|
|
852
954
|
}
|
|
853
955
|
};
|
|
854
956
|
|
|
855
|
-
// libs/testing/src/
|
|
856
|
-
var
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
957
|
+
// libs/testing/src/platform/platform-client-info.ts
|
|
958
|
+
var MCP_APPS_EXTENSION_KEY = "io.modelcontextprotocol/ui";
|
|
959
|
+
function getPlatformClientInfo(platform) {
|
|
960
|
+
switch (platform) {
|
|
961
|
+
case "openai":
|
|
962
|
+
return {
|
|
963
|
+
name: "ChatGPT",
|
|
964
|
+
version: "1.0"
|
|
965
|
+
};
|
|
966
|
+
case "ext-apps":
|
|
967
|
+
return {
|
|
968
|
+
name: "mcp-ext-apps",
|
|
969
|
+
version: "1.0"
|
|
970
|
+
};
|
|
971
|
+
case "claude":
|
|
972
|
+
return {
|
|
973
|
+
name: "claude-desktop",
|
|
974
|
+
version: "1.0"
|
|
975
|
+
};
|
|
976
|
+
case "cursor":
|
|
977
|
+
return {
|
|
978
|
+
name: "cursor",
|
|
979
|
+
version: "1.0"
|
|
980
|
+
};
|
|
981
|
+
case "continue":
|
|
982
|
+
return {
|
|
983
|
+
name: "continue",
|
|
984
|
+
version: "1.0"
|
|
985
|
+
};
|
|
986
|
+
case "cody":
|
|
987
|
+
return {
|
|
988
|
+
name: "cody",
|
|
989
|
+
version: "1.0"
|
|
990
|
+
};
|
|
991
|
+
case "gemini":
|
|
992
|
+
return {
|
|
993
|
+
name: "gemini",
|
|
994
|
+
version: "1.0"
|
|
995
|
+
};
|
|
996
|
+
case "generic-mcp":
|
|
997
|
+
return {
|
|
998
|
+
name: "generic-mcp-client",
|
|
999
|
+
version: "1.0"
|
|
1000
|
+
};
|
|
1001
|
+
case "unknown":
|
|
1002
|
+
default:
|
|
1003
|
+
return {
|
|
1004
|
+
name: "mcp-test-client",
|
|
1005
|
+
version: "1.0"
|
|
1006
|
+
};
|
|
882
1007
|
}
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1008
|
+
}
|
|
1009
|
+
function buildUserAgent(clientInfo) {
|
|
1010
|
+
return `${clientInfo.name}/${clientInfo.version}`;
|
|
1011
|
+
}
|
|
1012
|
+
function getPlatformUserAgent(platform) {
|
|
1013
|
+
return buildUserAgent(getPlatformClientInfo(platform));
|
|
1014
|
+
}
|
|
1015
|
+
var PLATFORM_DETECTION_PATTERNS = {
|
|
1016
|
+
openai: /chatgpt/i,
|
|
1017
|
+
"ext-apps": /mcp-ext-apps/i,
|
|
1018
|
+
// Note: Actual detection uses capabilities
|
|
1019
|
+
claude: /claude|claude-desktop/i,
|
|
1020
|
+
cursor: /cursor/i,
|
|
1021
|
+
continue: /continue/i,
|
|
1022
|
+
cody: /cody/i,
|
|
1023
|
+
gemini: /gemini/i,
|
|
1024
|
+
"generic-mcp": /generic-mcp/i,
|
|
1025
|
+
unknown: /.*/
|
|
1026
|
+
// Matches anything (fallback)
|
|
1027
|
+
};
|
|
1028
|
+
function getPlatformCapabilities(platform) {
|
|
1029
|
+
const baseCapabilities = {
|
|
1030
|
+
sampling: {},
|
|
1031
|
+
// Include elicitation.form by default for testing elicitation workflows
|
|
1032
|
+
// Note: MCP SDK expects form to be an object, not boolean
|
|
1033
|
+
elicitation: {
|
|
1034
|
+
form: {}
|
|
900
1035
|
}
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
if (!(key in actual)) return false;
|
|
909
|
-
const actualValue = actual[key];
|
|
910
|
-
if (Array.isArray(value)) {
|
|
911
|
-
if (!Array.isArray(actualValue)) return false;
|
|
912
|
-
if (value.length !== actualValue.length) return false;
|
|
913
|
-
for (let i = 0; i < value.length; i++) {
|
|
914
|
-
const expectedItem = value[i];
|
|
915
|
-
const actualItem = actualValue[i];
|
|
916
|
-
if (typeof expectedItem === "object" && expectedItem !== null) {
|
|
917
|
-
if (typeof actualItem !== "object" || actualItem === null) return false;
|
|
918
|
-
if (!this.paramsMatch(expectedItem, actualItem)) {
|
|
919
|
-
return false;
|
|
920
|
-
}
|
|
921
|
-
} else if (actualItem !== expectedItem) {
|
|
922
|
-
return false;
|
|
923
|
-
}
|
|
924
|
-
}
|
|
925
|
-
} else if (typeof value === "object" && value !== null) {
|
|
926
|
-
if (typeof actualValue !== "object" || actualValue === null) return false;
|
|
927
|
-
if (!this.paramsMatch(value, actualValue)) {
|
|
928
|
-
return false;
|
|
1036
|
+
};
|
|
1037
|
+
if (platform === "ext-apps") {
|
|
1038
|
+
return {
|
|
1039
|
+
...baseCapabilities,
|
|
1040
|
+
experimental: {
|
|
1041
|
+
[MCP_APPS_EXTENSION_KEY]: {
|
|
1042
|
+
mimeTypes: ["text/html;profile=mcp-app"]
|
|
929
1043
|
}
|
|
930
|
-
} else if (actualValue !== value) {
|
|
931
|
-
return false;
|
|
932
1044
|
}
|
|
933
|
-
}
|
|
934
|
-
return true;
|
|
935
|
-
}
|
|
936
|
-
};
|
|
937
|
-
var mockResponse = {
|
|
938
|
-
/**
|
|
939
|
-
* Create a successful JSON-RPC response
|
|
940
|
-
*/
|
|
941
|
-
success(result, id = 1) {
|
|
942
|
-
return {
|
|
943
|
-
jsonrpc: "2.0",
|
|
944
|
-
id,
|
|
945
|
-
result
|
|
946
|
-
};
|
|
947
|
-
},
|
|
948
|
-
/**
|
|
949
|
-
* Create an error JSON-RPC response
|
|
950
|
-
*/
|
|
951
|
-
error(code, message, data, id = 1) {
|
|
952
|
-
return {
|
|
953
|
-
jsonrpc: "2.0",
|
|
954
|
-
id,
|
|
955
|
-
error: { code, message, data }
|
|
956
1045
|
};
|
|
957
|
-
},
|
|
958
|
-
/**
|
|
959
|
-
* Create a tool result response
|
|
960
|
-
*/
|
|
961
|
-
toolResult(content, id = 1) {
|
|
962
|
-
return {
|
|
963
|
-
jsonrpc: "2.0",
|
|
964
|
-
id,
|
|
965
|
-
result: { content }
|
|
966
|
-
};
|
|
967
|
-
},
|
|
968
|
-
/**
|
|
969
|
-
* Create a tools/list response
|
|
970
|
-
*/
|
|
971
|
-
toolsList(tools, id = 1) {
|
|
972
|
-
return {
|
|
973
|
-
jsonrpc: "2.0",
|
|
974
|
-
id,
|
|
975
|
-
result: { tools }
|
|
976
|
-
};
|
|
977
|
-
},
|
|
978
|
-
/**
|
|
979
|
-
* Create a resources/list response
|
|
980
|
-
*/
|
|
981
|
-
resourcesList(resources, id = 1) {
|
|
982
|
-
return {
|
|
983
|
-
jsonrpc: "2.0",
|
|
984
|
-
id,
|
|
985
|
-
result: { resources }
|
|
986
|
-
};
|
|
987
|
-
},
|
|
988
|
-
/**
|
|
989
|
-
* Create a resources/read response
|
|
990
|
-
*/
|
|
991
|
-
resourceRead(contents, id = 1) {
|
|
992
|
-
return {
|
|
993
|
-
jsonrpc: "2.0",
|
|
994
|
-
id,
|
|
995
|
-
result: { contents }
|
|
996
|
-
};
|
|
997
|
-
},
|
|
998
|
-
/**
|
|
999
|
-
* Common MCP errors
|
|
1000
|
-
*/
|
|
1001
|
-
errors: {
|
|
1002
|
-
methodNotFound: (method, id = 1) => mockResponse.error(-32601, `Method not found: ${method}`, void 0, id),
|
|
1003
|
-
invalidParams: (message, id = 1) => mockResponse.error(-32602, message, void 0, id),
|
|
1004
|
-
internalError: (message, id = 1) => mockResponse.error(-32603, message, void 0, id),
|
|
1005
|
-
resourceNotFound: (uri, id = 1) => mockResponse.error(-32002, `Resource not found: ${uri}`, { uri }, id),
|
|
1006
|
-
toolNotFound: (name, id = 1) => mockResponse.error(-32601, `Tool not found: ${name}`, { name }, id),
|
|
1007
|
-
unauthorized: (id = 1) => mockResponse.error(-32001, "Unauthorized", void 0, id),
|
|
1008
|
-
forbidden: (id = 1) => mockResponse.error(-32003, "Forbidden", void 0, id)
|
|
1009
1046
|
}
|
|
1010
|
-
|
|
1047
|
+
return baseCapabilities;
|
|
1048
|
+
}
|
|
1011
1049
|
|
|
1012
|
-
// libs/testing/src/
|
|
1013
|
-
var
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
constructor() {
|
|
1018
|
-
this.mocks = new DefaultMockRegistry();
|
|
1019
|
-
}
|
|
1020
|
-
/**
|
|
1021
|
-
* Add a request interceptor
|
|
1022
|
-
*/
|
|
1023
|
-
addRequestInterceptor(interceptor) {
|
|
1024
|
-
this.request.push(interceptor);
|
|
1025
|
-
return () => {
|
|
1026
|
-
const index = this.request.indexOf(interceptor);
|
|
1027
|
-
if (index !== -1) {
|
|
1028
|
-
this.request.splice(index, 1);
|
|
1029
|
-
}
|
|
1030
|
-
};
|
|
1031
|
-
}
|
|
1032
|
-
/**
|
|
1033
|
-
* Add a response interceptor
|
|
1034
|
-
*/
|
|
1035
|
-
addResponseInterceptor(interceptor) {
|
|
1036
|
-
this.response.push(interceptor);
|
|
1037
|
-
return () => {
|
|
1038
|
-
const index = this.response.indexOf(interceptor);
|
|
1039
|
-
if (index !== -1) {
|
|
1040
|
-
this.response.splice(index, 1);
|
|
1041
|
-
}
|
|
1042
|
-
};
|
|
1050
|
+
// libs/testing/src/client/mcp-test-client.builder.ts
|
|
1051
|
+
var McpTestClientBuilder = class {
|
|
1052
|
+
config;
|
|
1053
|
+
constructor(config) {
|
|
1054
|
+
this.config = { ...config };
|
|
1043
1055
|
}
|
|
1044
1056
|
/**
|
|
1045
|
-
*
|
|
1046
|
-
* Returns either:
|
|
1047
|
-
* - { type: 'continue', request } - continue with (possibly modified) request
|
|
1048
|
-
* - { type: 'mock', response } - return mock response immediately
|
|
1049
|
-
* - { type: 'error', error } - throw error
|
|
1057
|
+
* Set the authentication configuration
|
|
1050
1058
|
*/
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
if (mockDef) {
|
|
1055
|
-
if (mockDef.delay && mockDef.delay > 0) {
|
|
1056
|
-
await sleep(mockDef.delay);
|
|
1057
|
-
}
|
|
1058
|
-
let mockResponse2;
|
|
1059
|
-
if (typeof mockDef.response === "function") {
|
|
1060
|
-
mockResponse2 = await mockDef.response(request);
|
|
1061
|
-
} else {
|
|
1062
|
-
mockResponse2 = mockDef.response;
|
|
1063
|
-
}
|
|
1064
|
-
return {
|
|
1065
|
-
type: "mock",
|
|
1066
|
-
response: { ...mockResponse2, id: request.id ?? mockResponse2.id }
|
|
1067
|
-
};
|
|
1068
|
-
}
|
|
1069
|
-
for (const interceptor of this.request) {
|
|
1070
|
-
const ctx = {
|
|
1071
|
-
request: currentRequest,
|
|
1072
|
-
meta
|
|
1073
|
-
};
|
|
1074
|
-
const result = await interceptor(ctx);
|
|
1075
|
-
switch (result.action) {
|
|
1076
|
-
case "passthrough":
|
|
1077
|
-
break;
|
|
1078
|
-
case "modify":
|
|
1079
|
-
currentRequest = result.request;
|
|
1080
|
-
break;
|
|
1081
|
-
case "mock":
|
|
1082
|
-
return {
|
|
1083
|
-
type: "mock",
|
|
1084
|
-
response: { ...result.response, id: request.id ?? result.response.id }
|
|
1085
|
-
};
|
|
1086
|
-
case "error":
|
|
1087
|
-
return { type: "error", error: result.error };
|
|
1088
|
-
}
|
|
1089
|
-
}
|
|
1090
|
-
return { type: "continue", request: currentRequest };
|
|
1059
|
+
withAuth(auth) {
|
|
1060
|
+
this.config.auth = { ...this.config.auth, ...auth };
|
|
1061
|
+
return this;
|
|
1091
1062
|
}
|
|
1092
1063
|
/**
|
|
1093
|
-
*
|
|
1064
|
+
* Set the bearer token for authentication
|
|
1094
1065
|
*/
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
const ctx = {
|
|
1099
|
-
request,
|
|
1100
|
-
response: currentResponse,
|
|
1101
|
-
durationMs
|
|
1102
|
-
};
|
|
1103
|
-
const result = await interceptor(ctx);
|
|
1104
|
-
switch (result.action) {
|
|
1105
|
-
case "passthrough":
|
|
1106
|
-
break;
|
|
1107
|
-
case "modify":
|
|
1108
|
-
currentResponse = result.response;
|
|
1109
|
-
break;
|
|
1110
|
-
}
|
|
1111
|
-
}
|
|
1112
|
-
return currentResponse;
|
|
1066
|
+
withToken(token) {
|
|
1067
|
+
this.config.auth = { ...this.config.auth, token };
|
|
1068
|
+
return this;
|
|
1113
1069
|
}
|
|
1114
1070
|
/**
|
|
1115
|
-
*
|
|
1071
|
+
* Add custom headers to all requests
|
|
1116
1072
|
*/
|
|
1117
|
-
|
|
1118
|
-
this.
|
|
1119
|
-
|
|
1120
|
-
|
|
1073
|
+
withHeaders(headers) {
|
|
1074
|
+
this.config.auth = {
|
|
1075
|
+
...this.config.auth,
|
|
1076
|
+
headers: { ...this.config.auth?.headers, ...headers }
|
|
1077
|
+
};
|
|
1078
|
+
return this;
|
|
1121
1079
|
}
|
|
1122
|
-
};
|
|
1123
|
-
function sleep(ms) {
|
|
1124
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1125
|
-
}
|
|
1126
|
-
var interceptors = {
|
|
1127
1080
|
/**
|
|
1128
|
-
*
|
|
1081
|
+
* Set the transport type
|
|
1129
1082
|
*/
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
};
|
|
1135
|
-
},
|
|
1083
|
+
withTransport(transport) {
|
|
1084
|
+
this.config.transport = transport;
|
|
1085
|
+
return this;
|
|
1086
|
+
}
|
|
1136
1087
|
/**
|
|
1137
|
-
*
|
|
1088
|
+
* Set the request timeout in milliseconds
|
|
1138
1089
|
*/
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
};
|
|
1144
|
-
},
|
|
1090
|
+
withTimeout(timeoutMs) {
|
|
1091
|
+
this.config.timeout = timeoutMs;
|
|
1092
|
+
return this;
|
|
1093
|
+
}
|
|
1145
1094
|
/**
|
|
1146
|
-
*
|
|
1095
|
+
* Enable debug logging
|
|
1147
1096
|
*/
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
return { action: "error", error: err };
|
|
1153
|
-
}
|
|
1154
|
-
return { action: "passthrough" };
|
|
1155
|
-
};
|
|
1156
|
-
},
|
|
1097
|
+
withDebug(enabled = true) {
|
|
1098
|
+
this.config.debug = enabled;
|
|
1099
|
+
return this;
|
|
1100
|
+
}
|
|
1157
1101
|
/**
|
|
1158
|
-
*
|
|
1102
|
+
* Enable public mode - skip authentication entirely.
|
|
1103
|
+
* When true, no Authorization header is sent and anonymous token is not requested.
|
|
1104
|
+
* Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
|
|
1159
1105
|
*/
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
}
|
|
1165
|
-
return { action: "passthrough" };
|
|
1166
|
-
};
|
|
1167
|
-
},
|
|
1106
|
+
withPublicMode(enabled = true) {
|
|
1107
|
+
this.config.publicMode = enabled;
|
|
1108
|
+
return this;
|
|
1109
|
+
}
|
|
1168
1110
|
/**
|
|
1169
|
-
*
|
|
1111
|
+
* Set the MCP protocol version to request
|
|
1170
1112
|
*/
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
return { action: "passthrough" };
|
|
1176
|
-
};
|
|
1177
|
-
},
|
|
1113
|
+
withProtocolVersion(version) {
|
|
1114
|
+
this.config.protocolVersion = version;
|
|
1115
|
+
return this;
|
|
1116
|
+
}
|
|
1178
1117
|
/**
|
|
1179
|
-
*
|
|
1118
|
+
* Set the client info sent during initialization
|
|
1180
1119
|
*/
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1120
|
+
withClientInfo(info) {
|
|
1121
|
+
this.config.clientInfo = info;
|
|
1122
|
+
return this;
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Set the platform type for testing platform-specific meta keys.
|
|
1126
|
+
* Automatically configures clientInfo and capabilities for platform detection.
|
|
1127
|
+
*
|
|
1128
|
+
* Platform-specific behavior:
|
|
1129
|
+
* - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
|
|
1130
|
+
* - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
|
|
1131
|
+
* - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
|
|
1132
|
+
* - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
|
|
1133
|
+
* - Other platforms follow similar patterns
|
|
1134
|
+
*
|
|
1135
|
+
* @example
|
|
1136
|
+
* ```typescript
|
|
1137
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
1138
|
+
* .withPlatform('openai')
|
|
1139
|
+
* .buildAndConnect();
|
|
1140
|
+
*
|
|
1141
|
+
* // ext-apps automatically sets the io.modelcontextprotocol/ui capability
|
|
1142
|
+
* const extAppsClient = await McpTestClient.create({ baseUrl })
|
|
1143
|
+
* .withPlatform('ext-apps')
|
|
1144
|
+
* .buildAndConnect();
|
|
1145
|
+
* ```
|
|
1146
|
+
*/
|
|
1147
|
+
withPlatform(platform) {
|
|
1148
|
+
this.config.platform = platform;
|
|
1149
|
+
this.config.clientInfo = getPlatformClientInfo(platform);
|
|
1150
|
+
this.config.capabilities = getPlatformCapabilities(platform);
|
|
1151
|
+
return this;
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Set custom client capabilities for MCP initialization.
|
|
1155
|
+
* Use this for fine-grained control over capabilities sent during initialization.
|
|
1156
|
+
*
|
|
1157
|
+
* @example
|
|
1158
|
+
* ```typescript
|
|
1159
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
1160
|
+
* .withCapabilities({
|
|
1161
|
+
* sampling: {},
|
|
1162
|
+
* experimental: {
|
|
1163
|
+
* 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
|
|
1164
|
+
* }
|
|
1165
|
+
* })
|
|
1166
|
+
* .buildAndConnect();
|
|
1167
|
+
* ```
|
|
1168
|
+
*/
|
|
1169
|
+
withCapabilities(capabilities) {
|
|
1170
|
+
this.config.capabilities = capabilities;
|
|
1171
|
+
return this;
|
|
1172
|
+
}
|
|
1173
|
+
/**
|
|
1174
|
+
* Set query parameters to append to the connection URL.
|
|
1175
|
+
* Useful for testing mode switches like `?mode=skills_only`.
|
|
1176
|
+
*
|
|
1177
|
+
* @example
|
|
1178
|
+
* ```typescript
|
|
1179
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
1180
|
+
* .withQueryParams({ mode: 'skills_only' })
|
|
1181
|
+
* .buildAndConnect();
|
|
1182
|
+
* ```
|
|
1183
|
+
*/
|
|
1184
|
+
withQueryParams(params) {
|
|
1185
|
+
this.config.queryParams = { ...this.config.queryParams, ...params };
|
|
1186
|
+
return this;
|
|
1187
|
+
}
|
|
1188
|
+
/**
|
|
1189
|
+
* Build the McpTestClient instance (does not connect)
|
|
1190
|
+
*/
|
|
1191
|
+
build() {
|
|
1192
|
+
return new McpTestClient(this.config);
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* Build the McpTestClient and connect to the server
|
|
1196
|
+
*/
|
|
1197
|
+
async buildAndConnect() {
|
|
1198
|
+
const client = this.build();
|
|
1199
|
+
await client.connect();
|
|
1200
|
+
return client;
|
|
1188
1201
|
}
|
|
1189
1202
|
};
|
|
1190
1203
|
|
|
@@ -1195,6 +1208,21 @@ var DEFAULT_CLIENT_INFO = {
|
|
|
1195
1208
|
name: "@frontmcp/testing",
|
|
1196
1209
|
version: "0.4.0"
|
|
1197
1210
|
};
|
|
1211
|
+
function extractEntryPaths(data) {
|
|
1212
|
+
let payload = data;
|
|
1213
|
+
if (typeof payload === "string") {
|
|
1214
|
+
try {
|
|
1215
|
+
payload = JSON.parse(payload);
|
|
1216
|
+
} catch {
|
|
1217
|
+
return void 0;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
if (!payload || typeof payload !== "object") return void 0;
|
|
1221
|
+
const paths = payload.entryPaths;
|
|
1222
|
+
if (!Array.isArray(paths)) return void 0;
|
|
1223
|
+
const strings = paths.filter((p) => typeof p === "string");
|
|
1224
|
+
return strings.length > 0 ? strings : void 0;
|
|
1225
|
+
}
|
|
1198
1226
|
var McpTestClient = class {
|
|
1199
1227
|
// Platform, capabilities, and queryParams are optional - only set when needed
|
|
1200
1228
|
config;
|
|
@@ -1203,6 +1231,8 @@ var McpTestClient = class {
|
|
|
1203
1231
|
requestIdCounter = 0;
|
|
1204
1232
|
_lastRequestId = 0;
|
|
1205
1233
|
_sessionId;
|
|
1234
|
+
/** #543 — discovery is attempted at most once per connect. */
|
|
1235
|
+
triedDiscoveredEntryPath = false;
|
|
1206
1236
|
_sessionInfo = null;
|
|
1207
1237
|
_authState = { isAnonymous: true, scopes: [] };
|
|
1208
1238
|
// Logging and tracing
|
|
@@ -1220,6 +1250,7 @@ var McpTestClient = class {
|
|
|
1220
1250
|
constructor(config) {
|
|
1221
1251
|
this.config = {
|
|
1222
1252
|
baseUrl: config.baseUrl,
|
|
1253
|
+
entryPath: config.entryPath,
|
|
1223
1254
|
transport: config.transport ?? "streamable-http",
|
|
1224
1255
|
auth: config.auth ?? {},
|
|
1225
1256
|
publicMode: config.publicMode ?? false,
|
|
@@ -1255,11 +1286,13 @@ var McpTestClient = class {
|
|
|
1255
1286
|
*/
|
|
1256
1287
|
async connect() {
|
|
1257
1288
|
this.log("debug", `Connecting to ${this.config.baseUrl}...`);
|
|
1289
|
+
this.triedDiscoveredEntryPath = false;
|
|
1258
1290
|
this.transport = this.createTransport();
|
|
1259
1291
|
await this.transport.connect();
|
|
1260
1292
|
const maxRetries = 3;
|
|
1261
1293
|
const retryDelayMs = 500;
|
|
1262
1294
|
let lastError;
|
|
1295
|
+
let reportedEntryPaths;
|
|
1263
1296
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
1264
1297
|
const initResponse = await this.initialize();
|
|
1265
1298
|
if (initResponse.success && initResponse.data) {
|
|
@@ -1267,13 +1300,27 @@ var McpTestClient = class {
|
|
|
1267
1300
|
break;
|
|
1268
1301
|
}
|
|
1269
1302
|
lastError = initResponse.error?.message ?? "Unknown error";
|
|
1303
|
+
reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
|
|
1304
|
+
if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
|
|
1305
|
+
this.triedDiscoveredEntryPath = true;
|
|
1306
|
+
const discovered = reportedEntryPaths[0];
|
|
1307
|
+
this.log("info", `Server serves MCP at ${discovered}; reconnecting there (configured: ${this.mcpPathLabel()})`);
|
|
1308
|
+
this.config.entryPath = discovered;
|
|
1309
|
+
await this.transport.close();
|
|
1310
|
+
this.transport = this.createTransport();
|
|
1311
|
+
await this.transport.connect();
|
|
1312
|
+
continue;
|
|
1313
|
+
}
|
|
1270
1314
|
if (attempt < maxRetries) {
|
|
1271
1315
|
this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
|
|
1272
1316
|
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
1273
1317
|
}
|
|
1274
1318
|
}
|
|
1275
1319
|
if (!this.initResult) {
|
|
1276
|
-
|
|
1320
|
+
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.` : "";
|
|
1321
|
+
throw new Error(
|
|
1322
|
+
`Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${maxRetries} attempts: ${lastError}.${served}`
|
|
1323
|
+
);
|
|
1277
1324
|
}
|
|
1278
1325
|
this._sessionId = this.transport.getSessionId();
|
|
1279
1326
|
this._sessionInfo = {
|
|
@@ -1871,6 +1918,11 @@ var McpTestClient = class {
|
|
|
1871
1918
|
// ═══════════════════════════════════════════════════════════════════
|
|
1872
1919
|
// PRIVATE: TRANSPORT & REQUEST HELPERS
|
|
1873
1920
|
// ═══════════════════════════════════════════════════════════════════
|
|
1921
|
+
/** The MCP path this client is currently pointed at, for log and error text. */
|
|
1922
|
+
mcpPathLabel() {
|
|
1923
|
+
const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
|
|
1924
|
+
return entry ? `/${entry}` : "/";
|
|
1925
|
+
}
|
|
1874
1926
|
createTransport() {
|
|
1875
1927
|
let baseUrl = this.config.baseUrl;
|
|
1876
1928
|
if (this.config.queryParams && Object.keys(this.config.queryParams).length > 0) {
|
|
@@ -1884,6 +1936,7 @@ var McpTestClient = class {
|
|
|
1884
1936
|
case "streamable-http":
|
|
1885
1937
|
return new StreamableHttpTransport({
|
|
1886
1938
|
baseUrl,
|
|
1939
|
+
entryPath: this.config.entryPath,
|
|
1887
1940
|
timeout: this.config.timeout,
|
|
1888
1941
|
auth: this.config.auth,
|
|
1889
1942
|
publicMode: this.config.publicMode,
|
|
@@ -4770,13 +4823,17 @@ var currentConfig = {};
|
|
|
4770
4823
|
var serverInstance = null;
|
|
4771
4824
|
var tokenFactory = null;
|
|
4772
4825
|
var serverStartedByUs = false;
|
|
4826
|
+
var skipScopes = [{ skipped: false }];
|
|
4827
|
+
function currentSkipScope() {
|
|
4828
|
+
return skipScopes[skipScopes.length - 1];
|
|
4829
|
+
}
|
|
4773
4830
|
async function initializeSharedResources() {
|
|
4774
4831
|
if (!tokenFactory) {
|
|
4775
4832
|
const sharedSecret = currentConfig.env?.["JWT_SECRET"];
|
|
4776
4833
|
tokenFactory = new TestTokenFactory(sharedSecret ? { hmacSecret: sharedSecret } : {});
|
|
4777
4834
|
}
|
|
4778
4835
|
if (!serverInstance) {
|
|
4779
|
-
if (currentConfig.baseUrl) {
|
|
4836
|
+
if (currentConfig.baseUrl && !currentConfig.server) {
|
|
4780
4837
|
serverInstance = TestServer.connect(currentConfig.baseUrl);
|
|
4781
4838
|
serverStartedByUs = false;
|
|
4782
4839
|
} else if (currentConfig.server) {
|
|
@@ -4826,7 +4883,8 @@ async function createTestFixtures() {
|
|
|
4826
4883
|
throw new Error("Token factory not initialized");
|
|
4827
4884
|
}
|
|
4828
4885
|
const clientInstance = McpTestClient.create({
|
|
4829
|
-
baseUrl: serverInstance
|
|
4886
|
+
baseUrl: resolveClientBaseUrl(serverInstance),
|
|
4887
|
+
entryPath: currentConfig.entryPath,
|
|
4830
4888
|
transport: currentConfig.transport ?? "streamable-http",
|
|
4831
4889
|
publicMode: currentConfig.publicMode
|
|
4832
4890
|
}).build();
|
|
@@ -4875,6 +4933,9 @@ async function cleanupSharedResources() {
|
|
|
4875
4933
|
tokenFactory = null;
|
|
4876
4934
|
serverStartedByUs = false;
|
|
4877
4935
|
}
|
|
4936
|
+
function resolveClientBaseUrl(server) {
|
|
4937
|
+
return currentConfig.baseUrl ?? server.info.baseUrl;
|
|
4938
|
+
}
|
|
4878
4939
|
function createAuthFixture(factory) {
|
|
4879
4940
|
const users = {
|
|
4880
4941
|
admin: {
|
|
@@ -4924,7 +4985,8 @@ function createServerFixture(server) {
|
|
|
4924
4985
|
info: server.info,
|
|
4925
4986
|
createClient: async (opts) => {
|
|
4926
4987
|
return McpTestClient.create({
|
|
4927
|
-
baseUrl: server
|
|
4988
|
+
baseUrl: resolveClientBaseUrl(server),
|
|
4989
|
+
entryPath: opts?.entryPath ?? currentConfig.entryPath,
|
|
4928
4990
|
transport: opts?.transport ?? "streamable-http",
|
|
4929
4991
|
auth: opts?.token ? { token: opts.token } : void 0,
|
|
4930
4992
|
clientInfo: opts?.clientInfo,
|
|
@@ -4933,7 +4995,8 @@ function createServerFixture(server) {
|
|
|
4933
4995
|
},
|
|
4934
4996
|
createClientBuilder: () => {
|
|
4935
4997
|
const builder = new McpTestClientBuilder({
|
|
4936
|
-
baseUrl: server
|
|
4998
|
+
baseUrl: resolveClientBaseUrl(server),
|
|
4999
|
+
entryPath: currentConfig.entryPath,
|
|
4937
5000
|
publicMode: currentConfig.publicMode
|
|
4938
5001
|
});
|
|
4939
5002
|
return builder;
|
|
@@ -4949,8 +5012,8 @@ function resolveServerCommand(server) {
|
|
|
4949
5012
|
}
|
|
4950
5013
|
return `npx tsx ${server}`;
|
|
4951
5014
|
}
|
|
4952
|
-
function
|
|
4953
|
-
|
|
5015
|
+
function runWithFixtures(fn) {
|
|
5016
|
+
return async () => {
|
|
4954
5017
|
const fixtures = await createTestFixtures();
|
|
4955
5018
|
let testFailed = false;
|
|
4956
5019
|
try {
|
|
@@ -4961,7 +5024,15 @@ function testWithFixtures(name, fn) {
|
|
|
4961
5024
|
} finally {
|
|
4962
5025
|
await cleanupTestFixtures(fixtures, testFailed);
|
|
4963
5026
|
}
|
|
4964
|
-
}
|
|
5027
|
+
};
|
|
5028
|
+
}
|
|
5029
|
+
function testWithFixtures(name, fn) {
|
|
5030
|
+
const scope = currentSkipScope();
|
|
5031
|
+
if (scope.skipped) {
|
|
5032
|
+
it2.skip(scope.reason ? `${name} (skipped: ${scope.reason})` : name, runWithFixtures(fn));
|
|
5033
|
+
return;
|
|
5034
|
+
}
|
|
5035
|
+
it2(name, runWithFixtures(fn));
|
|
4965
5036
|
}
|
|
4966
5037
|
function use(config) {
|
|
4967
5038
|
currentConfig = { ...currentConfig, ...config };
|
|
@@ -4969,40 +5040,60 @@ function use(config) {
|
|
|
4969
5040
|
await cleanupSharedResources();
|
|
4970
5041
|
});
|
|
4971
5042
|
}
|
|
4972
|
-
function skip(
|
|
4973
|
-
|
|
4974
|
-
|
|
4975
|
-
|
|
4976
|
-
|
|
4977
|
-
|
|
4978
|
-
} catch (error) {
|
|
4979
|
-
testFailed = true;
|
|
4980
|
-
throw error;
|
|
4981
|
-
} finally {
|
|
4982
|
-
await cleanupTestFixtures(fixtures, testFailed);
|
|
5043
|
+
function skip(nameOrCondition, fnOrReason) {
|
|
5044
|
+
if (typeof nameOrCondition === "boolean") {
|
|
5045
|
+
if (nameOrCondition) {
|
|
5046
|
+
const scope = currentSkipScope();
|
|
5047
|
+
scope.skipped = true;
|
|
5048
|
+
scope.reason = typeof fnOrReason === "string" ? fnOrReason : void 0;
|
|
4983
5049
|
}
|
|
4984
|
-
|
|
5050
|
+
return;
|
|
5051
|
+
}
|
|
5052
|
+
if (typeof fnOrReason !== "function") {
|
|
5053
|
+
throw new TypeError(
|
|
5054
|
+
`test.skip expects either (name: string, fn) or (condition: boolean, reason?: string); received (${typeof nameOrCondition}, ${typeof fnOrReason}).`
|
|
5055
|
+
);
|
|
5056
|
+
}
|
|
5057
|
+
it2.skip(nameOrCondition, runWithFixtures(fnOrReason));
|
|
4985
5058
|
}
|
|
4986
5059
|
function only(name, fn) {
|
|
4987
|
-
|
|
4988
|
-
|
|
4989
|
-
|
|
4990
|
-
|
|
4991
|
-
|
|
4992
|
-
|
|
4993
|
-
testFailed = true;
|
|
4994
|
-
throw error;
|
|
4995
|
-
} finally {
|
|
4996
|
-
await cleanupTestFixtures(fixtures, testFailed);
|
|
4997
|
-
}
|
|
4998
|
-
});
|
|
5060
|
+
const scope = currentSkipScope();
|
|
5061
|
+
if (scope.skipped) {
|
|
5062
|
+
it2.skip(scope.reason ? `${name} (skipped: ${scope.reason})` : name, runWithFixtures(fn));
|
|
5063
|
+
return;
|
|
5064
|
+
}
|
|
5065
|
+
it2.only(name, runWithFixtures(fn));
|
|
4999
5066
|
}
|
|
5000
5067
|
function todo(name) {
|
|
5001
5068
|
it2.todo(name);
|
|
5002
5069
|
}
|
|
5070
|
+
function withSkipScope(register) {
|
|
5071
|
+
return (name, fn) => {
|
|
5072
|
+
register(name, () => {
|
|
5073
|
+
const parent = currentSkipScope();
|
|
5074
|
+
skipScopes.push({ skipped: parent.skipped, reason: parent.reason });
|
|
5075
|
+
try {
|
|
5076
|
+
fn();
|
|
5077
|
+
} finally {
|
|
5078
|
+
skipScopes.pop();
|
|
5079
|
+
}
|
|
5080
|
+
});
|
|
5081
|
+
};
|
|
5082
|
+
}
|
|
5083
|
+
function eachWithSkipScope(each) {
|
|
5084
|
+
return ((...eachArgs) => {
|
|
5085
|
+
const register = each(...eachArgs);
|
|
5086
|
+
return withSkipScope(register);
|
|
5087
|
+
});
|
|
5088
|
+
}
|
|
5089
|
+
var describeWithSkipScope = Object.assign(withSkipScope(describe2), describe2, {
|
|
5090
|
+
only: Object.assign(withSkipScope(describe2.only), { each: eachWithSkipScope(describe2.only.each) }),
|
|
5091
|
+
skip: Object.assign(withSkipScope(describe2.skip), { each: eachWithSkipScope(describe2.skip.each) }),
|
|
5092
|
+
each: eachWithSkipScope(describe2.each)
|
|
5093
|
+
});
|
|
5003
5094
|
var test = testWithFixtures;
|
|
5004
5095
|
test.use = use;
|
|
5005
|
-
test.describe =
|
|
5096
|
+
test.describe = describeWithSkipScope;
|
|
5006
5097
|
test.beforeAll = beforeAll2;
|
|
5007
5098
|
test.beforeEach = beforeEach2;
|
|
5008
5099
|
test.afterEach = afterEach2;
|