@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/esm/index.mjs
CHANGED
|
@@ -5,255 +5,345 @@ 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
|
|
|
255
344
|
// libs/testing/src/transport/streamable-http.transport.ts
|
|
256
345
|
var DEFAULT_TIMEOUT = 3e4;
|
|
346
|
+
var NOTIFICATION_STREAM_REOPEN_DELAY_MS = 250;
|
|
257
347
|
var StreamableHttpTransport = class {
|
|
258
348
|
config;
|
|
259
349
|
state = "disconnected";
|
|
@@ -265,10 +355,15 @@ var StreamableHttpTransport = class {
|
|
|
265
355
|
interceptors;
|
|
266
356
|
publicMode;
|
|
267
357
|
elicitationHandler;
|
|
358
|
+
notificationHandler;
|
|
359
|
+
notificationStream;
|
|
360
|
+
notificationProtocolVersion = "";
|
|
361
|
+
notificationStreamReopenTimer;
|
|
268
362
|
constructor(config) {
|
|
269
363
|
this.config = {
|
|
270
364
|
baseUrl: config.baseUrl.replace(/\/$/, ""),
|
|
271
365
|
// Remove trailing slash
|
|
366
|
+
entryPath: config.entryPath,
|
|
272
367
|
timeout: config.timeout ?? DEFAULT_TIMEOUT,
|
|
273
368
|
auth: config.auth ?? {},
|
|
274
369
|
publicMode: config.publicMode ?? false,
|
|
@@ -280,6 +375,7 @@ var StreamableHttpTransport = class {
|
|
|
280
375
|
this.interceptors = config.interceptors;
|
|
281
376
|
this.publicMode = config.publicMode ?? false;
|
|
282
377
|
this.elicitationHandler = config.elicitationHandler;
|
|
378
|
+
this.notificationHandler = config.notificationHandler;
|
|
283
379
|
}
|
|
284
380
|
async connect() {
|
|
285
381
|
this.state = "connecting";
|
|
@@ -361,7 +457,7 @@ var StreamableHttpTransport = class {
|
|
|
361
457
|
}
|
|
362
458
|
const headers = this.buildHeaders();
|
|
363
459
|
this.lastRequestHeaders = headers;
|
|
364
|
-
const url =
|
|
460
|
+
const url = this.mcpUrl();
|
|
365
461
|
this.log(`POST ${url}`, message);
|
|
366
462
|
const controller = new AbortController();
|
|
367
463
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
@@ -431,7 +527,7 @@ var StreamableHttpTransport = class {
|
|
|
431
527
|
this.ensureConnected();
|
|
432
528
|
const headers = this.buildHeaders();
|
|
433
529
|
this.lastRequestHeaders = headers;
|
|
434
|
-
const url =
|
|
530
|
+
const url = this.mcpUrl();
|
|
435
531
|
this.log(`POST ${url} (notification)`, message);
|
|
436
532
|
const controller = new AbortController();
|
|
437
533
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
@@ -462,7 +558,7 @@ var StreamableHttpTransport = class {
|
|
|
462
558
|
this.ensureConnected();
|
|
463
559
|
const headers = this.buildHeaders();
|
|
464
560
|
this.lastRequestHeaders = headers;
|
|
465
|
-
const url =
|
|
561
|
+
const url = this.mcpUrl();
|
|
466
562
|
this.log(`POST ${url} (raw)`, data);
|
|
467
563
|
const controller = new AbortController();
|
|
468
564
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
@@ -499,7 +595,12 @@ var StreamableHttpTransport = class {
|
|
|
499
595
|
};
|
|
500
596
|
}
|
|
501
597
|
}
|
|
598
|
+
async openNotificationStream(protocolVersion) {
|
|
599
|
+
this.notificationProtocolVersion = protocolVersion;
|
|
600
|
+
await this.connectNotificationStream();
|
|
601
|
+
}
|
|
502
602
|
async close() {
|
|
603
|
+
this.closeNotificationStream();
|
|
503
604
|
this.state = "disconnected";
|
|
504
605
|
this.sessionId = void 0;
|
|
505
606
|
this.log("StreamableHTTP transport closed");
|
|
@@ -538,6 +639,7 @@ var StreamableHttpTransport = class {
|
|
|
538
639
|
return { ...this.lastRequestHeaders };
|
|
539
640
|
}
|
|
540
641
|
async simulateDisconnect() {
|
|
642
|
+
this.closeNotificationStream();
|
|
541
643
|
this.state = "disconnected";
|
|
542
644
|
this.sessionId = void 0;
|
|
543
645
|
}
|
|
@@ -556,23 +658,89 @@ var StreamableHttpTransport = class {
|
|
|
556
658
|
// ═══════════════════════════════════════════════════════════════════
|
|
557
659
|
// PRIVATE HELPERS
|
|
558
660
|
// ═══════════════════════════════════════════════════════════════════
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
async
|
|
566
|
-
this.
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
661
|
+
closeNotificationStream() {
|
|
662
|
+
clearTimeout(this.notificationStreamReopenTimer);
|
|
663
|
+
this.notificationStream?.abort();
|
|
664
|
+
this.notificationStream = void 0;
|
|
665
|
+
}
|
|
666
|
+
/** Opens the stream without holding up the caller past the request timeout; close() aborts it at any point. */
|
|
667
|
+
async connectNotificationStream() {
|
|
668
|
+
if (!this.sessionId || this.notificationStream) return;
|
|
669
|
+
const controller = new AbortController();
|
|
670
|
+
this.notificationStream = controller;
|
|
671
|
+
const { "Content-Type": _contentType, ...headers } = this.buildHeaders();
|
|
672
|
+
const established = fetch(this.mcpUrl(), {
|
|
673
|
+
method: "GET",
|
|
674
|
+
headers: { ...headers, Accept: "text/event-stream", "MCP-Protocol-Version": this.notificationProtocolVersion },
|
|
675
|
+
signal: controller.signal
|
|
676
|
+
}).then(
|
|
677
|
+
async (response) => {
|
|
678
|
+
const isEventStream = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
|
|
679
|
+
if (!response.ok || !response.body || !isEventStream) {
|
|
680
|
+
await response.body?.cancel();
|
|
681
|
+
this.log(`Server offers no notification stream (HTTP ${response.status})`);
|
|
682
|
+
this.releaseNotificationStream(controller);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
void this.readNotificationStream(controller, response.body.getReader());
|
|
686
|
+
},
|
|
687
|
+
(error) => {
|
|
688
|
+
this.log("Failed to open the notification stream:", error);
|
|
689
|
+
this.releaseNotificationStream(controller);
|
|
690
|
+
}
|
|
691
|
+
);
|
|
692
|
+
let establishTimer;
|
|
693
|
+
const establishDeadline = new Promise((resolve) => {
|
|
694
|
+
establishTimer = setTimeout(resolve, this.config.timeout);
|
|
695
|
+
});
|
|
696
|
+
await Promise.race([established, establishDeadline]);
|
|
697
|
+
clearTimeout(establishTimer);
|
|
698
|
+
}
|
|
699
|
+
releaseNotificationStream(controller) {
|
|
700
|
+
if (this.notificationStream === controller) this.notificationStream = void 0;
|
|
701
|
+
}
|
|
702
|
+
async readNotificationStream(controller, reader) {
|
|
703
|
+
const decoder = new TextDecoder();
|
|
704
|
+
let buffer = "";
|
|
705
|
+
try {
|
|
706
|
+
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
|
|
707
|
+
const { completeEvents, remainder } = splitSSEEvents(buffer + decoder.decode(chunk.value, { stream: true }));
|
|
708
|
+
buffer = remainder;
|
|
709
|
+
for (const eventText of completeEvents) {
|
|
710
|
+
for (const event of this.parseSSEEvents(eventText, void 0).events) {
|
|
711
|
+
await this.handleSSEEvent(event);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
} catch (error) {
|
|
716
|
+
this.log("Notification stream ended:", error);
|
|
717
|
+
} finally {
|
|
718
|
+
reader.releaseLock();
|
|
719
|
+
}
|
|
720
|
+
if (controller.signal.aborted || this.notificationStream !== controller) return;
|
|
721
|
+
this.releaseNotificationStream(controller);
|
|
722
|
+
this.notificationStreamReopenTimer = setTimeout(
|
|
723
|
+
() => void this.connectNotificationStream(),
|
|
724
|
+
NOTIFICATION_STREAM_REOPEN_DELAY_MS
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Handle SSE response with elicitation support.
|
|
729
|
+
*
|
|
730
|
+
* Streams the SSE response, detects elicitation/create requests, and handles them
|
|
731
|
+
* by calling the registered handler and sending the response back to the server.
|
|
732
|
+
*/
|
|
733
|
+
async handleSSEResponseWithElicitation(response, originalRequest) {
|
|
734
|
+
this.log("handleSSEResponseWithElicitation: starting", { requestId: originalRequest.id });
|
|
735
|
+
const reader = response.body?.getReader();
|
|
736
|
+
if (!reader) {
|
|
737
|
+
this.log("handleSSEResponseWithElicitation: no response body");
|
|
738
|
+
return {
|
|
739
|
+
jsonrpc: "2.0",
|
|
740
|
+
id: originalRequest.id ?? null,
|
|
741
|
+
error: { code: -32e3, message: "No response body" }
|
|
742
|
+
};
|
|
743
|
+
}
|
|
576
744
|
const decoder = new TextDecoder();
|
|
577
745
|
let buffer = "";
|
|
578
746
|
let finalResponse = null;
|
|
@@ -693,6 +861,13 @@ var StreamableHttpTransport = class {
|
|
|
693
861
|
response: { jsonrpc: "2.0", id: null, result: void 0 }
|
|
694
862
|
};
|
|
695
863
|
}
|
|
864
|
+
if ("method" in parsed && parsed.id === void 0) {
|
|
865
|
+
this.notificationHandler?.(parsed);
|
|
866
|
+
return {
|
|
867
|
+
isFinal: false,
|
|
868
|
+
response: { jsonrpc: "2.0", id: null, result: void 0 }
|
|
869
|
+
};
|
|
870
|
+
}
|
|
696
871
|
if ("result" in parsed || "error" in parsed) {
|
|
697
872
|
return { isFinal: true, response: parsed };
|
|
698
873
|
}
|
|
@@ -745,7 +920,7 @@ var StreamableHttpTransport = class {
|
|
|
745
920
|
*/
|
|
746
921
|
async sendElicitationResponse(requestId, response) {
|
|
747
922
|
const headers = this.buildHeaders();
|
|
748
|
-
const url =
|
|
923
|
+
const url = this.mcpUrl();
|
|
749
924
|
const rpcResponse = {
|
|
750
925
|
jsonrpc: "2.0",
|
|
751
926
|
id: requestId,
|
|
@@ -765,6 +940,18 @@ var StreamableHttpTransport = class {
|
|
|
765
940
|
this.log("Failed to send elicitation response:", error);
|
|
766
941
|
}
|
|
767
942
|
}
|
|
943
|
+
/**
|
|
944
|
+
* The MCP endpoint URL. `entryPath` is where the server mounts MCP; the
|
|
945
|
+
* trailing slash is kept because both hosts normalize it and the original
|
|
946
|
+
* behaviour (`${baseUrl}/`) relied on it (issue #543).
|
|
947
|
+
*/
|
|
948
|
+
mcpUrl() {
|
|
949
|
+
const url = new URL(this.config.baseUrl);
|
|
950
|
+
const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
|
|
951
|
+
const basePath = url.pathname.replace(/\/+$/, "");
|
|
952
|
+
url.pathname = entry ? `${basePath}/${entry}` : `${basePath}/`;
|
|
953
|
+
return url.toString();
|
|
954
|
+
}
|
|
768
955
|
buildHeaders() {
|
|
769
956
|
const headers = {
|
|
770
957
|
"Content-Type": "application/json",
|
|
@@ -851,350 +1038,285 @@ var StreamableHttpTransport = class {
|
|
|
851
1038
|
};
|
|
852
1039
|
}
|
|
853
1040
|
};
|
|
1041
|
+
function splitSSEEvents(buffer) {
|
|
1042
|
+
const endsWithCarriageReturn = buffer.endsWith("\r");
|
|
1043
|
+
const normalized = (endsWithCarriageReturn ? buffer.slice(0, -1) : buffer).replace(/\r\n?/g, "\n");
|
|
1044
|
+
const completeEvents = normalized.split("\n\n");
|
|
1045
|
+
const remainder = completeEvents.pop() ?? "";
|
|
1046
|
+
return { completeEvents, remainder: endsWithCarriageReturn ? `${remainder}\r` : remainder };
|
|
1047
|
+
}
|
|
854
1048
|
|
|
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
|
-
|
|
1049
|
+
// libs/testing/src/platform/platform-client-info.ts
|
|
1050
|
+
var MCP_APPS_EXTENSION_KEY = "io.modelcontextprotocol/ui";
|
|
1051
|
+
function getPlatformClientInfo(platform) {
|
|
1052
|
+
switch (platform) {
|
|
1053
|
+
case "openai":
|
|
1054
|
+
return {
|
|
1055
|
+
name: "ChatGPT",
|
|
1056
|
+
version: "1.0"
|
|
1057
|
+
};
|
|
1058
|
+
case "ext-apps":
|
|
1059
|
+
return {
|
|
1060
|
+
name: "mcp-ext-apps",
|
|
1061
|
+
version: "1.0"
|
|
1062
|
+
};
|
|
1063
|
+
case "claude":
|
|
1064
|
+
return {
|
|
1065
|
+
name: "claude-desktop",
|
|
1066
|
+
version: "1.0"
|
|
1067
|
+
};
|
|
1068
|
+
case "cursor":
|
|
1069
|
+
return {
|
|
1070
|
+
name: "cursor",
|
|
1071
|
+
version: "1.0"
|
|
1072
|
+
};
|
|
1073
|
+
case "continue":
|
|
1074
|
+
return {
|
|
1075
|
+
name: "continue",
|
|
1076
|
+
version: "1.0"
|
|
1077
|
+
};
|
|
1078
|
+
case "cody":
|
|
1079
|
+
return {
|
|
1080
|
+
name: "cody",
|
|
1081
|
+
version: "1.0"
|
|
1082
|
+
};
|
|
1083
|
+
case "gemini":
|
|
1084
|
+
return {
|
|
1085
|
+
name: "gemini",
|
|
1086
|
+
version: "1.0"
|
|
1087
|
+
};
|
|
1088
|
+
case "generic-mcp":
|
|
1089
|
+
return {
|
|
1090
|
+
name: "generic-mcp-client",
|
|
1091
|
+
version: "1.0"
|
|
1092
|
+
};
|
|
1093
|
+
case "unknown":
|
|
1094
|
+
default:
|
|
1095
|
+
return {
|
|
1096
|
+
name: "mcp-test-client",
|
|
1097
|
+
version: "1.0"
|
|
1098
|
+
};
|
|
882
1099
|
}
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1100
|
+
}
|
|
1101
|
+
function buildUserAgent(clientInfo) {
|
|
1102
|
+
return `${clientInfo.name}/${clientInfo.version}`;
|
|
1103
|
+
}
|
|
1104
|
+
function getPlatformUserAgent(platform) {
|
|
1105
|
+
return buildUserAgent(getPlatformClientInfo(platform));
|
|
1106
|
+
}
|
|
1107
|
+
var PLATFORM_DETECTION_PATTERNS = {
|
|
1108
|
+
openai: /chatgpt/i,
|
|
1109
|
+
"ext-apps": /mcp-ext-apps/i,
|
|
1110
|
+
// Note: Actual detection uses capabilities
|
|
1111
|
+
claude: /claude|claude-desktop/i,
|
|
1112
|
+
cursor: /cursor/i,
|
|
1113
|
+
continue: /continue/i,
|
|
1114
|
+
cody: /cody/i,
|
|
1115
|
+
gemini: /gemini/i,
|
|
1116
|
+
"generic-mcp": /generic-mcp/i,
|
|
1117
|
+
unknown: /.*/
|
|
1118
|
+
// Matches anything (fallback)
|
|
1119
|
+
};
|
|
1120
|
+
function getPlatformCapabilities(platform) {
|
|
1121
|
+
const baseCapabilities = {
|
|
1122
|
+
sampling: {},
|
|
1123
|
+
// Include elicitation.form by default for testing elicitation workflows
|
|
1124
|
+
// Note: MCP SDK expects form to be an object, not boolean
|
|
1125
|
+
elicitation: {
|
|
1126
|
+
form: {}
|
|
900
1127
|
}
|
|
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;
|
|
1128
|
+
};
|
|
1129
|
+
if (platform === "ext-apps") {
|
|
1130
|
+
return {
|
|
1131
|
+
...baseCapabilities,
|
|
1132
|
+
experimental: {
|
|
1133
|
+
[MCP_APPS_EXTENSION_KEY]: {
|
|
1134
|
+
mimeTypes: ["text/html;profile=mcp-app"]
|
|
929
1135
|
}
|
|
930
|
-
} else if (actualValue !== value) {
|
|
931
|
-
return false;
|
|
932
1136
|
}
|
|
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
|
-
};
|
|
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
1137
|
};
|
|
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
1138
|
}
|
|
1010
|
-
|
|
1139
|
+
return baseCapabilities;
|
|
1140
|
+
}
|
|
1011
1141
|
|
|
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
|
-
};
|
|
1142
|
+
// libs/testing/src/client/mcp-test-client.builder.ts
|
|
1143
|
+
var McpTestClientBuilder = class {
|
|
1144
|
+
config;
|
|
1145
|
+
constructor(config) {
|
|
1146
|
+
this.config = { ...config };
|
|
1031
1147
|
}
|
|
1032
1148
|
/**
|
|
1033
|
-
*
|
|
1149
|
+
* Set the authentication configuration
|
|
1034
1150
|
*/
|
|
1035
|
-
|
|
1036
|
-
this.
|
|
1037
|
-
return
|
|
1038
|
-
const index = this.response.indexOf(interceptor);
|
|
1039
|
-
if (index !== -1) {
|
|
1040
|
-
this.response.splice(index, 1);
|
|
1041
|
-
}
|
|
1042
|
-
};
|
|
1151
|
+
withAuth(auth) {
|
|
1152
|
+
this.config.auth = { ...this.config.auth, ...auth };
|
|
1153
|
+
return this;
|
|
1043
1154
|
}
|
|
1044
1155
|
/**
|
|
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
|
|
1156
|
+
* Set the bearer token for authentication
|
|
1050
1157
|
*/
|
|
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 };
|
|
1158
|
+
withToken(token) {
|
|
1159
|
+
this.config.auth = { ...this.config.auth, token };
|
|
1160
|
+
return this;
|
|
1091
1161
|
}
|
|
1092
1162
|
/**
|
|
1093
|
-
*
|
|
1163
|
+
* Add custom headers to all requests
|
|
1094
1164
|
*/
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
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;
|
|
1165
|
+
withHeaders(headers) {
|
|
1166
|
+
this.config.auth = {
|
|
1167
|
+
...this.config.auth,
|
|
1168
|
+
headers: { ...this.config.auth?.headers, ...headers }
|
|
1169
|
+
};
|
|
1170
|
+
return this;
|
|
1113
1171
|
}
|
|
1114
1172
|
/**
|
|
1115
|
-
*
|
|
1173
|
+
* Set the transport type
|
|
1116
1174
|
*/
|
|
1117
|
-
|
|
1118
|
-
this.
|
|
1119
|
-
this
|
|
1120
|
-
this.mocks.clear();
|
|
1175
|
+
withTransport(transport) {
|
|
1176
|
+
this.config.transport = transport;
|
|
1177
|
+
return this;
|
|
1121
1178
|
}
|
|
1122
|
-
};
|
|
1123
|
-
function sleep(ms) {
|
|
1124
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1125
|
-
}
|
|
1126
|
-
var interceptors = {
|
|
1127
1179
|
/**
|
|
1128
|
-
*
|
|
1180
|
+
* Set the request timeout in milliseconds
|
|
1129
1181
|
*/
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
};
|
|
1135
|
-
},
|
|
1182
|
+
withTimeout(timeoutMs) {
|
|
1183
|
+
this.config.timeout = timeoutMs;
|
|
1184
|
+
return this;
|
|
1185
|
+
}
|
|
1136
1186
|
/**
|
|
1137
|
-
*
|
|
1187
|
+
* Enable debug logging
|
|
1138
1188
|
*/
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
};
|
|
1144
|
-
},
|
|
1189
|
+
withDebug(enabled = true) {
|
|
1190
|
+
this.config.debug = enabled;
|
|
1191
|
+
return this;
|
|
1192
|
+
}
|
|
1145
1193
|
/**
|
|
1146
|
-
*
|
|
1194
|
+
* Enable public mode - skip authentication entirely.
|
|
1195
|
+
* When true, no Authorization header is sent and anonymous token is not requested.
|
|
1196
|
+
* Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
|
|
1147
1197
|
*/
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
return { action: "error", error: err };
|
|
1153
|
-
}
|
|
1154
|
-
return { action: "passthrough" };
|
|
1155
|
-
};
|
|
1156
|
-
},
|
|
1198
|
+
withPublicMode(enabled = true) {
|
|
1199
|
+
this.config.publicMode = enabled;
|
|
1200
|
+
return this;
|
|
1201
|
+
}
|
|
1157
1202
|
/**
|
|
1158
|
-
*
|
|
1203
|
+
* Set the MCP protocol version to request (default '2025-06-18').
|
|
1204
|
+
* '2026-07-28' has no initialize handshake, so `build()` throws for it.
|
|
1159
1205
|
*/
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
}
|
|
1165
|
-
return { action: "passthrough" };
|
|
1166
|
-
};
|
|
1167
|
-
},
|
|
1206
|
+
withProtocolVersion(version) {
|
|
1207
|
+
this.config.protocolVersion = version;
|
|
1208
|
+
return this;
|
|
1209
|
+
}
|
|
1168
1210
|
/**
|
|
1169
|
-
*
|
|
1211
|
+
* Set the client info sent during initialization
|
|
1170
1212
|
*/
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
return { action: "passthrough" };
|
|
1176
|
-
};
|
|
1177
|
-
},
|
|
1213
|
+
withClientInfo(info) {
|
|
1214
|
+
this.config.clientInfo = info;
|
|
1215
|
+
return this;
|
|
1216
|
+
}
|
|
1178
1217
|
/**
|
|
1179
|
-
*
|
|
1218
|
+
* Set the platform type for testing platform-specific meta keys.
|
|
1219
|
+
* Automatically configures clientInfo and capabilities for platform detection.
|
|
1220
|
+
*
|
|
1221
|
+
* Platform-specific behavior:
|
|
1222
|
+
* - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
|
|
1223
|
+
* - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
|
|
1224
|
+
* - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
|
|
1225
|
+
* - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
|
|
1226
|
+
* - Other platforms follow similar patterns
|
|
1227
|
+
*
|
|
1228
|
+
* @example
|
|
1229
|
+
* ```typescript
|
|
1230
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
1231
|
+
* .withPlatform('openai')
|
|
1232
|
+
* .buildAndConnect();
|
|
1233
|
+
*
|
|
1234
|
+
* // ext-apps automatically sets the io.modelcontextprotocol/ui capability
|
|
1235
|
+
* const extAppsClient = await McpTestClient.create({ baseUrl })
|
|
1236
|
+
* .withPlatform('ext-apps')
|
|
1237
|
+
* .buildAndConnect();
|
|
1238
|
+
* ```
|
|
1180
1239
|
*/
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1240
|
+
withPlatform(platform) {
|
|
1241
|
+
this.config.platform = platform;
|
|
1242
|
+
this.config.clientInfo = getPlatformClientInfo(platform);
|
|
1243
|
+
this.config.capabilities = getPlatformCapabilities(platform);
|
|
1244
|
+
return this;
|
|
1245
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* Set custom client capabilities for MCP initialization.
|
|
1248
|
+
* Use this for fine-grained control over capabilities sent during initialization.
|
|
1249
|
+
*
|
|
1250
|
+
* @example
|
|
1251
|
+
* ```typescript
|
|
1252
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
1253
|
+
* .withCapabilities({
|
|
1254
|
+
* sampling: {},
|
|
1255
|
+
* experimental: {
|
|
1256
|
+
* 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
|
|
1257
|
+
* }
|
|
1258
|
+
* })
|
|
1259
|
+
* .buildAndConnect();
|
|
1260
|
+
* ```
|
|
1261
|
+
*/
|
|
1262
|
+
withCapabilities(capabilities) {
|
|
1263
|
+
this.config.capabilities = capabilities;
|
|
1264
|
+
return this;
|
|
1265
|
+
}
|
|
1266
|
+
/**
|
|
1267
|
+
* Set query parameters to append to the connection URL.
|
|
1268
|
+
* Useful for testing mode switches like `?mode=skills_only`.
|
|
1269
|
+
*
|
|
1270
|
+
* @example
|
|
1271
|
+
* ```typescript
|
|
1272
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
1273
|
+
* .withQueryParams({ mode: 'skills_only' })
|
|
1274
|
+
* .buildAndConnect();
|
|
1275
|
+
* ```
|
|
1276
|
+
*/
|
|
1277
|
+
withQueryParams(params) {
|
|
1278
|
+
this.config.queryParams = { ...this.config.queryParams, ...params };
|
|
1279
|
+
return this;
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Build the McpTestClient instance (does not connect)
|
|
1283
|
+
*/
|
|
1284
|
+
build() {
|
|
1285
|
+
return new McpTestClient(this.config);
|
|
1286
|
+
}
|
|
1287
|
+
/**
|
|
1288
|
+
* Build the McpTestClient and connect to the server
|
|
1289
|
+
*/
|
|
1290
|
+
async buildAndConnect() {
|
|
1291
|
+
const client = this.build();
|
|
1292
|
+
await client.connect();
|
|
1293
|
+
return client;
|
|
1188
1294
|
}
|
|
1189
1295
|
};
|
|
1190
1296
|
|
|
1191
1297
|
// libs/testing/src/client/mcp-test-client.ts
|
|
1192
1298
|
var DEFAULT_TIMEOUT2 = 3e4;
|
|
1193
1299
|
var DEFAULT_PROTOCOL_VERSION = "2025-06-18";
|
|
1300
|
+
var UNSUPPORTED_PROTOCOL_VERSIONS = ["2026-07-28"];
|
|
1194
1301
|
var DEFAULT_CLIENT_INFO = {
|
|
1195
1302
|
name: "@frontmcp/testing",
|
|
1196
1303
|
version: "0.4.0"
|
|
1197
1304
|
};
|
|
1305
|
+
function extractEntryPaths(data) {
|
|
1306
|
+
let payload = data;
|
|
1307
|
+
if (typeof payload === "string") {
|
|
1308
|
+
try {
|
|
1309
|
+
payload = JSON.parse(payload);
|
|
1310
|
+
} catch {
|
|
1311
|
+
return void 0;
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
if (!payload || typeof payload !== "object") return void 0;
|
|
1315
|
+
const paths = payload.entryPaths;
|
|
1316
|
+
if (!Array.isArray(paths)) return void 0;
|
|
1317
|
+
const strings = paths.filter((p) => typeof p === "string");
|
|
1318
|
+
return strings.length > 0 ? strings : void 0;
|
|
1319
|
+
}
|
|
1198
1320
|
var McpTestClient = class {
|
|
1199
1321
|
// Platform, capabilities, and queryParams are optional - only set when needed
|
|
1200
1322
|
config;
|
|
@@ -1203,6 +1325,8 @@ var McpTestClient = class {
|
|
|
1203
1325
|
requestIdCounter = 0;
|
|
1204
1326
|
_lastRequestId = 0;
|
|
1205
1327
|
_sessionId;
|
|
1328
|
+
/** #543 — discovery is attempted at most once per connect. */
|
|
1329
|
+
triedDiscoveredEntryPath = false;
|
|
1206
1330
|
_sessionInfo = null;
|
|
1207
1331
|
_authState = { isAnonymous: true, scopes: [] };
|
|
1208
1332
|
// Logging and tracing
|
|
@@ -1210,6 +1334,8 @@ var McpTestClient = class {
|
|
|
1210
1334
|
_traces = [];
|
|
1211
1335
|
_notifications = [];
|
|
1212
1336
|
_progressUpdates = [];
|
|
1337
|
+
collectingProgress = false;
|
|
1338
|
+
progressTokenCounter = 0;
|
|
1213
1339
|
// Interceptor chain
|
|
1214
1340
|
_interceptors;
|
|
1215
1341
|
// Elicitation handler for server→client elicit requests
|
|
@@ -1218,8 +1344,14 @@ var McpTestClient = class {
|
|
|
1218
1344
|
// CONSTRUCTOR & FACTORY
|
|
1219
1345
|
// ═══════════════════════════════════════════════════════════════════
|
|
1220
1346
|
constructor(config) {
|
|
1347
|
+
if (config.protocolVersion && UNSUPPORTED_PROTOCOL_VERSIONS.includes(config.protocolVersion)) {
|
|
1348
|
+
throw new Error(
|
|
1349
|
+
`McpTestClient does not support protocol version ${config.protocolVersion}: it has no initialize handshake. Use ${DEFAULT_PROTOCOL_VERSION} or earlier.`
|
|
1350
|
+
);
|
|
1351
|
+
}
|
|
1221
1352
|
this.config = {
|
|
1222
1353
|
baseUrl: config.baseUrl,
|
|
1354
|
+
entryPath: config.entryPath,
|
|
1223
1355
|
transport: config.transport ?? "streamable-http",
|
|
1224
1356
|
auth: config.auth ?? {},
|
|
1225
1357
|
publicMode: config.publicMode ?? false,
|
|
@@ -1255,11 +1387,13 @@ var McpTestClient = class {
|
|
|
1255
1387
|
*/
|
|
1256
1388
|
async connect() {
|
|
1257
1389
|
this.log("debug", `Connecting to ${this.config.baseUrl}...`);
|
|
1390
|
+
this.triedDiscoveredEntryPath = false;
|
|
1258
1391
|
this.transport = this.createTransport();
|
|
1259
1392
|
await this.transport.connect();
|
|
1260
1393
|
const maxRetries = 3;
|
|
1261
1394
|
const retryDelayMs = 500;
|
|
1262
1395
|
let lastError;
|
|
1396
|
+
let reportedEntryPaths;
|
|
1263
1397
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
1264
1398
|
const initResponse = await this.initialize();
|
|
1265
1399
|
if (initResponse.success && initResponse.data) {
|
|
@@ -1267,13 +1401,27 @@ var McpTestClient = class {
|
|
|
1267
1401
|
break;
|
|
1268
1402
|
}
|
|
1269
1403
|
lastError = initResponse.error?.message ?? "Unknown error";
|
|
1404
|
+
reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
|
|
1405
|
+
if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
|
|
1406
|
+
this.triedDiscoveredEntryPath = true;
|
|
1407
|
+
const discovered = reportedEntryPaths[0];
|
|
1408
|
+
this.log("info", `Server serves MCP at ${discovered}; reconnecting there (configured: ${this.mcpPathLabel()})`);
|
|
1409
|
+
this.config.entryPath = discovered;
|
|
1410
|
+
await this.transport.close();
|
|
1411
|
+
this.transport = this.createTransport();
|
|
1412
|
+
await this.transport.connect();
|
|
1413
|
+
continue;
|
|
1414
|
+
}
|
|
1270
1415
|
if (attempt < maxRetries) {
|
|
1271
1416
|
this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
|
|
1272
1417
|
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
1273
1418
|
}
|
|
1274
1419
|
}
|
|
1275
1420
|
if (!this.initResult) {
|
|
1276
|
-
|
|
1421
|
+
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.` : "";
|
|
1422
|
+
throw new Error(
|
|
1423
|
+
`Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${maxRetries} attempts: ${lastError}.${served}`
|
|
1424
|
+
);
|
|
1277
1425
|
}
|
|
1278
1426
|
this._sessionId = this.transport.getSessionId();
|
|
1279
1427
|
this._sessionInfo = {
|
|
@@ -1286,6 +1434,7 @@ var McpTestClient = class {
|
|
|
1286
1434
|
jsonrpc: "2.0",
|
|
1287
1435
|
method: "notifications/initialized"
|
|
1288
1436
|
});
|
|
1437
|
+
await this.transport.openNotificationStream?.(this.initResult.protocolVersion);
|
|
1289
1438
|
this.log("info", `Connected to ${this.initResult.serverInfo?.name ?? "MCP Server"}`);
|
|
1290
1439
|
return this.initResult;
|
|
1291
1440
|
}
|
|
@@ -1396,8 +1545,8 @@ var McpTestClient = class {
|
|
|
1396
1545
|
/**
|
|
1397
1546
|
* Call a tool by name with arguments
|
|
1398
1547
|
*/
|
|
1399
|
-
call: async (name, args) => {
|
|
1400
|
-
const response = await this.callTool(name, args);
|
|
1548
|
+
call: async (name, args, options = {}) => {
|
|
1549
|
+
const response = await this.callTool(name, args, this.progressTokenFor(options));
|
|
1401
1550
|
return this.wrapToolResult(response);
|
|
1402
1551
|
}
|
|
1403
1552
|
};
|
|
@@ -1539,6 +1688,7 @@ var McpTestClient = class {
|
|
|
1539
1688
|
* Collect progress notifications specifically
|
|
1540
1689
|
*/
|
|
1541
1690
|
collectProgress: () => {
|
|
1691
|
+
this.collectingProgress = true;
|
|
1542
1692
|
return new ProgressCollector(this._progressUpdates);
|
|
1543
1693
|
},
|
|
1544
1694
|
/**
|
|
@@ -1844,12 +1994,29 @@ var McpTestClient = class {
|
|
|
1844
1994
|
async listTools() {
|
|
1845
1995
|
return this.request("tools/list", {});
|
|
1846
1996
|
}
|
|
1847
|
-
async callTool(name, args) {
|
|
1997
|
+
async callTool(name, args, progressToken) {
|
|
1848
1998
|
return this.request("tools/call", {
|
|
1849
1999
|
name,
|
|
1850
|
-
arguments: args ?? {}
|
|
2000
|
+
arguments: args ?? {},
|
|
2001
|
+
...progressToken !== void 0 && { _meta: { progressToken } }
|
|
1851
2002
|
});
|
|
1852
2003
|
}
|
|
2004
|
+
progressTokenFor(options) {
|
|
2005
|
+
if (options.progressToken !== void 0) return options.progressToken;
|
|
2006
|
+
return this.collectingProgress ? `progress-${++this.progressTokenCounter}` : void 0;
|
|
2007
|
+
}
|
|
2008
|
+
recordNotification = (notification) => {
|
|
2009
|
+
const timestamp = /* @__PURE__ */ new Date();
|
|
2010
|
+
this._notifications.push({ method: notification.method, params: notification.params, timestamp });
|
|
2011
|
+
if (notification.method !== "notifications/progress") return;
|
|
2012
|
+
const { progress, total, progressToken } = notification.params ?? {};
|
|
2013
|
+
this._progressUpdates.push({
|
|
2014
|
+
progress: Number(progress),
|
|
2015
|
+
total: typeof total === "number" ? total : void 0,
|
|
2016
|
+
progressToken: typeof progressToken === "string" || typeof progressToken === "number" ? progressToken : void 0,
|
|
2017
|
+
timestamp
|
|
2018
|
+
});
|
|
2019
|
+
};
|
|
1853
2020
|
async listResources() {
|
|
1854
2021
|
return this.request("resources/list", {});
|
|
1855
2022
|
}
|
|
@@ -1871,6 +2038,11 @@ var McpTestClient = class {
|
|
|
1871
2038
|
// ═══════════════════════════════════════════════════════════════════
|
|
1872
2039
|
// PRIVATE: TRANSPORT & REQUEST HELPERS
|
|
1873
2040
|
// ═══════════════════════════════════════════════════════════════════
|
|
2041
|
+
/** The MCP path this client is currently pointed at, for log and error text. */
|
|
2042
|
+
mcpPathLabel() {
|
|
2043
|
+
const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
|
|
2044
|
+
return entry ? `/${entry}` : "/";
|
|
2045
|
+
}
|
|
1874
2046
|
createTransport() {
|
|
1875
2047
|
let baseUrl = this.config.baseUrl;
|
|
1876
2048
|
if (this.config.queryParams && Object.keys(this.config.queryParams).length > 0) {
|
|
@@ -1884,13 +2056,15 @@ var McpTestClient = class {
|
|
|
1884
2056
|
case "streamable-http":
|
|
1885
2057
|
return new StreamableHttpTransport({
|
|
1886
2058
|
baseUrl,
|
|
2059
|
+
entryPath: this.config.entryPath,
|
|
1887
2060
|
timeout: this.config.timeout,
|
|
1888
2061
|
auth: this.config.auth,
|
|
1889
2062
|
publicMode: this.config.publicMode,
|
|
1890
2063
|
debug: this.config.debug,
|
|
1891
2064
|
interceptors: this._interceptors,
|
|
1892
2065
|
clientInfo: this.config.clientInfo,
|
|
1893
|
-
elicitationHandler: this._elicitationHandler
|
|
2066
|
+
elicitationHandler: this._elicitationHandler,
|
|
2067
|
+
notificationHandler: this.recordNotification
|
|
1894
2068
|
});
|
|
1895
2069
|
case "sse":
|
|
1896
2070
|
throw new Error("SSE transport not yet implemented");
|
|
@@ -4770,13 +4944,17 @@ var currentConfig = {};
|
|
|
4770
4944
|
var serverInstance = null;
|
|
4771
4945
|
var tokenFactory = null;
|
|
4772
4946
|
var serverStartedByUs = false;
|
|
4947
|
+
var skipScopes = [{ skipped: false }];
|
|
4948
|
+
function currentSkipScope() {
|
|
4949
|
+
return skipScopes[skipScopes.length - 1];
|
|
4950
|
+
}
|
|
4773
4951
|
async function initializeSharedResources() {
|
|
4774
4952
|
if (!tokenFactory) {
|
|
4775
4953
|
const sharedSecret = currentConfig.env?.["JWT_SECRET"];
|
|
4776
4954
|
tokenFactory = new TestTokenFactory(sharedSecret ? { hmacSecret: sharedSecret } : {});
|
|
4777
4955
|
}
|
|
4778
4956
|
if (!serverInstance) {
|
|
4779
|
-
if (currentConfig.baseUrl) {
|
|
4957
|
+
if (currentConfig.baseUrl && !currentConfig.server) {
|
|
4780
4958
|
serverInstance = TestServer.connect(currentConfig.baseUrl);
|
|
4781
4959
|
serverStartedByUs = false;
|
|
4782
4960
|
} else if (currentConfig.server) {
|
|
@@ -4826,7 +5004,8 @@ async function createTestFixtures() {
|
|
|
4826
5004
|
throw new Error("Token factory not initialized");
|
|
4827
5005
|
}
|
|
4828
5006
|
const clientInstance = McpTestClient.create({
|
|
4829
|
-
baseUrl: serverInstance
|
|
5007
|
+
baseUrl: resolveClientBaseUrl(serverInstance),
|
|
5008
|
+
entryPath: currentConfig.entryPath,
|
|
4830
5009
|
transport: currentConfig.transport ?? "streamable-http",
|
|
4831
5010
|
publicMode: currentConfig.publicMode
|
|
4832
5011
|
}).build();
|
|
@@ -4875,6 +5054,9 @@ async function cleanupSharedResources() {
|
|
|
4875
5054
|
tokenFactory = null;
|
|
4876
5055
|
serverStartedByUs = false;
|
|
4877
5056
|
}
|
|
5057
|
+
function resolveClientBaseUrl(server) {
|
|
5058
|
+
return currentConfig.baseUrl ?? server.info.baseUrl;
|
|
5059
|
+
}
|
|
4878
5060
|
function createAuthFixture(factory) {
|
|
4879
5061
|
const users = {
|
|
4880
5062
|
admin: {
|
|
@@ -4924,7 +5106,8 @@ function createServerFixture(server) {
|
|
|
4924
5106
|
info: server.info,
|
|
4925
5107
|
createClient: async (opts) => {
|
|
4926
5108
|
return McpTestClient.create({
|
|
4927
|
-
baseUrl: server
|
|
5109
|
+
baseUrl: resolveClientBaseUrl(server),
|
|
5110
|
+
entryPath: opts?.entryPath ?? currentConfig.entryPath,
|
|
4928
5111
|
transport: opts?.transport ?? "streamable-http",
|
|
4929
5112
|
auth: opts?.token ? { token: opts.token } : void 0,
|
|
4930
5113
|
clientInfo: opts?.clientInfo,
|
|
@@ -4933,7 +5116,8 @@ function createServerFixture(server) {
|
|
|
4933
5116
|
},
|
|
4934
5117
|
createClientBuilder: () => {
|
|
4935
5118
|
const builder = new McpTestClientBuilder({
|
|
4936
|
-
baseUrl: server
|
|
5119
|
+
baseUrl: resolveClientBaseUrl(server),
|
|
5120
|
+
entryPath: currentConfig.entryPath,
|
|
4937
5121
|
publicMode: currentConfig.publicMode
|
|
4938
5122
|
});
|
|
4939
5123
|
return builder;
|
|
@@ -4949,8 +5133,8 @@ function resolveServerCommand(server) {
|
|
|
4949
5133
|
}
|
|
4950
5134
|
return `npx tsx ${server}`;
|
|
4951
5135
|
}
|
|
4952
|
-
function
|
|
4953
|
-
|
|
5136
|
+
function runWithFixtures(fn) {
|
|
5137
|
+
return async () => {
|
|
4954
5138
|
const fixtures = await createTestFixtures();
|
|
4955
5139
|
let testFailed = false;
|
|
4956
5140
|
try {
|
|
@@ -4961,7 +5145,15 @@ function testWithFixtures(name, fn) {
|
|
|
4961
5145
|
} finally {
|
|
4962
5146
|
await cleanupTestFixtures(fixtures, testFailed);
|
|
4963
5147
|
}
|
|
4964
|
-
}
|
|
5148
|
+
};
|
|
5149
|
+
}
|
|
5150
|
+
function testWithFixtures(name, fn) {
|
|
5151
|
+
const scope = currentSkipScope();
|
|
5152
|
+
if (scope.skipped) {
|
|
5153
|
+
it2.skip(scope.reason ? `${name} (skipped: ${scope.reason})` : name, runWithFixtures(fn));
|
|
5154
|
+
return;
|
|
5155
|
+
}
|
|
5156
|
+
it2(name, runWithFixtures(fn));
|
|
4965
5157
|
}
|
|
4966
5158
|
function use(config) {
|
|
4967
5159
|
currentConfig = { ...currentConfig, ...config };
|
|
@@ -4969,40 +5161,60 @@ function use(config) {
|
|
|
4969
5161
|
await cleanupSharedResources();
|
|
4970
5162
|
});
|
|
4971
5163
|
}
|
|
4972
|
-
function skip(
|
|
4973
|
-
|
|
4974
|
-
|
|
4975
|
-
|
|
4976
|
-
|
|
4977
|
-
|
|
4978
|
-
} catch (error) {
|
|
4979
|
-
testFailed = true;
|
|
4980
|
-
throw error;
|
|
4981
|
-
} finally {
|
|
4982
|
-
await cleanupTestFixtures(fixtures, testFailed);
|
|
5164
|
+
function skip(nameOrCondition, fnOrReason) {
|
|
5165
|
+
if (typeof nameOrCondition === "boolean") {
|
|
5166
|
+
if (nameOrCondition) {
|
|
5167
|
+
const scope = currentSkipScope();
|
|
5168
|
+
scope.skipped = true;
|
|
5169
|
+
scope.reason = typeof fnOrReason === "string" ? fnOrReason : void 0;
|
|
4983
5170
|
}
|
|
4984
|
-
|
|
5171
|
+
return;
|
|
5172
|
+
}
|
|
5173
|
+
if (typeof fnOrReason !== "function") {
|
|
5174
|
+
throw new TypeError(
|
|
5175
|
+
`test.skip expects either (name: string, fn) or (condition: boolean, reason?: string); received (${typeof nameOrCondition}, ${typeof fnOrReason}).`
|
|
5176
|
+
);
|
|
5177
|
+
}
|
|
5178
|
+
it2.skip(nameOrCondition, runWithFixtures(fnOrReason));
|
|
4985
5179
|
}
|
|
4986
5180
|
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
|
-
});
|
|
5181
|
+
const scope = currentSkipScope();
|
|
5182
|
+
if (scope.skipped) {
|
|
5183
|
+
it2.skip(scope.reason ? `${name} (skipped: ${scope.reason})` : name, runWithFixtures(fn));
|
|
5184
|
+
return;
|
|
5185
|
+
}
|
|
5186
|
+
it2.only(name, runWithFixtures(fn));
|
|
4999
5187
|
}
|
|
5000
5188
|
function todo(name) {
|
|
5001
5189
|
it2.todo(name);
|
|
5002
5190
|
}
|
|
5191
|
+
function withSkipScope(register) {
|
|
5192
|
+
return (name, fn) => {
|
|
5193
|
+
register(name, () => {
|
|
5194
|
+
const parent = currentSkipScope();
|
|
5195
|
+
skipScopes.push({ skipped: parent.skipped, reason: parent.reason });
|
|
5196
|
+
try {
|
|
5197
|
+
fn();
|
|
5198
|
+
} finally {
|
|
5199
|
+
skipScopes.pop();
|
|
5200
|
+
}
|
|
5201
|
+
});
|
|
5202
|
+
};
|
|
5203
|
+
}
|
|
5204
|
+
function eachWithSkipScope(each) {
|
|
5205
|
+
return ((...eachArgs) => {
|
|
5206
|
+
const register = each(...eachArgs);
|
|
5207
|
+
return withSkipScope(register);
|
|
5208
|
+
});
|
|
5209
|
+
}
|
|
5210
|
+
var describeWithSkipScope = Object.assign(withSkipScope(describe2), describe2, {
|
|
5211
|
+
only: Object.assign(withSkipScope(describe2.only), { each: eachWithSkipScope(describe2.only.each) }),
|
|
5212
|
+
skip: Object.assign(withSkipScope(describe2.skip), { each: eachWithSkipScope(describe2.skip.each) }),
|
|
5213
|
+
each: eachWithSkipScope(describe2.each)
|
|
5214
|
+
});
|
|
5003
5215
|
var test = testWithFixtures;
|
|
5004
5216
|
test.use = use;
|
|
5005
|
-
test.describe =
|
|
5217
|
+
test.describe = describeWithSkipScope;
|
|
5006
5218
|
test.beforeAll = beforeAll2;
|
|
5007
5219
|
test.beforeEach = beforeEach2;
|
|
5008
5220
|
test.afterEach = afterEach2;
|
|
@@ -5395,18 +5607,17 @@ var toBeError = function(received, expectedCode) {
|
|
|
5395
5607
|
message: () => `Expected a result wrapper object with isError property`
|
|
5396
5608
|
};
|
|
5397
5609
|
}
|
|
5398
|
-
|
|
5399
|
-
|
|
5400
|
-
|
|
5401
|
-
}
|
|
5610
|
+
const receivedCode = typeof expectedCode === "string" ? result.raw?._meta?.["code"] : result.error?.code;
|
|
5611
|
+
const codeMatches = expectedCode === void 0 || receivedCode === expectedCode;
|
|
5612
|
+
const pass = result.isError && codeMatches;
|
|
5402
5613
|
return {
|
|
5403
5614
|
pass,
|
|
5404
5615
|
message: () => {
|
|
5405
5616
|
if (!result.isError) {
|
|
5406
5617
|
return "Expected result to be an error, but it was successful";
|
|
5407
5618
|
}
|
|
5408
|
-
if (
|
|
5409
|
-
return `Expected error code ${expectedCode}, but got ${
|
|
5619
|
+
if (!codeMatches) {
|
|
5620
|
+
return `Expected error code ${expectedCode}, but got ${String(receivedCode)}`;
|
|
5410
5621
|
}
|
|
5411
5622
|
return "Expected result not to be an error";
|
|
5412
5623
|
}
|