@frontmcp/testing 0.6.1 → 0.6.3
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/esm/fixtures/index.mjs +2377 -0
- package/esm/index.mjs +4768 -0
- package/esm/matchers/index.mjs +646 -0
- package/esm/package.json +122 -0
- package/esm/playwright/index.mjs +19 -0
- package/esm/setup.mjs +680 -0
- package/fixtures/index.js +2418 -0
- package/index.js +4866 -0
- package/jest-preset.js +3 -3
- package/matchers/index.js +673 -0
- package/package.json +51 -23
- package/playwright/index.js +46 -0
- package/setup.js +651 -0
- package/src/assertions/index.js +0 -18
- package/src/assertions/index.js.map +0 -1
- package/src/assertions/mcp-assertions.js +0 -220
- package/src/assertions/mcp-assertions.js.map +0 -1
- package/src/auth/auth-headers.js +0 -62
- package/src/auth/auth-headers.js.map +0 -1
- package/src/auth/index.js +0 -15
- package/src/auth/index.js.map +0 -1
- package/src/auth/mock-api-server.js +0 -200
- package/src/auth/mock-api-server.js.map +0 -1
- package/src/auth/mock-oauth-server.js +0 -253
- package/src/auth/mock-oauth-server.js.map +0 -1
- package/src/auth/token-factory.js +0 -181
- package/src/auth/token-factory.js.map +0 -1
- package/src/auth/user-fixtures.js +0 -92
- package/src/auth/user-fixtures.js.map +0 -1
- package/src/client/index.js +0 -12
- package/src/client/index.js.map +0 -1
- package/src/client/mcp-test-client.builder.js +0 -163
- package/src/client/mcp-test-client.builder.js.map +0 -1
- package/src/client/mcp-test-client.js +0 -937
- package/src/client/mcp-test-client.js.map +0 -1
- package/src/client/mcp-test-client.types.js +0 -16
- package/src/client/mcp-test-client.types.js.map +0 -1
- package/src/errors/index.js +0 -85
- package/src/errors/index.js.map +0 -1
- package/src/example-tools/index.js +0 -40
- package/src/example-tools/index.js.map +0 -1
- package/src/example-tools/tool-configs.js +0 -222
- package/src/example-tools/tool-configs.js.map +0 -1
- package/src/expect.js +0 -31
- package/src/expect.js.map +0 -1
- package/src/fixtures/fixture-types.js +0 -7
- package/src/fixtures/fixture-types.js.map +0 -1
- package/src/fixtures/index.js +0 -16
- package/src/fixtures/index.js.map +0 -1
- package/src/fixtures/test-fixture.js +0 -311
- package/src/fixtures/test-fixture.js.map +0 -1
- package/src/http-mock/http-mock.js +0 -544
- package/src/http-mock/http-mock.js.map +0 -1
- package/src/http-mock/http-mock.types.js +0 -10
- package/src/http-mock/http-mock.types.js.map +0 -1
- package/src/http-mock/index.js +0 -11
- package/src/http-mock/index.js.map +0 -1
- package/src/index.js +0 -167
- package/src/index.js.map +0 -1
- package/src/interceptor/index.js +0 -15
- package/src/interceptor/index.js.map +0 -1
- package/src/interceptor/interceptor-chain.js +0 -207
- package/src/interceptor/interceptor-chain.js.map +0 -1
- package/src/interceptor/interceptor.types.js +0 -7
- package/src/interceptor/interceptor.types.js.map +0 -1
- package/src/interceptor/mock-registry.js +0 -189
- package/src/interceptor/mock-registry.js.map +0 -1
- package/src/matchers/index.js +0 -12
- package/src/matchers/index.js.map +0 -1
- package/src/matchers/matcher-types.js +0 -10
- package/src/matchers/matcher-types.js.map +0 -1
- package/src/matchers/mcp-matchers.js +0 -395
- package/src/matchers/mcp-matchers.js.map +0 -1
- package/src/platform/index.js +0 -47
- package/src/platform/index.js.map +0 -1
- package/src/platform/platform-client-info.js +0 -155
- package/src/platform/platform-client-info.js.map +0 -1
- package/src/platform/platform-types.js +0 -110
- package/src/platform/platform-types.js.map +0 -1
- package/src/playwright/index.js +0 -49
- package/src/playwright/index.js.map +0 -1
- package/src/server/index.js +0 -10
- package/src/server/index.js.map +0 -1
- package/src/server/test-server.js +0 -341
- package/src/server/test-server.js.map +0 -1
- package/src/setup.js +0 -30
- package/src/setup.js.map +0 -1
- package/src/transport/index.js +0 -10
- package/src/transport/index.js.map +0 -1
- package/src/transport/streamable-http.transport.js +0 -438
- package/src/transport/streamable-http.transport.js.map +0 -1
- package/src/transport/transport.interface.js +0 -7
- package/src/transport/transport.interface.js.map +0 -1
- package/src/ui/index.js +0 -23
- package/src/ui/index.js.map +0 -1
- package/src/ui/ui-assertions.js +0 -367
- package/src/ui/ui-assertions.js.map +0 -1
- package/src/ui/ui-matchers.js +0 -493
- package/src/ui/ui-matchers.js.map +0 -1
- /package/{src/assertions → assertions}/index.d.ts +0 -0
- /package/{src/assertions → assertions}/mcp-assertions.d.ts +0 -0
- /package/{src/auth → auth}/auth-headers.d.ts +0 -0
- /package/{src/auth → auth}/index.d.ts +0 -0
- /package/{src/auth → auth}/mock-api-server.d.ts +0 -0
- /package/{src/auth → auth}/mock-oauth-server.d.ts +0 -0
- /package/{src/auth → auth}/token-factory.d.ts +0 -0
- /package/{src/auth → auth}/user-fixtures.d.ts +0 -0
- /package/{src/client → client}/index.d.ts +0 -0
- /package/{src/client → client}/mcp-test-client.builder.d.ts +0 -0
- /package/{src/client → client}/mcp-test-client.d.ts +0 -0
- /package/{src/client → client}/mcp-test-client.types.d.ts +0 -0
- /package/{src/errors → errors}/index.d.ts +0 -0
- /package/{src/example-tools → example-tools}/index.d.ts +0 -0
- /package/{src/example-tools → example-tools}/tool-configs.d.ts +0 -0
- /package/{src/expect.d.ts → expect.d.ts} +0 -0
- /package/{src/fixtures → fixtures}/fixture-types.d.ts +0 -0
- /package/{src/fixtures → fixtures}/index.d.ts +0 -0
- /package/{src/fixtures → fixtures}/test-fixture.d.ts +0 -0
- /package/{src/http-mock → http-mock}/http-mock.d.ts +0 -0
- /package/{src/http-mock → http-mock}/http-mock.types.d.ts +0 -0
- /package/{src/http-mock → http-mock}/index.d.ts +0 -0
- /package/{src/index.d.ts → index.d.ts} +0 -0
- /package/{src/interceptor → interceptor}/index.d.ts +0 -0
- /package/{src/interceptor → interceptor}/interceptor-chain.d.ts +0 -0
- /package/{src/interceptor → interceptor}/interceptor.types.d.ts +0 -0
- /package/{src/interceptor → interceptor}/mock-registry.d.ts +0 -0
- /package/{src/matchers → matchers}/index.d.ts +0 -0
- /package/{src/matchers → matchers}/matcher-types.d.ts +0 -0
- /package/{src/matchers → matchers}/mcp-matchers.d.ts +0 -0
- /package/{src/platform → platform}/index.d.ts +0 -0
- /package/{src/platform → platform}/platform-client-info.d.ts +0 -0
- /package/{src/platform → platform}/platform-types.d.ts +0 -0
- /package/{src/playwright → playwright}/index.d.ts +0 -0
- /package/{src/server → server}/index.d.ts +0 -0
- /package/{src/server → server}/test-server.d.ts +0 -0
- /package/{src/setup.d.ts → setup.d.ts} +0 -0
- /package/{src/transport → transport}/index.d.ts +0 -0
- /package/{src/transport → transport}/streamable-http.transport.d.ts +0 -0
- /package/{src/transport → transport}/transport.interface.d.ts +0 -0
- /package/{src/ui → ui}/index.d.ts +0 -0
- /package/{src/ui → ui}/ui-assertions.d.ts +0 -0
- /package/{src/ui → ui}/ui-matchers.d.ts +0 -0
package/esm/index.mjs
ADDED
|
@@ -0,0 +1,4768 @@
|
|
|
1
|
+
// libs/testing/src/platform/platform-client-info.ts
|
|
2
|
+
var MCP_APPS_EXTENSION_KEY = "io.modelcontextprotocol/ui";
|
|
3
|
+
function getPlatformClientInfo(platform) {
|
|
4
|
+
switch (platform) {
|
|
5
|
+
case "openai":
|
|
6
|
+
return {
|
|
7
|
+
name: "ChatGPT",
|
|
8
|
+
version: "1.0"
|
|
9
|
+
};
|
|
10
|
+
case "ext-apps":
|
|
11
|
+
return {
|
|
12
|
+
name: "mcp-ext-apps",
|
|
13
|
+
version: "1.0"
|
|
14
|
+
};
|
|
15
|
+
case "claude":
|
|
16
|
+
return {
|
|
17
|
+
name: "claude-desktop",
|
|
18
|
+
version: "1.0"
|
|
19
|
+
};
|
|
20
|
+
case "cursor":
|
|
21
|
+
return {
|
|
22
|
+
name: "cursor",
|
|
23
|
+
version: "1.0"
|
|
24
|
+
};
|
|
25
|
+
case "continue":
|
|
26
|
+
return {
|
|
27
|
+
name: "continue",
|
|
28
|
+
version: "1.0"
|
|
29
|
+
};
|
|
30
|
+
case "cody":
|
|
31
|
+
return {
|
|
32
|
+
name: "cody",
|
|
33
|
+
version: "1.0"
|
|
34
|
+
};
|
|
35
|
+
case "gemini":
|
|
36
|
+
return {
|
|
37
|
+
name: "gemini",
|
|
38
|
+
version: "1.0"
|
|
39
|
+
};
|
|
40
|
+
case "generic-mcp":
|
|
41
|
+
return {
|
|
42
|
+
name: "generic-mcp-client",
|
|
43
|
+
version: "1.0"
|
|
44
|
+
};
|
|
45
|
+
case "unknown":
|
|
46
|
+
default:
|
|
47
|
+
return {
|
|
48
|
+
name: "mcp-test-client",
|
|
49
|
+
version: "1.0"
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function buildUserAgent(clientInfo) {
|
|
54
|
+
return `${clientInfo.name}/${clientInfo.version}`;
|
|
55
|
+
}
|
|
56
|
+
function getPlatformUserAgent(platform) {
|
|
57
|
+
return buildUserAgent(getPlatformClientInfo(platform));
|
|
58
|
+
}
|
|
59
|
+
var PLATFORM_DETECTION_PATTERNS = {
|
|
60
|
+
openai: /chatgpt/i,
|
|
61
|
+
"ext-apps": /mcp-ext-apps/i,
|
|
62
|
+
// Note: Actual detection uses capabilities
|
|
63
|
+
claude: /claude|claude-desktop/i,
|
|
64
|
+
cursor: /cursor/i,
|
|
65
|
+
continue: /continue/i,
|
|
66
|
+
cody: /cody/i,
|
|
67
|
+
gemini: /gemini/i,
|
|
68
|
+
"generic-mcp": /generic-mcp/i,
|
|
69
|
+
unknown: /.*/
|
|
70
|
+
// Matches anything (fallback)
|
|
71
|
+
};
|
|
72
|
+
function getPlatformCapabilities(platform) {
|
|
73
|
+
const baseCapabilities = {
|
|
74
|
+
sampling: {}
|
|
75
|
+
};
|
|
76
|
+
if (platform === "ext-apps") {
|
|
77
|
+
return {
|
|
78
|
+
...baseCapabilities,
|
|
79
|
+
experimental: {
|
|
80
|
+
[MCP_APPS_EXTENSION_KEY]: {
|
|
81
|
+
mimeTypes: ["text/html+mcp"]
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return baseCapabilities;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// libs/testing/src/client/mcp-test-client.builder.ts
|
|
90
|
+
var McpTestClientBuilder = class {
|
|
91
|
+
config;
|
|
92
|
+
constructor(config) {
|
|
93
|
+
this.config = { ...config };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Set the authentication configuration
|
|
97
|
+
*/
|
|
98
|
+
withAuth(auth) {
|
|
99
|
+
this.config.auth = { ...this.config.auth, ...auth };
|
|
100
|
+
return this;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Set the bearer token for authentication
|
|
104
|
+
*/
|
|
105
|
+
withToken(token) {
|
|
106
|
+
this.config.auth = { ...this.config.auth, token };
|
|
107
|
+
return this;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Add custom headers to all requests
|
|
111
|
+
*/
|
|
112
|
+
withHeaders(headers) {
|
|
113
|
+
this.config.auth = {
|
|
114
|
+
...this.config.auth,
|
|
115
|
+
headers: { ...this.config.auth?.headers, ...headers }
|
|
116
|
+
};
|
|
117
|
+
return this;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Set the transport type
|
|
121
|
+
*/
|
|
122
|
+
withTransport(transport) {
|
|
123
|
+
this.config.transport = transport;
|
|
124
|
+
return this;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Set the request timeout in milliseconds
|
|
128
|
+
*/
|
|
129
|
+
withTimeout(timeoutMs) {
|
|
130
|
+
this.config.timeout = timeoutMs;
|
|
131
|
+
return this;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Enable debug logging
|
|
135
|
+
*/
|
|
136
|
+
withDebug(enabled = true) {
|
|
137
|
+
this.config.debug = enabled;
|
|
138
|
+
return this;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Enable public mode - skip authentication entirely.
|
|
142
|
+
* When true, no Authorization header is sent and anonymous token is not requested.
|
|
143
|
+
* Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
|
|
144
|
+
*/
|
|
145
|
+
withPublicMode(enabled = true) {
|
|
146
|
+
this.config.publicMode = enabled;
|
|
147
|
+
return this;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Set the MCP protocol version to request
|
|
151
|
+
*/
|
|
152
|
+
withProtocolVersion(version) {
|
|
153
|
+
this.config.protocolVersion = version;
|
|
154
|
+
return this;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Set the client info sent during initialization
|
|
158
|
+
*/
|
|
159
|
+
withClientInfo(info) {
|
|
160
|
+
this.config.clientInfo = info;
|
|
161
|
+
return this;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Set the platform type for testing platform-specific meta keys.
|
|
165
|
+
* Automatically configures clientInfo and capabilities for platform detection.
|
|
166
|
+
*
|
|
167
|
+
* Platform-specific behavior:
|
|
168
|
+
* - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
|
|
169
|
+
* - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
|
|
170
|
+
* - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
|
|
171
|
+
* - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
|
|
172
|
+
* - Other platforms follow similar patterns
|
|
173
|
+
*
|
|
174
|
+
* @example
|
|
175
|
+
* ```typescript
|
|
176
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
177
|
+
* .withPlatform('openai')
|
|
178
|
+
* .buildAndConnect();
|
|
179
|
+
*
|
|
180
|
+
* // ext-apps automatically sets the io.modelcontextprotocol/ui capability
|
|
181
|
+
* const extAppsClient = await McpTestClient.create({ baseUrl })
|
|
182
|
+
* .withPlatform('ext-apps')
|
|
183
|
+
* .buildAndConnect();
|
|
184
|
+
* ```
|
|
185
|
+
*/
|
|
186
|
+
withPlatform(platform) {
|
|
187
|
+
this.config.platform = platform;
|
|
188
|
+
this.config.clientInfo = getPlatformClientInfo(platform);
|
|
189
|
+
this.config.capabilities = getPlatformCapabilities(platform);
|
|
190
|
+
return this;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Set custom client capabilities for MCP initialization.
|
|
194
|
+
* Use this for fine-grained control over capabilities sent during initialization.
|
|
195
|
+
*
|
|
196
|
+
* @example
|
|
197
|
+
* ```typescript
|
|
198
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
199
|
+
* .withCapabilities({
|
|
200
|
+
* sampling: {},
|
|
201
|
+
* experimental: {
|
|
202
|
+
* 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html+mcp'] }
|
|
203
|
+
* }
|
|
204
|
+
* })
|
|
205
|
+
* .buildAndConnect();
|
|
206
|
+
* ```
|
|
207
|
+
*/
|
|
208
|
+
withCapabilities(capabilities) {
|
|
209
|
+
this.config.capabilities = capabilities;
|
|
210
|
+
return this;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Build the McpTestClient instance (does not connect)
|
|
214
|
+
*/
|
|
215
|
+
build() {
|
|
216
|
+
return new McpTestClient(this.config);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Build the McpTestClient and connect to the server
|
|
220
|
+
*/
|
|
221
|
+
async buildAndConnect() {
|
|
222
|
+
const client = this.build();
|
|
223
|
+
await client.connect();
|
|
224
|
+
return client;
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// libs/testing/src/transport/streamable-http.transport.ts
|
|
229
|
+
var DEFAULT_TIMEOUT = 3e4;
|
|
230
|
+
var StreamableHttpTransport = class {
|
|
231
|
+
config;
|
|
232
|
+
state = "disconnected";
|
|
233
|
+
sessionId;
|
|
234
|
+
authToken;
|
|
235
|
+
connectionCount = 0;
|
|
236
|
+
reconnectCount = 0;
|
|
237
|
+
lastRequestHeaders = {};
|
|
238
|
+
interceptors;
|
|
239
|
+
publicMode;
|
|
240
|
+
constructor(config) {
|
|
241
|
+
this.config = {
|
|
242
|
+
baseUrl: config.baseUrl.replace(/\/$/, ""),
|
|
243
|
+
// Remove trailing slash
|
|
244
|
+
timeout: config.timeout ?? DEFAULT_TIMEOUT,
|
|
245
|
+
auth: config.auth ?? {},
|
|
246
|
+
publicMode: config.publicMode ?? false,
|
|
247
|
+
debug: config.debug ?? false,
|
|
248
|
+
interceptors: config.interceptors,
|
|
249
|
+
clientInfo: config.clientInfo
|
|
250
|
+
};
|
|
251
|
+
this.authToken = config.auth?.token;
|
|
252
|
+
this.interceptors = config.interceptors;
|
|
253
|
+
this.publicMode = config.publicMode ?? false;
|
|
254
|
+
}
|
|
255
|
+
async connect() {
|
|
256
|
+
this.state = "connecting";
|
|
257
|
+
this.connectionCount++;
|
|
258
|
+
try {
|
|
259
|
+
if (this.publicMode) {
|
|
260
|
+
this.log("Public mode: connecting without authentication");
|
|
261
|
+
this.state = "connected";
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (!this.authToken) {
|
|
265
|
+
await this.requestAnonymousToken();
|
|
266
|
+
}
|
|
267
|
+
this.state = "connected";
|
|
268
|
+
this.log("Connected to StreamableHTTP transport");
|
|
269
|
+
} catch (error) {
|
|
270
|
+
this.state = "error";
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Request an anonymous token from the FrontMCP OAuth endpoint
|
|
276
|
+
* This allows the test client to authenticate without user interaction
|
|
277
|
+
*/
|
|
278
|
+
async requestAnonymousToken() {
|
|
279
|
+
const clientId = crypto.randomUUID();
|
|
280
|
+
const tokenUrl = `${this.config.baseUrl}/oauth/token`;
|
|
281
|
+
this.log(`Requesting anonymous token from ${tokenUrl}`);
|
|
282
|
+
try {
|
|
283
|
+
const response = await fetch(tokenUrl, {
|
|
284
|
+
method: "POST",
|
|
285
|
+
headers: {
|
|
286
|
+
"Content-Type": "application/json"
|
|
287
|
+
},
|
|
288
|
+
body: JSON.stringify({
|
|
289
|
+
grant_type: "anonymous",
|
|
290
|
+
client_id: clientId,
|
|
291
|
+
resource: this.config.baseUrl
|
|
292
|
+
})
|
|
293
|
+
});
|
|
294
|
+
if (!response.ok) {
|
|
295
|
+
const errorText = await response.text();
|
|
296
|
+
this.log(`Failed to get anonymous token: ${response.status} ${errorText}`);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const tokenResponse = await response.json();
|
|
300
|
+
if (tokenResponse.access_token) {
|
|
301
|
+
this.authToken = tokenResponse.access_token;
|
|
302
|
+
this.log("Anonymous token acquired successfully");
|
|
303
|
+
}
|
|
304
|
+
} catch (error) {
|
|
305
|
+
this.log(`Error requesting anonymous token: ${error}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
async request(message) {
|
|
309
|
+
this.ensureConnected();
|
|
310
|
+
const startTime = Date.now();
|
|
311
|
+
if (this.interceptors) {
|
|
312
|
+
const interceptResult = await this.interceptors.processRequest(message, {
|
|
313
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
314
|
+
transport: "streamable-http",
|
|
315
|
+
sessionId: this.sessionId
|
|
316
|
+
});
|
|
317
|
+
switch (interceptResult.type) {
|
|
318
|
+
case "mock": {
|
|
319
|
+
const mockResponse2 = await this.interceptors.processResponse(
|
|
320
|
+
message,
|
|
321
|
+
interceptResult.response,
|
|
322
|
+
Date.now() - startTime
|
|
323
|
+
);
|
|
324
|
+
return mockResponse2;
|
|
325
|
+
}
|
|
326
|
+
case "error":
|
|
327
|
+
throw interceptResult.error;
|
|
328
|
+
case "continue":
|
|
329
|
+
message = interceptResult.request;
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const headers = this.buildHeaders();
|
|
334
|
+
this.lastRequestHeaders = headers;
|
|
335
|
+
const url = `${this.config.baseUrl}/`;
|
|
336
|
+
this.log(`POST ${url}`, message);
|
|
337
|
+
const controller = new AbortController();
|
|
338
|
+
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
339
|
+
try {
|
|
340
|
+
const response = await fetch(url, {
|
|
341
|
+
method: "POST",
|
|
342
|
+
headers,
|
|
343
|
+
body: JSON.stringify(message),
|
|
344
|
+
signal: controller.signal
|
|
345
|
+
});
|
|
346
|
+
clearTimeout(timeoutId);
|
|
347
|
+
const newSessionId = response.headers.get("mcp-session-id");
|
|
348
|
+
if (newSessionId) {
|
|
349
|
+
this.sessionId = newSessionId;
|
|
350
|
+
}
|
|
351
|
+
let jsonResponse;
|
|
352
|
+
if (!response.ok) {
|
|
353
|
+
const errorText = await response.text();
|
|
354
|
+
this.log(`HTTP Error ${response.status}: ${errorText}`);
|
|
355
|
+
jsonResponse = {
|
|
356
|
+
jsonrpc: "2.0",
|
|
357
|
+
id: message.id ?? null,
|
|
358
|
+
error: {
|
|
359
|
+
code: -32e3,
|
|
360
|
+
message: `HTTP ${response.status}: ${response.statusText}`,
|
|
361
|
+
data: errorText
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
} else {
|
|
365
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
366
|
+
const text = await response.text();
|
|
367
|
+
this.log("Response:", text);
|
|
368
|
+
if (!text.trim()) {
|
|
369
|
+
jsonResponse = {
|
|
370
|
+
jsonrpc: "2.0",
|
|
371
|
+
id: message.id ?? null,
|
|
372
|
+
result: void 0
|
|
373
|
+
};
|
|
374
|
+
} else if (contentType.includes("text/event-stream")) {
|
|
375
|
+
const { response: sseResponse, sseSessionId } = this.parseSSEResponseWithSession(text, message.id);
|
|
376
|
+
jsonResponse = sseResponse;
|
|
377
|
+
if (sseSessionId && !this.sessionId) {
|
|
378
|
+
this.sessionId = sseSessionId;
|
|
379
|
+
this.log("Session ID from SSE:", this.sessionId);
|
|
380
|
+
}
|
|
381
|
+
} else {
|
|
382
|
+
jsonResponse = JSON.parse(text);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (this.interceptors) {
|
|
386
|
+
jsonResponse = await this.interceptors.processResponse(message, jsonResponse, Date.now() - startTime);
|
|
387
|
+
}
|
|
388
|
+
return jsonResponse;
|
|
389
|
+
} catch (error) {
|
|
390
|
+
clearTimeout(timeoutId);
|
|
391
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
392
|
+
return {
|
|
393
|
+
jsonrpc: "2.0",
|
|
394
|
+
id: message.id ?? null,
|
|
395
|
+
error: {
|
|
396
|
+
code: -32e3,
|
|
397
|
+
message: `Request timeout after ${this.config.timeout}ms`
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
throw error;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
async notify(message) {
|
|
405
|
+
this.ensureConnected();
|
|
406
|
+
const headers = this.buildHeaders();
|
|
407
|
+
this.lastRequestHeaders = headers;
|
|
408
|
+
const url = `${this.config.baseUrl}/`;
|
|
409
|
+
this.log(`POST ${url} (notification)`, message);
|
|
410
|
+
const controller = new AbortController();
|
|
411
|
+
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
412
|
+
try {
|
|
413
|
+
const response = await fetch(url, {
|
|
414
|
+
method: "POST",
|
|
415
|
+
headers,
|
|
416
|
+
body: JSON.stringify(message),
|
|
417
|
+
signal: controller.signal
|
|
418
|
+
});
|
|
419
|
+
clearTimeout(timeoutId);
|
|
420
|
+
const newSessionId = response.headers.get("mcp-session-id");
|
|
421
|
+
if (newSessionId) {
|
|
422
|
+
this.sessionId = newSessionId;
|
|
423
|
+
}
|
|
424
|
+
if (!response.ok) {
|
|
425
|
+
const errorText = await response.text();
|
|
426
|
+
this.log(`HTTP Error ${response.status} on notification: ${errorText}`);
|
|
427
|
+
}
|
|
428
|
+
} catch (error) {
|
|
429
|
+
clearTimeout(timeoutId);
|
|
430
|
+
if (error instanceof Error && error.name !== "AbortError") {
|
|
431
|
+
throw error;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
async sendRaw(data) {
|
|
436
|
+
this.ensureConnected();
|
|
437
|
+
const headers = this.buildHeaders();
|
|
438
|
+
this.lastRequestHeaders = headers;
|
|
439
|
+
const url = `${this.config.baseUrl}/`;
|
|
440
|
+
this.log(`POST ${url} (raw)`, data);
|
|
441
|
+
const controller = new AbortController();
|
|
442
|
+
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
443
|
+
try {
|
|
444
|
+
const response = await fetch(url, {
|
|
445
|
+
method: "POST",
|
|
446
|
+
headers,
|
|
447
|
+
body: data,
|
|
448
|
+
signal: controller.signal
|
|
449
|
+
});
|
|
450
|
+
clearTimeout(timeoutId);
|
|
451
|
+
const text = await response.text();
|
|
452
|
+
if (!text.trim()) {
|
|
453
|
+
return {
|
|
454
|
+
jsonrpc: "2.0",
|
|
455
|
+
id: null,
|
|
456
|
+
error: {
|
|
457
|
+
code: -32700,
|
|
458
|
+
message: "Parse error"
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
return JSON.parse(text);
|
|
463
|
+
} catch (error) {
|
|
464
|
+
clearTimeout(timeoutId);
|
|
465
|
+
return {
|
|
466
|
+
jsonrpc: "2.0",
|
|
467
|
+
id: null,
|
|
468
|
+
error: {
|
|
469
|
+
code: -32700,
|
|
470
|
+
message: "Parse error",
|
|
471
|
+
data: error instanceof Error ? error.message : "Unknown error"
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
async close() {
|
|
477
|
+
this.state = "disconnected";
|
|
478
|
+
this.sessionId = void 0;
|
|
479
|
+
this.log("StreamableHTTP transport closed");
|
|
480
|
+
}
|
|
481
|
+
isConnected() {
|
|
482
|
+
return this.state === "connected";
|
|
483
|
+
}
|
|
484
|
+
getState() {
|
|
485
|
+
return this.state;
|
|
486
|
+
}
|
|
487
|
+
getSessionId() {
|
|
488
|
+
return this.sessionId;
|
|
489
|
+
}
|
|
490
|
+
setAuthToken(token) {
|
|
491
|
+
this.authToken = token;
|
|
492
|
+
}
|
|
493
|
+
setTimeout(ms) {
|
|
494
|
+
this.config.timeout = ms;
|
|
495
|
+
}
|
|
496
|
+
setInterceptors(interceptors2) {
|
|
497
|
+
this.interceptors = interceptors2;
|
|
498
|
+
}
|
|
499
|
+
getInterceptors() {
|
|
500
|
+
return this.interceptors;
|
|
501
|
+
}
|
|
502
|
+
getConnectionCount() {
|
|
503
|
+
return this.connectionCount;
|
|
504
|
+
}
|
|
505
|
+
getReconnectCount() {
|
|
506
|
+
return this.reconnectCount;
|
|
507
|
+
}
|
|
508
|
+
getLastRequestHeaders() {
|
|
509
|
+
return { ...this.lastRequestHeaders };
|
|
510
|
+
}
|
|
511
|
+
async simulateDisconnect() {
|
|
512
|
+
this.state = "disconnected";
|
|
513
|
+
this.sessionId = void 0;
|
|
514
|
+
}
|
|
515
|
+
async waitForReconnect(timeoutMs) {
|
|
516
|
+
const deadline = Date.now() + timeoutMs;
|
|
517
|
+
this.reconnectCount++;
|
|
518
|
+
await this.connect();
|
|
519
|
+
while (Date.now() < deadline) {
|
|
520
|
+
if (this.state === "connected") {
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
524
|
+
}
|
|
525
|
+
throw new Error("Timeout waiting for reconnection");
|
|
526
|
+
}
|
|
527
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
528
|
+
// PRIVATE HELPERS
|
|
529
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
530
|
+
buildHeaders() {
|
|
531
|
+
const headers = {
|
|
532
|
+
"Content-Type": "application/json",
|
|
533
|
+
Accept: "application/json, text/event-stream"
|
|
534
|
+
};
|
|
535
|
+
if (this.config.clientInfo) {
|
|
536
|
+
headers["User-Agent"] = `${this.config.clientInfo.name}/${this.config.clientInfo.version}`;
|
|
537
|
+
}
|
|
538
|
+
if (this.authToken && !this.publicMode) {
|
|
539
|
+
headers["Authorization"] = `Bearer ${this.authToken}`;
|
|
540
|
+
}
|
|
541
|
+
if (this.sessionId) {
|
|
542
|
+
headers["mcp-session-id"] = this.sessionId;
|
|
543
|
+
}
|
|
544
|
+
if (this.config.auth.headers) {
|
|
545
|
+
Object.assign(headers, this.config.auth.headers);
|
|
546
|
+
}
|
|
547
|
+
return headers;
|
|
548
|
+
}
|
|
549
|
+
ensureConnected() {
|
|
550
|
+
if (this.state !== "connected") {
|
|
551
|
+
throw new Error("Transport not connected. Call connect() first.");
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
log(message, data) {
|
|
555
|
+
if (this.config.debug) {
|
|
556
|
+
console.log(`[StreamableHTTP] ${message}`, data ?? "");
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Parse SSE (Server-Sent Events) response format with session ID extraction
|
|
561
|
+
* SSE format is:
|
|
562
|
+
* event: message
|
|
563
|
+
* id: sessionId:messageId
|
|
564
|
+
* data: {"jsonrpc":"2.0",...}
|
|
565
|
+
*
|
|
566
|
+
* The id field contains the session ID followed by a colon and the message ID.
|
|
567
|
+
*
|
|
568
|
+
* @param text - The raw SSE response text
|
|
569
|
+
* @param requestId - The original request ID
|
|
570
|
+
* @returns Object with parsed JSON-RPC response and session ID (if found)
|
|
571
|
+
*/
|
|
572
|
+
parseSSEResponseWithSession(text, requestId) {
|
|
573
|
+
const lines = text.split("\n");
|
|
574
|
+
const dataLines = [];
|
|
575
|
+
let sseSessionId;
|
|
576
|
+
for (const line of lines) {
|
|
577
|
+
if (line.startsWith("data: ")) {
|
|
578
|
+
dataLines.push(line.slice(6));
|
|
579
|
+
} else if (line === "data:") {
|
|
580
|
+
dataLines.push("");
|
|
581
|
+
} else if (line.startsWith("id: ")) {
|
|
582
|
+
const idValue = line.slice(4);
|
|
583
|
+
const colonIndex = idValue.lastIndexOf(":");
|
|
584
|
+
if (colonIndex > 0) {
|
|
585
|
+
sseSessionId = idValue.substring(0, colonIndex);
|
|
586
|
+
} else {
|
|
587
|
+
sseSessionId = idValue;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
if (dataLines.length > 0) {
|
|
592
|
+
const jsonData = dataLines.join("\n");
|
|
593
|
+
try {
|
|
594
|
+
return {
|
|
595
|
+
response: JSON.parse(jsonData),
|
|
596
|
+
sseSessionId
|
|
597
|
+
};
|
|
598
|
+
} catch {
|
|
599
|
+
this.log("Failed to parse SSE data as JSON:", jsonData);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return {
|
|
603
|
+
response: {
|
|
604
|
+
jsonrpc: "2.0",
|
|
605
|
+
id: requestId ?? null,
|
|
606
|
+
error: {
|
|
607
|
+
code: -32700,
|
|
608
|
+
message: "Failed to parse SSE response",
|
|
609
|
+
data: text
|
|
610
|
+
}
|
|
611
|
+
},
|
|
612
|
+
sseSessionId
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
// libs/testing/src/interceptor/mock-registry.ts
|
|
618
|
+
var DefaultMockRegistry = class {
|
|
619
|
+
mocks = [];
|
|
620
|
+
add(mock) {
|
|
621
|
+
const entry = {
|
|
622
|
+
definition: mock,
|
|
623
|
+
callCount: 0,
|
|
624
|
+
calls: [],
|
|
625
|
+
remainingUses: mock.times ?? Infinity
|
|
626
|
+
};
|
|
627
|
+
this.mocks.push(entry);
|
|
628
|
+
return {
|
|
629
|
+
remove: () => {
|
|
630
|
+
const index = this.mocks.indexOf(entry);
|
|
631
|
+
if (index !== -1) {
|
|
632
|
+
this.mocks.splice(index, 1);
|
|
633
|
+
}
|
|
634
|
+
},
|
|
635
|
+
callCount: () => entry.callCount,
|
|
636
|
+
calls: () => [...entry.calls]
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
clear() {
|
|
640
|
+
this.mocks = [];
|
|
641
|
+
}
|
|
642
|
+
getAll() {
|
|
643
|
+
return this.mocks.map((e) => e.definition);
|
|
644
|
+
}
|
|
645
|
+
match(request) {
|
|
646
|
+
for (const entry of this.mocks) {
|
|
647
|
+
if (entry.remainingUses <= 0) continue;
|
|
648
|
+
const { definition } = entry;
|
|
649
|
+
if (definition.method !== request.method) continue;
|
|
650
|
+
if (definition.params !== void 0) {
|
|
651
|
+
const params = request.params ?? {};
|
|
652
|
+
if (typeof definition.params === "function") {
|
|
653
|
+
if (!definition.params(params)) continue;
|
|
654
|
+
} else {
|
|
655
|
+
if (!this.paramsMatch(definition.params, params)) continue;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
entry.callCount++;
|
|
659
|
+
entry.calls.push(request);
|
|
660
|
+
entry.remainingUses--;
|
|
661
|
+
return definition;
|
|
662
|
+
}
|
|
663
|
+
return void 0;
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Check if request params match the mock params definition
|
|
667
|
+
*/
|
|
668
|
+
paramsMatch(expected, actual) {
|
|
669
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
670
|
+
if (!(key in actual)) return false;
|
|
671
|
+
const actualValue = actual[key];
|
|
672
|
+
if (Array.isArray(value)) {
|
|
673
|
+
if (!Array.isArray(actualValue)) return false;
|
|
674
|
+
if (value.length !== actualValue.length) return false;
|
|
675
|
+
for (let i = 0; i < value.length; i++) {
|
|
676
|
+
const expectedItem = value[i];
|
|
677
|
+
const actualItem = actualValue[i];
|
|
678
|
+
if (typeof expectedItem === "object" && expectedItem !== null) {
|
|
679
|
+
if (typeof actualItem !== "object" || actualItem === null) return false;
|
|
680
|
+
if (!this.paramsMatch(expectedItem, actualItem)) {
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
683
|
+
} else if (actualItem !== expectedItem) {
|
|
684
|
+
return false;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
} else if (typeof value === "object" && value !== null) {
|
|
688
|
+
if (typeof actualValue !== "object" || actualValue === null) return false;
|
|
689
|
+
if (!this.paramsMatch(value, actualValue)) {
|
|
690
|
+
return false;
|
|
691
|
+
}
|
|
692
|
+
} else if (actualValue !== value) {
|
|
693
|
+
return false;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return true;
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
var mockResponse = {
|
|
700
|
+
/**
|
|
701
|
+
* Create a successful JSON-RPC response
|
|
702
|
+
*/
|
|
703
|
+
success(result, id = 1) {
|
|
704
|
+
return {
|
|
705
|
+
jsonrpc: "2.0",
|
|
706
|
+
id,
|
|
707
|
+
result
|
|
708
|
+
};
|
|
709
|
+
},
|
|
710
|
+
/**
|
|
711
|
+
* Create an error JSON-RPC response
|
|
712
|
+
*/
|
|
713
|
+
error(code, message, data, id = 1) {
|
|
714
|
+
return {
|
|
715
|
+
jsonrpc: "2.0",
|
|
716
|
+
id,
|
|
717
|
+
error: { code, message, data }
|
|
718
|
+
};
|
|
719
|
+
},
|
|
720
|
+
/**
|
|
721
|
+
* Create a tool result response
|
|
722
|
+
*/
|
|
723
|
+
toolResult(content, id = 1) {
|
|
724
|
+
return {
|
|
725
|
+
jsonrpc: "2.0",
|
|
726
|
+
id,
|
|
727
|
+
result: { content }
|
|
728
|
+
};
|
|
729
|
+
},
|
|
730
|
+
/**
|
|
731
|
+
* Create a tools/list response
|
|
732
|
+
*/
|
|
733
|
+
toolsList(tools, id = 1) {
|
|
734
|
+
return {
|
|
735
|
+
jsonrpc: "2.0",
|
|
736
|
+
id,
|
|
737
|
+
result: { tools }
|
|
738
|
+
};
|
|
739
|
+
},
|
|
740
|
+
/**
|
|
741
|
+
* Create a resources/list response
|
|
742
|
+
*/
|
|
743
|
+
resourcesList(resources, id = 1) {
|
|
744
|
+
return {
|
|
745
|
+
jsonrpc: "2.0",
|
|
746
|
+
id,
|
|
747
|
+
result: { resources }
|
|
748
|
+
};
|
|
749
|
+
},
|
|
750
|
+
/**
|
|
751
|
+
* Create a resources/read response
|
|
752
|
+
*/
|
|
753
|
+
resourceRead(contents, id = 1) {
|
|
754
|
+
return {
|
|
755
|
+
jsonrpc: "2.0",
|
|
756
|
+
id,
|
|
757
|
+
result: { contents }
|
|
758
|
+
};
|
|
759
|
+
},
|
|
760
|
+
/**
|
|
761
|
+
* Common MCP errors
|
|
762
|
+
*/
|
|
763
|
+
errors: {
|
|
764
|
+
methodNotFound: (method, id = 1) => mockResponse.error(-32601, `Method not found: ${method}`, void 0, id),
|
|
765
|
+
invalidParams: (message, id = 1) => mockResponse.error(-32602, message, void 0, id),
|
|
766
|
+
internalError: (message, id = 1) => mockResponse.error(-32603, message, void 0, id),
|
|
767
|
+
resourceNotFound: (uri, id = 1) => mockResponse.error(-32002, `Resource not found: ${uri}`, { uri }, id),
|
|
768
|
+
toolNotFound: (name, id = 1) => mockResponse.error(-32601, `Tool not found: ${name}`, { name }, id),
|
|
769
|
+
unauthorized: (id = 1) => mockResponse.error(-32001, "Unauthorized", void 0, id),
|
|
770
|
+
forbidden: (id = 1) => mockResponse.error(-32003, "Forbidden", void 0, id)
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
// libs/testing/src/interceptor/interceptor-chain.ts
|
|
775
|
+
var DefaultInterceptorChain = class {
|
|
776
|
+
request = [];
|
|
777
|
+
response = [];
|
|
778
|
+
mocks;
|
|
779
|
+
constructor() {
|
|
780
|
+
this.mocks = new DefaultMockRegistry();
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Add a request interceptor
|
|
784
|
+
*/
|
|
785
|
+
addRequestInterceptor(interceptor) {
|
|
786
|
+
this.request.push(interceptor);
|
|
787
|
+
return () => {
|
|
788
|
+
const index = this.request.indexOf(interceptor);
|
|
789
|
+
if (index !== -1) {
|
|
790
|
+
this.request.splice(index, 1);
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Add a response interceptor
|
|
796
|
+
*/
|
|
797
|
+
addResponseInterceptor(interceptor) {
|
|
798
|
+
this.response.push(interceptor);
|
|
799
|
+
return () => {
|
|
800
|
+
const index = this.response.indexOf(interceptor);
|
|
801
|
+
if (index !== -1) {
|
|
802
|
+
this.response.splice(index, 1);
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Process a request through the interceptor chain
|
|
808
|
+
* Returns either:
|
|
809
|
+
* - { type: 'continue', request } - continue with (possibly modified) request
|
|
810
|
+
* - { type: 'mock', response } - return mock response immediately
|
|
811
|
+
* - { type: 'error', error } - throw error
|
|
812
|
+
*/
|
|
813
|
+
async processRequest(request, meta) {
|
|
814
|
+
let currentRequest = request;
|
|
815
|
+
const mockDef = this.mocks.match(request);
|
|
816
|
+
if (mockDef) {
|
|
817
|
+
if (mockDef.delay && mockDef.delay > 0) {
|
|
818
|
+
await sleep(mockDef.delay);
|
|
819
|
+
}
|
|
820
|
+
let mockResponse2;
|
|
821
|
+
if (typeof mockDef.response === "function") {
|
|
822
|
+
mockResponse2 = await mockDef.response(request);
|
|
823
|
+
} else {
|
|
824
|
+
mockResponse2 = mockDef.response;
|
|
825
|
+
}
|
|
826
|
+
return {
|
|
827
|
+
type: "mock",
|
|
828
|
+
response: { ...mockResponse2, id: request.id ?? mockResponse2.id }
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
for (const interceptor of this.request) {
|
|
832
|
+
const ctx = {
|
|
833
|
+
request: currentRequest,
|
|
834
|
+
meta
|
|
835
|
+
};
|
|
836
|
+
const result = await interceptor(ctx);
|
|
837
|
+
switch (result.action) {
|
|
838
|
+
case "passthrough":
|
|
839
|
+
break;
|
|
840
|
+
case "modify":
|
|
841
|
+
currentRequest = result.request;
|
|
842
|
+
break;
|
|
843
|
+
case "mock":
|
|
844
|
+
return {
|
|
845
|
+
type: "mock",
|
|
846
|
+
response: { ...result.response, id: request.id ?? result.response.id }
|
|
847
|
+
};
|
|
848
|
+
case "error":
|
|
849
|
+
return { type: "error", error: result.error };
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return { type: "continue", request: currentRequest };
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Process a response through the interceptor chain
|
|
856
|
+
*/
|
|
857
|
+
async processResponse(request, response, durationMs) {
|
|
858
|
+
let currentResponse = response;
|
|
859
|
+
for (const interceptor of this.response) {
|
|
860
|
+
const ctx = {
|
|
861
|
+
request,
|
|
862
|
+
response: currentResponse,
|
|
863
|
+
durationMs
|
|
864
|
+
};
|
|
865
|
+
const result = await interceptor(ctx);
|
|
866
|
+
switch (result.action) {
|
|
867
|
+
case "passthrough":
|
|
868
|
+
break;
|
|
869
|
+
case "modify":
|
|
870
|
+
currentResponse = result.response;
|
|
871
|
+
break;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
return currentResponse;
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Clear all interceptors and mocks
|
|
878
|
+
*/
|
|
879
|
+
clear() {
|
|
880
|
+
this.request = [];
|
|
881
|
+
this.response = [];
|
|
882
|
+
this.mocks.clear();
|
|
883
|
+
}
|
|
884
|
+
};
|
|
885
|
+
function sleep(ms) {
|
|
886
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
887
|
+
}
|
|
888
|
+
var interceptors = {
|
|
889
|
+
/**
|
|
890
|
+
* Create an interceptor that logs all requests
|
|
891
|
+
*/
|
|
892
|
+
logger(logFn = console.log) {
|
|
893
|
+
return (ctx) => {
|
|
894
|
+
logFn(`[MCP Request] ${ctx.request.method}`, ctx.request.params);
|
|
895
|
+
return { action: "passthrough" };
|
|
896
|
+
};
|
|
897
|
+
},
|
|
898
|
+
/**
|
|
899
|
+
* Create an interceptor that adds latency to all requests
|
|
900
|
+
*/
|
|
901
|
+
delay(ms) {
|
|
902
|
+
return async () => {
|
|
903
|
+
await sleep(ms);
|
|
904
|
+
return { action: "passthrough" };
|
|
905
|
+
};
|
|
906
|
+
},
|
|
907
|
+
/**
|
|
908
|
+
* Create an interceptor that fails requests matching a condition
|
|
909
|
+
*/
|
|
910
|
+
failWhen(condition, error) {
|
|
911
|
+
return (ctx) => {
|
|
912
|
+
if (condition(ctx)) {
|
|
913
|
+
const err = typeof error === "string" ? new Error(error) : error;
|
|
914
|
+
return { action: "error", error: err };
|
|
915
|
+
}
|
|
916
|
+
return { action: "passthrough" };
|
|
917
|
+
};
|
|
918
|
+
},
|
|
919
|
+
/**
|
|
920
|
+
* Create an interceptor that modifies specific methods
|
|
921
|
+
*/
|
|
922
|
+
modifyMethod(method, modifier) {
|
|
923
|
+
return (ctx) => {
|
|
924
|
+
if (ctx.request.method === method) {
|
|
925
|
+
return { action: "modify", request: modifier(ctx.request) };
|
|
926
|
+
}
|
|
927
|
+
return { action: "passthrough" };
|
|
928
|
+
};
|
|
929
|
+
},
|
|
930
|
+
/**
|
|
931
|
+
* Create a response interceptor that logs responses
|
|
932
|
+
*/
|
|
933
|
+
responseLogger(logFn = console.log) {
|
|
934
|
+
return (ctx) => {
|
|
935
|
+
const status = ctx.response.error ? "ERROR" : "OK";
|
|
936
|
+
logFn(`[MCP Response] ${ctx.request.method} ${status} (${ctx.durationMs}ms)`, ctx.response);
|
|
937
|
+
return { action: "passthrough" };
|
|
938
|
+
};
|
|
939
|
+
},
|
|
940
|
+
/**
|
|
941
|
+
* Create a response interceptor that modifies specific responses
|
|
942
|
+
*/
|
|
943
|
+
modifyResponse(method, modifier) {
|
|
944
|
+
return (ctx) => {
|
|
945
|
+
if (ctx.request.method === method) {
|
|
946
|
+
return { action: "modify", response: modifier(ctx.response) };
|
|
947
|
+
}
|
|
948
|
+
return { action: "passthrough" };
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
};
|
|
952
|
+
|
|
953
|
+
// libs/testing/src/client/mcp-test-client.ts
|
|
954
|
+
var DEFAULT_TIMEOUT2 = 3e4;
|
|
955
|
+
var DEFAULT_PROTOCOL_VERSION = "2025-06-18";
|
|
956
|
+
var DEFAULT_CLIENT_INFO = {
|
|
957
|
+
name: "@frontmcp/testing",
|
|
958
|
+
version: "0.4.0"
|
|
959
|
+
};
|
|
960
|
+
var McpTestClient = class {
|
|
961
|
+
// Platform and capabilities are optional - only set when testing platform-specific behavior
|
|
962
|
+
config;
|
|
963
|
+
transport = null;
|
|
964
|
+
initResult = null;
|
|
965
|
+
requestIdCounter = 0;
|
|
966
|
+
_lastRequestId = 0;
|
|
967
|
+
_sessionId;
|
|
968
|
+
_sessionInfo = null;
|
|
969
|
+
_authState = { isAnonymous: true, scopes: [] };
|
|
970
|
+
// Logging and tracing
|
|
971
|
+
_logs = [];
|
|
972
|
+
_traces = [];
|
|
973
|
+
_notifications = [];
|
|
974
|
+
_progressUpdates = [];
|
|
975
|
+
// Interceptor chain
|
|
976
|
+
_interceptors;
|
|
977
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
978
|
+
// CONSTRUCTOR & FACTORY
|
|
979
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
980
|
+
constructor(config) {
|
|
981
|
+
this.config = {
|
|
982
|
+
baseUrl: config.baseUrl,
|
|
983
|
+
transport: config.transport ?? "streamable-http",
|
|
984
|
+
auth: config.auth ?? {},
|
|
985
|
+
publicMode: config.publicMode ?? false,
|
|
986
|
+
timeout: config.timeout ?? DEFAULT_TIMEOUT2,
|
|
987
|
+
debug: config.debug ?? false,
|
|
988
|
+
protocolVersion: config.protocolVersion ?? DEFAULT_PROTOCOL_VERSION,
|
|
989
|
+
clientInfo: config.clientInfo ?? DEFAULT_CLIENT_INFO,
|
|
990
|
+
platform: config.platform,
|
|
991
|
+
capabilities: config.capabilities
|
|
992
|
+
};
|
|
993
|
+
if (config.auth?.token) {
|
|
994
|
+
this._authState = {
|
|
995
|
+
isAnonymous: false,
|
|
996
|
+
token: config.auth.token,
|
|
997
|
+
scopes: this.parseScopesFromToken(config.auth.token),
|
|
998
|
+
user: this.parseUserFromToken(config.auth.token)
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
this._interceptors = new DefaultInterceptorChain();
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Create a new McpTestClientBuilder for fluent configuration
|
|
1005
|
+
*/
|
|
1006
|
+
static create(config) {
|
|
1007
|
+
return new McpTestClientBuilder(config);
|
|
1008
|
+
}
|
|
1009
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1010
|
+
// CONNECTION & LIFECYCLE
|
|
1011
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1012
|
+
/**
|
|
1013
|
+
* Connect to the MCP server and perform initialization
|
|
1014
|
+
*/
|
|
1015
|
+
async connect() {
|
|
1016
|
+
this.log("debug", `Connecting to ${this.config.baseUrl}...`);
|
|
1017
|
+
this.transport = this.createTransport();
|
|
1018
|
+
await this.transport.connect();
|
|
1019
|
+
const initResponse = await this.initialize();
|
|
1020
|
+
if (!initResponse.success || !initResponse.data) {
|
|
1021
|
+
throw new Error(`Failed to initialize MCP connection: ${initResponse.error?.message ?? "Unknown error"}`);
|
|
1022
|
+
}
|
|
1023
|
+
this.initResult = initResponse.data;
|
|
1024
|
+
this._sessionId = this.transport.getSessionId();
|
|
1025
|
+
this._sessionInfo = {
|
|
1026
|
+
id: this._sessionId ?? `session-${Date.now()}`,
|
|
1027
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1028
|
+
lastActivityAt: /* @__PURE__ */ new Date(),
|
|
1029
|
+
requestCount: 1
|
|
1030
|
+
};
|
|
1031
|
+
await this.transport.notify({
|
|
1032
|
+
jsonrpc: "2.0",
|
|
1033
|
+
method: "notifications/initialized"
|
|
1034
|
+
});
|
|
1035
|
+
this.log("info", `Connected to ${this.initResult.serverInfo?.name ?? "MCP Server"}`);
|
|
1036
|
+
return this.initResult;
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* Disconnect from the MCP server
|
|
1040
|
+
*/
|
|
1041
|
+
async disconnect() {
|
|
1042
|
+
if (this.transport) {
|
|
1043
|
+
await this.transport.close();
|
|
1044
|
+
this.transport = null;
|
|
1045
|
+
}
|
|
1046
|
+
this.initResult = null;
|
|
1047
|
+
this.log("info", "Disconnected from MCP server");
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* Reconnect to the server, optionally with an existing session ID
|
|
1051
|
+
*/
|
|
1052
|
+
async reconnect(options) {
|
|
1053
|
+
await this.disconnect();
|
|
1054
|
+
if (options?.sessionId && this.transport) {
|
|
1055
|
+
this._sessionId = options.sessionId;
|
|
1056
|
+
}
|
|
1057
|
+
await this.connect();
|
|
1058
|
+
}
|
|
1059
|
+
/**
|
|
1060
|
+
* Check if the client is currently connected
|
|
1061
|
+
*/
|
|
1062
|
+
isConnected() {
|
|
1063
|
+
return this.transport?.isConnected() ?? false;
|
|
1064
|
+
}
|
|
1065
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1066
|
+
// SESSION & AUTH PROPERTIES
|
|
1067
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1068
|
+
get sessionId() {
|
|
1069
|
+
return this._sessionId ?? "";
|
|
1070
|
+
}
|
|
1071
|
+
get session() {
|
|
1072
|
+
const info = this._sessionInfo ?? {
|
|
1073
|
+
id: "",
|
|
1074
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1075
|
+
lastActivityAt: /* @__PURE__ */ new Date(),
|
|
1076
|
+
requestCount: 0
|
|
1077
|
+
};
|
|
1078
|
+
return {
|
|
1079
|
+
...info,
|
|
1080
|
+
expire: async () => {
|
|
1081
|
+
this._sessionId = void 0;
|
|
1082
|
+
this._sessionInfo = null;
|
|
1083
|
+
}
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
get auth() {
|
|
1087
|
+
return this._authState;
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* Authenticate with a token
|
|
1091
|
+
*/
|
|
1092
|
+
async authenticate(token) {
|
|
1093
|
+
this._authState = {
|
|
1094
|
+
isAnonymous: false,
|
|
1095
|
+
token,
|
|
1096
|
+
scopes: this.parseScopesFromToken(token),
|
|
1097
|
+
user: this.parseUserFromToken(token)
|
|
1098
|
+
};
|
|
1099
|
+
if (this.transport) {
|
|
1100
|
+
this.transport.setAuthToken(token);
|
|
1101
|
+
}
|
|
1102
|
+
this.log("debug", "Authentication updated");
|
|
1103
|
+
}
|
|
1104
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1105
|
+
// SERVER INFO & CAPABILITIES
|
|
1106
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1107
|
+
get serverInfo() {
|
|
1108
|
+
return {
|
|
1109
|
+
name: this.initResult?.serverInfo?.name ?? "",
|
|
1110
|
+
version: this.initResult?.serverInfo?.version ?? ""
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
get protocolVersion() {
|
|
1114
|
+
return this.initResult?.protocolVersion ?? "";
|
|
1115
|
+
}
|
|
1116
|
+
get instructions() {
|
|
1117
|
+
return this.initResult?.instructions ?? "";
|
|
1118
|
+
}
|
|
1119
|
+
get capabilities() {
|
|
1120
|
+
return this.initResult?.capabilities ?? {};
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* Check if server has a specific capability
|
|
1124
|
+
*/
|
|
1125
|
+
hasCapability(name) {
|
|
1126
|
+
return !!this.capabilities[name];
|
|
1127
|
+
}
|
|
1128
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1129
|
+
// TOOLS API
|
|
1130
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1131
|
+
tools = {
|
|
1132
|
+
/**
|
|
1133
|
+
* List all available tools
|
|
1134
|
+
*/
|
|
1135
|
+
list: async () => {
|
|
1136
|
+
const response = await this.listTools();
|
|
1137
|
+
if (!response.success || !response.data) {
|
|
1138
|
+
throw new Error(`Failed to list tools: ${response.error?.message}`);
|
|
1139
|
+
}
|
|
1140
|
+
return response.data.tools;
|
|
1141
|
+
},
|
|
1142
|
+
/**
|
|
1143
|
+
* Call a tool by name with arguments
|
|
1144
|
+
*/
|
|
1145
|
+
call: async (name, args) => {
|
|
1146
|
+
const response = await this.callTool(name, args);
|
|
1147
|
+
return this.wrapToolResult(response);
|
|
1148
|
+
}
|
|
1149
|
+
};
|
|
1150
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1151
|
+
// RESOURCES API
|
|
1152
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1153
|
+
resources = {
|
|
1154
|
+
/**
|
|
1155
|
+
* List all static resources
|
|
1156
|
+
*/
|
|
1157
|
+
list: async () => {
|
|
1158
|
+
const response = await this.listResources();
|
|
1159
|
+
if (!response.success || !response.data) {
|
|
1160
|
+
throw new Error(`Failed to list resources: ${response.error?.message}`);
|
|
1161
|
+
}
|
|
1162
|
+
return response.data.resources;
|
|
1163
|
+
},
|
|
1164
|
+
/**
|
|
1165
|
+
* List all resource templates
|
|
1166
|
+
*/
|
|
1167
|
+
listTemplates: async () => {
|
|
1168
|
+
const response = await this.listResourceTemplates();
|
|
1169
|
+
if (!response.success || !response.data) {
|
|
1170
|
+
throw new Error(`Failed to list resource templates: ${response.error?.message}`);
|
|
1171
|
+
}
|
|
1172
|
+
return response.data.resourceTemplates;
|
|
1173
|
+
},
|
|
1174
|
+
/**
|
|
1175
|
+
* Read a resource by URI
|
|
1176
|
+
*/
|
|
1177
|
+
read: async (uri) => {
|
|
1178
|
+
const response = await this.readResource(uri);
|
|
1179
|
+
return this.wrapResourceContent(response);
|
|
1180
|
+
},
|
|
1181
|
+
/**
|
|
1182
|
+
* Subscribe to resource changes (placeholder for future implementation)
|
|
1183
|
+
*/
|
|
1184
|
+
subscribe: async (_uri) => {
|
|
1185
|
+
this.log("warn", "Resource subscription not yet implemented");
|
|
1186
|
+
},
|
|
1187
|
+
/**
|
|
1188
|
+
* Unsubscribe from resource changes (placeholder for future implementation)
|
|
1189
|
+
*/
|
|
1190
|
+
unsubscribe: async (_uri) => {
|
|
1191
|
+
this.log("warn", "Resource unsubscription not yet implemented");
|
|
1192
|
+
}
|
|
1193
|
+
};
|
|
1194
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1195
|
+
// PROMPTS API
|
|
1196
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1197
|
+
prompts = {
|
|
1198
|
+
/**
|
|
1199
|
+
* List all available prompts
|
|
1200
|
+
*/
|
|
1201
|
+
list: async () => {
|
|
1202
|
+
const response = await this.listPrompts();
|
|
1203
|
+
if (!response.success || !response.data) {
|
|
1204
|
+
throw new Error(`Failed to list prompts: ${response.error?.message}`);
|
|
1205
|
+
}
|
|
1206
|
+
return response.data.prompts;
|
|
1207
|
+
},
|
|
1208
|
+
/**
|
|
1209
|
+
* Get a prompt with arguments
|
|
1210
|
+
*/
|
|
1211
|
+
get: async (name, args) => {
|
|
1212
|
+
const response = await this.getPrompt(name, args);
|
|
1213
|
+
return this.wrapPromptResult(response);
|
|
1214
|
+
}
|
|
1215
|
+
};
|
|
1216
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1217
|
+
// RAW PROTOCOL ACCESS
|
|
1218
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1219
|
+
raw = {
|
|
1220
|
+
/**
|
|
1221
|
+
* Send any JSON-RPC request
|
|
1222
|
+
*/
|
|
1223
|
+
request: async (message) => {
|
|
1224
|
+
this.ensureConnected();
|
|
1225
|
+
const start = Date.now();
|
|
1226
|
+
const response = await this.transport.request(message);
|
|
1227
|
+
this.traceRequest(message.method, message.params, message.id, response, Date.now() - start);
|
|
1228
|
+
return response;
|
|
1229
|
+
},
|
|
1230
|
+
/**
|
|
1231
|
+
* Send a notification (no response expected)
|
|
1232
|
+
*/
|
|
1233
|
+
notify: async (message) => {
|
|
1234
|
+
this.ensureConnected();
|
|
1235
|
+
await this.transport.notify(message);
|
|
1236
|
+
},
|
|
1237
|
+
/**
|
|
1238
|
+
* Send raw string data (for error testing)
|
|
1239
|
+
*/
|
|
1240
|
+
sendRaw: async (data) => {
|
|
1241
|
+
this.ensureConnected();
|
|
1242
|
+
return this.transport.sendRaw(data);
|
|
1243
|
+
}
|
|
1244
|
+
};
|
|
1245
|
+
get lastRequestId() {
|
|
1246
|
+
return this._lastRequestId;
|
|
1247
|
+
}
|
|
1248
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1249
|
+
// TRANSPORT INFO
|
|
1250
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1251
|
+
/**
|
|
1252
|
+
* Get transport information and utilities
|
|
1253
|
+
*/
|
|
1254
|
+
get transport_info() {
|
|
1255
|
+
return {
|
|
1256
|
+
type: this.config.transport,
|
|
1257
|
+
isConnected: () => this.transport?.isConnected() ?? false,
|
|
1258
|
+
messageEndpoint: this.transport?.getMessageEndpoint?.(),
|
|
1259
|
+
connectionCount: this.transport?.getConnectionCount?.() ?? 0,
|
|
1260
|
+
reconnectCount: this.transport?.getReconnectCount?.() ?? 0,
|
|
1261
|
+
lastRequestHeaders: this.transport?.getLastRequestHeaders?.() ?? {},
|
|
1262
|
+
simulateDisconnect: async () => {
|
|
1263
|
+
await this.transport?.simulateDisconnect?.();
|
|
1264
|
+
},
|
|
1265
|
+
waitForReconnect: async (timeoutMs) => {
|
|
1266
|
+
await this.transport?.waitForReconnect?.(timeoutMs);
|
|
1267
|
+
}
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
// Alias for transport info
|
|
1271
|
+
get transport_() {
|
|
1272
|
+
return this.transport_info;
|
|
1273
|
+
}
|
|
1274
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1275
|
+
// NOTIFICATIONS
|
|
1276
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1277
|
+
notifications = {
|
|
1278
|
+
/**
|
|
1279
|
+
* Start collecting server notifications
|
|
1280
|
+
*/
|
|
1281
|
+
collect: () => {
|
|
1282
|
+
return new NotificationCollector(this._notifications);
|
|
1283
|
+
},
|
|
1284
|
+
/**
|
|
1285
|
+
* Collect progress notifications specifically
|
|
1286
|
+
*/
|
|
1287
|
+
collectProgress: () => {
|
|
1288
|
+
return new ProgressCollector(this._progressUpdates);
|
|
1289
|
+
},
|
|
1290
|
+
/**
|
|
1291
|
+
* Send a notification to the server
|
|
1292
|
+
*/
|
|
1293
|
+
send: async (method, params) => {
|
|
1294
|
+
await this.raw.notify({ jsonrpc: "2.0", method, params });
|
|
1295
|
+
}
|
|
1296
|
+
};
|
|
1297
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1298
|
+
// LOGGING & DEBUGGING
|
|
1299
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1300
|
+
logs = {
|
|
1301
|
+
all: () => [...this._logs],
|
|
1302
|
+
filter: (level) => this._logs.filter((l) => l.level === level),
|
|
1303
|
+
search: (text) => this._logs.filter((l) => l.message.includes(text)),
|
|
1304
|
+
last: () => this._logs[this._logs.length - 1],
|
|
1305
|
+
clear: () => {
|
|
1306
|
+
this._logs = [];
|
|
1307
|
+
}
|
|
1308
|
+
};
|
|
1309
|
+
trace = {
|
|
1310
|
+
all: () => [...this._traces],
|
|
1311
|
+
last: () => this._traces[this._traces.length - 1],
|
|
1312
|
+
clear: () => {
|
|
1313
|
+
this._traces = [];
|
|
1314
|
+
}
|
|
1315
|
+
};
|
|
1316
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1317
|
+
// MOCKING & INTERCEPTION
|
|
1318
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1319
|
+
/**
|
|
1320
|
+
* API for mocking MCP requests
|
|
1321
|
+
*
|
|
1322
|
+
* @example
|
|
1323
|
+
* ```typescript
|
|
1324
|
+
* // Mock a specific tool call
|
|
1325
|
+
* const handle = mcp.mock.tool('my-tool', { result: 'mocked!' });
|
|
1326
|
+
*
|
|
1327
|
+
* // Mock with params matching
|
|
1328
|
+
* mcp.mock.add({
|
|
1329
|
+
* method: 'tools/call',
|
|
1330
|
+
* params: { name: 'my-tool' },
|
|
1331
|
+
* response: mockResponse.toolResult([{ type: 'text', text: 'mocked' }]),
|
|
1332
|
+
* });
|
|
1333
|
+
*
|
|
1334
|
+
* // Clear all mocks after test
|
|
1335
|
+
* mcp.mock.clear();
|
|
1336
|
+
* ```
|
|
1337
|
+
*/
|
|
1338
|
+
mock = {
|
|
1339
|
+
/**
|
|
1340
|
+
* Add a mock definition
|
|
1341
|
+
*/
|
|
1342
|
+
add: (mock) => {
|
|
1343
|
+
return this._interceptors.mocks.add(mock);
|
|
1344
|
+
},
|
|
1345
|
+
/**
|
|
1346
|
+
* Mock a tools/call request for a specific tool
|
|
1347
|
+
*/
|
|
1348
|
+
tool: (name, result, options) => {
|
|
1349
|
+
return this._interceptors.mocks.add({
|
|
1350
|
+
method: "tools/call",
|
|
1351
|
+
params: { name },
|
|
1352
|
+
response: mockResponse.toolResult([
|
|
1353
|
+
{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result) }
|
|
1354
|
+
]),
|
|
1355
|
+
times: options?.times,
|
|
1356
|
+
delay: options?.delay
|
|
1357
|
+
});
|
|
1358
|
+
},
|
|
1359
|
+
/**
|
|
1360
|
+
* Mock a tools/call request to return an error
|
|
1361
|
+
*/
|
|
1362
|
+
toolError: (name, code, message, options) => {
|
|
1363
|
+
return this._interceptors.mocks.add({
|
|
1364
|
+
method: "tools/call",
|
|
1365
|
+
params: { name },
|
|
1366
|
+
response: mockResponse.error(code, message),
|
|
1367
|
+
times: options?.times
|
|
1368
|
+
});
|
|
1369
|
+
},
|
|
1370
|
+
/**
|
|
1371
|
+
* Mock a resources/read request
|
|
1372
|
+
*/
|
|
1373
|
+
resource: (uri, content, options) => {
|
|
1374
|
+
const contentObj = typeof content === "string" ? { uri, text: content } : { uri, ...content };
|
|
1375
|
+
return this._interceptors.mocks.add({
|
|
1376
|
+
method: "resources/read",
|
|
1377
|
+
params: { uri },
|
|
1378
|
+
response: mockResponse.resourceRead([contentObj]),
|
|
1379
|
+
times: options?.times,
|
|
1380
|
+
delay: options?.delay
|
|
1381
|
+
});
|
|
1382
|
+
},
|
|
1383
|
+
/**
|
|
1384
|
+
* Mock a resources/read request to return an error
|
|
1385
|
+
*/
|
|
1386
|
+
resourceError: (uri, options) => {
|
|
1387
|
+
return this._interceptors.mocks.add({
|
|
1388
|
+
method: "resources/read",
|
|
1389
|
+
params: { uri },
|
|
1390
|
+
response: mockResponse.errors.resourceNotFound(uri),
|
|
1391
|
+
times: options?.times
|
|
1392
|
+
});
|
|
1393
|
+
},
|
|
1394
|
+
/**
|
|
1395
|
+
* Mock the tools/list response
|
|
1396
|
+
*/
|
|
1397
|
+
toolsList: (tools, options) => {
|
|
1398
|
+
return this._interceptors.mocks.add({
|
|
1399
|
+
method: "tools/list",
|
|
1400
|
+
response: mockResponse.toolsList(tools),
|
|
1401
|
+
times: options?.times
|
|
1402
|
+
});
|
|
1403
|
+
},
|
|
1404
|
+
/**
|
|
1405
|
+
* Mock the resources/list response
|
|
1406
|
+
*/
|
|
1407
|
+
resourcesList: (resources, options) => {
|
|
1408
|
+
return this._interceptors.mocks.add({
|
|
1409
|
+
method: "resources/list",
|
|
1410
|
+
response: mockResponse.resourcesList(resources),
|
|
1411
|
+
times: options?.times
|
|
1412
|
+
});
|
|
1413
|
+
},
|
|
1414
|
+
/**
|
|
1415
|
+
* Clear all mocks
|
|
1416
|
+
*/
|
|
1417
|
+
clear: () => {
|
|
1418
|
+
this._interceptors.mocks.clear();
|
|
1419
|
+
},
|
|
1420
|
+
/**
|
|
1421
|
+
* Get all active mocks
|
|
1422
|
+
*/
|
|
1423
|
+
all: () => {
|
|
1424
|
+
return this._interceptors.mocks.getAll();
|
|
1425
|
+
}
|
|
1426
|
+
};
|
|
1427
|
+
/**
|
|
1428
|
+
* API for intercepting requests and responses
|
|
1429
|
+
*
|
|
1430
|
+
* @example
|
|
1431
|
+
* ```typescript
|
|
1432
|
+
* // Log all requests
|
|
1433
|
+
* const remove = mcp.intercept.request((ctx) => {
|
|
1434
|
+
* console.log('Request:', ctx.request.method);
|
|
1435
|
+
* return { action: 'passthrough' };
|
|
1436
|
+
* });
|
|
1437
|
+
*
|
|
1438
|
+
* // Modify requests
|
|
1439
|
+
* mcp.intercept.request((ctx) => {
|
|
1440
|
+
* if (ctx.request.method === 'tools/call') {
|
|
1441
|
+
* return {
|
|
1442
|
+
* action: 'modify',
|
|
1443
|
+
* request: { ...ctx.request, params: { ...ctx.request.params, extra: true } },
|
|
1444
|
+
* };
|
|
1445
|
+
* }
|
|
1446
|
+
* return { action: 'passthrough' };
|
|
1447
|
+
* });
|
|
1448
|
+
*
|
|
1449
|
+
* // Add latency to all requests
|
|
1450
|
+
* mcp.intercept.delay(100);
|
|
1451
|
+
*
|
|
1452
|
+
* // Clean up
|
|
1453
|
+
* remove();
|
|
1454
|
+
* mcp.intercept.clear();
|
|
1455
|
+
* ```
|
|
1456
|
+
*/
|
|
1457
|
+
intercept = {
|
|
1458
|
+
/**
|
|
1459
|
+
* Add a request interceptor
|
|
1460
|
+
* @returns Function to remove the interceptor
|
|
1461
|
+
*/
|
|
1462
|
+
request: (interceptor) => {
|
|
1463
|
+
return this._interceptors.addRequestInterceptor(interceptor);
|
|
1464
|
+
},
|
|
1465
|
+
/**
|
|
1466
|
+
* Add a response interceptor
|
|
1467
|
+
* @returns Function to remove the interceptor
|
|
1468
|
+
*/
|
|
1469
|
+
response: (interceptor) => {
|
|
1470
|
+
return this._interceptors.addResponseInterceptor(interceptor);
|
|
1471
|
+
},
|
|
1472
|
+
/**
|
|
1473
|
+
* Add latency to all requests
|
|
1474
|
+
* @returns Function to remove the interceptor
|
|
1475
|
+
*/
|
|
1476
|
+
delay: (ms) => {
|
|
1477
|
+
return this._interceptors.addRequestInterceptor(async () => {
|
|
1478
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
1479
|
+
return { action: "passthrough" };
|
|
1480
|
+
});
|
|
1481
|
+
},
|
|
1482
|
+
/**
|
|
1483
|
+
* Fail requests matching a method
|
|
1484
|
+
* @returns Function to remove the interceptor
|
|
1485
|
+
*/
|
|
1486
|
+
failMethod: (method, error) => {
|
|
1487
|
+
return this._interceptors.addRequestInterceptor((ctx) => {
|
|
1488
|
+
if (ctx.request.method === method) {
|
|
1489
|
+
return { action: "error", error: new Error(error ?? `Intercepted: ${method}`) };
|
|
1490
|
+
}
|
|
1491
|
+
return { action: "passthrough" };
|
|
1492
|
+
});
|
|
1493
|
+
},
|
|
1494
|
+
/**
|
|
1495
|
+
* Clear all interceptors (but not mocks)
|
|
1496
|
+
*/
|
|
1497
|
+
clear: () => {
|
|
1498
|
+
this._interceptors.request = [];
|
|
1499
|
+
this._interceptors.response = [];
|
|
1500
|
+
},
|
|
1501
|
+
/**
|
|
1502
|
+
* Clear everything (interceptors and mocks)
|
|
1503
|
+
*/
|
|
1504
|
+
clearAll: () => {
|
|
1505
|
+
this._interceptors.clear();
|
|
1506
|
+
}
|
|
1507
|
+
};
|
|
1508
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1509
|
+
// TIMEOUT
|
|
1510
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1511
|
+
setTimeout(ms) {
|
|
1512
|
+
this.config.timeout = ms;
|
|
1513
|
+
if (this.transport) {
|
|
1514
|
+
this.transport.setTimeout(ms);
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1518
|
+
// PRIVATE: MCP OPERATIONS
|
|
1519
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1520
|
+
async initialize() {
|
|
1521
|
+
const capabilities = this.config.capabilities ?? {
|
|
1522
|
+
sampling: {}
|
|
1523
|
+
};
|
|
1524
|
+
return this.request("initialize", {
|
|
1525
|
+
protocolVersion: this.config.protocolVersion,
|
|
1526
|
+
capabilities,
|
|
1527
|
+
clientInfo: this.config.clientInfo
|
|
1528
|
+
});
|
|
1529
|
+
}
|
|
1530
|
+
async listTools() {
|
|
1531
|
+
return this.request("tools/list", {});
|
|
1532
|
+
}
|
|
1533
|
+
async callTool(name, args) {
|
|
1534
|
+
return this.request("tools/call", {
|
|
1535
|
+
name,
|
|
1536
|
+
arguments: args ?? {}
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
async listResources() {
|
|
1540
|
+
return this.request("resources/list", {});
|
|
1541
|
+
}
|
|
1542
|
+
async listResourceTemplates() {
|
|
1543
|
+
return this.request("resources/templates/list", {});
|
|
1544
|
+
}
|
|
1545
|
+
async readResource(uri) {
|
|
1546
|
+
return this.request("resources/read", { uri });
|
|
1547
|
+
}
|
|
1548
|
+
async listPrompts() {
|
|
1549
|
+
return this.request("prompts/list", {});
|
|
1550
|
+
}
|
|
1551
|
+
async getPrompt(name, args) {
|
|
1552
|
+
return this.request("prompts/get", {
|
|
1553
|
+
name,
|
|
1554
|
+
arguments: args ?? {}
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1558
|
+
// PRIVATE: TRANSPORT & REQUEST HELPERS
|
|
1559
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1560
|
+
createTransport() {
|
|
1561
|
+
switch (this.config.transport) {
|
|
1562
|
+
case "streamable-http":
|
|
1563
|
+
return new StreamableHttpTransport({
|
|
1564
|
+
baseUrl: this.config.baseUrl,
|
|
1565
|
+
timeout: this.config.timeout,
|
|
1566
|
+
auth: this.config.auth,
|
|
1567
|
+
publicMode: this.config.publicMode,
|
|
1568
|
+
debug: this.config.debug,
|
|
1569
|
+
interceptors: this._interceptors,
|
|
1570
|
+
clientInfo: this.config.clientInfo
|
|
1571
|
+
});
|
|
1572
|
+
case "sse":
|
|
1573
|
+
throw new Error("SSE transport not yet implemented");
|
|
1574
|
+
default:
|
|
1575
|
+
throw new Error(`Unknown transport type: ${this.config.transport}`);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
async request(method, params) {
|
|
1579
|
+
this.ensureConnected();
|
|
1580
|
+
const id = ++this.requestIdCounter;
|
|
1581
|
+
this._lastRequestId = id;
|
|
1582
|
+
const start = Date.now();
|
|
1583
|
+
try {
|
|
1584
|
+
const response = await this.transport.request({
|
|
1585
|
+
jsonrpc: "2.0",
|
|
1586
|
+
id,
|
|
1587
|
+
method,
|
|
1588
|
+
params
|
|
1589
|
+
});
|
|
1590
|
+
const durationMs = Date.now() - start;
|
|
1591
|
+
this.updateSessionActivity();
|
|
1592
|
+
if ("error" in response && response.error) {
|
|
1593
|
+
const error = response.error;
|
|
1594
|
+
this.traceRequest(method, params, id, response, durationMs);
|
|
1595
|
+
return {
|
|
1596
|
+
success: false,
|
|
1597
|
+
error,
|
|
1598
|
+
durationMs,
|
|
1599
|
+
requestId: id
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1602
|
+
this.traceRequest(method, params, id, response, durationMs);
|
|
1603
|
+
return {
|
|
1604
|
+
success: true,
|
|
1605
|
+
data: response.result,
|
|
1606
|
+
durationMs,
|
|
1607
|
+
requestId: id
|
|
1608
|
+
};
|
|
1609
|
+
} catch (err) {
|
|
1610
|
+
const durationMs = Date.now() - start;
|
|
1611
|
+
const error = {
|
|
1612
|
+
code: -32603,
|
|
1613
|
+
message: err instanceof Error ? err.message : "Unknown error"
|
|
1614
|
+
};
|
|
1615
|
+
return {
|
|
1616
|
+
success: false,
|
|
1617
|
+
error,
|
|
1618
|
+
durationMs,
|
|
1619
|
+
requestId: id
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
ensureConnected() {
|
|
1624
|
+
if (!this.transport?.isConnected()) {
|
|
1625
|
+
throw new Error("Not connected to MCP server. Call connect() first.");
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
updateSessionActivity() {
|
|
1629
|
+
if (this._sessionInfo) {
|
|
1630
|
+
this._sessionInfo.lastActivityAt = /* @__PURE__ */ new Date();
|
|
1631
|
+
this._sessionInfo.requestCount++;
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1635
|
+
// PRIVATE: RESULT WRAPPERS
|
|
1636
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1637
|
+
wrapToolResult(response) {
|
|
1638
|
+
const raw = response.data ?? { content: [] };
|
|
1639
|
+
const isError2 = !response.success || raw.isError === true;
|
|
1640
|
+
const meta = raw._meta;
|
|
1641
|
+
const hasUI = meta?.["ui/html"] !== void 0 || meta?.["ui/component"] !== void 0 || meta?.["openai/html"] !== void 0 || meta?.["frontmcp/html"] !== void 0;
|
|
1642
|
+
const structuredContent = raw["structuredContent"];
|
|
1643
|
+
return {
|
|
1644
|
+
raw,
|
|
1645
|
+
isSuccess: !isError2,
|
|
1646
|
+
isError: isError2,
|
|
1647
|
+
error: response.error,
|
|
1648
|
+
durationMs: response.durationMs,
|
|
1649
|
+
json() {
|
|
1650
|
+
if (hasUI && structuredContent !== void 0) {
|
|
1651
|
+
return structuredContent;
|
|
1652
|
+
}
|
|
1653
|
+
const textContent = raw.content?.find((c) => c.type === "text");
|
|
1654
|
+
if (textContent && "text" in textContent) {
|
|
1655
|
+
return JSON.parse(textContent.text);
|
|
1656
|
+
}
|
|
1657
|
+
throw new Error("No text content to parse as JSON");
|
|
1658
|
+
},
|
|
1659
|
+
text() {
|
|
1660
|
+
const textContent = raw.content?.find((c) => c.type === "text");
|
|
1661
|
+
if (textContent && "text" in textContent) {
|
|
1662
|
+
return textContent.text;
|
|
1663
|
+
}
|
|
1664
|
+
return void 0;
|
|
1665
|
+
},
|
|
1666
|
+
hasTextContent() {
|
|
1667
|
+
return raw.content?.some((c) => c.type === "text") ?? false;
|
|
1668
|
+
},
|
|
1669
|
+
hasImageContent() {
|
|
1670
|
+
return raw.content?.some((c) => c.type === "image") ?? false;
|
|
1671
|
+
},
|
|
1672
|
+
hasResourceContent() {
|
|
1673
|
+
return raw.content?.some((c) => c.type === "resource") ?? false;
|
|
1674
|
+
},
|
|
1675
|
+
hasToolUI() {
|
|
1676
|
+
return hasUI;
|
|
1677
|
+
}
|
|
1678
|
+
};
|
|
1679
|
+
}
|
|
1680
|
+
wrapResourceContent(response) {
|
|
1681
|
+
const raw = response.data ?? { contents: [] };
|
|
1682
|
+
const isError2 = !response.success;
|
|
1683
|
+
const firstContent = raw.contents?.[0];
|
|
1684
|
+
return {
|
|
1685
|
+
raw,
|
|
1686
|
+
isSuccess: !isError2,
|
|
1687
|
+
isError: isError2,
|
|
1688
|
+
error: response.error,
|
|
1689
|
+
durationMs: response.durationMs,
|
|
1690
|
+
json() {
|
|
1691
|
+
if (firstContent && "text" in firstContent) {
|
|
1692
|
+
return JSON.parse(firstContent.text);
|
|
1693
|
+
}
|
|
1694
|
+
throw new Error("No text content to parse as JSON");
|
|
1695
|
+
},
|
|
1696
|
+
text() {
|
|
1697
|
+
if (firstContent && "text" in firstContent) {
|
|
1698
|
+
return firstContent.text;
|
|
1699
|
+
}
|
|
1700
|
+
return void 0;
|
|
1701
|
+
},
|
|
1702
|
+
mimeType() {
|
|
1703
|
+
return firstContent?.mimeType;
|
|
1704
|
+
},
|
|
1705
|
+
hasMimeType(type) {
|
|
1706
|
+
return firstContent?.mimeType === type;
|
|
1707
|
+
}
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
wrapPromptResult(response) {
|
|
1711
|
+
const raw = response.data ?? { messages: [] };
|
|
1712
|
+
const isError2 = !response.success;
|
|
1713
|
+
return {
|
|
1714
|
+
raw,
|
|
1715
|
+
isSuccess: !isError2,
|
|
1716
|
+
isError: isError2,
|
|
1717
|
+
error: response.error,
|
|
1718
|
+
durationMs: response.durationMs,
|
|
1719
|
+
messages: raw.messages ?? [],
|
|
1720
|
+
description: raw.description
|
|
1721
|
+
};
|
|
1722
|
+
}
|
|
1723
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1724
|
+
// PRIVATE: LOGGING & TRACING
|
|
1725
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1726
|
+
log(level, message, data) {
|
|
1727
|
+
const entry = {
|
|
1728
|
+
level,
|
|
1729
|
+
message,
|
|
1730
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
1731
|
+
data
|
|
1732
|
+
};
|
|
1733
|
+
this._logs.push(entry);
|
|
1734
|
+
if (this.config.debug) {
|
|
1735
|
+
console.log(`[${level.toUpperCase()}] ${message}`, data ?? "");
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
traceRequest(method, params, id, response, durationMs) {
|
|
1739
|
+
this._traces.push({
|
|
1740
|
+
request: { method, params, id },
|
|
1741
|
+
response: {
|
|
1742
|
+
result: "result" in response ? response.result : void 0,
|
|
1743
|
+
error: "error" in response ? response.error : void 0
|
|
1744
|
+
},
|
|
1745
|
+
durationMs,
|
|
1746
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1749
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1750
|
+
// PRIVATE: TOKEN PARSING
|
|
1751
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1752
|
+
parseScopesFromToken(token) {
|
|
1753
|
+
try {
|
|
1754
|
+
const payload = this.decodeJwtPayload(token);
|
|
1755
|
+
if (!payload) return [];
|
|
1756
|
+
const scope = payload["scope"];
|
|
1757
|
+
const scopes = payload["scopes"];
|
|
1758
|
+
if (typeof scope === "string") {
|
|
1759
|
+
return scope.split(" ");
|
|
1760
|
+
}
|
|
1761
|
+
if (Array.isArray(scopes)) {
|
|
1762
|
+
return scopes;
|
|
1763
|
+
}
|
|
1764
|
+
return [];
|
|
1765
|
+
} catch {
|
|
1766
|
+
return [];
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
parseUserFromToken(token) {
|
|
1770
|
+
try {
|
|
1771
|
+
const payload = this.decodeJwtPayload(token);
|
|
1772
|
+
const sub = payload?.["sub"];
|
|
1773
|
+
if (!sub || typeof sub !== "string") return void 0;
|
|
1774
|
+
return {
|
|
1775
|
+
sub,
|
|
1776
|
+
email: payload["email"],
|
|
1777
|
+
name: payload["name"]
|
|
1778
|
+
};
|
|
1779
|
+
} catch {
|
|
1780
|
+
return void 0;
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
decodeJwtPayload(token) {
|
|
1784
|
+
try {
|
|
1785
|
+
const parts = token.split(".");
|
|
1786
|
+
if (parts.length !== 3) return null;
|
|
1787
|
+
const payload = Buffer.from(parts[1], "base64url").toString("utf-8");
|
|
1788
|
+
return JSON.parse(payload);
|
|
1789
|
+
} catch {
|
|
1790
|
+
return null;
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
};
|
|
1794
|
+
var NotificationCollector = class {
|
|
1795
|
+
constructor(notifications) {
|
|
1796
|
+
this.notifications = notifications;
|
|
1797
|
+
}
|
|
1798
|
+
get received() {
|
|
1799
|
+
return [...this.notifications];
|
|
1800
|
+
}
|
|
1801
|
+
has(method) {
|
|
1802
|
+
return this.notifications.some((n) => n.method === method);
|
|
1803
|
+
}
|
|
1804
|
+
async waitFor(method, timeoutMs) {
|
|
1805
|
+
const deadline = Date.now() + timeoutMs;
|
|
1806
|
+
while (Date.now() < deadline) {
|
|
1807
|
+
const found = this.notifications.find((n) => n.method === method);
|
|
1808
|
+
if (found) return found;
|
|
1809
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1810
|
+
}
|
|
1811
|
+
throw new Error(`Timeout waiting for notification: ${method}`);
|
|
1812
|
+
}
|
|
1813
|
+
};
|
|
1814
|
+
var ProgressCollector = class {
|
|
1815
|
+
constructor(updates) {
|
|
1816
|
+
this.updates = updates;
|
|
1817
|
+
}
|
|
1818
|
+
get all() {
|
|
1819
|
+
return [...this.updates];
|
|
1820
|
+
}
|
|
1821
|
+
async waitForComplete(timeoutMs) {
|
|
1822
|
+
const deadline = Date.now() + timeoutMs;
|
|
1823
|
+
while (Date.now() < deadline) {
|
|
1824
|
+
const last = this.updates[this.updates.length - 1];
|
|
1825
|
+
if (last && last.total !== void 0 && last.progress >= last.total) {
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1829
|
+
}
|
|
1830
|
+
throw new Error("Timeout waiting for progress to complete");
|
|
1831
|
+
}
|
|
1832
|
+
};
|
|
1833
|
+
|
|
1834
|
+
// libs/testing/src/auth/token-factory.ts
|
|
1835
|
+
import { SignJWT, generateKeyPair, exportJWK } from "jose";
|
|
1836
|
+
var TestTokenFactory = class {
|
|
1837
|
+
issuer;
|
|
1838
|
+
audience;
|
|
1839
|
+
privateKey = null;
|
|
1840
|
+
publicKey = null;
|
|
1841
|
+
jwk = null;
|
|
1842
|
+
keyId;
|
|
1843
|
+
constructor(options = {}) {
|
|
1844
|
+
this.issuer = options.issuer ?? "https://test.frontmcp.local";
|
|
1845
|
+
this.audience = options.audience ?? "frontmcp-test";
|
|
1846
|
+
this.keyId = `test-key-${Date.now()}`;
|
|
1847
|
+
}
|
|
1848
|
+
/**
|
|
1849
|
+
* Initialize the key pair (called automatically on first use)
|
|
1850
|
+
*/
|
|
1851
|
+
async ensureKeys() {
|
|
1852
|
+
if (this.privateKey && this.publicKey) return;
|
|
1853
|
+
const { publicKey, privateKey } = await generateKeyPair("RS256", {
|
|
1854
|
+
extractable: true
|
|
1855
|
+
});
|
|
1856
|
+
this.privateKey = privateKey;
|
|
1857
|
+
this.publicKey = publicKey;
|
|
1858
|
+
this.jwk = await exportJWK(publicKey);
|
|
1859
|
+
this.jwk.kid = this.keyId;
|
|
1860
|
+
this.jwk.use = "sig";
|
|
1861
|
+
this.jwk.alg = "RS256";
|
|
1862
|
+
}
|
|
1863
|
+
/**
|
|
1864
|
+
* Create a JWT token with the specified claims
|
|
1865
|
+
*/
|
|
1866
|
+
async createTestToken(options) {
|
|
1867
|
+
await this.ensureKeys();
|
|
1868
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1869
|
+
const exp = options.exp ?? 3600;
|
|
1870
|
+
const payload = {
|
|
1871
|
+
iss: options.iss ?? this.issuer,
|
|
1872
|
+
sub: options.sub,
|
|
1873
|
+
aud: options.aud ?? this.audience,
|
|
1874
|
+
iat: now,
|
|
1875
|
+
exp: now + exp,
|
|
1876
|
+
scope: options.scopes?.join(" "),
|
|
1877
|
+
...options.claims
|
|
1878
|
+
};
|
|
1879
|
+
const token = await new SignJWT(payload).setProtectedHeader({ alg: "RS256", kid: this.keyId }).sign(this.privateKey);
|
|
1880
|
+
return token;
|
|
1881
|
+
}
|
|
1882
|
+
/**
|
|
1883
|
+
* Create an admin token with full access
|
|
1884
|
+
*/
|
|
1885
|
+
async createAdminToken(sub = "admin-001") {
|
|
1886
|
+
return this.createTestToken({
|
|
1887
|
+
sub,
|
|
1888
|
+
scopes: ["admin:*", "read", "write", "delete"],
|
|
1889
|
+
claims: {
|
|
1890
|
+
email: "admin@test.local",
|
|
1891
|
+
name: "Test Admin",
|
|
1892
|
+
role: "admin"
|
|
1893
|
+
}
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
/**
|
|
1897
|
+
* Create a regular user token
|
|
1898
|
+
*/
|
|
1899
|
+
async createUserToken(sub = "user-001", scopes = ["read", "write"]) {
|
|
1900
|
+
return this.createTestToken({
|
|
1901
|
+
sub,
|
|
1902
|
+
scopes,
|
|
1903
|
+
claims: {
|
|
1904
|
+
email: "user@test.local",
|
|
1905
|
+
name: "Test User",
|
|
1906
|
+
role: "user"
|
|
1907
|
+
}
|
|
1908
|
+
});
|
|
1909
|
+
}
|
|
1910
|
+
/**
|
|
1911
|
+
* Create an anonymous user token
|
|
1912
|
+
*/
|
|
1913
|
+
async createAnonymousToken() {
|
|
1914
|
+
return this.createTestToken({
|
|
1915
|
+
sub: `anon:${Date.now()}`,
|
|
1916
|
+
scopes: ["anonymous"],
|
|
1917
|
+
claims: {
|
|
1918
|
+
name: "Anonymous",
|
|
1919
|
+
role: "anonymous"
|
|
1920
|
+
}
|
|
1921
|
+
});
|
|
1922
|
+
}
|
|
1923
|
+
/**
|
|
1924
|
+
* Create an expired token (for testing token expiration)
|
|
1925
|
+
*/
|
|
1926
|
+
async createExpiredToken(options) {
|
|
1927
|
+
await this.ensureKeys();
|
|
1928
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1929
|
+
const payload = {
|
|
1930
|
+
iss: this.issuer,
|
|
1931
|
+
sub: options.sub,
|
|
1932
|
+
aud: this.audience,
|
|
1933
|
+
iat: now - 7200,
|
|
1934
|
+
// 2 hours ago
|
|
1935
|
+
exp: now - 3600
|
|
1936
|
+
// Expired 1 hour ago
|
|
1937
|
+
};
|
|
1938
|
+
const token = await new SignJWT(payload).setProtectedHeader({ alg: "RS256", kid: this.keyId }).sign(this.privateKey);
|
|
1939
|
+
return token;
|
|
1940
|
+
}
|
|
1941
|
+
/**
|
|
1942
|
+
* Create a token with an invalid signature (for testing signature validation)
|
|
1943
|
+
*/
|
|
1944
|
+
createTokenWithInvalidSignature(options) {
|
|
1945
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1946
|
+
const header = Buffer.from(JSON.stringify({ alg: "RS256", kid: this.keyId })).toString("base64url");
|
|
1947
|
+
const payload = Buffer.from(
|
|
1948
|
+
JSON.stringify({
|
|
1949
|
+
iss: this.issuer,
|
|
1950
|
+
sub: options.sub,
|
|
1951
|
+
aud: this.audience,
|
|
1952
|
+
iat: now,
|
|
1953
|
+
exp: now + 3600
|
|
1954
|
+
})
|
|
1955
|
+
).toString("base64url");
|
|
1956
|
+
const signature = Buffer.from("invalid-signature-" + Date.now()).toString("base64url");
|
|
1957
|
+
return `${header}.${payload}.${signature}`;
|
|
1958
|
+
}
|
|
1959
|
+
/**
|
|
1960
|
+
* Get the public JWKS for verifying tokens
|
|
1961
|
+
*/
|
|
1962
|
+
async getPublicJwks() {
|
|
1963
|
+
await this.ensureKeys();
|
|
1964
|
+
return {
|
|
1965
|
+
keys: [this.jwk]
|
|
1966
|
+
};
|
|
1967
|
+
}
|
|
1968
|
+
/**
|
|
1969
|
+
* Get the issuer URL
|
|
1970
|
+
*/
|
|
1971
|
+
getIssuer() {
|
|
1972
|
+
return this.issuer;
|
|
1973
|
+
}
|
|
1974
|
+
/**
|
|
1975
|
+
* Get the audience
|
|
1976
|
+
*/
|
|
1977
|
+
getAudience() {
|
|
1978
|
+
return this.audience;
|
|
1979
|
+
}
|
|
1980
|
+
};
|
|
1981
|
+
|
|
1982
|
+
// libs/testing/src/auth/auth-headers.ts
|
|
1983
|
+
var AuthHeaders = {
|
|
1984
|
+
/**
|
|
1985
|
+
* Create Authorization header with Bearer token
|
|
1986
|
+
*/
|
|
1987
|
+
bearer(token) {
|
|
1988
|
+
return {
|
|
1989
|
+
Authorization: `Bearer ${token}`
|
|
1990
|
+
};
|
|
1991
|
+
},
|
|
1992
|
+
/**
|
|
1993
|
+
* Create headers with no authentication
|
|
1994
|
+
*/
|
|
1995
|
+
noAuth() {
|
|
1996
|
+
return {};
|
|
1997
|
+
},
|
|
1998
|
+
/**
|
|
1999
|
+
* Create full MCP request headers with auth and session
|
|
2000
|
+
*/
|
|
2001
|
+
mcpRequest(token, sessionId) {
|
|
2002
|
+
const headers = {
|
|
2003
|
+
"Content-Type": "application/json",
|
|
2004
|
+
Accept: "application/json",
|
|
2005
|
+
Authorization: `Bearer ${token}`
|
|
2006
|
+
};
|
|
2007
|
+
if (sessionId) {
|
|
2008
|
+
headers["mcp-session-id"] = sessionId;
|
|
2009
|
+
}
|
|
2010
|
+
return headers;
|
|
2011
|
+
},
|
|
2012
|
+
/**
|
|
2013
|
+
* Create headers for public mode (no auth required)
|
|
2014
|
+
*/
|
|
2015
|
+
publicMode(sessionId) {
|
|
2016
|
+
const headers = {
|
|
2017
|
+
"Content-Type": "application/json",
|
|
2018
|
+
Accept: "application/json"
|
|
2019
|
+
};
|
|
2020
|
+
if (sessionId) {
|
|
2021
|
+
headers["mcp-session-id"] = sessionId;
|
|
2022
|
+
}
|
|
2023
|
+
return headers;
|
|
2024
|
+
},
|
|
2025
|
+
/**
|
|
2026
|
+
* Create headers with custom auth header value
|
|
2027
|
+
*/
|
|
2028
|
+
custom(headerName, headerValue) {
|
|
2029
|
+
return {
|
|
2030
|
+
[headerName]: headerValue
|
|
2031
|
+
};
|
|
2032
|
+
}
|
|
2033
|
+
};
|
|
2034
|
+
|
|
2035
|
+
// libs/testing/src/auth/user-fixtures.ts
|
|
2036
|
+
var TestUsers = {
|
|
2037
|
+
/**
|
|
2038
|
+
* Admin user with full access
|
|
2039
|
+
*/
|
|
2040
|
+
admin: {
|
|
2041
|
+
sub: "admin-001",
|
|
2042
|
+
email: "admin@test.local",
|
|
2043
|
+
name: "Test Admin",
|
|
2044
|
+
scopes: ["admin:*", "read", "write", "delete"],
|
|
2045
|
+
role: "admin"
|
|
2046
|
+
},
|
|
2047
|
+
/**
|
|
2048
|
+
* Regular user with read/write access
|
|
2049
|
+
*/
|
|
2050
|
+
user: {
|
|
2051
|
+
sub: "user-001",
|
|
2052
|
+
email: "user@test.local",
|
|
2053
|
+
name: "Test User",
|
|
2054
|
+
scopes: ["read", "write"],
|
|
2055
|
+
role: "user"
|
|
2056
|
+
},
|
|
2057
|
+
/**
|
|
2058
|
+
* Read-only user
|
|
2059
|
+
*/
|
|
2060
|
+
readOnly: {
|
|
2061
|
+
sub: "readonly-001",
|
|
2062
|
+
email: "readonly@test.local",
|
|
2063
|
+
name: "Read Only User",
|
|
2064
|
+
scopes: ["read"],
|
|
2065
|
+
role: "readonly"
|
|
2066
|
+
},
|
|
2067
|
+
/**
|
|
2068
|
+
* Anonymous user
|
|
2069
|
+
*/
|
|
2070
|
+
anonymous: {
|
|
2071
|
+
sub: "anon:001",
|
|
2072
|
+
name: "Anonymous",
|
|
2073
|
+
scopes: ["anonymous"],
|
|
2074
|
+
role: "anonymous"
|
|
2075
|
+
},
|
|
2076
|
+
/**
|
|
2077
|
+
* User with no scopes (for testing access denied)
|
|
2078
|
+
*/
|
|
2079
|
+
noScopes: {
|
|
2080
|
+
sub: "noscopes-001",
|
|
2081
|
+
email: "noscopes@test.local",
|
|
2082
|
+
name: "No Scopes User",
|
|
2083
|
+
scopes: [],
|
|
2084
|
+
role: "user"
|
|
2085
|
+
},
|
|
2086
|
+
/**
|
|
2087
|
+
* User with only tool execution scope
|
|
2088
|
+
*/
|
|
2089
|
+
toolsOnly: {
|
|
2090
|
+
sub: "toolsonly-001",
|
|
2091
|
+
email: "toolsonly@test.local",
|
|
2092
|
+
name: "Tools Only User",
|
|
2093
|
+
scopes: ["tools:execute"],
|
|
2094
|
+
role: "user"
|
|
2095
|
+
},
|
|
2096
|
+
/**
|
|
2097
|
+
* User with only resource read scope
|
|
2098
|
+
*/
|
|
2099
|
+
resourcesOnly: {
|
|
2100
|
+
sub: "resourcesonly-001",
|
|
2101
|
+
email: "resourcesonly@test.local",
|
|
2102
|
+
name: "Resources Only User",
|
|
2103
|
+
scopes: ["resources:read"],
|
|
2104
|
+
role: "user"
|
|
2105
|
+
}
|
|
2106
|
+
};
|
|
2107
|
+
function createTestUser(overrides) {
|
|
2108
|
+
return {
|
|
2109
|
+
scopes: [],
|
|
2110
|
+
...overrides
|
|
2111
|
+
};
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
// libs/testing/src/auth/mock-oauth-server.ts
|
|
2115
|
+
import { createServer } from "http";
|
|
2116
|
+
var MockOAuthServer = class {
|
|
2117
|
+
tokenFactory;
|
|
2118
|
+
options;
|
|
2119
|
+
server = null;
|
|
2120
|
+
_info = null;
|
|
2121
|
+
connections = /* @__PURE__ */ new Set();
|
|
2122
|
+
constructor(tokenFactory2, options = {}) {
|
|
2123
|
+
this.tokenFactory = tokenFactory2;
|
|
2124
|
+
this.options = options;
|
|
2125
|
+
}
|
|
2126
|
+
/**
|
|
2127
|
+
* Start the mock OAuth server
|
|
2128
|
+
*/
|
|
2129
|
+
async start() {
|
|
2130
|
+
if (this.server) {
|
|
2131
|
+
throw new Error("Mock OAuth server is already running");
|
|
2132
|
+
}
|
|
2133
|
+
const port = this.options.port ?? 0;
|
|
2134
|
+
return new Promise((resolve, reject) => {
|
|
2135
|
+
const server = createServer(this.handleRequest.bind(this));
|
|
2136
|
+
this.server = server;
|
|
2137
|
+
server.on("connection", (socket) => {
|
|
2138
|
+
this.connections.add(socket);
|
|
2139
|
+
socket.on("close", () => this.connections.delete(socket));
|
|
2140
|
+
});
|
|
2141
|
+
server.on("error", (err) => {
|
|
2142
|
+
this.log(`Server error: ${err.message}`);
|
|
2143
|
+
reject(err);
|
|
2144
|
+
});
|
|
2145
|
+
server.listen(port, () => {
|
|
2146
|
+
const address = server.address();
|
|
2147
|
+
if (!address || typeof address === "string") {
|
|
2148
|
+
reject(new Error("Failed to get server address"));
|
|
2149
|
+
return;
|
|
2150
|
+
}
|
|
2151
|
+
const actualPort = address.port;
|
|
2152
|
+
const issuer = this.options.issuer ?? `http://localhost:${actualPort}`;
|
|
2153
|
+
this._info = {
|
|
2154
|
+
baseUrl: `http://localhost:${actualPort}`,
|
|
2155
|
+
port: actualPort,
|
|
2156
|
+
issuer,
|
|
2157
|
+
jwksUrl: `http://localhost:${actualPort}/.well-known/jwks.json`
|
|
2158
|
+
};
|
|
2159
|
+
this.log(`Mock OAuth server started at ${this._info.baseUrl}`);
|
|
2160
|
+
resolve(this._info);
|
|
2161
|
+
});
|
|
2162
|
+
});
|
|
2163
|
+
}
|
|
2164
|
+
/**
|
|
2165
|
+
* Stop the mock OAuth server
|
|
2166
|
+
*/
|
|
2167
|
+
async stop() {
|
|
2168
|
+
const server = this.server;
|
|
2169
|
+
if (!server) {
|
|
2170
|
+
return;
|
|
2171
|
+
}
|
|
2172
|
+
for (const socket of this.connections) {
|
|
2173
|
+
socket.destroy();
|
|
2174
|
+
}
|
|
2175
|
+
this.connections.clear();
|
|
2176
|
+
return new Promise((resolve, reject) => {
|
|
2177
|
+
server.close((err) => {
|
|
2178
|
+
if (err) {
|
|
2179
|
+
reject(err);
|
|
2180
|
+
} else {
|
|
2181
|
+
this.server = null;
|
|
2182
|
+
this._info = null;
|
|
2183
|
+
this.log("Mock OAuth server stopped");
|
|
2184
|
+
resolve();
|
|
2185
|
+
}
|
|
2186
|
+
});
|
|
2187
|
+
});
|
|
2188
|
+
}
|
|
2189
|
+
/**
|
|
2190
|
+
* Get server info
|
|
2191
|
+
*/
|
|
2192
|
+
get info() {
|
|
2193
|
+
if (!this._info) {
|
|
2194
|
+
throw new Error("Mock OAuth server is not running");
|
|
2195
|
+
}
|
|
2196
|
+
return this._info;
|
|
2197
|
+
}
|
|
2198
|
+
/**
|
|
2199
|
+
* Get the token factory (for creating tokens)
|
|
2200
|
+
*/
|
|
2201
|
+
getTokenFactory() {
|
|
2202
|
+
return this.tokenFactory;
|
|
2203
|
+
}
|
|
2204
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2205
|
+
// PRIVATE
|
|
2206
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2207
|
+
async handleRequest(req, res) {
|
|
2208
|
+
const url = req.url ?? "/";
|
|
2209
|
+
this.log(`${req.method} ${url}`);
|
|
2210
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
2211
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
2212
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
2213
|
+
if (req.method === "OPTIONS") {
|
|
2214
|
+
res.writeHead(204);
|
|
2215
|
+
res.end();
|
|
2216
|
+
return;
|
|
2217
|
+
}
|
|
2218
|
+
try {
|
|
2219
|
+
if (url === "/.well-known/jwks.json" || url === "/.well-known/jwks") {
|
|
2220
|
+
await this.handleJwks(req, res);
|
|
2221
|
+
} else if (url === "/.well-known/openid-configuration") {
|
|
2222
|
+
await this.handleOidcConfig(req, res);
|
|
2223
|
+
} else if (url === "/.well-known/oauth-authorization-server") {
|
|
2224
|
+
await this.handleOAuthMetadata(req, res);
|
|
2225
|
+
} else if (url === "/oauth/token") {
|
|
2226
|
+
await this.handleTokenEndpoint(req, res);
|
|
2227
|
+
} else {
|
|
2228
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
2229
|
+
res.end(JSON.stringify({ error: "not_found", error_description: "Endpoint not found" }));
|
|
2230
|
+
}
|
|
2231
|
+
} catch (error) {
|
|
2232
|
+
this.log(`Error handling request: ${error}`);
|
|
2233
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
2234
|
+
res.end(JSON.stringify({ error: "server_error", error_description: "Internal server error" }));
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
async handleJwks(_req, res) {
|
|
2238
|
+
const jwks = await this.tokenFactory.getPublicJwks();
|
|
2239
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2240
|
+
res.end(JSON.stringify(jwks));
|
|
2241
|
+
this.log("Served JWKS");
|
|
2242
|
+
}
|
|
2243
|
+
async handleOidcConfig(_req, res) {
|
|
2244
|
+
const issuer = this._info?.issuer ?? "http://localhost";
|
|
2245
|
+
const config = {
|
|
2246
|
+
issuer,
|
|
2247
|
+
authorization_endpoint: `${issuer}/oauth/authorize`,
|
|
2248
|
+
token_endpoint: `${issuer}/oauth/token`,
|
|
2249
|
+
jwks_uri: `${issuer}/.well-known/jwks.json`,
|
|
2250
|
+
response_types_supported: ["code", "token"],
|
|
2251
|
+
subject_types_supported: ["public"],
|
|
2252
|
+
id_token_signing_alg_values_supported: ["RS256"],
|
|
2253
|
+
scopes_supported: ["openid", "profile", "email"],
|
|
2254
|
+
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
|
|
2255
|
+
claims_supported: ["sub", "iss", "aud", "exp", "iat", "email", "name"],
|
|
2256
|
+
grant_types_supported: ["authorization_code", "refresh_token", "client_credentials", "anonymous"]
|
|
2257
|
+
};
|
|
2258
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2259
|
+
res.end(JSON.stringify(config));
|
|
2260
|
+
this.log("Served OIDC configuration");
|
|
2261
|
+
}
|
|
2262
|
+
async handleOAuthMetadata(_req, res) {
|
|
2263
|
+
const issuer = this._info?.issuer ?? "http://localhost";
|
|
2264
|
+
const metadata = {
|
|
2265
|
+
issuer,
|
|
2266
|
+
authorization_endpoint: `${issuer}/oauth/authorize`,
|
|
2267
|
+
token_endpoint: `${issuer}/oauth/token`,
|
|
2268
|
+
jwks_uri: `${issuer}/.well-known/jwks.json`,
|
|
2269
|
+
response_types_supported: ["code", "token"],
|
|
2270
|
+
grant_types_supported: ["authorization_code", "refresh_token", "client_credentials", "anonymous"],
|
|
2271
|
+
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
|
|
2272
|
+
scopes_supported: ["openid", "profile", "email", "anonymous"]
|
|
2273
|
+
};
|
|
2274
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2275
|
+
res.end(JSON.stringify(metadata));
|
|
2276
|
+
this.log("Served OAuth metadata");
|
|
2277
|
+
}
|
|
2278
|
+
async handleTokenEndpoint(req, res) {
|
|
2279
|
+
const body = await this.readBody(req);
|
|
2280
|
+
const params = new URLSearchParams(body);
|
|
2281
|
+
const grantType = params.get("grant_type");
|
|
2282
|
+
if (grantType === "anonymous") {
|
|
2283
|
+
const token = await this.tokenFactory.createAnonymousToken();
|
|
2284
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2285
|
+
res.end(
|
|
2286
|
+
JSON.stringify({
|
|
2287
|
+
access_token: token,
|
|
2288
|
+
token_type: "Bearer",
|
|
2289
|
+
expires_in: 3600
|
|
2290
|
+
})
|
|
2291
|
+
);
|
|
2292
|
+
this.log("Issued anonymous token");
|
|
2293
|
+
} else {
|
|
2294
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2295
|
+
res.end(
|
|
2296
|
+
JSON.stringify({
|
|
2297
|
+
error: "unsupported_grant_type",
|
|
2298
|
+
error_description: "Only anonymous grant type is supported in mock server"
|
|
2299
|
+
})
|
|
2300
|
+
);
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
readBody(req) {
|
|
2304
|
+
return new Promise((resolve, reject) => {
|
|
2305
|
+
const chunks = [];
|
|
2306
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
2307
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
2308
|
+
req.on("error", reject);
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
2311
|
+
log(message) {
|
|
2312
|
+
if (this.options.debug) {
|
|
2313
|
+
console.log(`[MockOAuthServer] ${message}`);
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
};
|
|
2317
|
+
|
|
2318
|
+
// libs/testing/src/auth/mock-api-server.ts
|
|
2319
|
+
import { createServer as createServer2 } from "http";
|
|
2320
|
+
var MockAPIServer = class {
|
|
2321
|
+
options;
|
|
2322
|
+
server = null;
|
|
2323
|
+
_info = null;
|
|
2324
|
+
routes;
|
|
2325
|
+
constructor(options) {
|
|
2326
|
+
this.options = options;
|
|
2327
|
+
this.routes = options.routes ?? [];
|
|
2328
|
+
}
|
|
2329
|
+
/**
|
|
2330
|
+
* Start the mock API server
|
|
2331
|
+
*/
|
|
2332
|
+
async start() {
|
|
2333
|
+
if (this.server) {
|
|
2334
|
+
throw new Error("Mock API server is already running");
|
|
2335
|
+
}
|
|
2336
|
+
const port = this.options.port ?? 0;
|
|
2337
|
+
return new Promise((resolve, reject) => {
|
|
2338
|
+
this.server = createServer2(this.handleRequest.bind(this));
|
|
2339
|
+
this.server.on("error", (err) => {
|
|
2340
|
+
this.log(`Server error: ${err.message}`);
|
|
2341
|
+
reject(err);
|
|
2342
|
+
});
|
|
2343
|
+
this.server.listen(port, () => {
|
|
2344
|
+
const address = this.server.address();
|
|
2345
|
+
if (!address || typeof address === "string") {
|
|
2346
|
+
reject(new Error("Failed to get server address"));
|
|
2347
|
+
return;
|
|
2348
|
+
}
|
|
2349
|
+
const actualPort = address.port;
|
|
2350
|
+
this._info = {
|
|
2351
|
+
baseUrl: `http://localhost:${actualPort}`,
|
|
2352
|
+
port: actualPort,
|
|
2353
|
+
specUrl: `http://localhost:${actualPort}/openapi.json`
|
|
2354
|
+
};
|
|
2355
|
+
this.log(`Mock API server started at ${this._info.baseUrl}`);
|
|
2356
|
+
resolve(this._info);
|
|
2357
|
+
});
|
|
2358
|
+
});
|
|
2359
|
+
}
|
|
2360
|
+
/**
|
|
2361
|
+
* Stop the mock API server
|
|
2362
|
+
*/
|
|
2363
|
+
async stop() {
|
|
2364
|
+
if (!this.server) {
|
|
2365
|
+
return;
|
|
2366
|
+
}
|
|
2367
|
+
return new Promise((resolve, reject) => {
|
|
2368
|
+
this.server.close((err) => {
|
|
2369
|
+
if (err) {
|
|
2370
|
+
reject(err);
|
|
2371
|
+
} else {
|
|
2372
|
+
this.server = null;
|
|
2373
|
+
this._info = null;
|
|
2374
|
+
this.log("Mock API server stopped");
|
|
2375
|
+
resolve();
|
|
2376
|
+
}
|
|
2377
|
+
});
|
|
2378
|
+
});
|
|
2379
|
+
}
|
|
2380
|
+
/**
|
|
2381
|
+
* Get server info
|
|
2382
|
+
*/
|
|
2383
|
+
get info() {
|
|
2384
|
+
if (!this._info) {
|
|
2385
|
+
throw new Error("Mock API server is not running");
|
|
2386
|
+
}
|
|
2387
|
+
return this._info;
|
|
2388
|
+
}
|
|
2389
|
+
/**
|
|
2390
|
+
* Add a route dynamically
|
|
2391
|
+
*/
|
|
2392
|
+
addRoute(route) {
|
|
2393
|
+
this.routes.push(route);
|
|
2394
|
+
}
|
|
2395
|
+
/**
|
|
2396
|
+
* Clear all routes
|
|
2397
|
+
*/
|
|
2398
|
+
clearRoutes() {
|
|
2399
|
+
this.routes = [];
|
|
2400
|
+
}
|
|
2401
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2402
|
+
// PRIVATE
|
|
2403
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2404
|
+
async handleRequest(req, res) {
|
|
2405
|
+
const url = req.url ?? "/";
|
|
2406
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
2407
|
+
this.log(`${method} ${url}`);
|
|
2408
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
2409
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS");
|
|
2410
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
2411
|
+
if (method === "OPTIONS") {
|
|
2412
|
+
res.writeHead(204);
|
|
2413
|
+
res.end();
|
|
2414
|
+
return;
|
|
2415
|
+
}
|
|
2416
|
+
try {
|
|
2417
|
+
if (url === "/openapi.json" || url === "/openapi.yaml") {
|
|
2418
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2419
|
+
res.end(JSON.stringify(this.options.openApiSpec));
|
|
2420
|
+
this.log("Served OpenAPI spec");
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
2423
|
+
const route = this.findRoute(method, url);
|
|
2424
|
+
if (route) {
|
|
2425
|
+
const status = route.response.status ?? 200;
|
|
2426
|
+
const headers = {
|
|
2427
|
+
"Content-Type": "application/json",
|
|
2428
|
+
...route.response.headers
|
|
2429
|
+
};
|
|
2430
|
+
res.writeHead(status, headers);
|
|
2431
|
+
res.end(JSON.stringify(route.response.body));
|
|
2432
|
+
this.log(`Matched route: ${method} ${route.path} -> ${status}`);
|
|
2433
|
+
return;
|
|
2434
|
+
}
|
|
2435
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
2436
|
+
res.end(JSON.stringify({ error: "not_found", message: `No mock for ${method} ${url}` }));
|
|
2437
|
+
} catch (error) {
|
|
2438
|
+
this.log(`Error handling request: ${error}`);
|
|
2439
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
2440
|
+
res.end(JSON.stringify({ error: "server_error", message: "Internal server error" }));
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
findRoute(method, url) {
|
|
2444
|
+
const path = url.split("?")[0];
|
|
2445
|
+
return this.routes.find((route) => {
|
|
2446
|
+
if (route.method !== method) return false;
|
|
2447
|
+
if (route.path === path) return true;
|
|
2448
|
+
const routeParts = route.path.split("/");
|
|
2449
|
+
const urlParts = path.split("/");
|
|
2450
|
+
if (routeParts.length !== urlParts.length) return false;
|
|
2451
|
+
return routeParts.every((part, i) => {
|
|
2452
|
+
if (part.startsWith(":")) return true;
|
|
2453
|
+
return part === urlParts[i];
|
|
2454
|
+
});
|
|
2455
|
+
});
|
|
2456
|
+
}
|
|
2457
|
+
log(message) {
|
|
2458
|
+
if (this.options.debug) {
|
|
2459
|
+
console.log(`[MockAPIServer] ${message}`);
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
};
|
|
2463
|
+
|
|
2464
|
+
// libs/testing/src/server/test-server.ts
|
|
2465
|
+
import { spawn } from "child_process";
|
|
2466
|
+
var TestServer = class _TestServer {
|
|
2467
|
+
process = null;
|
|
2468
|
+
options;
|
|
2469
|
+
_info;
|
|
2470
|
+
logs = [];
|
|
2471
|
+
constructor(options, port) {
|
|
2472
|
+
this.options = {
|
|
2473
|
+
port,
|
|
2474
|
+
command: options.command ?? "",
|
|
2475
|
+
cwd: options.cwd ?? process.cwd(),
|
|
2476
|
+
env: options.env ?? {},
|
|
2477
|
+
startupTimeout: options.startupTimeout ?? 3e4,
|
|
2478
|
+
healthCheckPath: options.healthCheckPath ?? "/health",
|
|
2479
|
+
debug: options.debug ?? false
|
|
2480
|
+
};
|
|
2481
|
+
this._info = {
|
|
2482
|
+
baseUrl: `http://localhost:${port}`,
|
|
2483
|
+
port
|
|
2484
|
+
};
|
|
2485
|
+
}
|
|
2486
|
+
/**
|
|
2487
|
+
* Start a test server with custom command
|
|
2488
|
+
*/
|
|
2489
|
+
static async start(options) {
|
|
2490
|
+
const port = options.port ?? await findAvailablePort();
|
|
2491
|
+
const server = new _TestServer(options, port);
|
|
2492
|
+
try {
|
|
2493
|
+
await server.startProcess();
|
|
2494
|
+
} catch (error) {
|
|
2495
|
+
await server.stop();
|
|
2496
|
+
throw error;
|
|
2497
|
+
}
|
|
2498
|
+
return server;
|
|
2499
|
+
}
|
|
2500
|
+
/**
|
|
2501
|
+
* Start an Nx project as test server
|
|
2502
|
+
*/
|
|
2503
|
+
static async startNx(project, options = {}) {
|
|
2504
|
+
if (!/^[\w-]+$/.test(project)) {
|
|
2505
|
+
throw new Error(
|
|
2506
|
+
`Invalid project name: ${project}. Must contain only alphanumeric, underscore, and hyphen characters.`
|
|
2507
|
+
);
|
|
2508
|
+
}
|
|
2509
|
+
const port = options.port ?? await findAvailablePort();
|
|
2510
|
+
const serverOptions = {
|
|
2511
|
+
...options,
|
|
2512
|
+
port,
|
|
2513
|
+
command: `npx nx serve ${project} --port ${port}`,
|
|
2514
|
+
cwd: options.cwd ?? process.cwd()
|
|
2515
|
+
};
|
|
2516
|
+
const server = new _TestServer(serverOptions, port);
|
|
2517
|
+
try {
|
|
2518
|
+
await server.startProcess();
|
|
2519
|
+
} catch (error) {
|
|
2520
|
+
await server.stop();
|
|
2521
|
+
throw error;
|
|
2522
|
+
}
|
|
2523
|
+
return server;
|
|
2524
|
+
}
|
|
2525
|
+
/**
|
|
2526
|
+
* Create a test server connected to an already running server
|
|
2527
|
+
*/
|
|
2528
|
+
static connect(baseUrl) {
|
|
2529
|
+
const url = new URL(baseUrl);
|
|
2530
|
+
const port = parseInt(url.port, 10) || (url.protocol === "https:" ? 443 : 80);
|
|
2531
|
+
const server = new _TestServer(
|
|
2532
|
+
{
|
|
2533
|
+
command: "",
|
|
2534
|
+
port
|
|
2535
|
+
},
|
|
2536
|
+
port
|
|
2537
|
+
);
|
|
2538
|
+
server._info = {
|
|
2539
|
+
baseUrl: baseUrl.replace(/\/$/, ""),
|
|
2540
|
+
port
|
|
2541
|
+
};
|
|
2542
|
+
return server;
|
|
2543
|
+
}
|
|
2544
|
+
/**
|
|
2545
|
+
* Get server information
|
|
2546
|
+
*/
|
|
2547
|
+
get info() {
|
|
2548
|
+
return { ...this._info };
|
|
2549
|
+
}
|
|
2550
|
+
/**
|
|
2551
|
+
* Stop the test server
|
|
2552
|
+
*/
|
|
2553
|
+
async stop() {
|
|
2554
|
+
if (this.process) {
|
|
2555
|
+
this.log("Stopping server...");
|
|
2556
|
+
this.process.kill("SIGTERM");
|
|
2557
|
+
const exitPromise = new Promise((resolve) => {
|
|
2558
|
+
if (this.process) {
|
|
2559
|
+
this.process.once("exit", () => resolve());
|
|
2560
|
+
} else {
|
|
2561
|
+
resolve();
|
|
2562
|
+
}
|
|
2563
|
+
});
|
|
2564
|
+
const killTimeout = setTimeout(() => {
|
|
2565
|
+
if (this.process) {
|
|
2566
|
+
this.log("Force killing server after timeout...");
|
|
2567
|
+
this.process.kill("SIGKILL");
|
|
2568
|
+
}
|
|
2569
|
+
}, 5e3);
|
|
2570
|
+
await exitPromise;
|
|
2571
|
+
clearTimeout(killTimeout);
|
|
2572
|
+
this.process = null;
|
|
2573
|
+
this.log("Server stopped");
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
/**
|
|
2577
|
+
* Wait for server to be ready
|
|
2578
|
+
*/
|
|
2579
|
+
async waitForReady(timeout) {
|
|
2580
|
+
const timeoutMs = timeout ?? this.options.startupTimeout;
|
|
2581
|
+
const deadline = Date.now() + timeoutMs;
|
|
2582
|
+
const checkInterval = 100;
|
|
2583
|
+
while (Date.now() < deadline) {
|
|
2584
|
+
try {
|
|
2585
|
+
const response = await fetch(`${this._info.baseUrl}${this.options.healthCheckPath}`, {
|
|
2586
|
+
method: "GET",
|
|
2587
|
+
signal: AbortSignal.timeout(1e3)
|
|
2588
|
+
});
|
|
2589
|
+
if (response.ok || response.status === 404) {
|
|
2590
|
+
this.log("Server is ready");
|
|
2591
|
+
return;
|
|
2592
|
+
}
|
|
2593
|
+
} catch {
|
|
2594
|
+
}
|
|
2595
|
+
await sleep2(checkInterval);
|
|
2596
|
+
}
|
|
2597
|
+
throw new Error(`Server did not become ready within ${timeoutMs}ms`);
|
|
2598
|
+
}
|
|
2599
|
+
/**
|
|
2600
|
+
* Restart the server
|
|
2601
|
+
*/
|
|
2602
|
+
async restart() {
|
|
2603
|
+
await this.stop();
|
|
2604
|
+
await this.startProcess();
|
|
2605
|
+
}
|
|
2606
|
+
/**
|
|
2607
|
+
* Get captured server logs
|
|
2608
|
+
*/
|
|
2609
|
+
getLogs() {
|
|
2610
|
+
return [...this.logs];
|
|
2611
|
+
}
|
|
2612
|
+
/**
|
|
2613
|
+
* Clear captured logs
|
|
2614
|
+
*/
|
|
2615
|
+
clearLogs() {
|
|
2616
|
+
this.logs = [];
|
|
2617
|
+
}
|
|
2618
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2619
|
+
// PRIVATE METHODS
|
|
2620
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2621
|
+
async startProcess() {
|
|
2622
|
+
if (!this.options.command) {
|
|
2623
|
+
await this.waitForReady();
|
|
2624
|
+
return;
|
|
2625
|
+
}
|
|
2626
|
+
this.log(`Starting server: ${this.options.command}`);
|
|
2627
|
+
const env = {
|
|
2628
|
+
...process.env,
|
|
2629
|
+
...this.options.env,
|
|
2630
|
+
PORT: String(this.options.port)
|
|
2631
|
+
};
|
|
2632
|
+
this.process = spawn(this.options.command, [], {
|
|
2633
|
+
cwd: this.options.cwd,
|
|
2634
|
+
env,
|
|
2635
|
+
shell: true,
|
|
2636
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
2637
|
+
});
|
|
2638
|
+
if (this.process.pid !== void 0) {
|
|
2639
|
+
this._info.pid = this.process.pid;
|
|
2640
|
+
}
|
|
2641
|
+
let processExited = false;
|
|
2642
|
+
let exitCode = null;
|
|
2643
|
+
let exitError = null;
|
|
2644
|
+
this.process.stdout?.on("data", (data) => {
|
|
2645
|
+
const text = data.toString();
|
|
2646
|
+
this.logs.push(text);
|
|
2647
|
+
if (this.options.debug) {
|
|
2648
|
+
console.log("[SERVER]", text);
|
|
2649
|
+
}
|
|
2650
|
+
});
|
|
2651
|
+
this.process.stderr?.on("data", (data) => {
|
|
2652
|
+
const text = data.toString();
|
|
2653
|
+
this.logs.push(`[ERROR] ${text}`);
|
|
2654
|
+
if (this.options.debug) {
|
|
2655
|
+
console.error("[SERVER ERROR]", text);
|
|
2656
|
+
}
|
|
2657
|
+
});
|
|
2658
|
+
this.process.on("error", (err) => {
|
|
2659
|
+
this.logs.push(`[SPAWN ERROR] ${err.message}`);
|
|
2660
|
+
exitError = err;
|
|
2661
|
+
if (this.options.debug) {
|
|
2662
|
+
console.error("[SERVER SPAWN ERROR]", err);
|
|
2663
|
+
}
|
|
2664
|
+
});
|
|
2665
|
+
this.process.once("exit", (code) => {
|
|
2666
|
+
processExited = true;
|
|
2667
|
+
exitCode = code;
|
|
2668
|
+
this.log(`Server process exited with code ${code}`);
|
|
2669
|
+
});
|
|
2670
|
+
await this.waitForReadyWithExitDetection(() => {
|
|
2671
|
+
if (exitError) {
|
|
2672
|
+
return { exited: true, error: exitError };
|
|
2673
|
+
}
|
|
2674
|
+
if (processExited) {
|
|
2675
|
+
const recentLogs = this.logs.slice(-10).join("\n");
|
|
2676
|
+
return {
|
|
2677
|
+
exited: true,
|
|
2678
|
+
error: new Error(`Server process exited unexpectedly with code ${exitCode}.
|
|
2679
|
+
|
|
2680
|
+
Recent logs:
|
|
2681
|
+
${recentLogs}`)
|
|
2682
|
+
};
|
|
2683
|
+
}
|
|
2684
|
+
return { exited: false };
|
|
2685
|
+
});
|
|
2686
|
+
}
|
|
2687
|
+
/**
|
|
2688
|
+
* Wait for server to be ready, but also detect early process exit
|
|
2689
|
+
*/
|
|
2690
|
+
async waitForReadyWithExitDetection(checkExit) {
|
|
2691
|
+
const timeoutMs = this.options.startupTimeout;
|
|
2692
|
+
const deadline = Date.now() + timeoutMs;
|
|
2693
|
+
const checkInterval = 100;
|
|
2694
|
+
while (Date.now() < deadline) {
|
|
2695
|
+
const exitStatus = checkExit();
|
|
2696
|
+
if (exitStatus.exited) {
|
|
2697
|
+
throw exitStatus.error ?? new Error("Server process exited unexpectedly");
|
|
2698
|
+
}
|
|
2699
|
+
try {
|
|
2700
|
+
const response = await fetch(`${this._info.baseUrl}${this.options.healthCheckPath}`, {
|
|
2701
|
+
method: "GET",
|
|
2702
|
+
signal: AbortSignal.timeout(1e3)
|
|
2703
|
+
});
|
|
2704
|
+
if (response.ok || response.status === 404) {
|
|
2705
|
+
this.log("Server is ready");
|
|
2706
|
+
return;
|
|
2707
|
+
}
|
|
2708
|
+
} catch {
|
|
2709
|
+
}
|
|
2710
|
+
await sleep2(checkInterval);
|
|
2711
|
+
}
|
|
2712
|
+
const finalExitStatus = checkExit();
|
|
2713
|
+
if (finalExitStatus.exited) {
|
|
2714
|
+
throw finalExitStatus.error ?? new Error("Server process exited unexpectedly");
|
|
2715
|
+
}
|
|
2716
|
+
throw new Error(`Server did not become ready within ${timeoutMs}ms`);
|
|
2717
|
+
}
|
|
2718
|
+
log(message) {
|
|
2719
|
+
if (this.options.debug) {
|
|
2720
|
+
console.log(`[TestServer] ${message}`);
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
};
|
|
2724
|
+
async function findAvailablePort() {
|
|
2725
|
+
const { createServer: createServer3 } = await import("net");
|
|
2726
|
+
return new Promise((resolve, reject) => {
|
|
2727
|
+
const server = createServer3();
|
|
2728
|
+
server.listen(0, () => {
|
|
2729
|
+
const address = server.address();
|
|
2730
|
+
if (address && typeof address !== "string") {
|
|
2731
|
+
const port = address.port;
|
|
2732
|
+
server.close(() => resolve(port));
|
|
2733
|
+
} else {
|
|
2734
|
+
reject(new Error("Could not get port"));
|
|
2735
|
+
}
|
|
2736
|
+
});
|
|
2737
|
+
server.on("error", reject);
|
|
2738
|
+
});
|
|
2739
|
+
}
|
|
2740
|
+
function sleep2(ms) {
|
|
2741
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
// libs/testing/src/assertions/mcp-assertions.ts
|
|
2745
|
+
var McpAssertions = {
|
|
2746
|
+
/**
|
|
2747
|
+
* Assert that an MCP response was successful and return the data
|
|
2748
|
+
* @throws Error if response was not successful
|
|
2749
|
+
*/
|
|
2750
|
+
assertSuccess(response, message) {
|
|
2751
|
+
if (!response.success) {
|
|
2752
|
+
const errorMsg = response.error?.message ?? "Unknown error";
|
|
2753
|
+
throw new Error(message ?? `Expected success but got error: ${errorMsg} (code: ${response.error?.code})`);
|
|
2754
|
+
}
|
|
2755
|
+
if (response.data === void 0) {
|
|
2756
|
+
throw new Error(message ?? "Expected data but got undefined");
|
|
2757
|
+
}
|
|
2758
|
+
return response.data;
|
|
2759
|
+
},
|
|
2760
|
+
/**
|
|
2761
|
+
* Assert that an MCP response was an error
|
|
2762
|
+
* @param expectedCode Optional expected error code
|
|
2763
|
+
*/
|
|
2764
|
+
assertError(response, expectedCode) {
|
|
2765
|
+
if (response.success) {
|
|
2766
|
+
throw new Error("Expected error but got success");
|
|
2767
|
+
}
|
|
2768
|
+
if (!response.error) {
|
|
2769
|
+
throw new Error("Expected error info but got undefined");
|
|
2770
|
+
}
|
|
2771
|
+
if (expectedCode !== void 0 && response.error.code !== expectedCode) {
|
|
2772
|
+
throw new Error(`Expected error code ${expectedCode} but got ${response.error.code}: ${response.error.message}`);
|
|
2773
|
+
}
|
|
2774
|
+
return response.error;
|
|
2775
|
+
},
|
|
2776
|
+
/**
|
|
2777
|
+
* Assert that a tool call was successful (not isError)
|
|
2778
|
+
*/
|
|
2779
|
+
assertToolSuccess(result) {
|
|
2780
|
+
if ("raw" in result) {
|
|
2781
|
+
if (result.isError) {
|
|
2782
|
+
throw new Error(`Tool call failed: ${result.error?.message ?? "Unknown error"}`);
|
|
2783
|
+
}
|
|
2784
|
+
} else {
|
|
2785
|
+
if (!result.success) {
|
|
2786
|
+
throw new Error(`Tool call failed: ${result.error?.message ?? "Unknown error"}`);
|
|
2787
|
+
}
|
|
2788
|
+
if (result.data?.isError) {
|
|
2789
|
+
throw new Error("Tool returned isError=true");
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
},
|
|
2793
|
+
/**
|
|
2794
|
+
* Assert that a tool result has specific content type
|
|
2795
|
+
*/
|
|
2796
|
+
assertToolContent(result, type) {
|
|
2797
|
+
let content;
|
|
2798
|
+
if ("raw" in result) {
|
|
2799
|
+
content = result.raw.content;
|
|
2800
|
+
} else {
|
|
2801
|
+
if (!result.success || !result.data) {
|
|
2802
|
+
throw new Error("Tool call was not successful");
|
|
2803
|
+
}
|
|
2804
|
+
content = result.data.content;
|
|
2805
|
+
}
|
|
2806
|
+
const hasContent = content?.some((c) => c.type === type);
|
|
2807
|
+
if (!hasContent) {
|
|
2808
|
+
throw new Error(`Expected tool result to have ${type} content`);
|
|
2809
|
+
}
|
|
2810
|
+
},
|
|
2811
|
+
/**
|
|
2812
|
+
* Assert that a resource read was successful and return the text content
|
|
2813
|
+
*/
|
|
2814
|
+
assertTextResource(response) {
|
|
2815
|
+
if ("raw" in response) {
|
|
2816
|
+
if (response.isError) {
|
|
2817
|
+
throw new Error(`Resource read failed: ${response.error?.message ?? "Unknown error"}`);
|
|
2818
|
+
}
|
|
2819
|
+
const text = response.text();
|
|
2820
|
+
if (text === void 0) {
|
|
2821
|
+
throw new Error("Expected text content but got undefined");
|
|
2822
|
+
}
|
|
2823
|
+
return text;
|
|
2824
|
+
} else {
|
|
2825
|
+
if (!response.success || !response.data) {
|
|
2826
|
+
throw new Error(`Resource read failed: ${response.error?.message ?? "Unknown error"}`);
|
|
2827
|
+
}
|
|
2828
|
+
const content = response.data.contents?.[0];
|
|
2829
|
+
if (!content || !("text" in content)) {
|
|
2830
|
+
throw new Error("Expected text content but got undefined");
|
|
2831
|
+
}
|
|
2832
|
+
return content.text;
|
|
2833
|
+
}
|
|
2834
|
+
},
|
|
2835
|
+
/**
|
|
2836
|
+
* Assert that tools array contains a tool with given name
|
|
2837
|
+
*/
|
|
2838
|
+
assertContainsTool(tools, name) {
|
|
2839
|
+
const tool = tools.find((t) => t.name === name);
|
|
2840
|
+
if (!tool) {
|
|
2841
|
+
const available = tools.map((t) => t.name).join(", ");
|
|
2842
|
+
throw new Error(`Expected to find tool "${name}" but got: [${available}]`);
|
|
2843
|
+
}
|
|
2844
|
+
return tool;
|
|
2845
|
+
},
|
|
2846
|
+
/**
|
|
2847
|
+
* Assert that resources array contains a resource with given URI
|
|
2848
|
+
*/
|
|
2849
|
+
assertContainsResource(resources, uri) {
|
|
2850
|
+
const resource = resources.find((r) => r.uri === uri);
|
|
2851
|
+
if (!resource) {
|
|
2852
|
+
const available = resources.map((r) => r.uri).join(", ");
|
|
2853
|
+
throw new Error(`Expected to find resource "${uri}" but got: [${available}]`);
|
|
2854
|
+
}
|
|
2855
|
+
return resource;
|
|
2856
|
+
},
|
|
2857
|
+
/**
|
|
2858
|
+
* Assert that resource templates array contains a template with given URI template
|
|
2859
|
+
*/
|
|
2860
|
+
assertContainsResourceTemplate(templates, uriTemplate) {
|
|
2861
|
+
const template = templates.find((t) => t.uriTemplate === uriTemplate);
|
|
2862
|
+
if (!template) {
|
|
2863
|
+
const available = templates.map((t) => t.uriTemplate).join(", ");
|
|
2864
|
+
throw new Error(`Expected to find resource template "${uriTemplate}" but got: [${available}]`);
|
|
2865
|
+
}
|
|
2866
|
+
return template;
|
|
2867
|
+
},
|
|
2868
|
+
/**
|
|
2869
|
+
* Assert that prompts array contains a prompt with given name
|
|
2870
|
+
*/
|
|
2871
|
+
assertContainsPrompt(prompts, name) {
|
|
2872
|
+
const prompt = prompts.find((p) => p.name === name);
|
|
2873
|
+
if (!prompt) {
|
|
2874
|
+
const available = prompts.map((p) => p.name).join(", ");
|
|
2875
|
+
throw new Error(`Expected to find prompt "${name}" but got: [${available}]`);
|
|
2876
|
+
}
|
|
2877
|
+
return prompt;
|
|
2878
|
+
}
|
|
2879
|
+
};
|
|
2880
|
+
function containsTool(tools, name) {
|
|
2881
|
+
return tools.some((t) => t.name === name);
|
|
2882
|
+
}
|
|
2883
|
+
function containsResource(resources, uri) {
|
|
2884
|
+
return resources.some((r) => r.uri === uri);
|
|
2885
|
+
}
|
|
2886
|
+
function containsResourceTemplate(templates, uriTemplate) {
|
|
2887
|
+
return templates.some((t) => t.uriTemplate === uriTemplate);
|
|
2888
|
+
}
|
|
2889
|
+
function containsPrompt(prompts, name) {
|
|
2890
|
+
return prompts.some((p) => p.name === name);
|
|
2891
|
+
}
|
|
2892
|
+
function isSuccessful(result) {
|
|
2893
|
+
return result.isSuccess;
|
|
2894
|
+
}
|
|
2895
|
+
function isError(result, expectedCode) {
|
|
2896
|
+
if (!result.isError) return false;
|
|
2897
|
+
if (expectedCode !== void 0) {
|
|
2898
|
+
return result.error?.code === expectedCode;
|
|
2899
|
+
}
|
|
2900
|
+
return true;
|
|
2901
|
+
}
|
|
2902
|
+
function hasTextContent(result) {
|
|
2903
|
+
return result.hasTextContent();
|
|
2904
|
+
}
|
|
2905
|
+
function hasMimeType(result, mimeType) {
|
|
2906
|
+
return result.hasMimeType(mimeType);
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
// libs/testing/src/errors/index.ts
|
|
2910
|
+
var TestClientError = class extends Error {
|
|
2911
|
+
constructor(message) {
|
|
2912
|
+
super(message);
|
|
2913
|
+
this.name = "TestClientError";
|
|
2914
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
2915
|
+
}
|
|
2916
|
+
};
|
|
2917
|
+
var ConnectionError = class extends TestClientError {
|
|
2918
|
+
constructor(message, cause) {
|
|
2919
|
+
super(message);
|
|
2920
|
+
this.cause = cause;
|
|
2921
|
+
this.name = "ConnectionError";
|
|
2922
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
2923
|
+
}
|
|
2924
|
+
};
|
|
2925
|
+
var TimeoutError = class extends TestClientError {
|
|
2926
|
+
constructor(message, timeoutMs) {
|
|
2927
|
+
super(message);
|
|
2928
|
+
this.timeoutMs = timeoutMs;
|
|
2929
|
+
this.name = "TimeoutError";
|
|
2930
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
2931
|
+
}
|
|
2932
|
+
};
|
|
2933
|
+
var McpProtocolError = class extends TestClientError {
|
|
2934
|
+
constructor(message, code, data) {
|
|
2935
|
+
super(message);
|
|
2936
|
+
this.code = code;
|
|
2937
|
+
this.data = data;
|
|
2938
|
+
this.name = "McpProtocolError";
|
|
2939
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
2940
|
+
}
|
|
2941
|
+
};
|
|
2942
|
+
var ServerStartError = class extends TestClientError {
|
|
2943
|
+
constructor(message, cause) {
|
|
2944
|
+
super(message);
|
|
2945
|
+
this.cause = cause;
|
|
2946
|
+
this.name = "ServerStartError";
|
|
2947
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
2948
|
+
}
|
|
2949
|
+
};
|
|
2950
|
+
var AssertionError = class extends TestClientError {
|
|
2951
|
+
constructor(message) {
|
|
2952
|
+
super(message);
|
|
2953
|
+
this.name = "AssertionError";
|
|
2954
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
2955
|
+
}
|
|
2956
|
+
};
|
|
2957
|
+
|
|
2958
|
+
// libs/testing/src/fixtures/test-fixture.ts
|
|
2959
|
+
var currentConfig = {};
|
|
2960
|
+
var serverInstance = null;
|
|
2961
|
+
var tokenFactory = null;
|
|
2962
|
+
var serverStartedByUs = false;
|
|
2963
|
+
async function initializeSharedResources() {
|
|
2964
|
+
if (!tokenFactory) {
|
|
2965
|
+
tokenFactory = new TestTokenFactory();
|
|
2966
|
+
}
|
|
2967
|
+
if (!serverInstance) {
|
|
2968
|
+
if (currentConfig.baseUrl) {
|
|
2969
|
+
serverInstance = TestServer.connect(currentConfig.baseUrl);
|
|
2970
|
+
serverStartedByUs = false;
|
|
2971
|
+
} else if (currentConfig.server) {
|
|
2972
|
+
serverInstance = await TestServer.start({
|
|
2973
|
+
port: currentConfig.port,
|
|
2974
|
+
command: resolveServerCommand(currentConfig.server),
|
|
2975
|
+
env: currentConfig.env,
|
|
2976
|
+
startupTimeout: currentConfig.startupTimeout ?? 3e4,
|
|
2977
|
+
debug: currentConfig.logLevel === "debug"
|
|
2978
|
+
});
|
|
2979
|
+
serverStartedByUs = true;
|
|
2980
|
+
} else {
|
|
2981
|
+
throw new Error(
|
|
2982
|
+
'test.use() requires either "server" (entry file path) or "baseUrl" (for external server) option'
|
|
2983
|
+
);
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
async function createTestFixtures() {
|
|
2988
|
+
await initializeSharedResources();
|
|
2989
|
+
const clientInstance = await McpTestClient.create({
|
|
2990
|
+
baseUrl: serverInstance.info.baseUrl,
|
|
2991
|
+
transport: currentConfig.transport ?? "streamable-http",
|
|
2992
|
+
publicMode: currentConfig.publicMode
|
|
2993
|
+
}).buildAndConnect();
|
|
2994
|
+
const auth = createAuthFixture(tokenFactory);
|
|
2995
|
+
const server = createServerFixture(serverInstance);
|
|
2996
|
+
return {
|
|
2997
|
+
mcp: clientInstance,
|
|
2998
|
+
auth,
|
|
2999
|
+
server
|
|
3000
|
+
};
|
|
3001
|
+
}
|
|
3002
|
+
async function cleanupTestFixtures(fixtures, _testFailed = false) {
|
|
3003
|
+
if (fixtures.mcp.isConnected()) {
|
|
3004
|
+
await fixtures.mcp.disconnect();
|
|
3005
|
+
}
|
|
3006
|
+
}
|
|
3007
|
+
async function cleanupSharedResources() {
|
|
3008
|
+
if (serverInstance && serverStartedByUs) {
|
|
3009
|
+
await serverInstance.stop();
|
|
3010
|
+
}
|
|
3011
|
+
serverInstance = null;
|
|
3012
|
+
tokenFactory = null;
|
|
3013
|
+
serverStartedByUs = false;
|
|
3014
|
+
}
|
|
3015
|
+
function createAuthFixture(factory) {
|
|
3016
|
+
const users = {
|
|
3017
|
+
admin: {
|
|
3018
|
+
sub: "admin-001",
|
|
3019
|
+
scopes: ["admin:*", "read", "write", "delete"],
|
|
3020
|
+
email: "admin@test.local",
|
|
3021
|
+
name: "Test Admin"
|
|
3022
|
+
},
|
|
3023
|
+
user: {
|
|
3024
|
+
sub: "user-001",
|
|
3025
|
+
scopes: ["read", "write"],
|
|
3026
|
+
email: "user@test.local",
|
|
3027
|
+
name: "Test User"
|
|
3028
|
+
},
|
|
3029
|
+
readOnly: {
|
|
3030
|
+
sub: "readonly-001",
|
|
3031
|
+
scopes: ["read"],
|
|
3032
|
+
email: "readonly@test.local",
|
|
3033
|
+
name: "Read Only User"
|
|
3034
|
+
}
|
|
3035
|
+
};
|
|
3036
|
+
return {
|
|
3037
|
+
createToken: (opts) => factory.createTestToken({
|
|
3038
|
+
sub: opts.sub,
|
|
3039
|
+
scopes: opts.scopes,
|
|
3040
|
+
claims: {
|
|
3041
|
+
email: opts.email,
|
|
3042
|
+
name: opts.name,
|
|
3043
|
+
...opts.claims
|
|
3044
|
+
},
|
|
3045
|
+
exp: opts.expiresIn
|
|
3046
|
+
}),
|
|
3047
|
+
createExpiredToken: (opts) => factory.createExpiredToken(opts),
|
|
3048
|
+
createInvalidToken: (opts) => factory.createTokenWithInvalidSignature(opts),
|
|
3049
|
+
users: {
|
|
3050
|
+
admin: users["admin"],
|
|
3051
|
+
user: users["user"],
|
|
3052
|
+
readOnly: users["readOnly"]
|
|
3053
|
+
},
|
|
3054
|
+
getJwks: () => factory.getPublicJwks(),
|
|
3055
|
+
getIssuer: () => factory.getIssuer(),
|
|
3056
|
+
getAudience: () => factory.getAudience()
|
|
3057
|
+
};
|
|
3058
|
+
}
|
|
3059
|
+
function createServerFixture(server) {
|
|
3060
|
+
return {
|
|
3061
|
+
info: server.info,
|
|
3062
|
+
createClient: async (opts) => {
|
|
3063
|
+
return McpTestClient.create({
|
|
3064
|
+
baseUrl: server.info.baseUrl,
|
|
3065
|
+
transport: opts?.transport ?? "streamable-http",
|
|
3066
|
+
auth: opts?.token ? { token: opts.token } : void 0,
|
|
3067
|
+
clientInfo: opts?.clientInfo,
|
|
3068
|
+
publicMode: currentConfig.publicMode
|
|
3069
|
+
}).buildAndConnect();
|
|
3070
|
+
},
|
|
3071
|
+
createClientBuilder: () => {
|
|
3072
|
+
const builder = new McpTestClientBuilder({
|
|
3073
|
+
baseUrl: server.info.baseUrl,
|
|
3074
|
+
publicMode: currentConfig.publicMode
|
|
3075
|
+
});
|
|
3076
|
+
return builder;
|
|
3077
|
+
},
|
|
3078
|
+
restart: () => server.restart(),
|
|
3079
|
+
getLogs: () => server.getLogs(),
|
|
3080
|
+
clearLogs: () => server.clearLogs()
|
|
3081
|
+
};
|
|
3082
|
+
}
|
|
3083
|
+
function resolveServerCommand(server) {
|
|
3084
|
+
if (server.includes(" ")) {
|
|
3085
|
+
return server;
|
|
3086
|
+
}
|
|
3087
|
+
return `npx tsx ${server}`;
|
|
3088
|
+
}
|
|
3089
|
+
function testWithFixtures(name, fn) {
|
|
3090
|
+
it(name, async () => {
|
|
3091
|
+
const fixtures = await createTestFixtures();
|
|
3092
|
+
let testFailed = false;
|
|
3093
|
+
try {
|
|
3094
|
+
await fn(fixtures);
|
|
3095
|
+
} catch (error) {
|
|
3096
|
+
testFailed = true;
|
|
3097
|
+
throw error;
|
|
3098
|
+
} finally {
|
|
3099
|
+
await cleanupTestFixtures(fixtures, testFailed);
|
|
3100
|
+
}
|
|
3101
|
+
});
|
|
3102
|
+
}
|
|
3103
|
+
function use(config) {
|
|
3104
|
+
currentConfig = { ...currentConfig, ...config };
|
|
3105
|
+
afterAll(async () => {
|
|
3106
|
+
await cleanupSharedResources();
|
|
3107
|
+
});
|
|
3108
|
+
}
|
|
3109
|
+
function skip(name, fn) {
|
|
3110
|
+
it.skip(name, async () => {
|
|
3111
|
+
const fixtures = await createTestFixtures();
|
|
3112
|
+
let testFailed = false;
|
|
3113
|
+
try {
|
|
3114
|
+
await fn(fixtures);
|
|
3115
|
+
} catch (error) {
|
|
3116
|
+
testFailed = true;
|
|
3117
|
+
throw error;
|
|
3118
|
+
} finally {
|
|
3119
|
+
await cleanupTestFixtures(fixtures, testFailed);
|
|
3120
|
+
}
|
|
3121
|
+
});
|
|
3122
|
+
}
|
|
3123
|
+
function only(name, fn) {
|
|
3124
|
+
it.only(name, async () => {
|
|
3125
|
+
const fixtures = await createTestFixtures();
|
|
3126
|
+
let testFailed = false;
|
|
3127
|
+
try {
|
|
3128
|
+
await fn(fixtures);
|
|
3129
|
+
} catch (error) {
|
|
3130
|
+
testFailed = true;
|
|
3131
|
+
throw error;
|
|
3132
|
+
} finally {
|
|
3133
|
+
await cleanupTestFixtures(fixtures, testFailed);
|
|
3134
|
+
}
|
|
3135
|
+
});
|
|
3136
|
+
}
|
|
3137
|
+
function todo(name) {
|
|
3138
|
+
it.todo(name);
|
|
3139
|
+
}
|
|
3140
|
+
var test = testWithFixtures;
|
|
3141
|
+
test.use = use;
|
|
3142
|
+
test.describe = describe;
|
|
3143
|
+
test.beforeAll = beforeAll;
|
|
3144
|
+
test.beforeEach = beforeEach;
|
|
3145
|
+
test.afterEach = afterEach;
|
|
3146
|
+
test.afterAll = afterAll;
|
|
3147
|
+
test.skip = skip;
|
|
3148
|
+
test.only = only;
|
|
3149
|
+
test.todo = todo;
|
|
3150
|
+
|
|
3151
|
+
// libs/testing/src/expect.ts
|
|
3152
|
+
import { expect as jestExpect } from "@jest/globals";
|
|
3153
|
+
var expect = jestExpect;
|
|
3154
|
+
|
|
3155
|
+
// libs/testing/src/platform/platform-types.ts
|
|
3156
|
+
function getPlatformMetaNamespace(platform) {
|
|
3157
|
+
switch (platform) {
|
|
3158
|
+
case "openai":
|
|
3159
|
+
return "openai";
|
|
3160
|
+
case "ext-apps":
|
|
3161
|
+
return "ui";
|
|
3162
|
+
default:
|
|
3163
|
+
return "frontmcp";
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
function getPlatformMimeType(platform) {
|
|
3167
|
+
return platform === "openai" ? "text/html+skybridge" : "text/html+mcp";
|
|
3168
|
+
}
|
|
3169
|
+
function isOpenAIPlatform(platform) {
|
|
3170
|
+
return platform === "openai";
|
|
3171
|
+
}
|
|
3172
|
+
function isExtAppsPlatform(platform) {
|
|
3173
|
+
return platform === "ext-apps";
|
|
3174
|
+
}
|
|
3175
|
+
function isFrontmcpPlatform(platform) {
|
|
3176
|
+
return platform !== "openai" && platform !== "ext-apps";
|
|
3177
|
+
}
|
|
3178
|
+
function getToolsListMetaPrefixes(platform) {
|
|
3179
|
+
switch (platform) {
|
|
3180
|
+
case "openai":
|
|
3181
|
+
return ["openai/"];
|
|
3182
|
+
case "ext-apps":
|
|
3183
|
+
return ["ui/"];
|
|
3184
|
+
default:
|
|
3185
|
+
return ["frontmcp/", "ui/"];
|
|
3186
|
+
}
|
|
3187
|
+
}
|
|
3188
|
+
function getToolCallMetaPrefixes(platform) {
|
|
3189
|
+
switch (platform) {
|
|
3190
|
+
case "openai":
|
|
3191
|
+
return ["openai/"];
|
|
3192
|
+
case "ext-apps":
|
|
3193
|
+
return ["ui/"];
|
|
3194
|
+
default:
|
|
3195
|
+
return ["frontmcp/", "ui/"];
|
|
3196
|
+
}
|
|
3197
|
+
}
|
|
3198
|
+
function getForbiddenMetaPrefixes(platform) {
|
|
3199
|
+
switch (platform) {
|
|
3200
|
+
case "openai":
|
|
3201
|
+
return ["ui/", "frontmcp/"];
|
|
3202
|
+
case "ext-apps":
|
|
3203
|
+
return ["openai/", "frontmcp/"];
|
|
3204
|
+
default:
|
|
3205
|
+
return ["openai/"];
|
|
3206
|
+
}
|
|
3207
|
+
}
|
|
3208
|
+
|
|
3209
|
+
// libs/testing/src/ui/ui-matchers.ts
|
|
3210
|
+
function escapeRegex(str) {
|
|
3211
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3212
|
+
}
|
|
3213
|
+
function extractUiHtml(received) {
|
|
3214
|
+
if (typeof received === "string") {
|
|
3215
|
+
return received;
|
|
3216
|
+
}
|
|
3217
|
+
const wrapper = received;
|
|
3218
|
+
const meta = wrapper?.raw?._meta || wrapper?._meta;
|
|
3219
|
+
if (meta && typeof meta === "object") {
|
|
3220
|
+
const uiHtml = meta["ui/html"];
|
|
3221
|
+
if (typeof uiHtml === "string") {
|
|
3222
|
+
return uiHtml;
|
|
3223
|
+
}
|
|
3224
|
+
}
|
|
3225
|
+
return void 0;
|
|
3226
|
+
}
|
|
3227
|
+
function extractMeta(received) {
|
|
3228
|
+
const wrapper = received;
|
|
3229
|
+
const meta = wrapper?.raw?._meta || wrapper?._meta;
|
|
3230
|
+
if (meta && typeof meta === "object") {
|
|
3231
|
+
return meta;
|
|
3232
|
+
}
|
|
3233
|
+
return void 0;
|
|
3234
|
+
}
|
|
3235
|
+
var toHaveRenderedHtml = function(received) {
|
|
3236
|
+
const html = extractUiHtml(received);
|
|
3237
|
+
const hasHtml = html !== void 0 && html.length > 0;
|
|
3238
|
+
const isFallback = hasHtml && html.includes("mdx-fallback");
|
|
3239
|
+
const pass = hasHtml && !isFallback;
|
|
3240
|
+
return {
|
|
3241
|
+
pass,
|
|
3242
|
+
message: () => {
|
|
3243
|
+
if (isFallback) {
|
|
3244
|
+
return "Expected rendered HTML but got mdx-fallback (raw escaped content). MDX rendering may have failed.";
|
|
3245
|
+
}
|
|
3246
|
+
if (!hasHtml) {
|
|
3247
|
+
return "Expected _meta to have ui/html property with rendered HTML";
|
|
3248
|
+
}
|
|
3249
|
+
return "Expected result not to have rendered HTML";
|
|
3250
|
+
}
|
|
3251
|
+
};
|
|
3252
|
+
};
|
|
3253
|
+
var toContainHtmlElement = function(received, tag) {
|
|
3254
|
+
const html = extractUiHtml(received);
|
|
3255
|
+
if (!html) {
|
|
3256
|
+
return {
|
|
3257
|
+
pass: false,
|
|
3258
|
+
message: () => `Expected to find <${tag}> element, but no HTML content found`
|
|
3259
|
+
};
|
|
3260
|
+
}
|
|
3261
|
+
const regex = new RegExp(`<${escapeRegex(tag)}[\\s>]`, "i");
|
|
3262
|
+
const pass = regex.test(html);
|
|
3263
|
+
return {
|
|
3264
|
+
pass,
|
|
3265
|
+
message: () => pass ? `Expected HTML not to contain <${tag}> element` : `Expected HTML to contain <${tag}> element`
|
|
3266
|
+
};
|
|
3267
|
+
};
|
|
3268
|
+
var toContainBoundValue = function(received, value) {
|
|
3269
|
+
const html = extractUiHtml(received);
|
|
3270
|
+
if (!html) {
|
|
3271
|
+
return {
|
|
3272
|
+
pass: false,
|
|
3273
|
+
message: () => `Expected HTML to contain bound value "${value}", but no HTML content found`
|
|
3274
|
+
};
|
|
3275
|
+
}
|
|
3276
|
+
const stringValue = String(value);
|
|
3277
|
+
const pass = html.includes(stringValue);
|
|
3278
|
+
return {
|
|
3279
|
+
pass,
|
|
3280
|
+
message: () => pass ? `Expected HTML not to contain bound value "${stringValue}"` : `Expected HTML to contain bound value "${stringValue}"`
|
|
3281
|
+
};
|
|
3282
|
+
};
|
|
3283
|
+
var toBeXssSafe = function(received) {
|
|
3284
|
+
const html = extractUiHtml(received);
|
|
3285
|
+
if (!html) {
|
|
3286
|
+
return {
|
|
3287
|
+
pass: true,
|
|
3288
|
+
message: () => "Expected HTML to be XSS unsafe (no HTML found)"
|
|
3289
|
+
};
|
|
3290
|
+
}
|
|
3291
|
+
const hasScript = /<script[\s>]/i.test(html);
|
|
3292
|
+
const hasOnHandler = /\son\w+\s*=/i.test(html);
|
|
3293
|
+
const hasJavascriptUri = /javascript:/i.test(html);
|
|
3294
|
+
const issues = [];
|
|
3295
|
+
if (hasScript) issues.push("<script> tag");
|
|
3296
|
+
if (hasOnHandler) issues.push("inline event handler (onclick, etc.)");
|
|
3297
|
+
if (hasJavascriptUri) issues.push("javascript: URI");
|
|
3298
|
+
const pass = !hasScript && !hasOnHandler && !hasJavascriptUri;
|
|
3299
|
+
return {
|
|
3300
|
+
pass,
|
|
3301
|
+
message: () => pass ? "Expected HTML not to be XSS safe" : `Expected HTML to be XSS safe, but found: ${issues.join(", ")}`
|
|
3302
|
+
};
|
|
3303
|
+
};
|
|
3304
|
+
var toHaveWidgetMetadata = function(received) {
|
|
3305
|
+
const meta = extractMeta(received);
|
|
3306
|
+
if (!meta) {
|
|
3307
|
+
return {
|
|
3308
|
+
pass: false,
|
|
3309
|
+
message: () => "Expected _meta to have widget metadata, but no _meta found"
|
|
3310
|
+
};
|
|
3311
|
+
}
|
|
3312
|
+
const hasUiHtml = Boolean(meta["ui/html"]);
|
|
3313
|
+
const hasOutputTemplate = Boolean(meta["openai/outputTemplate"]);
|
|
3314
|
+
const hasMimeType2 = Boolean(meta["ui/mimeType"]);
|
|
3315
|
+
const pass = hasUiHtml || hasOutputTemplate || hasMimeType2;
|
|
3316
|
+
return {
|
|
3317
|
+
pass,
|
|
3318
|
+
message: () => pass ? "Expected result not to have widget metadata" : "Expected _meta to have widget metadata (ui/html, openai/outputTemplate, or ui/mimeType)"
|
|
3319
|
+
};
|
|
3320
|
+
};
|
|
3321
|
+
var toHaveCssClass = function(received, className) {
|
|
3322
|
+
const html = extractUiHtml(received);
|
|
3323
|
+
if (!html) {
|
|
3324
|
+
return {
|
|
3325
|
+
pass: false,
|
|
3326
|
+
message: () => `Expected HTML to have CSS class "${className}", but no HTML content found`
|
|
3327
|
+
};
|
|
3328
|
+
}
|
|
3329
|
+
const classRegex = new RegExp(`class(?:Name)?\\s*=\\s*["'][^"']*\\b${escapeRegex(className)}\\b[^"']*["']`, "i");
|
|
3330
|
+
const pass = classRegex.test(html);
|
|
3331
|
+
return {
|
|
3332
|
+
pass,
|
|
3333
|
+
message: () => pass ? `Expected HTML not to have CSS class "${className}"` : `Expected HTML to have CSS class "${className}"`
|
|
3334
|
+
};
|
|
3335
|
+
};
|
|
3336
|
+
var toNotContainRawContent = function(received, content) {
|
|
3337
|
+
const html = extractUiHtml(received);
|
|
3338
|
+
if (!html) {
|
|
3339
|
+
return {
|
|
3340
|
+
pass: true,
|
|
3341
|
+
message: () => `Expected HTML to contain raw content "${content}", but no HTML found`
|
|
3342
|
+
};
|
|
3343
|
+
}
|
|
3344
|
+
const pass = !html.includes(content);
|
|
3345
|
+
return {
|
|
3346
|
+
pass,
|
|
3347
|
+
message: () => pass ? `Expected HTML to contain raw content "${content}"` : `Expected HTML not to contain raw content "${content}" (may indicate rendering failure)`
|
|
3348
|
+
};
|
|
3349
|
+
};
|
|
3350
|
+
var toHaveProperHtmlStructure = function(received) {
|
|
3351
|
+
const html = extractUiHtml(received);
|
|
3352
|
+
if (!html) {
|
|
3353
|
+
return {
|
|
3354
|
+
pass: false,
|
|
3355
|
+
message: () => "Expected proper HTML structure, but no HTML content found"
|
|
3356
|
+
};
|
|
3357
|
+
}
|
|
3358
|
+
const hasEscapedTags = html.includes("<") && html.includes(">");
|
|
3359
|
+
const hasHtmlTags = /<[a-z]/i.test(html);
|
|
3360
|
+
const pass = hasHtmlTags && !hasEscapedTags;
|
|
3361
|
+
return {
|
|
3362
|
+
pass,
|
|
3363
|
+
message: () => {
|
|
3364
|
+
if (hasEscapedTags) {
|
|
3365
|
+
return "Expected proper HTML structure, but found escaped HTML entities - content may not have been rendered";
|
|
3366
|
+
}
|
|
3367
|
+
if (!hasHtmlTags) {
|
|
3368
|
+
return "Expected proper HTML structure, but found no HTML tags";
|
|
3369
|
+
}
|
|
3370
|
+
return "Expected result not to have proper HTML structure";
|
|
3371
|
+
}
|
|
3372
|
+
};
|
|
3373
|
+
};
|
|
3374
|
+
var toHavePlatformMeta = function(received, platform) {
|
|
3375
|
+
const meta = extractMeta(received);
|
|
3376
|
+
if (!meta) {
|
|
3377
|
+
return {
|
|
3378
|
+
pass: false,
|
|
3379
|
+
message: () => `Expected _meta to have platform meta for "${platform}", but no _meta found`
|
|
3380
|
+
};
|
|
3381
|
+
}
|
|
3382
|
+
const expectedPrefixes = getToolCallMetaPrefixes(platform);
|
|
3383
|
+
const forbiddenPrefixes = getForbiddenMetaPrefixes(platform);
|
|
3384
|
+
const metaKeys = Object.keys(meta);
|
|
3385
|
+
const hasExpectedPrefix = metaKeys.some((key) => expectedPrefixes.some((prefix) => key.startsWith(prefix)));
|
|
3386
|
+
const forbiddenKeys = metaKeys.filter((key) => forbiddenPrefixes.some((prefix) => key.startsWith(prefix)));
|
|
3387
|
+
const pass = hasExpectedPrefix && forbiddenKeys.length === 0;
|
|
3388
|
+
return {
|
|
3389
|
+
pass,
|
|
3390
|
+
message: () => {
|
|
3391
|
+
if (!hasExpectedPrefix) {
|
|
3392
|
+
return `Expected _meta to have keys with prefixes [${expectedPrefixes.join(
|
|
3393
|
+
", "
|
|
3394
|
+
)}] for platform "${platform}", but found: [${metaKeys.join(", ")}]`;
|
|
3395
|
+
}
|
|
3396
|
+
if (forbiddenKeys.length > 0) {
|
|
3397
|
+
return `Expected _meta NOT to have keys [${forbiddenKeys.join(
|
|
3398
|
+
", "
|
|
3399
|
+
)}] for platform "${platform}" (forbidden prefixes: [${forbiddenPrefixes.join(", ")}])`;
|
|
3400
|
+
}
|
|
3401
|
+
return `Expected result not to have platform meta for "${platform}"`;
|
|
3402
|
+
}
|
|
3403
|
+
};
|
|
3404
|
+
};
|
|
3405
|
+
var toHaveMetaKey = function(received, key) {
|
|
3406
|
+
const meta = extractMeta(received);
|
|
3407
|
+
if (!meta) {
|
|
3408
|
+
return {
|
|
3409
|
+
pass: false,
|
|
3410
|
+
message: () => `Expected _meta to have key "${key}", but no _meta found`
|
|
3411
|
+
};
|
|
3412
|
+
}
|
|
3413
|
+
const pass = key in meta;
|
|
3414
|
+
return {
|
|
3415
|
+
pass,
|
|
3416
|
+
message: () => pass ? `Expected _meta not to have key "${key}"` : `Expected _meta to have key "${key}"`
|
|
3417
|
+
};
|
|
3418
|
+
};
|
|
3419
|
+
var toHaveMetaValue = function(received, key, value) {
|
|
3420
|
+
const meta = extractMeta(received);
|
|
3421
|
+
if (!meta) {
|
|
3422
|
+
return {
|
|
3423
|
+
pass: false,
|
|
3424
|
+
message: () => `Expected _meta["${key}"] to equal ${JSON.stringify(value)}, but no _meta found`
|
|
3425
|
+
};
|
|
3426
|
+
}
|
|
3427
|
+
const actualValue = meta[key];
|
|
3428
|
+
const pass = JSON.stringify(actualValue) === JSON.stringify(value);
|
|
3429
|
+
return {
|
|
3430
|
+
pass,
|
|
3431
|
+
message: () => pass ? `Expected _meta["${key}"] not to equal ${JSON.stringify(value)}` : `Expected _meta["${key}"] to equal ${JSON.stringify(value)}, but got ${JSON.stringify(actualValue)}`
|
|
3432
|
+
};
|
|
3433
|
+
};
|
|
3434
|
+
var toNotHaveMetaKey = function(received, key) {
|
|
3435
|
+
const meta = extractMeta(received);
|
|
3436
|
+
if (!meta) {
|
|
3437
|
+
return {
|
|
3438
|
+
pass: true,
|
|
3439
|
+
message: () => `Expected _meta to have key "${key}", but no _meta found`
|
|
3440
|
+
};
|
|
3441
|
+
}
|
|
3442
|
+
const pass = !(key in meta);
|
|
3443
|
+
return {
|
|
3444
|
+
pass,
|
|
3445
|
+
message: () => pass ? `Expected _meta to have key "${key}"` : `Expected _meta not to have key "${key}"`
|
|
3446
|
+
};
|
|
3447
|
+
};
|
|
3448
|
+
var toHaveOnlyNamespacedMeta = function(received, namespace) {
|
|
3449
|
+
const meta = extractMeta(received);
|
|
3450
|
+
if (!meta) {
|
|
3451
|
+
return {
|
|
3452
|
+
pass: false,
|
|
3453
|
+
message: () => `Expected _meta to have keys with namespace "${namespace}", but no _meta found`
|
|
3454
|
+
};
|
|
3455
|
+
}
|
|
3456
|
+
const metaKeys = Object.keys(meta);
|
|
3457
|
+
const wrongKeys = metaKeys.filter((key) => !key.startsWith(namespace));
|
|
3458
|
+
const pass = wrongKeys.length === 0 && metaKeys.length > 0;
|
|
3459
|
+
return {
|
|
3460
|
+
pass,
|
|
3461
|
+
message: () => {
|
|
3462
|
+
if (metaKeys.length === 0) {
|
|
3463
|
+
return `Expected _meta to have keys with namespace "${namespace}", but _meta is empty`;
|
|
3464
|
+
}
|
|
3465
|
+
if (wrongKeys.length > 0) {
|
|
3466
|
+
return `Expected _meta to ONLY have keys with namespace "${namespace}", but found: [${wrongKeys.join(", ")}]`;
|
|
3467
|
+
}
|
|
3468
|
+
return `Expected _meta not to have only keys with namespace "${namespace}"`;
|
|
3469
|
+
}
|
|
3470
|
+
};
|
|
3471
|
+
};
|
|
3472
|
+
var toHavePlatformMimeType = function(received, platform) {
|
|
3473
|
+
const meta = extractMeta(received);
|
|
3474
|
+
const expectedMimeType = getPlatformMimeType(platform);
|
|
3475
|
+
if (!meta) {
|
|
3476
|
+
return {
|
|
3477
|
+
pass: false,
|
|
3478
|
+
message: () => `Expected _meta to have MIME type "${expectedMimeType}" for platform "${platform}", but no _meta found`
|
|
3479
|
+
};
|
|
3480
|
+
}
|
|
3481
|
+
let mimeTypeKey;
|
|
3482
|
+
switch (platform) {
|
|
3483
|
+
case "openai":
|
|
3484
|
+
mimeTypeKey = "openai/mimeType";
|
|
3485
|
+
break;
|
|
3486
|
+
case "ext-apps":
|
|
3487
|
+
mimeTypeKey = "ui/mimeType";
|
|
3488
|
+
break;
|
|
3489
|
+
default:
|
|
3490
|
+
mimeTypeKey = "frontmcp/mimeType";
|
|
3491
|
+
}
|
|
3492
|
+
const actualMimeType = meta[mimeTypeKey];
|
|
3493
|
+
const pass = actualMimeType === expectedMimeType;
|
|
3494
|
+
return {
|
|
3495
|
+
pass,
|
|
3496
|
+
message: () => pass ? `Expected _meta["${mimeTypeKey}"] not to be "${expectedMimeType}"` : `Expected _meta["${mimeTypeKey}"] to be "${expectedMimeType}" for platform "${platform}", but got "${actualMimeType}"`
|
|
3497
|
+
};
|
|
3498
|
+
};
|
|
3499
|
+
var toHavePlatformHtml = function(received, platform) {
|
|
3500
|
+
const meta = extractMeta(received);
|
|
3501
|
+
if (!meta) {
|
|
3502
|
+
return {
|
|
3503
|
+
pass: false,
|
|
3504
|
+
message: () => `Expected _meta to have platform HTML for "${platform}", but no _meta found`
|
|
3505
|
+
};
|
|
3506
|
+
}
|
|
3507
|
+
let htmlKey;
|
|
3508
|
+
switch (platform) {
|
|
3509
|
+
case "openai":
|
|
3510
|
+
htmlKey = "openai/html";
|
|
3511
|
+
break;
|
|
3512
|
+
case "ext-apps":
|
|
3513
|
+
htmlKey = "ui/html";
|
|
3514
|
+
break;
|
|
3515
|
+
default:
|
|
3516
|
+
htmlKey = "frontmcp/html";
|
|
3517
|
+
}
|
|
3518
|
+
const html = meta[htmlKey];
|
|
3519
|
+
const pass = typeof html === "string" && html.length > 0;
|
|
3520
|
+
return {
|
|
3521
|
+
pass,
|
|
3522
|
+
message: () => pass ? `Expected _meta not to have platform HTML in "${htmlKey}"` : `Expected _meta["${htmlKey}"] to contain HTML for platform "${platform}", but ${html === void 0 ? "key not found" : `got ${typeof html}`}`
|
|
3523
|
+
};
|
|
3524
|
+
};
|
|
3525
|
+
var uiMatchers = {
|
|
3526
|
+
// Existing HTML matchers
|
|
3527
|
+
toHaveRenderedHtml,
|
|
3528
|
+
toContainHtmlElement,
|
|
3529
|
+
toContainBoundValue,
|
|
3530
|
+
toBeXssSafe,
|
|
3531
|
+
toHaveWidgetMetadata,
|
|
3532
|
+
toHaveCssClass,
|
|
3533
|
+
toNotContainRawContent,
|
|
3534
|
+
toHaveProperHtmlStructure,
|
|
3535
|
+
// Platform meta matchers
|
|
3536
|
+
toHavePlatformMeta,
|
|
3537
|
+
toHaveMetaKey,
|
|
3538
|
+
toHaveMetaValue,
|
|
3539
|
+
toNotHaveMetaKey,
|
|
3540
|
+
toHaveOnlyNamespacedMeta,
|
|
3541
|
+
toHavePlatformMimeType,
|
|
3542
|
+
toHavePlatformHtml
|
|
3543
|
+
};
|
|
3544
|
+
|
|
3545
|
+
// libs/testing/src/matchers/mcp-matchers.ts
|
|
3546
|
+
var toContainTool = function(received, toolName) {
|
|
3547
|
+
const tools = received;
|
|
3548
|
+
if (!Array.isArray(tools)) {
|
|
3549
|
+
return {
|
|
3550
|
+
pass: false,
|
|
3551
|
+
message: () => `Expected an array of tools, but received ${typeof received}`
|
|
3552
|
+
};
|
|
3553
|
+
}
|
|
3554
|
+
const pass = tools.some((t) => t.name === toolName);
|
|
3555
|
+
const availableTools = tools.map((t) => t.name).join(", ");
|
|
3556
|
+
return {
|
|
3557
|
+
pass,
|
|
3558
|
+
message: () => pass ? `Expected tools not to contain "${toolName}"` : `Expected tools to contain "${toolName}", but got: [${availableTools}]`
|
|
3559
|
+
};
|
|
3560
|
+
};
|
|
3561
|
+
var toBeSuccessful = function(received) {
|
|
3562
|
+
const result = received;
|
|
3563
|
+
if (typeof result !== "object" || result === null || !("isSuccess" in result)) {
|
|
3564
|
+
return {
|
|
3565
|
+
pass: false,
|
|
3566
|
+
message: () => `Expected a result wrapper object with isSuccess property`
|
|
3567
|
+
};
|
|
3568
|
+
}
|
|
3569
|
+
const pass = result.isSuccess;
|
|
3570
|
+
return {
|
|
3571
|
+
pass,
|
|
3572
|
+
message: () => pass ? "Expected result not to be successful" : `Expected result to be successful, but got error: ${result.error?.message ?? "unknown error"}`
|
|
3573
|
+
};
|
|
3574
|
+
};
|
|
3575
|
+
var toBeError = function(received, expectedCode) {
|
|
3576
|
+
const result = received;
|
|
3577
|
+
if (typeof result !== "object" || result === null || !("isError" in result)) {
|
|
3578
|
+
return {
|
|
3579
|
+
pass: false,
|
|
3580
|
+
message: () => `Expected a result wrapper object with isError property`
|
|
3581
|
+
};
|
|
3582
|
+
}
|
|
3583
|
+
let pass = result.isError;
|
|
3584
|
+
if (pass && expectedCode !== void 0) {
|
|
3585
|
+
pass = result.error?.code === expectedCode;
|
|
3586
|
+
}
|
|
3587
|
+
return {
|
|
3588
|
+
pass,
|
|
3589
|
+
message: () => {
|
|
3590
|
+
if (!result.isError) {
|
|
3591
|
+
return "Expected result to be an error, but it was successful";
|
|
3592
|
+
}
|
|
3593
|
+
if (expectedCode !== void 0 && result.error?.code !== expectedCode) {
|
|
3594
|
+
return `Expected error code ${expectedCode}, but got ${result.error?.code}`;
|
|
3595
|
+
}
|
|
3596
|
+
return "Expected result not to be an error";
|
|
3597
|
+
}
|
|
3598
|
+
};
|
|
3599
|
+
};
|
|
3600
|
+
var toHaveTextContent = function(received, expectedText) {
|
|
3601
|
+
const result = received;
|
|
3602
|
+
if (typeof result !== "object" || result === null || !("text" in result)) {
|
|
3603
|
+
return {
|
|
3604
|
+
pass: false,
|
|
3605
|
+
message: () => `Expected a ToolResultWrapper or ResourceContentWrapper object with text method`
|
|
3606
|
+
};
|
|
3607
|
+
}
|
|
3608
|
+
const text = result.text();
|
|
3609
|
+
const hasText = "hasTextContent" in result ? result.hasTextContent() : text !== void 0;
|
|
3610
|
+
let pass = hasText;
|
|
3611
|
+
if (pass && expectedText !== void 0) {
|
|
3612
|
+
pass = text?.includes(expectedText) ?? false;
|
|
3613
|
+
}
|
|
3614
|
+
return {
|
|
3615
|
+
pass,
|
|
3616
|
+
message: () => {
|
|
3617
|
+
if (!hasText) {
|
|
3618
|
+
return "Expected result to have text content";
|
|
3619
|
+
}
|
|
3620
|
+
if (expectedText !== void 0 && !text?.includes(expectedText)) {
|
|
3621
|
+
return `Expected text to contain "${expectedText}", but got: "${text}"`;
|
|
3622
|
+
}
|
|
3623
|
+
return "Expected result not to have text content";
|
|
3624
|
+
}
|
|
3625
|
+
};
|
|
3626
|
+
};
|
|
3627
|
+
var toHaveImageContent = function(received) {
|
|
3628
|
+
const result = received;
|
|
3629
|
+
if (typeof result !== "object" || result === null || !("hasImageContent" in result)) {
|
|
3630
|
+
return {
|
|
3631
|
+
pass: false,
|
|
3632
|
+
message: () => `Expected a ToolResultWrapper object with hasImageContent method`
|
|
3633
|
+
};
|
|
3634
|
+
}
|
|
3635
|
+
const pass = result.hasImageContent();
|
|
3636
|
+
return {
|
|
3637
|
+
pass,
|
|
3638
|
+
message: () => pass ? "Expected result not to have image content" : "Expected result to have image content"
|
|
3639
|
+
};
|
|
3640
|
+
};
|
|
3641
|
+
var toHaveResourceContent = function(received) {
|
|
3642
|
+
const result = received;
|
|
3643
|
+
if (typeof result !== "object" || result === null || !("hasResourceContent" in result)) {
|
|
3644
|
+
return {
|
|
3645
|
+
pass: false,
|
|
3646
|
+
message: () => `Expected a ToolResultWrapper object with hasResourceContent method`
|
|
3647
|
+
};
|
|
3648
|
+
}
|
|
3649
|
+
const pass = result.hasResourceContent();
|
|
3650
|
+
return {
|
|
3651
|
+
pass,
|
|
3652
|
+
message: () => pass ? "Expected result not to have resource content" : "Expected result to have resource content"
|
|
3653
|
+
};
|
|
3654
|
+
};
|
|
3655
|
+
var toContainResource = function(received, uri) {
|
|
3656
|
+
const resources = received;
|
|
3657
|
+
if (!Array.isArray(resources)) {
|
|
3658
|
+
return {
|
|
3659
|
+
pass: false,
|
|
3660
|
+
message: () => `Expected an array of resources, but received ${typeof received}`
|
|
3661
|
+
};
|
|
3662
|
+
}
|
|
3663
|
+
const pass = resources.some((r) => r.uri === uri);
|
|
3664
|
+
const availableUris = resources.map((r) => r.uri).join(", ");
|
|
3665
|
+
return {
|
|
3666
|
+
pass,
|
|
3667
|
+
message: () => pass ? `Expected resources not to contain "${uri}"` : `Expected resources to contain "${uri}", but got: [${availableUris}]`
|
|
3668
|
+
};
|
|
3669
|
+
};
|
|
3670
|
+
var toContainResourceTemplate = function(received, uriTemplate) {
|
|
3671
|
+
const templates = received;
|
|
3672
|
+
if (!Array.isArray(templates)) {
|
|
3673
|
+
return {
|
|
3674
|
+
pass: false,
|
|
3675
|
+
message: () => `Expected an array of resource templates, but received ${typeof received}`
|
|
3676
|
+
};
|
|
3677
|
+
}
|
|
3678
|
+
const pass = templates.some((t) => t.uriTemplate === uriTemplate);
|
|
3679
|
+
const availableTemplates = templates.map((t) => t.uriTemplate).join(", ");
|
|
3680
|
+
return {
|
|
3681
|
+
pass,
|
|
3682
|
+
message: () => pass ? `Expected templates not to contain "${uriTemplate}"` : `Expected templates to contain "${uriTemplate}", but got: [${availableTemplates}]`
|
|
3683
|
+
};
|
|
3684
|
+
};
|
|
3685
|
+
var toHaveMimeType = function(received, mimeType) {
|
|
3686
|
+
const result = received;
|
|
3687
|
+
if (typeof result !== "object" || result === null || !("hasMimeType" in result)) {
|
|
3688
|
+
return {
|
|
3689
|
+
pass: false,
|
|
3690
|
+
message: () => `Expected a ResourceContentWrapper object with hasMimeType method`
|
|
3691
|
+
};
|
|
3692
|
+
}
|
|
3693
|
+
const pass = result.hasMimeType(mimeType);
|
|
3694
|
+
const actualMimeType = result.mimeType();
|
|
3695
|
+
return {
|
|
3696
|
+
pass,
|
|
3697
|
+
message: () => pass ? `Expected content not to have MIME type "${mimeType}"` : `Expected MIME type "${mimeType}", but got "${actualMimeType}"`
|
|
3698
|
+
};
|
|
3699
|
+
};
|
|
3700
|
+
var toContainPrompt = function(received, name) {
|
|
3701
|
+
const prompts = received;
|
|
3702
|
+
if (!Array.isArray(prompts)) {
|
|
3703
|
+
return {
|
|
3704
|
+
pass: false,
|
|
3705
|
+
message: () => `Expected an array of prompts, but received ${typeof received}`
|
|
3706
|
+
};
|
|
3707
|
+
}
|
|
3708
|
+
const pass = prompts.some((p) => p.name === name);
|
|
3709
|
+
const availablePrompts = prompts.map((p) => p.name).join(", ");
|
|
3710
|
+
return {
|
|
3711
|
+
pass,
|
|
3712
|
+
message: () => pass ? `Expected prompts not to contain "${name}"` : `Expected prompts to contain "${name}", but got: [${availablePrompts}]`
|
|
3713
|
+
};
|
|
3714
|
+
};
|
|
3715
|
+
var toHaveMessages = function(received, count) {
|
|
3716
|
+
const result = received;
|
|
3717
|
+
if (typeof result !== "object" || result === null || !("messages" in result)) {
|
|
3718
|
+
return {
|
|
3719
|
+
pass: false,
|
|
3720
|
+
message: () => `Expected a PromptResultWrapper object with messages property`
|
|
3721
|
+
};
|
|
3722
|
+
}
|
|
3723
|
+
const actualCount = result.messages?.length ?? 0;
|
|
3724
|
+
const pass = actualCount === count;
|
|
3725
|
+
return {
|
|
3726
|
+
pass,
|
|
3727
|
+
message: () => pass ? `Expected prompt not to have ${count} messages` : `Expected prompt to have ${count} messages, but got ${actualCount}`
|
|
3728
|
+
};
|
|
3729
|
+
};
|
|
3730
|
+
var toBeValidJsonRpc = function(received) {
|
|
3731
|
+
const response = received;
|
|
3732
|
+
if (typeof response !== "object" || response === null) {
|
|
3733
|
+
return {
|
|
3734
|
+
pass: false,
|
|
3735
|
+
message: () => `Expected an object, but received ${typeof received}`
|
|
3736
|
+
};
|
|
3737
|
+
}
|
|
3738
|
+
const hasJsonRpc = response["jsonrpc"] === "2.0";
|
|
3739
|
+
const hasId = "id" in response;
|
|
3740
|
+
const hasResult = "result" in response;
|
|
3741
|
+
const hasError = "error" in response;
|
|
3742
|
+
const hasExactlyOneResultOrError = (hasResult || hasError) && !(hasResult && hasError);
|
|
3743
|
+
const pass = hasJsonRpc && hasId && hasExactlyOneResultOrError;
|
|
3744
|
+
return {
|
|
3745
|
+
pass,
|
|
3746
|
+
message: () => {
|
|
3747
|
+
if (pass) {
|
|
3748
|
+
return "Expected response not to be valid JSON-RPC";
|
|
3749
|
+
}
|
|
3750
|
+
const issues = [];
|
|
3751
|
+
if (!hasJsonRpc) issues.push('missing or invalid "jsonrpc": "2.0"');
|
|
3752
|
+
if (!hasId) issues.push('missing "id" field');
|
|
3753
|
+
if (!hasExactlyOneResultOrError) {
|
|
3754
|
+
if (!hasResult && !hasError) issues.push('missing "result" or "error"');
|
|
3755
|
+
else issues.push('cannot have both "result" and "error"');
|
|
3756
|
+
}
|
|
3757
|
+
return `Expected valid JSON-RPC 2.0 response: ${issues.join(", ")}`;
|
|
3758
|
+
}
|
|
3759
|
+
};
|
|
3760
|
+
};
|
|
3761
|
+
var toHaveResult = function(received) {
|
|
3762
|
+
const response = received;
|
|
3763
|
+
if (typeof response !== "object" || response === null) {
|
|
3764
|
+
return {
|
|
3765
|
+
pass: false,
|
|
3766
|
+
message: () => `Expected an object, but received ${typeof received}`
|
|
3767
|
+
};
|
|
3768
|
+
}
|
|
3769
|
+
const pass = "result" in response;
|
|
3770
|
+
return {
|
|
3771
|
+
pass,
|
|
3772
|
+
message: () => pass ? "Expected response not to have result" : "Expected response to have result"
|
|
3773
|
+
};
|
|
3774
|
+
};
|
|
3775
|
+
var toHaveError = function(received) {
|
|
3776
|
+
const response = received;
|
|
3777
|
+
if (typeof response !== "object" || response === null) {
|
|
3778
|
+
return {
|
|
3779
|
+
pass: false,
|
|
3780
|
+
message: () => `Expected an object, but received ${typeof received}`
|
|
3781
|
+
};
|
|
3782
|
+
}
|
|
3783
|
+
const pass = "error" in response;
|
|
3784
|
+
return {
|
|
3785
|
+
pass,
|
|
3786
|
+
message: () => pass ? "Expected response not to have error" : "Expected response to have error"
|
|
3787
|
+
};
|
|
3788
|
+
};
|
|
3789
|
+
var toHaveErrorCode = function(received, code) {
|
|
3790
|
+
const response = received;
|
|
3791
|
+
if (typeof response !== "object" || response === null) {
|
|
3792
|
+
return {
|
|
3793
|
+
pass: false,
|
|
3794
|
+
message: () => `Expected an object, but received ${typeof received}`
|
|
3795
|
+
};
|
|
3796
|
+
}
|
|
3797
|
+
const actualCode = response.error?.code;
|
|
3798
|
+
const pass = actualCode === code;
|
|
3799
|
+
return {
|
|
3800
|
+
pass,
|
|
3801
|
+
message: () => pass ? `Expected response not to have error code ${code}` : `Expected error code ${code}, but got ${actualCode ?? "no error"}`
|
|
3802
|
+
};
|
|
3803
|
+
};
|
|
3804
|
+
var mcpMatchers = {
|
|
3805
|
+
// Tool matchers
|
|
3806
|
+
toContainTool,
|
|
3807
|
+
toBeSuccessful,
|
|
3808
|
+
toBeError,
|
|
3809
|
+
toHaveTextContent,
|
|
3810
|
+
toHaveImageContent,
|
|
3811
|
+
toHaveResourceContent,
|
|
3812
|
+
// Resource matchers
|
|
3813
|
+
toContainResource,
|
|
3814
|
+
toContainResourceTemplate,
|
|
3815
|
+
toHaveMimeType,
|
|
3816
|
+
// Prompt matchers
|
|
3817
|
+
toContainPrompt,
|
|
3818
|
+
toHaveMessages,
|
|
3819
|
+
// Protocol matchers
|
|
3820
|
+
toBeValidJsonRpc,
|
|
3821
|
+
toHaveResult,
|
|
3822
|
+
toHaveError,
|
|
3823
|
+
toHaveErrorCode,
|
|
3824
|
+
// UI matchers (for testing tool UI responses)
|
|
3825
|
+
...uiMatchers
|
|
3826
|
+
};
|
|
3827
|
+
|
|
3828
|
+
// libs/testing/src/http-mock/http-mock.ts
|
|
3829
|
+
var originalFetch = null;
|
|
3830
|
+
var mockingEnabled = false;
|
|
3831
|
+
var activeInterceptors = [];
|
|
3832
|
+
var HttpInterceptorImpl = class {
|
|
3833
|
+
mocks = [];
|
|
3834
|
+
_allowPassthrough = false;
|
|
3835
|
+
_isActive = true;
|
|
3836
|
+
mock(definition) {
|
|
3837
|
+
const entry = {
|
|
3838
|
+
definition,
|
|
3839
|
+
callCount: 0,
|
|
3840
|
+
calls: [],
|
|
3841
|
+
remainingUses: definition.times ?? Infinity
|
|
3842
|
+
};
|
|
3843
|
+
this.mocks.push(entry);
|
|
3844
|
+
return {
|
|
3845
|
+
remove: () => {
|
|
3846
|
+
const index = this.mocks.indexOf(entry);
|
|
3847
|
+
if (index !== -1) {
|
|
3848
|
+
this.mocks.splice(index, 1);
|
|
3849
|
+
}
|
|
3850
|
+
},
|
|
3851
|
+
callCount: () => entry.callCount,
|
|
3852
|
+
calls: () => [...entry.calls],
|
|
3853
|
+
waitForCalls: async (count, timeoutMs = 5e3) => {
|
|
3854
|
+
const deadline = Date.now() + timeoutMs;
|
|
3855
|
+
while (Date.now() < deadline) {
|
|
3856
|
+
if (entry.callCount >= count) {
|
|
3857
|
+
return entry.calls.slice(0, count);
|
|
3858
|
+
}
|
|
3859
|
+
await sleep3(50);
|
|
3860
|
+
}
|
|
3861
|
+
throw new Error(`Timeout waiting for ${count} calls, got ${entry.callCount}`);
|
|
3862
|
+
}
|
|
3863
|
+
};
|
|
3864
|
+
}
|
|
3865
|
+
get(url, response) {
|
|
3866
|
+
return this.mock({
|
|
3867
|
+
match: { url, method: "GET" },
|
|
3868
|
+
response: normalizeResponse(response)
|
|
3869
|
+
});
|
|
3870
|
+
}
|
|
3871
|
+
post(url, response) {
|
|
3872
|
+
return this.mock({
|
|
3873
|
+
match: { url, method: "POST" },
|
|
3874
|
+
response: normalizeResponse(response)
|
|
3875
|
+
});
|
|
3876
|
+
}
|
|
3877
|
+
put(url, response) {
|
|
3878
|
+
return this.mock({
|
|
3879
|
+
match: { url, method: "PUT" },
|
|
3880
|
+
response: normalizeResponse(response)
|
|
3881
|
+
});
|
|
3882
|
+
}
|
|
3883
|
+
delete(url, response) {
|
|
3884
|
+
return this.mock({
|
|
3885
|
+
match: { url, method: "DELETE" },
|
|
3886
|
+
response: normalizeResponse(response)
|
|
3887
|
+
});
|
|
3888
|
+
}
|
|
3889
|
+
any(url, response) {
|
|
3890
|
+
return this.mock({
|
|
3891
|
+
match: { url },
|
|
3892
|
+
response: normalizeResponse(response)
|
|
3893
|
+
});
|
|
3894
|
+
}
|
|
3895
|
+
clear() {
|
|
3896
|
+
this.mocks = [];
|
|
3897
|
+
}
|
|
3898
|
+
pending() {
|
|
3899
|
+
return this.mocks.filter((m) => m.remainingUses > 0).map((m) => m.definition);
|
|
3900
|
+
}
|
|
3901
|
+
isDone() {
|
|
3902
|
+
return this.mocks.every((m) => m.remainingUses <= 0 || m.definition.times === void 0);
|
|
3903
|
+
}
|
|
3904
|
+
assertDone() {
|
|
3905
|
+
const pending = this.pending().filter((m) => m.times !== void 0);
|
|
3906
|
+
if (pending.length > 0) {
|
|
3907
|
+
const descriptions = pending.map((m) => {
|
|
3908
|
+
const url = m.match.url instanceof RegExp ? m.match.url.toString() : m.match.url;
|
|
3909
|
+
return ` - ${m.match.method ?? "ANY"} ${url}`;
|
|
3910
|
+
});
|
|
3911
|
+
throw new Error(`Unused HTTP mocks:
|
|
3912
|
+
${descriptions.join("\n")}`);
|
|
3913
|
+
}
|
|
3914
|
+
}
|
|
3915
|
+
allowPassthrough(allow) {
|
|
3916
|
+
this._allowPassthrough = allow;
|
|
3917
|
+
}
|
|
3918
|
+
restore() {
|
|
3919
|
+
this._isActive = false;
|
|
3920
|
+
const index = activeInterceptors.indexOf(this);
|
|
3921
|
+
if (index !== -1) {
|
|
3922
|
+
activeInterceptors.splice(index, 1);
|
|
3923
|
+
}
|
|
3924
|
+
}
|
|
3925
|
+
// Internal methods
|
|
3926
|
+
isActive() {
|
|
3927
|
+
return this._isActive;
|
|
3928
|
+
}
|
|
3929
|
+
canPassthrough() {
|
|
3930
|
+
return this._allowPassthrough;
|
|
3931
|
+
}
|
|
3932
|
+
/**
|
|
3933
|
+
* Try to match a request against mocks in this scope
|
|
3934
|
+
*/
|
|
3935
|
+
async matchRequest(info) {
|
|
3936
|
+
if (!this._isActive) return null;
|
|
3937
|
+
for (const entry of this.mocks) {
|
|
3938
|
+
if (entry.remainingUses <= 0) continue;
|
|
3939
|
+
if (this.requestMatches(info, entry.definition.match)) {
|
|
3940
|
+
entry.callCount++;
|
|
3941
|
+
entry.calls.push(info);
|
|
3942
|
+
entry.remainingUses--;
|
|
3943
|
+
let mockResponse2;
|
|
3944
|
+
if (typeof entry.definition.response === "function") {
|
|
3945
|
+
mockResponse2 = await entry.definition.response(info);
|
|
3946
|
+
} else {
|
|
3947
|
+
mockResponse2 = entry.definition.response;
|
|
3948
|
+
}
|
|
3949
|
+
if (mockResponse2.delay && mockResponse2.delay > 0) {
|
|
3950
|
+
await sleep3(mockResponse2.delay);
|
|
3951
|
+
}
|
|
3952
|
+
return createMockResponse(mockResponse2);
|
|
3953
|
+
}
|
|
3954
|
+
}
|
|
3955
|
+
return null;
|
|
3956
|
+
}
|
|
3957
|
+
requestMatches(info, matcher) {
|
|
3958
|
+
if (!this.urlMatches(info.url, matcher.url)) {
|
|
3959
|
+
return false;
|
|
3960
|
+
}
|
|
3961
|
+
if (matcher.method) {
|
|
3962
|
+
const methods = Array.isArray(matcher.method) ? matcher.method : [matcher.method];
|
|
3963
|
+
if (!methods.includes(info.method)) {
|
|
3964
|
+
return false;
|
|
3965
|
+
}
|
|
3966
|
+
}
|
|
3967
|
+
if (matcher.headers) {
|
|
3968
|
+
for (const [key, expected] of Object.entries(matcher.headers)) {
|
|
3969
|
+
const actual = info.headers[key.toLowerCase()];
|
|
3970
|
+
if (!actual) return false;
|
|
3971
|
+
if (expected instanceof RegExp) {
|
|
3972
|
+
if (!expected.test(actual)) return false;
|
|
3973
|
+
} else if (actual !== expected) {
|
|
3974
|
+
return false;
|
|
3975
|
+
}
|
|
3976
|
+
}
|
|
3977
|
+
}
|
|
3978
|
+
if (matcher.body !== void 0) {
|
|
3979
|
+
if (!this.bodyMatches(info.body, matcher.body)) {
|
|
3980
|
+
return false;
|
|
3981
|
+
}
|
|
3982
|
+
}
|
|
3983
|
+
return true;
|
|
3984
|
+
}
|
|
3985
|
+
urlMatches(url, matcher) {
|
|
3986
|
+
if (typeof matcher === "string") {
|
|
3987
|
+
return url === matcher || url.includes(matcher);
|
|
3988
|
+
} else if (matcher instanceof RegExp) {
|
|
3989
|
+
return matcher.test(url);
|
|
3990
|
+
} else {
|
|
3991
|
+
return matcher(url);
|
|
3992
|
+
}
|
|
3993
|
+
}
|
|
3994
|
+
bodyMatches(actual, expected) {
|
|
3995
|
+
if (typeof expected === "function") {
|
|
3996
|
+
return expected(actual);
|
|
3997
|
+
}
|
|
3998
|
+
if (typeof expected === "string") {
|
|
3999
|
+
const actualStr = typeof actual === "string" ? actual : JSON.stringify(actual);
|
|
4000
|
+
return actualStr === expected || actualStr.includes(expected);
|
|
4001
|
+
}
|
|
4002
|
+
if (expected instanceof RegExp) {
|
|
4003
|
+
const actualStr = typeof actual === "string" ? actual : JSON.stringify(actual);
|
|
4004
|
+
return expected.test(actualStr);
|
|
4005
|
+
}
|
|
4006
|
+
if (typeof actual !== "object" || actual === null) {
|
|
4007
|
+
return false;
|
|
4008
|
+
}
|
|
4009
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
4010
|
+
const actualValue = actual[key];
|
|
4011
|
+
if (typeof value === "object" && value !== null) {
|
|
4012
|
+
if (!this.bodyMatches(actualValue, value)) {
|
|
4013
|
+
return false;
|
|
4014
|
+
}
|
|
4015
|
+
} else if (actualValue !== value) {
|
|
4016
|
+
return false;
|
|
4017
|
+
}
|
|
4018
|
+
}
|
|
4019
|
+
return true;
|
|
4020
|
+
}
|
|
4021
|
+
};
|
|
4022
|
+
async function interceptedFetch(input, init) {
|
|
4023
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
4024
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
4025
|
+
const headers = {};
|
|
4026
|
+
if (init?.headers) {
|
|
4027
|
+
if (init.headers instanceof Headers) {
|
|
4028
|
+
init.headers.forEach((value, key) => {
|
|
4029
|
+
headers[key.toLowerCase()] = value;
|
|
4030
|
+
});
|
|
4031
|
+
} else if (Array.isArray(init.headers)) {
|
|
4032
|
+
for (const [key, value] of init.headers) {
|
|
4033
|
+
headers[key.toLowerCase()] = value;
|
|
4034
|
+
}
|
|
4035
|
+
} else {
|
|
4036
|
+
for (const [key, value] of Object.entries(init.headers)) {
|
|
4037
|
+
headers[key.toLowerCase()] = value;
|
|
4038
|
+
}
|
|
4039
|
+
}
|
|
4040
|
+
}
|
|
4041
|
+
let body;
|
|
4042
|
+
let rawBody;
|
|
4043
|
+
if (init?.body) {
|
|
4044
|
+
if (typeof init.body === "string") {
|
|
4045
|
+
rawBody = init.body;
|
|
4046
|
+
try {
|
|
4047
|
+
body = JSON.parse(init.body);
|
|
4048
|
+
} catch {
|
|
4049
|
+
body = init.body;
|
|
4050
|
+
}
|
|
4051
|
+
} else if (init.body instanceof ArrayBuffer || init.body instanceof Uint8Array) {
|
|
4052
|
+
rawBody = new TextDecoder().decode(init.body);
|
|
4053
|
+
try {
|
|
4054
|
+
body = JSON.parse(rawBody);
|
|
4055
|
+
} catch {
|
|
4056
|
+
body = rawBody;
|
|
4057
|
+
}
|
|
4058
|
+
} else {
|
|
4059
|
+
body = init.body;
|
|
4060
|
+
}
|
|
4061
|
+
}
|
|
4062
|
+
const requestInfo = {
|
|
4063
|
+
url,
|
|
4064
|
+
method,
|
|
4065
|
+
headers,
|
|
4066
|
+
body,
|
|
4067
|
+
rawBody
|
|
4068
|
+
};
|
|
4069
|
+
for (let i = activeInterceptors.length - 1; i >= 0; i--) {
|
|
4070
|
+
const interceptor = activeInterceptors[i];
|
|
4071
|
+
if (!interceptor.isActive()) continue;
|
|
4072
|
+
const mockResponse2 = await interceptor.matchRequest(requestInfo);
|
|
4073
|
+
if (mockResponse2) {
|
|
4074
|
+
return mockResponse2;
|
|
4075
|
+
}
|
|
4076
|
+
}
|
|
4077
|
+
for (const interceptor of activeInterceptors) {
|
|
4078
|
+
if (interceptor.isActive() && interceptor.canPassthrough()) {
|
|
4079
|
+
if (originalFetch) {
|
|
4080
|
+
return originalFetch(input, init);
|
|
4081
|
+
}
|
|
4082
|
+
}
|
|
4083
|
+
}
|
|
4084
|
+
throw new Error(
|
|
4085
|
+
`No HTTP mock found for ${method} ${url}
|
|
4086
|
+
Add a mock using httpMock.interceptor().${method.toLowerCase()}('${url}', { body: ... })`
|
|
4087
|
+
);
|
|
4088
|
+
}
|
|
4089
|
+
function normalizeResponse(response) {
|
|
4090
|
+
if (!("status" in response) && !("body" in response) && !("headers" in response)) {
|
|
4091
|
+
return { body: response };
|
|
4092
|
+
}
|
|
4093
|
+
return response;
|
|
4094
|
+
}
|
|
4095
|
+
function createMockResponse(mock) {
|
|
4096
|
+
const mockAny = mock;
|
|
4097
|
+
if (mockAny._throwError) {
|
|
4098
|
+
const body2 = mock.body;
|
|
4099
|
+
const message = body2 && typeof body2 === "object" && "message" in body2 ? String(body2["message"]) : "Network error";
|
|
4100
|
+
throw new TypeError(`fetch failed: ${message}`);
|
|
4101
|
+
}
|
|
4102
|
+
const status = mock.status ?? 200;
|
|
4103
|
+
const statusText = mock.statusText ?? "OK";
|
|
4104
|
+
const headers = new Headers(mock.headers ?? {});
|
|
4105
|
+
let body = null;
|
|
4106
|
+
if (mock.body !== void 0) {
|
|
4107
|
+
if (typeof mock.body === "string") {
|
|
4108
|
+
body = mock.body;
|
|
4109
|
+
if (!headers.has("content-type")) {
|
|
4110
|
+
headers.set("content-type", "text/plain");
|
|
4111
|
+
}
|
|
4112
|
+
} else if (mock.body instanceof ArrayBuffer) {
|
|
4113
|
+
body = mock.body;
|
|
4114
|
+
} else if (Buffer.isBuffer(mock.body)) {
|
|
4115
|
+
body = new Uint8Array(mock.body).buffer;
|
|
4116
|
+
} else {
|
|
4117
|
+
body = JSON.stringify(mock.body);
|
|
4118
|
+
if (!headers.has("content-type")) {
|
|
4119
|
+
headers.set("content-type", "application/json");
|
|
4120
|
+
}
|
|
4121
|
+
}
|
|
4122
|
+
}
|
|
4123
|
+
return new Response(body, { status, statusText, headers });
|
|
4124
|
+
}
|
|
4125
|
+
function sleep3(ms) {
|
|
4126
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
4127
|
+
}
|
|
4128
|
+
var HttpMockManagerImpl = class {
|
|
4129
|
+
interceptor() {
|
|
4130
|
+
if (!mockingEnabled) {
|
|
4131
|
+
this.enable();
|
|
4132
|
+
}
|
|
4133
|
+
const interceptor = new HttpInterceptorImpl();
|
|
4134
|
+
activeInterceptors.push(interceptor);
|
|
4135
|
+
return interceptor;
|
|
4136
|
+
}
|
|
4137
|
+
enable() {
|
|
4138
|
+
if (mockingEnabled) return;
|
|
4139
|
+
if (typeof globalThis.fetch === "function") {
|
|
4140
|
+
originalFetch = globalThis.fetch;
|
|
4141
|
+
globalThis.fetch = interceptedFetch;
|
|
4142
|
+
}
|
|
4143
|
+
mockingEnabled = true;
|
|
4144
|
+
}
|
|
4145
|
+
disable() {
|
|
4146
|
+
if (!mockingEnabled) return;
|
|
4147
|
+
if (originalFetch) {
|
|
4148
|
+
globalThis.fetch = originalFetch;
|
|
4149
|
+
originalFetch = null;
|
|
4150
|
+
}
|
|
4151
|
+
activeInterceptors.length = 0;
|
|
4152
|
+
mockingEnabled = false;
|
|
4153
|
+
}
|
|
4154
|
+
isEnabled() {
|
|
4155
|
+
return mockingEnabled;
|
|
4156
|
+
}
|
|
4157
|
+
clearAll() {
|
|
4158
|
+
for (const interceptor of activeInterceptors) {
|
|
4159
|
+
interceptor.clear();
|
|
4160
|
+
}
|
|
4161
|
+
activeInterceptors.length = 0;
|
|
4162
|
+
}
|
|
4163
|
+
};
|
|
4164
|
+
var httpMock = new HttpMockManagerImpl();
|
|
4165
|
+
var httpResponse = {
|
|
4166
|
+
/** Create a JSON response */
|
|
4167
|
+
json(data, status = 200) {
|
|
4168
|
+
return {
|
|
4169
|
+
status,
|
|
4170
|
+
headers: { "content-type": "application/json" },
|
|
4171
|
+
body: data
|
|
4172
|
+
};
|
|
4173
|
+
},
|
|
4174
|
+
/** Create a text response */
|
|
4175
|
+
text(data, status = 200) {
|
|
4176
|
+
return {
|
|
4177
|
+
status,
|
|
4178
|
+
headers: { "content-type": "text/plain" },
|
|
4179
|
+
body: data
|
|
4180
|
+
};
|
|
4181
|
+
},
|
|
4182
|
+
/** Create an HTML response */
|
|
4183
|
+
html(data, status = 200) {
|
|
4184
|
+
return {
|
|
4185
|
+
status,
|
|
4186
|
+
headers: { "content-type": "text/html" },
|
|
4187
|
+
body: data
|
|
4188
|
+
};
|
|
4189
|
+
},
|
|
4190
|
+
/** Create an error response */
|
|
4191
|
+
error(status, message) {
|
|
4192
|
+
return {
|
|
4193
|
+
status,
|
|
4194
|
+
statusText: message ?? getStatusText(status),
|
|
4195
|
+
body: message ? { error: message } : void 0
|
|
4196
|
+
};
|
|
4197
|
+
},
|
|
4198
|
+
/** Create a 404 Not Found response */
|
|
4199
|
+
notFound(message = "Not Found") {
|
|
4200
|
+
return httpResponse.error(404, message);
|
|
4201
|
+
},
|
|
4202
|
+
/** Create a 500 Internal Server Error response */
|
|
4203
|
+
serverError(message = "Internal Server Error") {
|
|
4204
|
+
return httpResponse.error(500, message);
|
|
4205
|
+
},
|
|
4206
|
+
/** Create a 401 Unauthorized response */
|
|
4207
|
+
unauthorized(message = "Unauthorized") {
|
|
4208
|
+
return httpResponse.error(401, message);
|
|
4209
|
+
},
|
|
4210
|
+
/** Create a 403 Forbidden response */
|
|
4211
|
+
forbidden(message = "Forbidden") {
|
|
4212
|
+
return httpResponse.error(403, message);
|
|
4213
|
+
},
|
|
4214
|
+
/**
|
|
4215
|
+
* Create a network error that causes fetch to reject
|
|
4216
|
+
* This simulates real network failures where fetch throws instead of returning a Response
|
|
4217
|
+
*/
|
|
4218
|
+
networkError(message = "Network error") {
|
|
4219
|
+
return {
|
|
4220
|
+
status: 0,
|
|
4221
|
+
body: { _networkError: true, message },
|
|
4222
|
+
// Mark this as a network error for the interceptor to throw
|
|
4223
|
+
_throwError: true
|
|
4224
|
+
};
|
|
4225
|
+
},
|
|
4226
|
+
/** Create a delayed response */
|
|
4227
|
+
delayed(data, delayMs, status = 200) {
|
|
4228
|
+
return {
|
|
4229
|
+
status,
|
|
4230
|
+
body: data,
|
|
4231
|
+
delay: delayMs
|
|
4232
|
+
};
|
|
4233
|
+
}
|
|
4234
|
+
};
|
|
4235
|
+
function getStatusText(status) {
|
|
4236
|
+
const texts = {
|
|
4237
|
+
200: "OK",
|
|
4238
|
+
201: "Created",
|
|
4239
|
+
204: "No Content",
|
|
4240
|
+
400: "Bad Request",
|
|
4241
|
+
401: "Unauthorized",
|
|
4242
|
+
403: "Forbidden",
|
|
4243
|
+
404: "Not Found",
|
|
4244
|
+
500: "Internal Server Error",
|
|
4245
|
+
502: "Bad Gateway",
|
|
4246
|
+
503: "Service Unavailable"
|
|
4247
|
+
};
|
|
4248
|
+
return texts[status] ?? "Unknown";
|
|
4249
|
+
}
|
|
4250
|
+
|
|
4251
|
+
// libs/testing/src/ui/ui-assertions.ts
|
|
4252
|
+
function escapeRegex2(str) {
|
|
4253
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4254
|
+
}
|
|
4255
|
+
var UIAssertions = {
|
|
4256
|
+
/**
|
|
4257
|
+
* Assert tool result has valid rendered UI HTML.
|
|
4258
|
+
* @param result - The tool result wrapper
|
|
4259
|
+
* @returns The rendered HTML string
|
|
4260
|
+
* @throws Error if no UI HTML found or if mdx-fallback detected
|
|
4261
|
+
*/
|
|
4262
|
+
assertRenderedUI(result) {
|
|
4263
|
+
const meta = result.raw._meta;
|
|
4264
|
+
if (!meta) {
|
|
4265
|
+
throw new Error("Expected tool result to have _meta, but _meta is undefined");
|
|
4266
|
+
}
|
|
4267
|
+
const html = meta["ui/html"];
|
|
4268
|
+
if (!html) {
|
|
4269
|
+
throw new Error("Expected tool result to have ui/html in _meta, but it is missing");
|
|
4270
|
+
}
|
|
4271
|
+
if (typeof html !== "string") {
|
|
4272
|
+
throw new Error(`Expected ui/html to be a string, but got ${typeof html}`);
|
|
4273
|
+
}
|
|
4274
|
+
if (html.includes("mdx-fallback")) {
|
|
4275
|
+
throw new Error(
|
|
4276
|
+
"Got mdx-fallback instead of rendered HTML - MDX/React rendering failed. Check that @mdx-js/mdx is installed and the template syntax is valid."
|
|
4277
|
+
);
|
|
4278
|
+
}
|
|
4279
|
+
return html;
|
|
4280
|
+
},
|
|
4281
|
+
/**
|
|
4282
|
+
* Assert HTML contains all expected bound values from tool output.
|
|
4283
|
+
* @param html - The rendered HTML string
|
|
4284
|
+
* @param output - The tool output object
|
|
4285
|
+
* @param keys - Array of keys whose values should appear in the HTML
|
|
4286
|
+
* @throws Error if any expected value is missing from the HTML
|
|
4287
|
+
*/
|
|
4288
|
+
assertDataBinding(html, output, keys) {
|
|
4289
|
+
const missingKeys = [];
|
|
4290
|
+
for (const key of keys) {
|
|
4291
|
+
const value = output[key];
|
|
4292
|
+
if (value === void 0 || value === null) {
|
|
4293
|
+
continue;
|
|
4294
|
+
}
|
|
4295
|
+
const stringValue = String(value);
|
|
4296
|
+
if (!html.includes(stringValue)) {
|
|
4297
|
+
missingKeys.push(`${key}="${stringValue}"`);
|
|
4298
|
+
}
|
|
4299
|
+
}
|
|
4300
|
+
if (missingKeys.length > 0) {
|
|
4301
|
+
throw new Error(
|
|
4302
|
+
`Expected HTML to contain bound values for: ${missingKeys.join(", ")}. Data binding may have failed.`
|
|
4303
|
+
);
|
|
4304
|
+
}
|
|
4305
|
+
},
|
|
4306
|
+
/**
|
|
4307
|
+
* Assert HTML is XSS-safe (no scripts, event handlers, or javascript: URIs).
|
|
4308
|
+
* @param html - The rendered HTML string
|
|
4309
|
+
* @throws Error if potential XSS vulnerabilities are detected
|
|
4310
|
+
*/
|
|
4311
|
+
assertXssSafe(html) {
|
|
4312
|
+
const vulnerabilities = [];
|
|
4313
|
+
if (/<script[\s>]/i.test(html)) {
|
|
4314
|
+
vulnerabilities.push("<script> tag detected");
|
|
4315
|
+
}
|
|
4316
|
+
if (/\son\w+\s*=/i.test(html)) {
|
|
4317
|
+
vulnerabilities.push("inline event handler detected (onclick, onerror, etc.)");
|
|
4318
|
+
}
|
|
4319
|
+
if (/javascript:/i.test(html)) {
|
|
4320
|
+
vulnerabilities.push("javascript: URI detected");
|
|
4321
|
+
}
|
|
4322
|
+
if (vulnerabilities.length > 0) {
|
|
4323
|
+
throw new Error(`Potential XSS vulnerabilities found: ${vulnerabilities.join("; ")}`);
|
|
4324
|
+
}
|
|
4325
|
+
},
|
|
4326
|
+
/**
|
|
4327
|
+
* Assert HTML has proper structure (not escaped raw content).
|
|
4328
|
+
* @param html - The rendered HTML string
|
|
4329
|
+
* @throws Error if HTML appears to be raw/unrendered content
|
|
4330
|
+
*/
|
|
4331
|
+
assertProperHtmlStructure(html) {
|
|
4332
|
+
if (html.includes("<") && html.includes(">")) {
|
|
4333
|
+
throw new Error(
|
|
4334
|
+
"HTML contains escaped HTML entities (<, >) - content was likely not rendered. Check that the template is being processed correctly."
|
|
4335
|
+
);
|
|
4336
|
+
}
|
|
4337
|
+
if (!/<[a-z]/i.test(html)) {
|
|
4338
|
+
throw new Error("HTML contains no HTML tags - content may be plain text or rendering failed.");
|
|
4339
|
+
}
|
|
4340
|
+
},
|
|
4341
|
+
/**
|
|
4342
|
+
* Assert HTML contains a specific element.
|
|
4343
|
+
* @param html - The rendered HTML string
|
|
4344
|
+
* @param tag - The HTML tag name to look for
|
|
4345
|
+
* @throws Error if the element is not found
|
|
4346
|
+
*/
|
|
4347
|
+
assertContainsElement(html, tag) {
|
|
4348
|
+
const regex = new RegExp(`<${escapeRegex2(tag)}[\\s>]`, "i");
|
|
4349
|
+
if (!regex.test(html)) {
|
|
4350
|
+
throw new Error(`Expected HTML to contain <${tag}> element`);
|
|
4351
|
+
}
|
|
4352
|
+
},
|
|
4353
|
+
/**
|
|
4354
|
+
* Assert HTML contains a specific CSS class.
|
|
4355
|
+
* @param html - The rendered HTML string
|
|
4356
|
+
* @param className - The CSS class name to look for
|
|
4357
|
+
* @throws Error if the class is not found
|
|
4358
|
+
*/
|
|
4359
|
+
assertHasCssClass(html, className) {
|
|
4360
|
+
const classRegex = new RegExp(`class(?:Name)?\\s*=\\s*["'][^"']*\\b${escapeRegex2(className)}\\b[^"']*["']`, "i");
|
|
4361
|
+
if (!classRegex.test(html)) {
|
|
4362
|
+
throw new Error(`Expected HTML to have CSS class "${className}"`);
|
|
4363
|
+
}
|
|
4364
|
+
},
|
|
4365
|
+
/**
|
|
4366
|
+
* Assert HTML does NOT contain specific content.
|
|
4367
|
+
* Useful for verifying custom components were rendered, not left as raw tags.
|
|
4368
|
+
* @param html - The rendered HTML string
|
|
4369
|
+
* @param content - The content that should NOT appear
|
|
4370
|
+
* @throws Error if the content is found
|
|
4371
|
+
*/
|
|
4372
|
+
assertNotContainsRaw(html, content) {
|
|
4373
|
+
if (html.includes(content)) {
|
|
4374
|
+
throw new Error(
|
|
4375
|
+
`HTML contains raw content "${content}" - this component may not have been rendered. Check that all custom components are properly passed to the renderer.`
|
|
4376
|
+
);
|
|
4377
|
+
}
|
|
4378
|
+
},
|
|
4379
|
+
/**
|
|
4380
|
+
* Assert that widget metadata is present in the result.
|
|
4381
|
+
* Checks for ui/html, openai/outputTemplate, or ui/mimeType.
|
|
4382
|
+
* @param result - The tool result wrapper
|
|
4383
|
+
* @throws Error if widget metadata is missing
|
|
4384
|
+
*/
|
|
4385
|
+
assertWidgetMetadata(result) {
|
|
4386
|
+
const meta = result.raw._meta;
|
|
4387
|
+
if (!meta) {
|
|
4388
|
+
throw new Error("Expected tool result to have _meta with widget metadata");
|
|
4389
|
+
}
|
|
4390
|
+
const hasUiHtml = Boolean(meta["ui/html"]);
|
|
4391
|
+
const hasOutputTemplate = Boolean(meta["openai/outputTemplate"]);
|
|
4392
|
+
const hasMimeType2 = Boolean(meta["ui/mimeType"]);
|
|
4393
|
+
if (!hasUiHtml && !hasOutputTemplate && !hasMimeType2) {
|
|
4394
|
+
throw new Error("Expected _meta to have widget metadata (ui/html, openai/outputTemplate, or ui/mimeType)");
|
|
4395
|
+
}
|
|
4396
|
+
},
|
|
4397
|
+
/**
|
|
4398
|
+
* Comprehensive UI validation that runs all checks.
|
|
4399
|
+
* @param result - The tool result wrapper
|
|
4400
|
+
* @param boundKeys - Optional array of output keys to check for data binding
|
|
4401
|
+
* @returns The rendered HTML string
|
|
4402
|
+
* @throws Error if any validation fails
|
|
4403
|
+
*/
|
|
4404
|
+
assertValidUI(result, boundKeys) {
|
|
4405
|
+
const html = UIAssertions.assertRenderedUI(result);
|
|
4406
|
+
UIAssertions.assertProperHtmlStructure(html);
|
|
4407
|
+
UIAssertions.assertXssSafe(html);
|
|
4408
|
+
if (boundKeys && boundKeys.length > 0) {
|
|
4409
|
+
try {
|
|
4410
|
+
const output = JSON.parse(result.text() || "{}");
|
|
4411
|
+
UIAssertions.assertDataBinding(html, output, boundKeys);
|
|
4412
|
+
} catch {
|
|
4413
|
+
}
|
|
4414
|
+
}
|
|
4415
|
+
return html;
|
|
4416
|
+
},
|
|
4417
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
4418
|
+
// PLATFORM META ASSERTIONS
|
|
4419
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
4420
|
+
/**
|
|
4421
|
+
* Assert tool result has correct meta keys for OpenAI platform.
|
|
4422
|
+
* Verifies openai/* keys are present and ui/*, frontmcp/* keys are absent.
|
|
4423
|
+
* @param result - The tool result wrapper
|
|
4424
|
+
* @throws Error if meta keys don't match OpenAI expectations
|
|
4425
|
+
*/
|
|
4426
|
+
assertOpenAIMeta(result) {
|
|
4427
|
+
UIAssertions.assertPlatformMeta(result, "openai");
|
|
4428
|
+
},
|
|
4429
|
+
/**
|
|
4430
|
+
* Assert tool result has correct meta keys for ext-apps platform (SEP-1865).
|
|
4431
|
+
* Verifies ui/* keys are present and openai/*, frontmcp/* keys are absent.
|
|
4432
|
+
* @param result - The tool result wrapper
|
|
4433
|
+
* @throws Error if meta keys don't match ext-apps expectations
|
|
4434
|
+
*/
|
|
4435
|
+
assertExtAppsMeta(result) {
|
|
4436
|
+
UIAssertions.assertPlatformMeta(result, "ext-apps");
|
|
4437
|
+
},
|
|
4438
|
+
/**
|
|
4439
|
+
* Assert tool result has correct meta keys for FrontMCP platforms (Claude, Cursor, etc.).
|
|
4440
|
+
* Verifies frontmcp/* + ui/* keys are present and openai/* keys are absent.
|
|
4441
|
+
* @param result - The tool result wrapper
|
|
4442
|
+
* @throws Error if meta keys don't match FrontMCP expectations
|
|
4443
|
+
*/
|
|
4444
|
+
assertFrontmcpMeta(result) {
|
|
4445
|
+
UIAssertions.assertPlatformMeta(result, "claude");
|
|
4446
|
+
},
|
|
4447
|
+
/**
|
|
4448
|
+
* Assert tool result has correct meta keys for a specific platform.
|
|
4449
|
+
* @param result - The tool result wrapper
|
|
4450
|
+
* @param platform - The platform type to check for
|
|
4451
|
+
* @throws Error if meta keys don't match platform expectations
|
|
4452
|
+
*/
|
|
4453
|
+
assertPlatformMeta(result, platform) {
|
|
4454
|
+
const meta = result.raw._meta;
|
|
4455
|
+
if (!meta) {
|
|
4456
|
+
throw new Error(`Expected tool result to have _meta with platform meta for "${platform}"`);
|
|
4457
|
+
}
|
|
4458
|
+
const expectedPrefixes = getToolCallMetaPrefixes(platform);
|
|
4459
|
+
const forbiddenPrefixes = getForbiddenMetaPrefixes(platform);
|
|
4460
|
+
const metaKeys = Object.keys(meta);
|
|
4461
|
+
const hasExpectedPrefix = metaKeys.some((key) => expectedPrefixes.some((prefix) => key.startsWith(prefix)));
|
|
4462
|
+
if (!hasExpectedPrefix) {
|
|
4463
|
+
throw new Error(
|
|
4464
|
+
`Expected _meta to have keys with prefixes [${expectedPrefixes.join(", ")}] for platform "${platform}", but found: [${metaKeys.join(", ")}]`
|
|
4465
|
+
);
|
|
4466
|
+
}
|
|
4467
|
+
const forbiddenKeys = metaKeys.filter((key) => forbiddenPrefixes.some((prefix) => key.startsWith(prefix)));
|
|
4468
|
+
if (forbiddenKeys.length > 0) {
|
|
4469
|
+
throw new Error(
|
|
4470
|
+
`Expected _meta NOT to have keys [${forbiddenKeys.join(", ")}] for platform "${platform}" (forbidden prefixes: [${forbiddenPrefixes.join(", ")}])`
|
|
4471
|
+
);
|
|
4472
|
+
}
|
|
4473
|
+
},
|
|
4474
|
+
/**
|
|
4475
|
+
* Assert that no cross-namespace pollution exists in meta.
|
|
4476
|
+
* @param result - The tool result wrapper
|
|
4477
|
+
* @param expectedNamespace - The namespace that SHOULD be present
|
|
4478
|
+
* @throws Error if other namespaces are found
|
|
4479
|
+
*/
|
|
4480
|
+
assertNoMixedNamespaces(result, expectedNamespace) {
|
|
4481
|
+
const meta = result.raw._meta;
|
|
4482
|
+
if (!meta) {
|
|
4483
|
+
throw new Error(`Expected tool result to have _meta with namespace "${expectedNamespace}"`);
|
|
4484
|
+
}
|
|
4485
|
+
const metaKeys = Object.keys(meta);
|
|
4486
|
+
const wrongKeys = metaKeys.filter((key) => !key.startsWith(expectedNamespace));
|
|
4487
|
+
if (wrongKeys.length > 0) {
|
|
4488
|
+
throw new Error(
|
|
4489
|
+
`Expected _meta to ONLY have keys with namespace "${expectedNamespace}", but found: [${wrongKeys.join(", ")}]`
|
|
4490
|
+
);
|
|
4491
|
+
}
|
|
4492
|
+
},
|
|
4493
|
+
/**
|
|
4494
|
+
* Assert that _meta has the correct MIME type for a platform.
|
|
4495
|
+
* @param result - The tool result wrapper
|
|
4496
|
+
* @param platform - The platform type to check for
|
|
4497
|
+
* @throws Error if MIME type doesn't match platform expectations
|
|
4498
|
+
*/
|
|
4499
|
+
assertPlatformMimeType(result, platform) {
|
|
4500
|
+
const meta = result.raw._meta;
|
|
4501
|
+
const expectedMimeType = getPlatformMimeType(platform);
|
|
4502
|
+
if (!meta) {
|
|
4503
|
+
throw new Error(`Expected tool result to have _meta with MIME type for platform "${platform}"`);
|
|
4504
|
+
}
|
|
4505
|
+
let mimeTypeKey;
|
|
4506
|
+
switch (platform) {
|
|
4507
|
+
case "openai":
|
|
4508
|
+
mimeTypeKey = "openai/mimeType";
|
|
4509
|
+
break;
|
|
4510
|
+
case "ext-apps":
|
|
4511
|
+
mimeTypeKey = "ui/mimeType";
|
|
4512
|
+
break;
|
|
4513
|
+
default:
|
|
4514
|
+
mimeTypeKey = "frontmcp/mimeType";
|
|
4515
|
+
}
|
|
4516
|
+
const actualMimeType = meta[mimeTypeKey];
|
|
4517
|
+
if (actualMimeType !== expectedMimeType) {
|
|
4518
|
+
throw new Error(
|
|
4519
|
+
`Expected _meta["${mimeTypeKey}"] to be "${expectedMimeType}" for platform "${platform}", but got "${actualMimeType}"`
|
|
4520
|
+
);
|
|
4521
|
+
}
|
|
4522
|
+
},
|
|
4523
|
+
/**
|
|
4524
|
+
* Assert that _meta has HTML in the correct platform-specific key.
|
|
4525
|
+
* @param result - The tool result wrapper
|
|
4526
|
+
* @param platform - The platform type to check for
|
|
4527
|
+
* @returns The HTML string
|
|
4528
|
+
* @throws Error if HTML is missing or in wrong key
|
|
4529
|
+
*/
|
|
4530
|
+
assertPlatformHtml(result, platform) {
|
|
4531
|
+
const meta = result.raw._meta;
|
|
4532
|
+
if (!meta) {
|
|
4533
|
+
throw new Error(`Expected tool result to have _meta with platform HTML for "${platform}"`);
|
|
4534
|
+
}
|
|
4535
|
+
let htmlKey;
|
|
4536
|
+
switch (platform) {
|
|
4537
|
+
case "openai":
|
|
4538
|
+
htmlKey = "openai/html";
|
|
4539
|
+
break;
|
|
4540
|
+
case "ext-apps":
|
|
4541
|
+
htmlKey = "ui/html";
|
|
4542
|
+
break;
|
|
4543
|
+
default:
|
|
4544
|
+
htmlKey = "frontmcp/html";
|
|
4545
|
+
}
|
|
4546
|
+
const html = meta[htmlKey];
|
|
4547
|
+
if (typeof html !== "string" || html.length === 0) {
|
|
4548
|
+
throw new Error(
|
|
4549
|
+
`Expected _meta["${htmlKey}"] to contain HTML for platform "${platform}", but ${html === void 0 ? "key not found" : `got ${typeof html}`}`
|
|
4550
|
+
);
|
|
4551
|
+
}
|
|
4552
|
+
return html;
|
|
4553
|
+
},
|
|
4554
|
+
/**
|
|
4555
|
+
* Comprehensive platform meta validation.
|
|
4556
|
+
* @param result - The tool result wrapper
|
|
4557
|
+
* @param platform - The platform type to validate for
|
|
4558
|
+
* @returns The platform-specific HTML string
|
|
4559
|
+
* @throws Error if any platform-specific validation fails
|
|
4560
|
+
*/
|
|
4561
|
+
assertValidPlatformMeta(result, platform) {
|
|
4562
|
+
UIAssertions.assertPlatformMeta(result, platform);
|
|
4563
|
+
UIAssertions.assertPlatformMimeType(result, platform);
|
|
4564
|
+
return UIAssertions.assertPlatformHtml(result, platform);
|
|
4565
|
+
}
|
|
4566
|
+
};
|
|
4567
|
+
|
|
4568
|
+
// libs/testing/src/example-tools/tool-configs.ts
|
|
4569
|
+
import { z } from "zod";
|
|
4570
|
+
var basicUIToolInputSchema = z.object({
|
|
4571
|
+
name: z.string().optional().default("World")
|
|
4572
|
+
});
|
|
4573
|
+
var basicUIToolOutputSchema = z.object({
|
|
4574
|
+
message: z.string(),
|
|
4575
|
+
timestamp: z.number()
|
|
4576
|
+
});
|
|
4577
|
+
var BASIC_UI_TOOL_CONFIG = {
|
|
4578
|
+
name: "platform-test-basic",
|
|
4579
|
+
description: "Basic UI tool for platform testing",
|
|
4580
|
+
inputSchema: basicUIToolInputSchema,
|
|
4581
|
+
outputSchema: basicUIToolOutputSchema,
|
|
4582
|
+
ui: {
|
|
4583
|
+
/**
|
|
4584
|
+
* Simple template that displays the output.
|
|
4585
|
+
* Works with all platforms.
|
|
4586
|
+
*/
|
|
4587
|
+
template: `
|
|
4588
|
+
<div class="platform-test-basic">
|
|
4589
|
+
<h1>Platform Test - Basic</h1>
|
|
4590
|
+
<p>Message: {output.message}</p>
|
|
4591
|
+
<p>Timestamp: {output.timestamp}</p>
|
|
4592
|
+
</div>
|
|
4593
|
+
`.trim()
|
|
4594
|
+
}
|
|
4595
|
+
};
|
|
4596
|
+
var fullUIToolInputSchema = z.object({
|
|
4597
|
+
name: z.string().optional().default("World"),
|
|
4598
|
+
count: z.number().optional().default(1)
|
|
4599
|
+
});
|
|
4600
|
+
var fullUIToolOutputSchema = z.object({
|
|
4601
|
+
message: z.string(),
|
|
4602
|
+
count: z.number(),
|
|
4603
|
+
items: z.array(z.string()),
|
|
4604
|
+
timestamp: z.number()
|
|
4605
|
+
});
|
|
4606
|
+
var FULL_UI_TOOL_CONFIG = {
|
|
4607
|
+
name: "platform-test-full",
|
|
4608
|
+
description: "Full UI tool with all options for comprehensive platform testing",
|
|
4609
|
+
inputSchema: fullUIToolInputSchema,
|
|
4610
|
+
outputSchema: fullUIToolOutputSchema,
|
|
4611
|
+
ui: {
|
|
4612
|
+
/**
|
|
4613
|
+
* Template with more complex UI elements.
|
|
4614
|
+
*/
|
|
4615
|
+
template: `
|
|
4616
|
+
<div class="platform-test-full">
|
|
4617
|
+
<h1>Platform Test - Full</h1>
|
|
4618
|
+
<div class="message-box">
|
|
4619
|
+
<strong>Message:</strong> {output.message}
|
|
4620
|
+
</div>
|
|
4621
|
+
<div class="count-box">
|
|
4622
|
+
<strong>Count:</strong> {output.count}
|
|
4623
|
+
</div>
|
|
4624
|
+
<div class="items-list">
|
|
4625
|
+
<strong>Items:</strong>
|
|
4626
|
+
<ul>
|
|
4627
|
+
{output.items.map(item => <li key={item}>{item}</li>)}
|
|
4628
|
+
</ul>
|
|
4629
|
+
</div>
|
|
4630
|
+
<footer>
|
|
4631
|
+
<small>Generated at: {new Date(output.timestamp).toISOString()}</small>
|
|
4632
|
+
</footer>
|
|
4633
|
+
</div>
|
|
4634
|
+
`.trim(),
|
|
4635
|
+
/**
|
|
4636
|
+
* Widget is accessible for callback invocations.
|
|
4637
|
+
*/
|
|
4638
|
+
widgetAccessible: true,
|
|
4639
|
+
/**
|
|
4640
|
+
* Invocation status messages for OpenAI.
|
|
4641
|
+
*/
|
|
4642
|
+
invocationStatus: {
|
|
4643
|
+
invoking: "Processing request...",
|
|
4644
|
+
invoked: "Request completed"
|
|
4645
|
+
},
|
|
4646
|
+
/**
|
|
4647
|
+
* Content Security Policy configuration.
|
|
4648
|
+
*/
|
|
4649
|
+
csp: {
|
|
4650
|
+
connectDomains: ["https://api.example.com"],
|
|
4651
|
+
resourceDomains: ["https://cdn.example.com"]
|
|
4652
|
+
},
|
|
4653
|
+
/**
|
|
4654
|
+
* Display mode for the widget.
|
|
4655
|
+
*/
|
|
4656
|
+
displayMode: "inline",
|
|
4657
|
+
/**
|
|
4658
|
+
* Prefers border around the widget.
|
|
4659
|
+
*/
|
|
4660
|
+
prefersBorder: true,
|
|
4661
|
+
/**
|
|
4662
|
+
* Custom sandbox domain.
|
|
4663
|
+
*/
|
|
4664
|
+
sandboxDomain: "sandbox.example.com"
|
|
4665
|
+
}
|
|
4666
|
+
};
|
|
4667
|
+
function generateBasicUIToolOutput(input) {
|
|
4668
|
+
return {
|
|
4669
|
+
message: `Hello, ${input.name}!`,
|
|
4670
|
+
timestamp: Date.now()
|
|
4671
|
+
};
|
|
4672
|
+
}
|
|
4673
|
+
function generateFullUIToolOutput(input) {
|
|
4674
|
+
const items = [];
|
|
4675
|
+
for (let i = 1; i <= input.count; i++) {
|
|
4676
|
+
items.push(`Item ${i}`);
|
|
4677
|
+
}
|
|
4678
|
+
return {
|
|
4679
|
+
message: `Hello, ${input.name}! You requested ${input.count} item(s).`,
|
|
4680
|
+
count: input.count,
|
|
4681
|
+
items,
|
|
4682
|
+
timestamp: Date.now()
|
|
4683
|
+
};
|
|
4684
|
+
}
|
|
4685
|
+
var EXPECTED_OPENAI_TOOLS_LIST_META_KEYS = [
|
|
4686
|
+
"openai/outputTemplate",
|
|
4687
|
+
"openai/resultCanProduceWidget",
|
|
4688
|
+
"openai/widgetAccessible"
|
|
4689
|
+
];
|
|
4690
|
+
var EXPECTED_OPENAI_TOOL_CALL_META_KEYS = ["openai/html", "openai/mimeType", "openai/type"];
|
|
4691
|
+
var EXPECTED_EXTAPPS_TOOLS_LIST_META_KEYS = ["ui/resourceUri", "ui/mimeType", "ui/cdn", "ui/type"];
|
|
4692
|
+
var EXPECTED_EXTAPPS_TOOL_CALL_META_KEYS = ["ui/html", "ui/mimeType", "ui/type"];
|
|
4693
|
+
var EXPECTED_FRONTMCP_TOOLS_LIST_META_KEYS = [
|
|
4694
|
+
"frontmcp/outputTemplate",
|
|
4695
|
+
"frontmcp/resultCanProduceWidget",
|
|
4696
|
+
"ui/cdn",
|
|
4697
|
+
"ui/type"
|
|
4698
|
+
];
|
|
4699
|
+
var EXPECTED_FRONTMCP_TOOL_CALL_META_KEYS = [
|
|
4700
|
+
"frontmcp/html",
|
|
4701
|
+
"frontmcp/mimeType",
|
|
4702
|
+
"ui/html",
|
|
4703
|
+
"ui/mimeType"
|
|
4704
|
+
];
|
|
4705
|
+
export {
|
|
4706
|
+
AssertionError,
|
|
4707
|
+
AuthHeaders,
|
|
4708
|
+
BASIC_UI_TOOL_CONFIG,
|
|
4709
|
+
ConnectionError,
|
|
4710
|
+
DefaultInterceptorChain,
|
|
4711
|
+
DefaultMockRegistry,
|
|
4712
|
+
EXPECTED_EXTAPPS_TOOLS_LIST_META_KEYS,
|
|
4713
|
+
EXPECTED_EXTAPPS_TOOL_CALL_META_KEYS,
|
|
4714
|
+
EXPECTED_FRONTMCP_TOOLS_LIST_META_KEYS,
|
|
4715
|
+
EXPECTED_FRONTMCP_TOOL_CALL_META_KEYS,
|
|
4716
|
+
EXPECTED_OPENAI_TOOLS_LIST_META_KEYS,
|
|
4717
|
+
EXPECTED_OPENAI_TOOL_CALL_META_KEYS,
|
|
4718
|
+
FULL_UI_TOOL_CONFIG,
|
|
4719
|
+
McpAssertions,
|
|
4720
|
+
McpProtocolError,
|
|
4721
|
+
McpTestClient,
|
|
4722
|
+
McpTestClientBuilder,
|
|
4723
|
+
MockAPIServer,
|
|
4724
|
+
MockOAuthServer,
|
|
4725
|
+
PLATFORM_DETECTION_PATTERNS,
|
|
4726
|
+
ServerStartError,
|
|
4727
|
+
StreamableHttpTransport,
|
|
4728
|
+
TestClientError,
|
|
4729
|
+
TestServer,
|
|
4730
|
+
TestTokenFactory,
|
|
4731
|
+
TestUsers,
|
|
4732
|
+
TimeoutError,
|
|
4733
|
+
UIAssertions,
|
|
4734
|
+
basicUIToolInputSchema,
|
|
4735
|
+
basicUIToolOutputSchema,
|
|
4736
|
+
buildUserAgent,
|
|
4737
|
+
containsPrompt,
|
|
4738
|
+
containsResource,
|
|
4739
|
+
containsResourceTemplate,
|
|
4740
|
+
containsTool,
|
|
4741
|
+
createTestUser,
|
|
4742
|
+
expect,
|
|
4743
|
+
fullUIToolInputSchema,
|
|
4744
|
+
fullUIToolOutputSchema,
|
|
4745
|
+
generateBasicUIToolOutput,
|
|
4746
|
+
generateFullUIToolOutput,
|
|
4747
|
+
getForbiddenMetaPrefixes,
|
|
4748
|
+
getPlatformClientInfo,
|
|
4749
|
+
getPlatformMetaNamespace,
|
|
4750
|
+
getPlatformMimeType,
|
|
4751
|
+
getPlatformUserAgent,
|
|
4752
|
+
getToolCallMetaPrefixes,
|
|
4753
|
+
getToolsListMetaPrefixes,
|
|
4754
|
+
hasMimeType,
|
|
4755
|
+
hasTextContent,
|
|
4756
|
+
httpMock,
|
|
4757
|
+
httpResponse,
|
|
4758
|
+
interceptors,
|
|
4759
|
+
isError,
|
|
4760
|
+
isExtAppsPlatform,
|
|
4761
|
+
isFrontmcpPlatform,
|
|
4762
|
+
isOpenAIPlatform,
|
|
4763
|
+
isSuccessful,
|
|
4764
|
+
mcpMatchers,
|
|
4765
|
+
mockResponse,
|
|
4766
|
+
test,
|
|
4767
|
+
uiMatchers
|
|
4768
|
+
};
|