@gleanwork/mcp-server-tester 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +421 -0
- package/dist/cli/index.js +2785 -0
- package/dist/fixtures/mcp.d.ts +605 -0
- package/dist/fixtures/mcp.js +2378 -0
- package/dist/fixtures/mcp.js.map +1 -0
- package/dist/fixtures/mcpAuth.d.ts +31 -0
- package/dist/fixtures/mcpAuth.js +317 -0
- package/dist/fixtures/mcpAuth.js.map +1 -0
- package/dist/index.cjs +3658 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +3857 -0
- package/dist/index.d.ts +3857 -0
- package/dist/index.js +3582 -0
- package/dist/index.js.map +1 -0
- package/dist/reporters/mcpReporter.cjs +301 -0
- package/dist/reporters/mcpReporter.cjs.map +1 -0
- package/dist/reporters/mcpReporter.d.cts +85 -0
- package/dist/reporters/mcpReporter.d.ts +85 -0
- package/dist/reporters/mcpReporter.js +297 -0
- package/dist/reporters/mcpReporter.js.map +1 -0
- package/dist/reporters/ui-dist/app.js +174 -0
- package/dist/reporters/ui-dist/index.html +28 -0
- package/dist/reporters/ui-dist/styles.css +1 -0
- package/package.json +138 -0
- package/src/reporters/ui-dist/app.js +174 -0
- package/src/reporters/ui-dist/index.html +28 -0
- package/src/reporters/ui-dist/styles.css +1 -0
|
@@ -0,0 +1,2378 @@
|
|
|
1
|
+
import { expect as expect$1, test as test$1 } from '@playwright/test';
|
|
2
|
+
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
3
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
4
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
5
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import createDebug from 'debug';
|
|
8
|
+
import * as fs2 from 'fs/promises';
|
|
9
|
+
import * as path2 from 'path';
|
|
10
|
+
import * as http from 'http';
|
|
11
|
+
import * as oauth from 'oauth4webapi';
|
|
12
|
+
import { homedir } from 'os';
|
|
13
|
+
|
|
14
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
15
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
16
|
+
}) : x)(function(x) {
|
|
17
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
18
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// src/mcp/response.ts
|
|
22
|
+
function normalizeToolResponse(result) {
|
|
23
|
+
const isError = result.isError ?? false;
|
|
24
|
+
const contentBlocks = [];
|
|
25
|
+
const textParts = [];
|
|
26
|
+
if (Array.isArray(result.content)) {
|
|
27
|
+
for (const block of result.content) {
|
|
28
|
+
if (block == null || typeof block !== "object") {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const b = block;
|
|
32
|
+
const contentBlock = {
|
|
33
|
+
type: typeof b.type === "string" ? b.type : "unknown"
|
|
34
|
+
};
|
|
35
|
+
if (typeof b.text === "string") {
|
|
36
|
+
contentBlock.text = b.text;
|
|
37
|
+
textParts.push(b.text);
|
|
38
|
+
}
|
|
39
|
+
if (b.data !== void 0) {
|
|
40
|
+
contentBlock.data = b.data;
|
|
41
|
+
}
|
|
42
|
+
if (typeof b.mimeType === "string") {
|
|
43
|
+
contentBlock.mimeType = b.mimeType;
|
|
44
|
+
}
|
|
45
|
+
contentBlocks.push(contentBlock);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
let structuredContent = null;
|
|
49
|
+
if (result.structuredContent !== void 0) {
|
|
50
|
+
structuredContent = result.structuredContent;
|
|
51
|
+
if (textParts.length === 0) {
|
|
52
|
+
if (typeof result.structuredContent === "string") {
|
|
53
|
+
textParts.push(result.structuredContent);
|
|
54
|
+
} else if (result.structuredContent != null) {
|
|
55
|
+
textParts.push(JSON.stringify(result.structuredContent));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const text = textParts.join("\n");
|
|
60
|
+
return {
|
|
61
|
+
text,
|
|
62
|
+
raw: result,
|
|
63
|
+
isError,
|
|
64
|
+
contentBlocks,
|
|
65
|
+
structuredContent
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function extractText(response) {
|
|
69
|
+
if (response == null) {
|
|
70
|
+
return "";
|
|
71
|
+
}
|
|
72
|
+
if (typeof response === "string") {
|
|
73
|
+
return response;
|
|
74
|
+
}
|
|
75
|
+
if (isNormalizedResponse(response)) {
|
|
76
|
+
return response.text;
|
|
77
|
+
}
|
|
78
|
+
if (isCallToolResult(response)) {
|
|
79
|
+
return normalizeToolResponse(response).text;
|
|
80
|
+
}
|
|
81
|
+
if (Array.isArray(response)) {
|
|
82
|
+
return extractTextFromContentArray(response);
|
|
83
|
+
}
|
|
84
|
+
if (typeof response === "object") {
|
|
85
|
+
const r = response;
|
|
86
|
+
if (Array.isArray(r.content)) {
|
|
87
|
+
return extractTextFromContentArray(r.content);
|
|
88
|
+
}
|
|
89
|
+
if (typeof r.content === "string") {
|
|
90
|
+
return r.content;
|
|
91
|
+
}
|
|
92
|
+
if (r.structuredContent !== void 0) {
|
|
93
|
+
if (typeof r.structuredContent === "string") {
|
|
94
|
+
return r.structuredContent;
|
|
95
|
+
}
|
|
96
|
+
return JSON.stringify(r.structuredContent);
|
|
97
|
+
}
|
|
98
|
+
if (typeof r.text === "string") {
|
|
99
|
+
return r.text;
|
|
100
|
+
}
|
|
101
|
+
return JSON.stringify(r);
|
|
102
|
+
}
|
|
103
|
+
if (typeof response === "number" || typeof response === "boolean" || typeof response === "bigint") {
|
|
104
|
+
return String(response);
|
|
105
|
+
}
|
|
106
|
+
return "";
|
|
107
|
+
}
|
|
108
|
+
function isNormalizedResponse(value) {
|
|
109
|
+
if (value == null || typeof value !== "object") {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
const v = value;
|
|
113
|
+
return typeof v.text === "string" && typeof v.isError === "boolean" && Array.isArray(v.contentBlocks) && v.raw !== void 0;
|
|
114
|
+
}
|
|
115
|
+
function isCallToolResult(value) {
|
|
116
|
+
if (value == null || typeof value !== "object") {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
const v = value;
|
|
120
|
+
return Array.isArray(v.content) || typeof v.isError === "boolean";
|
|
121
|
+
}
|
|
122
|
+
function extractTextFromContentArray(content) {
|
|
123
|
+
const textParts = [];
|
|
124
|
+
for (const block of content) {
|
|
125
|
+
if (block == null || typeof block !== "object") {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const b = block;
|
|
129
|
+
if (b.type === "text" && typeof b.text === "string") {
|
|
130
|
+
textParts.push(b.text);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (textParts.length > 0) {
|
|
134
|
+
return textParts.join("\n");
|
|
135
|
+
}
|
|
136
|
+
return JSON.stringify(content);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// src/assertions/validators/utils.ts
|
|
140
|
+
var extractText2 = extractText;
|
|
141
|
+
function getResponseSizeBytes(response) {
|
|
142
|
+
if (response === null || response === void 0) {
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
if (typeof response === "string") {
|
|
146
|
+
return Buffer.byteLength(response, "utf8");
|
|
147
|
+
}
|
|
148
|
+
const serialized = JSON.stringify(response, null, 2);
|
|
149
|
+
return Buffer.byteLength(serialized, "utf8");
|
|
150
|
+
}
|
|
151
|
+
function stringifyResponse(response) {
|
|
152
|
+
if (response === null || response === void 0) {
|
|
153
|
+
return "";
|
|
154
|
+
}
|
|
155
|
+
if (typeof response === "string") {
|
|
156
|
+
return response;
|
|
157
|
+
}
|
|
158
|
+
return JSON.stringify(response, null, 2);
|
|
159
|
+
}
|
|
160
|
+
function isErrorResponse(response) {
|
|
161
|
+
if (response === null || response === void 0) {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
if (typeof response !== "object") {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
const r = response;
|
|
168
|
+
if (r.isError === true) {
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
if ("raw" in r && typeof r.raw === "object" && r.raw !== null) {
|
|
172
|
+
const raw = r.raw;
|
|
173
|
+
return raw.isError === true;
|
|
174
|
+
}
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
function extractErrorMessage(response) {
|
|
178
|
+
if (!isErrorResponse(response)) {
|
|
179
|
+
return "";
|
|
180
|
+
}
|
|
181
|
+
return extractText2(response);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// src/assertions/validators/response.ts
|
|
185
|
+
function validateResponse(actual, expected) {
|
|
186
|
+
const actualStr = stringifyResponse(actual);
|
|
187
|
+
const expectedStr = stringifyResponse(expected);
|
|
188
|
+
if (actualStr === expectedStr) {
|
|
189
|
+
return {
|
|
190
|
+
pass: true,
|
|
191
|
+
message: "Response matches expected value"
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
pass: false,
|
|
196
|
+
message: `Response does not match expected value`,
|
|
197
|
+
details: {
|
|
198
|
+
actual: truncateForDisplay(actualStr),
|
|
199
|
+
expected: truncateForDisplay(expectedStr)
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function truncateForDisplay(str, maxLength = 500) {
|
|
204
|
+
if (str.length <= maxLength) {
|
|
205
|
+
return str;
|
|
206
|
+
}
|
|
207
|
+
return str.slice(0, maxLength) + "... (truncated)";
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// src/assertions/matchers/toMatchToolResponse.ts
|
|
211
|
+
function toMatchToolResponse(received, expected) {
|
|
212
|
+
const result = validateResponse(received, expected);
|
|
213
|
+
return {
|
|
214
|
+
pass: result.pass,
|
|
215
|
+
message: () => {
|
|
216
|
+
if (this.isNot) {
|
|
217
|
+
return result.pass ? "Expected response NOT to match, but it did" : result.message;
|
|
218
|
+
}
|
|
219
|
+
return result.message;
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// src/assertions/validators/schema.ts
|
|
225
|
+
function validateSchema(response, schema, options = {}) {
|
|
226
|
+
const valueToValidate = getValidatableValue(response);
|
|
227
|
+
if (options.strict && valueToValidate !== null) ;
|
|
228
|
+
try {
|
|
229
|
+
schema.parse(valueToValidate);
|
|
230
|
+
return {
|
|
231
|
+
pass: true,
|
|
232
|
+
message: "Response matches schema"
|
|
233
|
+
};
|
|
234
|
+
} catch (error) {
|
|
235
|
+
const zodError = error;
|
|
236
|
+
const issues = formatZodIssues(zodError);
|
|
237
|
+
return {
|
|
238
|
+
pass: false,
|
|
239
|
+
message: `Response does not match schema: ${issues}`,
|
|
240
|
+
details: {
|
|
241
|
+
issues: zodError.issues
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function getValidatableValue(response) {
|
|
247
|
+
if (response === null || response === void 0) {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
if (typeof response === "object" && !Array.isArray(response)) {
|
|
251
|
+
const r = response;
|
|
252
|
+
if ("structuredContent" in r && r.structuredContent !== void 0) {
|
|
253
|
+
return r.structuredContent;
|
|
254
|
+
}
|
|
255
|
+
if ("raw" in r && "text" in r && "isError" in r && "contentBlocks" in r) {
|
|
256
|
+
if (r.structuredContent !== void 0) {
|
|
257
|
+
return r.structuredContent;
|
|
258
|
+
}
|
|
259
|
+
const text = r.text;
|
|
260
|
+
return tryParseJson(text) ?? response;
|
|
261
|
+
}
|
|
262
|
+
if ("content" in r && Array.isArray(r.content)) {
|
|
263
|
+
const text = extractText2(response);
|
|
264
|
+
return tryParseJson(text) ?? response;
|
|
265
|
+
}
|
|
266
|
+
return response;
|
|
267
|
+
}
|
|
268
|
+
if (typeof response === "string") {
|
|
269
|
+
return tryParseJson(response) ?? response;
|
|
270
|
+
}
|
|
271
|
+
return response;
|
|
272
|
+
}
|
|
273
|
+
function tryParseJson(text) {
|
|
274
|
+
if (!text || typeof text !== "string") {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
const trimmed = text.trim();
|
|
278
|
+
if (!(trimmed.startsWith("{") || trimmed.startsWith("[")) || !(trimmed.endsWith("}") || trimmed.endsWith("]"))) {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
return JSON.parse(trimmed);
|
|
283
|
+
} catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
function formatZodIssues(error) {
|
|
288
|
+
const issues = error.issues.map((issue) => {
|
|
289
|
+
const path3 = issue.path.length > 0 ? issue.path.join(".") : "root";
|
|
290
|
+
return `${path3}: ${issue.message}`;
|
|
291
|
+
});
|
|
292
|
+
return issues.join("; ");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// src/assertions/matchers/toMatchToolSchema.ts
|
|
296
|
+
function toMatchToolSchema(received, schema, options = {}) {
|
|
297
|
+
const result = validateSchema(received, schema, options);
|
|
298
|
+
return {
|
|
299
|
+
pass: result.pass,
|
|
300
|
+
message: () => {
|
|
301
|
+
if (this.isNot) {
|
|
302
|
+
return result.pass ? "Expected response NOT to match schema, but it did" : result.message;
|
|
303
|
+
}
|
|
304
|
+
return result.message;
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// src/assertions/validators/text.ts
|
|
310
|
+
function validateText(response, expected, options = {}) {
|
|
311
|
+
const { caseSensitive = true } = options;
|
|
312
|
+
const expectedStrings = Array.isArray(expected) ? expected : [expected];
|
|
313
|
+
const text = extractText2(response);
|
|
314
|
+
const compareText = caseSensitive ? text : text.toLowerCase();
|
|
315
|
+
const missing = [];
|
|
316
|
+
for (const substring of expectedStrings) {
|
|
317
|
+
const compareSubstring = caseSensitive ? substring : substring.toLowerCase();
|
|
318
|
+
if (!compareText.includes(compareSubstring)) {
|
|
319
|
+
missing.push(substring);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (missing.length === 0) {
|
|
323
|
+
return {
|
|
324
|
+
pass: true,
|
|
325
|
+
message: expectedStrings.length === 1 ? `Response contains expected text` : `Response contains all ${expectedStrings.length} expected substrings`
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
return {
|
|
329
|
+
pass: false,
|
|
330
|
+
message: missing.length === 1 ? `Response does not contain expected text: "${missing[0]}"` : `Response is missing ${missing.length} expected substrings: ${missing.map((s) => `"${s}"`).join(", ")}`,
|
|
331
|
+
details: {
|
|
332
|
+
missing,
|
|
333
|
+
textLength: text.length,
|
|
334
|
+
textPreview: truncateForDisplay2(text)
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
function truncateForDisplay2(str, maxLength = 200) {
|
|
339
|
+
if (str.length <= maxLength) {
|
|
340
|
+
return str;
|
|
341
|
+
}
|
|
342
|
+
return str.slice(0, maxLength) + "... (truncated)";
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// src/assertions/matchers/toContainToolText.ts
|
|
346
|
+
function toContainToolText(received, expected, options = {}) {
|
|
347
|
+
const result = validateText(received, expected, options);
|
|
348
|
+
return {
|
|
349
|
+
pass: result.pass,
|
|
350
|
+
message: () => {
|
|
351
|
+
if (this.isNot) {
|
|
352
|
+
const expectedStr = Array.isArray(expected) ? expected.map((s) => `"${s}"`).join(", ") : `"${expected}"`;
|
|
353
|
+
return result.pass ? `Expected response NOT to contain ${expectedStr}, but it did` : result.message;
|
|
354
|
+
}
|
|
355
|
+
return result.message;
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// src/assertions/validators/pattern.ts
|
|
361
|
+
function validatePattern(response, patterns, options = {}) {
|
|
362
|
+
const { caseSensitive = true } = options;
|
|
363
|
+
const caseInsensitive = !caseSensitive;
|
|
364
|
+
const patternList = Array.isArray(patterns) ? patterns : [patterns];
|
|
365
|
+
const text = extractText2(response);
|
|
366
|
+
const unmatched = [];
|
|
367
|
+
for (const pattern of patternList) {
|
|
368
|
+
const regex = toRegExp(pattern, caseInsensitive);
|
|
369
|
+
if (!regex.test(text)) {
|
|
370
|
+
unmatched.push(patternToString(pattern));
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (unmatched.length === 0) {
|
|
374
|
+
return {
|
|
375
|
+
pass: true,
|
|
376
|
+
message: patternList.length === 1 ? `Response matches pattern` : `Response matches all ${patternList.length} patterns`
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
return {
|
|
380
|
+
pass: false,
|
|
381
|
+
message: unmatched.length === 1 ? `Response does not match pattern: ${unmatched[0]}` : `Response does not match ${unmatched.length} patterns: ${unmatched.join(", ")}`,
|
|
382
|
+
details: {
|
|
383
|
+
unmatched,
|
|
384
|
+
textLength: text.length,
|
|
385
|
+
textPreview: truncateForDisplay3(text)
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
function toRegExp(pattern, caseInsensitive) {
|
|
390
|
+
if (pattern instanceof RegExp) {
|
|
391
|
+
if (caseInsensitive && !pattern.flags.includes("i")) {
|
|
392
|
+
return new RegExp(pattern.source, pattern.flags + "i");
|
|
393
|
+
}
|
|
394
|
+
return pattern;
|
|
395
|
+
}
|
|
396
|
+
const flags = caseInsensitive ? "i" : "";
|
|
397
|
+
return new RegExp(pattern, flags);
|
|
398
|
+
}
|
|
399
|
+
function patternToString(pattern) {
|
|
400
|
+
if (pattern instanceof RegExp) {
|
|
401
|
+
return pattern.toString();
|
|
402
|
+
}
|
|
403
|
+
return `/${pattern}/`;
|
|
404
|
+
}
|
|
405
|
+
function truncateForDisplay3(str, maxLength = 200) {
|
|
406
|
+
if (str.length <= maxLength) {
|
|
407
|
+
return str;
|
|
408
|
+
}
|
|
409
|
+
return str.slice(0, maxLength) + "... (truncated)";
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// src/assertions/matchers/toMatchToolPattern.ts
|
|
413
|
+
function toMatchToolPattern(received, patterns, options = {}) {
|
|
414
|
+
const result = validatePattern(received, patterns, options);
|
|
415
|
+
return {
|
|
416
|
+
pass: result.pass,
|
|
417
|
+
message: () => {
|
|
418
|
+
if (this.isNot) {
|
|
419
|
+
return result.pass ? "Expected response NOT to match pattern(s), but it did" : result.message;
|
|
420
|
+
}
|
|
421
|
+
return result.message;
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
var BUILT_IN_PATTERNS = {
|
|
426
|
+
timestamp: {
|
|
427
|
+
pattern: /\b\d{10,13}\b/g,
|
|
428
|
+
replacement: "[TIMESTAMP]"
|
|
429
|
+
},
|
|
430
|
+
uuid: {
|
|
431
|
+
pattern: /\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/gi,
|
|
432
|
+
replacement: "[UUID]"
|
|
433
|
+
},
|
|
434
|
+
"iso-date": {
|
|
435
|
+
pattern: /\b\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d{1,3})?(Z|[+-]\d{2}:?\d{2})?)?\b/g,
|
|
436
|
+
replacement: "[ISO_DATE]"
|
|
437
|
+
},
|
|
438
|
+
objectId: {
|
|
439
|
+
pattern: /\b[0-9a-f]{24}\b/gi,
|
|
440
|
+
replacement: "[OBJECT_ID]"
|
|
441
|
+
},
|
|
442
|
+
jwt: {
|
|
443
|
+
pattern: /\beyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\b/g,
|
|
444
|
+
replacement: "[JWT]"
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
function isRegexSanitizer(sanitizer) {
|
|
448
|
+
return typeof sanitizer === "object" && sanitizer !== null && "pattern" in sanitizer;
|
|
449
|
+
}
|
|
450
|
+
function isFieldRemovalSanitizer(sanitizer) {
|
|
451
|
+
return typeof sanitizer === "object" && sanitizer !== null && "remove" in sanitizer;
|
|
452
|
+
}
|
|
453
|
+
function applySanitizers(value, sanitizers) {
|
|
454
|
+
let result = value;
|
|
455
|
+
for (const sanitizer of sanitizers) {
|
|
456
|
+
if (typeof sanitizer === "string") {
|
|
457
|
+
const builtIn = BUILT_IN_PATTERNS[sanitizer];
|
|
458
|
+
if (builtIn) {
|
|
459
|
+
result = result.replace(builtIn.pattern, builtIn.replacement);
|
|
460
|
+
}
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (isRegexSanitizer(sanitizer)) {
|
|
464
|
+
const pattern = sanitizer.pattern instanceof RegExp ? sanitizer.pattern : new RegExp(sanitizer.pattern, "g");
|
|
465
|
+
const replacement = sanitizer.replacement ?? "[SANITIZED]";
|
|
466
|
+
result = result.replace(pattern, replacement);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
if (isFieldRemovalSanitizer(sanitizer)) {
|
|
470
|
+
try {
|
|
471
|
+
const parsed = JSON.parse(result);
|
|
472
|
+
removeFields(parsed, sanitizer.remove);
|
|
473
|
+
result = JSON.stringify(parsed, null, 2);
|
|
474
|
+
} catch {
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return result;
|
|
479
|
+
}
|
|
480
|
+
function removeFields(obj, paths) {
|
|
481
|
+
if (typeof obj !== "object" || obj === null) {
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
for (const path3 of paths) {
|
|
485
|
+
const parts = path3.split(".");
|
|
486
|
+
if (parts.length === 0) {
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
let current = obj;
|
|
490
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
491
|
+
if (typeof current !== "object" || current === null) {
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
const key = parts[i];
|
|
495
|
+
if (key !== void 0) {
|
|
496
|
+
current = current[key];
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (typeof current === "object" && current !== null) {
|
|
500
|
+
const lastKey = parts[parts.length - 1];
|
|
501
|
+
if (lastKey !== void 0) {
|
|
502
|
+
delete current[lastKey];
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
async function toMatchToolSnapshot(received, name, sanitizers = []) {
|
|
508
|
+
let content = extractText2(received);
|
|
509
|
+
if (sanitizers.length > 0) {
|
|
510
|
+
content = applySanitizers(content, sanitizers);
|
|
511
|
+
}
|
|
512
|
+
if (this.isNot) {
|
|
513
|
+
try {
|
|
514
|
+
await expect$1(content).toMatchSnapshot(name);
|
|
515
|
+
return {
|
|
516
|
+
pass: false,
|
|
517
|
+
message: () => `Expected response NOT to match snapshot "${name}", but it did`
|
|
518
|
+
};
|
|
519
|
+
} catch {
|
|
520
|
+
return {
|
|
521
|
+
pass: true,
|
|
522
|
+
message: () => `Response does not match snapshot "${name}" as expected`
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
try {
|
|
527
|
+
await expect$1(content).toMatchSnapshot(name);
|
|
528
|
+
return {
|
|
529
|
+
pass: true,
|
|
530
|
+
message: () => `Response matches snapshot "${name}"`
|
|
531
|
+
};
|
|
532
|
+
} catch (error) {
|
|
533
|
+
return {
|
|
534
|
+
pass: false,
|
|
535
|
+
message: () => error instanceof Error ? error.message : `Response does not match snapshot "${name}"`
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// src/assertions/validators/error.ts
|
|
541
|
+
function validateError(response, expected = true) {
|
|
542
|
+
const actualIsError = isErrorResponse(response);
|
|
543
|
+
const errorMessage = actualIsError ? extractErrorMessage(response) : "";
|
|
544
|
+
if (typeof expected === "boolean") {
|
|
545
|
+
if (expected) {
|
|
546
|
+
if (actualIsError) {
|
|
547
|
+
return {
|
|
548
|
+
pass: true,
|
|
549
|
+
message: "Response is an error as expected"
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
return {
|
|
553
|
+
pass: false,
|
|
554
|
+
message: "Expected an error response but got success",
|
|
555
|
+
details: {
|
|
556
|
+
textPreview: truncateForDisplay4(extractText2(response))
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
} else {
|
|
560
|
+
if (!actualIsError) {
|
|
561
|
+
return {
|
|
562
|
+
pass: true,
|
|
563
|
+
message: "Response is not an error as expected"
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
return {
|
|
567
|
+
pass: false,
|
|
568
|
+
message: `Expected a success response but got error: "${truncateForDisplay4(errorMessage)}"`,
|
|
569
|
+
details: {
|
|
570
|
+
errorMessage
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
const expectedMessages = Array.isArray(expected) ? expected : [expected];
|
|
576
|
+
if (!actualIsError) {
|
|
577
|
+
return {
|
|
578
|
+
pass: false,
|
|
579
|
+
message: `Expected an error containing "${expectedMessages[0]}" but got success`,
|
|
580
|
+
details: {
|
|
581
|
+
textPreview: truncateForDisplay4(extractText2(response))
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
const matched = expectedMessages.some(
|
|
586
|
+
(msg) => errorMessage.toLowerCase().includes(msg.toLowerCase())
|
|
587
|
+
);
|
|
588
|
+
if (matched) {
|
|
589
|
+
return {
|
|
590
|
+
pass: true,
|
|
591
|
+
message: "Error message contains expected text"
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
return {
|
|
595
|
+
pass: false,
|
|
596
|
+
message: expectedMessages.length === 1 ? `Error message does not contain "${expectedMessages[0]}"` : `Error message does not contain any of: ${expectedMessages.map((m) => `"${m}"`).join(", ")}`,
|
|
597
|
+
details: {
|
|
598
|
+
actualErrorMessage: errorMessage,
|
|
599
|
+
expectedToContain: expectedMessages
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
function truncateForDisplay4(str, maxLength = 200) {
|
|
604
|
+
if (str.length <= maxLength) {
|
|
605
|
+
return str;
|
|
606
|
+
}
|
|
607
|
+
return str.slice(0, maxLength) + "... (truncated)";
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// src/assertions/matchers/toBeToolError.ts
|
|
611
|
+
function toBeToolError(received, expected = true) {
|
|
612
|
+
const effectiveExpected = this.isNot ? typeof expected === "boolean" ? !expected : false : expected;
|
|
613
|
+
const result = validateError(received, effectiveExpected);
|
|
614
|
+
return {
|
|
615
|
+
pass: this.isNot ? !result.pass : result.pass,
|
|
616
|
+
message: () => {
|
|
617
|
+
if (this.isNot) {
|
|
618
|
+
if (typeof expected === "boolean") {
|
|
619
|
+
return result.pass ? "Expected response NOT to be an error, but it was" : "Response is not an error as expected";
|
|
620
|
+
}
|
|
621
|
+
const expectedStr = Array.isArray(expected) ? expected.join(", ") : expected;
|
|
622
|
+
return result.pass ? `Expected response NOT to be an error with "${expectedStr}", but it was` : result.message;
|
|
623
|
+
}
|
|
624
|
+
return result.message;
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
function createClaudeAgentJudge(config) {
|
|
629
|
+
const model = config.model ?? "claude-sonnet-4-20250514";
|
|
630
|
+
const maxBudgetUsd = config.maxBudgetUsd ?? 0.1;
|
|
631
|
+
const maxToolOutputSize = config.maxToolOutputSize;
|
|
632
|
+
return {
|
|
633
|
+
async evaluate(candidate, reference, rubric) {
|
|
634
|
+
const candidateStr = typeof candidate === "string" ? candidate : JSON.stringify(candidate, null, 2);
|
|
635
|
+
const candidateSizeBytes = Buffer.byteLength(candidateStr, "utf8");
|
|
636
|
+
if (maxToolOutputSize !== void 0 && candidateSizeBytes > maxToolOutputSize) {
|
|
637
|
+
return {
|
|
638
|
+
pass: false,
|
|
639
|
+
score: 0,
|
|
640
|
+
reasoning: `Tool output size (${candidateSizeBytes} bytes) exceeds maximum allowed size (${maxToolOutputSize} bytes)`,
|
|
641
|
+
candidateSizeBytes,
|
|
642
|
+
exceedsMaxToolOutputSize: true
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
const prompt = buildJudgePrompt(candidate, reference, rubric);
|
|
646
|
+
try {
|
|
647
|
+
let resultMessage;
|
|
648
|
+
for await (const message of query({
|
|
649
|
+
prompt,
|
|
650
|
+
options: {
|
|
651
|
+
model,
|
|
652
|
+
maxBudgetUsd,
|
|
653
|
+
// Use empty tools array for response-only mode
|
|
654
|
+
tools: [],
|
|
655
|
+
// Bypass permissions since we're not using any tools
|
|
656
|
+
permissionMode: "bypassPermissions",
|
|
657
|
+
allowDangerouslySkipPermissions: true,
|
|
658
|
+
// Use a custom system prompt for JSON output
|
|
659
|
+
systemPrompt: buildSystemPrompt(),
|
|
660
|
+
// Limit to 1 turn since this is a simple evaluation
|
|
661
|
+
maxTurns: 1
|
|
662
|
+
}
|
|
663
|
+
})) {
|
|
664
|
+
if (message.type === "result") {
|
|
665
|
+
resultMessage = message;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
if (!resultMessage) {
|
|
669
|
+
throw new Error("No result message received from Claude Agent SDK");
|
|
670
|
+
}
|
|
671
|
+
if (resultMessage.subtype !== "success" && resultMessage.errors?.length) {
|
|
672
|
+
throw new Error(
|
|
673
|
+
`Claude Agent SDK error: ${resultMessage.errors.join(", ")}`
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
const responseText = resultMessage.result ?? "";
|
|
677
|
+
const parsed = parseJudgeResponse(responseText);
|
|
678
|
+
const usage = {
|
|
679
|
+
inputTokens: resultMessage.usage?.input_tokens ?? 0,
|
|
680
|
+
outputTokens: resultMessage.usage?.output_tokens ?? 0,
|
|
681
|
+
totalCostUsd: resultMessage.total_cost_usd ?? 0,
|
|
682
|
+
durationMs: resultMessage.duration_ms ?? 0,
|
|
683
|
+
durationApiMs: resultMessage.duration_api_ms,
|
|
684
|
+
cacheReadInputTokens: resultMessage.usage?.cache_read_input_tokens,
|
|
685
|
+
cacheCreationInputTokens: resultMessage.usage?.cache_creation_input_tokens
|
|
686
|
+
};
|
|
687
|
+
return {
|
|
688
|
+
pass: parsed.pass ?? false,
|
|
689
|
+
score: parsed.score,
|
|
690
|
+
reasoning: parsed.reasoning,
|
|
691
|
+
usage,
|
|
692
|
+
candidateSizeBytes,
|
|
693
|
+
exceedsMaxToolOutputSize: false
|
|
694
|
+
};
|
|
695
|
+
} catch (error) {
|
|
696
|
+
throw new Error(
|
|
697
|
+
`Claude Agent judge evaluation failed: ${error instanceof Error ? error.message : String(error)}`
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
function buildSystemPrompt() {
|
|
704
|
+
return 'You are an expert evaluator. Evaluate the candidate response based on the rubric provided. Respond ONLY with valid JSON in this exact format: {"pass": boolean, "score": number (0-1), "reasoning": string}. Do not include any other text, markdown formatting, or code blocks.';
|
|
705
|
+
}
|
|
706
|
+
function buildJudgePrompt(candidate, reference, rubric) {
|
|
707
|
+
const parts = [];
|
|
708
|
+
parts.push("# Evaluation Task\n");
|
|
709
|
+
parts.push(rubric);
|
|
710
|
+
parts.push("\n\n# Candidate Response\n");
|
|
711
|
+
parts.push(
|
|
712
|
+
typeof candidate === "string" ? candidate : JSON.stringify(candidate, null, 2)
|
|
713
|
+
);
|
|
714
|
+
if (reference !== null && reference !== void 0) {
|
|
715
|
+
parts.push("\n\n# Reference Response\n");
|
|
716
|
+
parts.push(
|
|
717
|
+
typeof reference === "string" ? reference : JSON.stringify(reference, null, 2)
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
parts.push(
|
|
721
|
+
"\n\n# Instructions\nEvaluate the candidate response based on the rubric. " + (reference !== null && reference !== void 0 ? "Compare it against the reference response if helpful. " : "") + 'Respond with JSON containing "pass" (boolean), "score" (0-1), and "reasoning" (string).'
|
|
722
|
+
);
|
|
723
|
+
return parts.join("");
|
|
724
|
+
}
|
|
725
|
+
function parseJudgeResponse(text) {
|
|
726
|
+
let jsonText = text.trim();
|
|
727
|
+
if (jsonText.startsWith("```json")) {
|
|
728
|
+
jsonText = jsonText.slice(7);
|
|
729
|
+
}
|
|
730
|
+
if (jsonText.startsWith("```")) {
|
|
731
|
+
jsonText = jsonText.slice(3);
|
|
732
|
+
}
|
|
733
|
+
if (jsonText.endsWith("```")) {
|
|
734
|
+
jsonText = jsonText.slice(0, -3);
|
|
735
|
+
}
|
|
736
|
+
jsonText = jsonText.trim();
|
|
737
|
+
try {
|
|
738
|
+
return JSON.parse(jsonText);
|
|
739
|
+
} catch {
|
|
740
|
+
const jsonMatch = jsonText.match(/\{[\s\S]*"pass"[\s\S]*\}/);
|
|
741
|
+
if (jsonMatch) {
|
|
742
|
+
return JSON.parse(jsonMatch[0]);
|
|
743
|
+
}
|
|
744
|
+
throw new Error(`Failed to parse judge response as JSON: ${text}`);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// src/judge/judgeClient.ts
|
|
749
|
+
function createJudge(config = {}) {
|
|
750
|
+
const provider = config.provider ?? "claude";
|
|
751
|
+
switch (provider) {
|
|
752
|
+
case "claude":
|
|
753
|
+
case "anthropic":
|
|
754
|
+
return createClaudeAgentJudge(config);
|
|
755
|
+
case "openai":
|
|
756
|
+
throw new Error(
|
|
757
|
+
'OpenAI provider is no longer supported. Please use createJudge() without specifying provider, or use provider: "claude". See migration guide at https://github.com/gleanwork/mcp-server-tester/blob/main/docs/migration-v0.11.md'
|
|
758
|
+
);
|
|
759
|
+
case "custom-http":
|
|
760
|
+
throw new Error(
|
|
761
|
+
"custom-http provider is no longer supported. Please use createJudge() without specifying provider."
|
|
762
|
+
);
|
|
763
|
+
default:
|
|
764
|
+
throw new Error(`Unsupported LLM provider: ${String(provider)}`);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// src/assertions/matchers/toPassToolJudge.ts
|
|
769
|
+
var DEFAULT_PASSING_THRESHOLD = 0.7;
|
|
770
|
+
var DEFAULT_JUDGE_CONFIG = {};
|
|
771
|
+
async function toPassToolJudge(received, rubric, options = {}) {
|
|
772
|
+
const {
|
|
773
|
+
reference = null,
|
|
774
|
+
passingThreshold = DEFAULT_PASSING_THRESHOLD,
|
|
775
|
+
judgeConfig = DEFAULT_JUDGE_CONFIG
|
|
776
|
+
} = options;
|
|
777
|
+
const judge = createJudge(judgeConfig);
|
|
778
|
+
try {
|
|
779
|
+
const result = await judge.evaluate(received, reference, rubric);
|
|
780
|
+
const score = result.score ?? (result.pass ? 1 : 0);
|
|
781
|
+
const passes = score >= passingThreshold;
|
|
782
|
+
if (this.isNot) {
|
|
783
|
+
return {
|
|
784
|
+
pass: !passes,
|
|
785
|
+
message: () => passes ? `Expected judge evaluation to fail, but it passed with score ${score.toFixed(2)}` : `Judge evaluation failed as expected with score ${score.toFixed(2)}`
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
if (passes) {
|
|
789
|
+
return {
|
|
790
|
+
pass: true,
|
|
791
|
+
message: () => `Judge evaluation passed with score ${score.toFixed(2)} (threshold: ${passingThreshold})`
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
return {
|
|
795
|
+
pass: false,
|
|
796
|
+
message: () => `Judge evaluation failed with score ${score.toFixed(2)} (threshold: ${passingThreshold}). Reasoning: ${result.reasoning ?? "No reasoning provided"}`
|
|
797
|
+
};
|
|
798
|
+
} catch (error) {
|
|
799
|
+
return {
|
|
800
|
+
pass: false,
|
|
801
|
+
message: () => `Judge evaluation failed with error: ${error instanceof Error ? error.message : String(error)}`
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// src/assertions/validators/size.ts
|
|
807
|
+
function validateSize(response, options) {
|
|
808
|
+
const { maxBytes, minBytes } = options;
|
|
809
|
+
if (maxBytes === void 0 && minBytes === void 0) {
|
|
810
|
+
return {
|
|
811
|
+
pass: false,
|
|
812
|
+
message: "Size validation requires at least one of maxBytes or minBytes"
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
const actualSize = getResponseSizeBytes(response);
|
|
816
|
+
const issues = [];
|
|
817
|
+
if (minBytes !== void 0 && actualSize < minBytes) {
|
|
818
|
+
issues.push(
|
|
819
|
+
`Response size (${formatBytes(actualSize)}) is below minimum (${formatBytes(minBytes)})`
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
if (maxBytes !== void 0 && actualSize > maxBytes) {
|
|
823
|
+
issues.push(
|
|
824
|
+
`Response size (${formatBytes(actualSize)}) exceeds maximum (${formatBytes(maxBytes)})`
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
if (issues.length === 0) {
|
|
828
|
+
return {
|
|
829
|
+
pass: true,
|
|
830
|
+
message: `Response size (${formatBytes(actualSize)}) is within bounds`,
|
|
831
|
+
details: {
|
|
832
|
+
actualBytes: actualSize
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
return {
|
|
837
|
+
pass: false,
|
|
838
|
+
message: issues.join("; "),
|
|
839
|
+
details: {
|
|
840
|
+
actualBytes: actualSize,
|
|
841
|
+
minBytes,
|
|
842
|
+
maxBytes
|
|
843
|
+
}
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
function formatBytes(bytes) {
|
|
847
|
+
if (bytes < 1024) {
|
|
848
|
+
return `${bytes} bytes`;
|
|
849
|
+
}
|
|
850
|
+
if (bytes < 1024 * 1024) {
|
|
851
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
852
|
+
}
|
|
853
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// src/assertions/matchers/toHaveToolResponseSize.ts
|
|
857
|
+
function toHaveToolResponseSize(received, options) {
|
|
858
|
+
const result = validateSize(received, options);
|
|
859
|
+
return {
|
|
860
|
+
pass: result.pass,
|
|
861
|
+
message: () => {
|
|
862
|
+
if (this.isNot) {
|
|
863
|
+
return result.pass ? "Expected response size NOT to be within bounds, but it was" : result.message;
|
|
864
|
+
}
|
|
865
|
+
return result.message;
|
|
866
|
+
}
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// src/assertions/matchers/toSatisfyToolPredicate.ts
|
|
871
|
+
function normalizeResult(result) {
|
|
872
|
+
if (typeof result === "boolean") {
|
|
873
|
+
return {
|
|
874
|
+
pass: result,
|
|
875
|
+
message: result ? "Predicate passed" : "Predicate returned false"
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
return result;
|
|
879
|
+
}
|
|
880
|
+
async function toSatisfyToolPredicate(received, predicate, description) {
|
|
881
|
+
const predicateDescription = description ?? "custom predicate";
|
|
882
|
+
try {
|
|
883
|
+
const text = extractText2(received);
|
|
884
|
+
const rawResult = await predicate(received, text);
|
|
885
|
+
const result = normalizeResult(rawResult);
|
|
886
|
+
if (this.isNot) {
|
|
887
|
+
return {
|
|
888
|
+
pass: !result.pass,
|
|
889
|
+
message: () => result.pass ? `Expected response NOT to satisfy ${predicateDescription}` : `Response does not satisfy ${predicateDescription} as expected`
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
return {
|
|
893
|
+
pass: result.pass,
|
|
894
|
+
message: () => result.pass ? result.message ?? `Response satisfies ${predicateDescription}` : result.message ?? `Expected response to satisfy ${predicateDescription}`
|
|
895
|
+
};
|
|
896
|
+
} catch (error) {
|
|
897
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
898
|
+
return {
|
|
899
|
+
pass: this.isNot,
|
|
900
|
+
// If using .not, an error means the predicate didn't pass
|
|
901
|
+
message: () => `Predicate threw error: ${errorMessage}`
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// src/assertions/matchers/index.ts
|
|
907
|
+
var expect = expect$1.extend({
|
|
908
|
+
toMatchToolResponse,
|
|
909
|
+
toMatchToolSchema,
|
|
910
|
+
toContainToolText,
|
|
911
|
+
toMatchToolPattern,
|
|
912
|
+
toMatchToolSnapshot,
|
|
913
|
+
toBeToolError,
|
|
914
|
+
toPassToolJudge,
|
|
915
|
+
toHaveToolResponseSize,
|
|
916
|
+
toSatisfyToolPredicate
|
|
917
|
+
});
|
|
918
|
+
var MCPHostCapabilitiesSchema = z.object({
|
|
919
|
+
sampling: z.record(z.unknown()).optional(),
|
|
920
|
+
roots: z.object({
|
|
921
|
+
listChanged: z.boolean()
|
|
922
|
+
}).optional()
|
|
923
|
+
});
|
|
924
|
+
var MCPOAuthConfigSchema = z.object({
|
|
925
|
+
serverUrl: z.string().url("serverUrl must be a valid URL"),
|
|
926
|
+
scopes: z.array(z.string()).optional(),
|
|
927
|
+
resource: z.string().url().optional(),
|
|
928
|
+
authStatePath: z.string().optional(),
|
|
929
|
+
clientId: z.string().optional(),
|
|
930
|
+
clientSecret: z.string().optional(),
|
|
931
|
+
redirectUri: z.string().url().optional()
|
|
932
|
+
});
|
|
933
|
+
var MCPAuthConfigSchema = z.object({
|
|
934
|
+
accessToken: z.string().optional(),
|
|
935
|
+
oauth: MCPOAuthConfigSchema.optional()
|
|
936
|
+
}).refine(
|
|
937
|
+
(data) => !(data.accessToken && data.oauth),
|
|
938
|
+
"Cannot specify both accessToken and oauth configuration"
|
|
939
|
+
);
|
|
940
|
+
var StdioConfigSchema = z.object({
|
|
941
|
+
transport: z.literal("stdio"),
|
|
942
|
+
command: z.string().min(1, "command is required for stdio transport"),
|
|
943
|
+
args: z.array(z.string()).optional(),
|
|
944
|
+
cwd: z.string().optional(),
|
|
945
|
+
capabilities: MCPHostCapabilitiesSchema.optional(),
|
|
946
|
+
connectTimeoutMs: z.number().positive().optional(),
|
|
947
|
+
requestTimeoutMs: z.number().positive().optional(),
|
|
948
|
+
quiet: z.boolean().optional()
|
|
949
|
+
});
|
|
950
|
+
var HttpConfigSchema = z.object({
|
|
951
|
+
transport: z.literal("http"),
|
|
952
|
+
serverUrl: z.string().url("serverUrl must be a valid URL"),
|
|
953
|
+
headers: z.record(z.string()).optional(),
|
|
954
|
+
capabilities: MCPHostCapabilitiesSchema.optional(),
|
|
955
|
+
connectTimeoutMs: z.number().positive().optional(),
|
|
956
|
+
requestTimeoutMs: z.number().positive().optional(),
|
|
957
|
+
auth: MCPAuthConfigSchema.optional()
|
|
958
|
+
});
|
|
959
|
+
var MCPConfigSchema = z.discriminatedUnion("transport", [
|
|
960
|
+
StdioConfigSchema,
|
|
961
|
+
HttpConfigSchema
|
|
962
|
+
]);
|
|
963
|
+
function validateMCPConfig(config) {
|
|
964
|
+
return MCPConfigSchema.parse(config);
|
|
965
|
+
}
|
|
966
|
+
function isStdioConfig(config) {
|
|
967
|
+
return config.transport === "stdio" && typeof config.command === "string";
|
|
968
|
+
}
|
|
969
|
+
function isHttpConfig(config) {
|
|
970
|
+
return config.transport === "http" && typeof config.serverUrl === "string";
|
|
971
|
+
}
|
|
972
|
+
var NAMESPACE = "mcp-server-tester";
|
|
973
|
+
var debugClient = createDebug(`${NAMESPACE}:client`);
|
|
974
|
+
createDebug(`${NAMESPACE}:oauth`);
|
|
975
|
+
createDebug(`${NAMESPACE}:eval`);
|
|
976
|
+
|
|
977
|
+
// src/mcp/clientFactory.ts
|
|
978
|
+
async function createMCPClientForConfig(config, options) {
|
|
979
|
+
const validatedConfig = validateMCPConfig(config);
|
|
980
|
+
const client = new Client(
|
|
981
|
+
{
|
|
982
|
+
name: options?.clientInfo?.name ?? "@gleanwork/mcp-server-tester",
|
|
983
|
+
version: options?.clientInfo?.version ?? "0.1.0"
|
|
984
|
+
},
|
|
985
|
+
{
|
|
986
|
+
capabilities: validatedConfig.capabilities ?? {}
|
|
987
|
+
}
|
|
988
|
+
);
|
|
989
|
+
if (isStdioConfig(validatedConfig)) {
|
|
990
|
+
const transport = new StdioClientTransport({
|
|
991
|
+
command: validatedConfig.command,
|
|
992
|
+
args: validatedConfig.args ?? [],
|
|
993
|
+
...validatedConfig.cwd && { cwd: validatedConfig.cwd },
|
|
994
|
+
// Suppress server stderr when quiet mode is enabled
|
|
995
|
+
...validatedConfig.quiet && { stderr: "ignore" }
|
|
996
|
+
});
|
|
997
|
+
debugClient("Connecting via stdio: %O", {
|
|
998
|
+
command: validatedConfig.command,
|
|
999
|
+
args: validatedConfig.args,
|
|
1000
|
+
cwd: validatedConfig.cwd
|
|
1001
|
+
});
|
|
1002
|
+
await client.connect(transport);
|
|
1003
|
+
} else if (isHttpConfig(validatedConfig)) {
|
|
1004
|
+
const headers = { ...validatedConfig.headers };
|
|
1005
|
+
if (validatedConfig.auth?.accessToken && !options?.authProvider) {
|
|
1006
|
+
headers.Authorization = `Bearer ${validatedConfig.auth.accessToken}`;
|
|
1007
|
+
}
|
|
1008
|
+
const transport = new StreamableHTTPClientTransport(
|
|
1009
|
+
new URL(validatedConfig.serverUrl),
|
|
1010
|
+
{
|
|
1011
|
+
requestInit: Object.keys(headers).length > 0 ? { headers } : void 0,
|
|
1012
|
+
// Pass auth provider for OAuth flow - MCP SDK handles it automatically
|
|
1013
|
+
authProvider: options?.authProvider
|
|
1014
|
+
}
|
|
1015
|
+
);
|
|
1016
|
+
debugClient("Connecting via HTTP: %O", {
|
|
1017
|
+
serverUrl: validatedConfig.serverUrl,
|
|
1018
|
+
headers: Object.keys(headers).length > 0 ? Object.keys(headers) : void 0,
|
|
1019
|
+
hasAuthProvider: !!options?.authProvider
|
|
1020
|
+
});
|
|
1021
|
+
await client.connect(transport);
|
|
1022
|
+
}
|
|
1023
|
+
debugClient("Connected successfully");
|
|
1024
|
+
const serverInfo = client.getServerVersion();
|
|
1025
|
+
if (serverInfo) {
|
|
1026
|
+
debugClient("Server info: %O", serverInfo);
|
|
1027
|
+
}
|
|
1028
|
+
return client;
|
|
1029
|
+
}
|
|
1030
|
+
async function closeMCPClient(client) {
|
|
1031
|
+
try {
|
|
1032
|
+
await client.close();
|
|
1033
|
+
} catch (error) {
|
|
1034
|
+
console.error("[MCP] Error closing client:", error);
|
|
1035
|
+
throw error;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// src/mcp/fixtures/mcpFixture.ts
|
|
1040
|
+
var testStep = null;
|
|
1041
|
+
try {
|
|
1042
|
+
const playwright = __require("@playwright/test");
|
|
1043
|
+
if (playwright && playwright.test && playwright.test.step) {
|
|
1044
|
+
testStep = playwright.test.step.bind(playwright.test);
|
|
1045
|
+
}
|
|
1046
|
+
} catch {
|
|
1047
|
+
}
|
|
1048
|
+
function createMCPFixture(client, testInfo, options) {
|
|
1049
|
+
const authType = options?.authType ?? "none";
|
|
1050
|
+
const project = options?.project;
|
|
1051
|
+
if (!testInfo) {
|
|
1052
|
+
return {
|
|
1053
|
+
client,
|
|
1054
|
+
authType,
|
|
1055
|
+
project,
|
|
1056
|
+
async listTools() {
|
|
1057
|
+
const result = await client.listTools();
|
|
1058
|
+
return result.tools;
|
|
1059
|
+
},
|
|
1060
|
+
async callTool(name, args) {
|
|
1061
|
+
const result = await client.callTool({
|
|
1062
|
+
name,
|
|
1063
|
+
arguments: args
|
|
1064
|
+
});
|
|
1065
|
+
return result;
|
|
1066
|
+
},
|
|
1067
|
+
getServerInfo() {
|
|
1068
|
+
const serverVersion = client.getServerVersion();
|
|
1069
|
+
if (!serverVersion) {
|
|
1070
|
+
return null;
|
|
1071
|
+
}
|
|
1072
|
+
return {
|
|
1073
|
+
name: serverVersion.name,
|
|
1074
|
+
version: serverVersion.version
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
return {
|
|
1080
|
+
client,
|
|
1081
|
+
authType,
|
|
1082
|
+
project,
|
|
1083
|
+
async listTools() {
|
|
1084
|
+
const execute = async () => {
|
|
1085
|
+
const result = await client.listTools();
|
|
1086
|
+
const tools = result.tools;
|
|
1087
|
+
await testInfo.attach("mcp-list-tools", {
|
|
1088
|
+
contentType: "application/json",
|
|
1089
|
+
body: JSON.stringify(
|
|
1090
|
+
{
|
|
1091
|
+
operation: "listTools",
|
|
1092
|
+
toolCount: tools.length,
|
|
1093
|
+
tools: tools.map((t) => ({
|
|
1094
|
+
name: t.name,
|
|
1095
|
+
description: t.description
|
|
1096
|
+
}))
|
|
1097
|
+
},
|
|
1098
|
+
null,
|
|
1099
|
+
2
|
|
1100
|
+
)
|
|
1101
|
+
});
|
|
1102
|
+
return tools;
|
|
1103
|
+
};
|
|
1104
|
+
return testStep ? testStep("MCP: listTools()", execute) : execute();
|
|
1105
|
+
},
|
|
1106
|
+
async callTool(name, args) {
|
|
1107
|
+
const execute = async () => {
|
|
1108
|
+
const startTime = Date.now();
|
|
1109
|
+
const result = await client.callTool({
|
|
1110
|
+
name,
|
|
1111
|
+
arguments: args
|
|
1112
|
+
});
|
|
1113
|
+
const durationMs = Date.now() - startTime;
|
|
1114
|
+
await testInfo.attach(`mcp-call-${name}`, {
|
|
1115
|
+
contentType: "application/json",
|
|
1116
|
+
body: JSON.stringify(
|
|
1117
|
+
{
|
|
1118
|
+
operation: "callTool",
|
|
1119
|
+
toolName: name,
|
|
1120
|
+
args,
|
|
1121
|
+
result,
|
|
1122
|
+
durationMs,
|
|
1123
|
+
isError: result.isError || false,
|
|
1124
|
+
authType,
|
|
1125
|
+
project
|
|
1126
|
+
},
|
|
1127
|
+
null,
|
|
1128
|
+
2
|
|
1129
|
+
)
|
|
1130
|
+
});
|
|
1131
|
+
return result;
|
|
1132
|
+
};
|
|
1133
|
+
return testStep ? testStep(`MCP: callTool("${name}")`, execute) : execute();
|
|
1134
|
+
},
|
|
1135
|
+
getServerInfo() {
|
|
1136
|
+
const serverVersion = client.getServerVersion();
|
|
1137
|
+
const result = serverVersion ? {
|
|
1138
|
+
name: serverVersion.name,
|
|
1139
|
+
version: serverVersion.version
|
|
1140
|
+
} : null;
|
|
1141
|
+
testInfo.attach("mcp-server-info", {
|
|
1142
|
+
contentType: "application/json",
|
|
1143
|
+
body: JSON.stringify(
|
|
1144
|
+
{
|
|
1145
|
+
operation: "getServerInfo",
|
|
1146
|
+
serverInfo: result
|
|
1147
|
+
},
|
|
1148
|
+
null,
|
|
1149
|
+
2
|
|
1150
|
+
)
|
|
1151
|
+
}).catch(() => {
|
|
1152
|
+
});
|
|
1153
|
+
return result;
|
|
1154
|
+
}
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
var PlaywrightOAuthClientProvider = class {
|
|
1158
|
+
config;
|
|
1159
|
+
cachedState = null;
|
|
1160
|
+
stateParam = null;
|
|
1161
|
+
constructor(config) {
|
|
1162
|
+
this.config = config;
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* The URL to redirect the user agent to after authorization
|
|
1166
|
+
*/
|
|
1167
|
+
get redirectUrl() {
|
|
1168
|
+
return this.config.redirectUri;
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* Metadata about this OAuth client
|
|
1172
|
+
*/
|
|
1173
|
+
get clientMetadata() {
|
|
1174
|
+
return {
|
|
1175
|
+
redirect_uris: [this.config.redirectUri],
|
|
1176
|
+
token_endpoint_auth_method: this.config.clientSecret ? "client_secret_basic" : "none",
|
|
1177
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
1178
|
+
response_types: ["code"],
|
|
1179
|
+
client_name: "@gleanwork/mcp-server-tester",
|
|
1180
|
+
...this.config.clientMetadata
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* Returns an OAuth2 state parameter
|
|
1185
|
+
*/
|
|
1186
|
+
state() {
|
|
1187
|
+
if (!this.stateParam) {
|
|
1188
|
+
this.stateParam = this.generateRandomString(32);
|
|
1189
|
+
}
|
|
1190
|
+
return this.stateParam;
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Loads information about this OAuth client
|
|
1194
|
+
*/
|
|
1195
|
+
async clientInformation() {
|
|
1196
|
+
if (this.config.clientId) {
|
|
1197
|
+
return {
|
|
1198
|
+
client_id: this.config.clientId,
|
|
1199
|
+
client_secret: this.config.clientSecret,
|
|
1200
|
+
redirect_uris: [this.config.redirectUri]
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
const state = await this.loadState();
|
|
1204
|
+
if (state?.clientInfo) {
|
|
1205
|
+
return {
|
|
1206
|
+
client_id: state.clientInfo.clientId,
|
|
1207
|
+
client_secret: state.clientInfo.clientSecret,
|
|
1208
|
+
client_id_issued_at: state.clientInfo.clientIdIssuedAt,
|
|
1209
|
+
client_secret_expires_at: state.clientInfo.clientSecretExpiresAt,
|
|
1210
|
+
redirect_uris: [this.config.redirectUri]
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
return void 0;
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* Saves client information from Dynamic Client Registration
|
|
1217
|
+
*/
|
|
1218
|
+
async saveClientInformation(clientInformation) {
|
|
1219
|
+
const state = await this.loadState() ?? this.createEmptyState();
|
|
1220
|
+
state.clientInfo = {
|
|
1221
|
+
clientId: clientInformation.client_id,
|
|
1222
|
+
clientSecret: clientInformation.client_secret,
|
|
1223
|
+
clientIdIssuedAt: clientInformation.client_id_issued_at,
|
|
1224
|
+
clientSecretExpiresAt: clientInformation.client_secret_expires_at
|
|
1225
|
+
};
|
|
1226
|
+
await this.saveState(state);
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* Loads any existing OAuth tokens for the current session
|
|
1230
|
+
*/
|
|
1231
|
+
async tokens() {
|
|
1232
|
+
const state = await this.loadState();
|
|
1233
|
+
if (state?.tokens) {
|
|
1234
|
+
return {
|
|
1235
|
+
access_token: state.tokens.accessToken,
|
|
1236
|
+
token_type: state.tokens.tokenType,
|
|
1237
|
+
refresh_token: state.tokens.refreshToken,
|
|
1238
|
+
expires_in: state.tokens.expiresAt ? Math.floor((state.tokens.expiresAt - Date.now()) / 1e3) : void 0
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
return void 0;
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* Stores new OAuth tokens for the current session
|
|
1245
|
+
*/
|
|
1246
|
+
async saveTokens(tokens) {
|
|
1247
|
+
const state = await this.loadState() ?? this.createEmptyState();
|
|
1248
|
+
state.tokens = {
|
|
1249
|
+
accessToken: tokens.access_token,
|
|
1250
|
+
tokenType: tokens.token_type,
|
|
1251
|
+
refreshToken: tokens.refresh_token,
|
|
1252
|
+
expiresAt: tokens.expires_in ? Date.now() + tokens.expires_in * 1e3 : void 0
|
|
1253
|
+
};
|
|
1254
|
+
await this.saveState(state);
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* Invoked to redirect the user agent to the given URL
|
|
1258
|
+
*
|
|
1259
|
+
* In a testing context, this is typically handled by Playwright automation.
|
|
1260
|
+
* This implementation throws an error to signal that the caller needs to
|
|
1261
|
+
* handle the redirect externally.
|
|
1262
|
+
*/
|
|
1263
|
+
async redirectToAuthorization(authorizationUrl) {
|
|
1264
|
+
throw new Error(
|
|
1265
|
+
`OAuth authorization required. Redirect to: ${authorizationUrl.toString()}
|
|
1266
|
+
In a testing context, use performOAuthSetup() in your Playwright globalSetup to complete the OAuth flow before running tests.`
|
|
1267
|
+
);
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Saves a PKCE code verifier for the current session
|
|
1271
|
+
*/
|
|
1272
|
+
async saveCodeVerifier(codeVerifier) {
|
|
1273
|
+
const state = await this.loadState() ?? this.createEmptyState();
|
|
1274
|
+
state.codeVerifier = codeVerifier;
|
|
1275
|
+
await this.saveState(state);
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Loads the PKCE code verifier for the current session
|
|
1279
|
+
*/
|
|
1280
|
+
async codeVerifier() {
|
|
1281
|
+
const state = await this.loadState();
|
|
1282
|
+
if (!state?.codeVerifier) {
|
|
1283
|
+
throw new Error("No code verifier found in auth state");
|
|
1284
|
+
}
|
|
1285
|
+
return state.codeVerifier;
|
|
1286
|
+
}
|
|
1287
|
+
/**
|
|
1288
|
+
* Invalidates the specified credentials
|
|
1289
|
+
*/
|
|
1290
|
+
async invalidateCredentials(scope) {
|
|
1291
|
+
const state = await this.loadState();
|
|
1292
|
+
if (!state) {
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
switch (scope) {
|
|
1296
|
+
case "all":
|
|
1297
|
+
await this.deleteState();
|
|
1298
|
+
break;
|
|
1299
|
+
case "client":
|
|
1300
|
+
delete state.clientInfo;
|
|
1301
|
+
await this.saveState(state);
|
|
1302
|
+
break;
|
|
1303
|
+
case "tokens":
|
|
1304
|
+
delete state.tokens;
|
|
1305
|
+
await this.saveState(state);
|
|
1306
|
+
break;
|
|
1307
|
+
case "verifier":
|
|
1308
|
+
delete state.codeVerifier;
|
|
1309
|
+
await this.saveState(state);
|
|
1310
|
+
break;
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
// ---- Private helper methods ----
|
|
1314
|
+
async loadState() {
|
|
1315
|
+
if (this.cachedState) {
|
|
1316
|
+
return this.cachedState;
|
|
1317
|
+
}
|
|
1318
|
+
try {
|
|
1319
|
+
const content = await fs2.readFile(this.config.storagePath, "utf-8");
|
|
1320
|
+
this.cachedState = JSON.parse(content);
|
|
1321
|
+
return this.cachedState;
|
|
1322
|
+
} catch (error) {
|
|
1323
|
+
if (error.code === "ENOENT") {
|
|
1324
|
+
return null;
|
|
1325
|
+
}
|
|
1326
|
+
throw error;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
async saveState(state) {
|
|
1330
|
+
state.savedAt = Date.now();
|
|
1331
|
+
this.cachedState = state;
|
|
1332
|
+
const dir = path2.dirname(this.config.storagePath);
|
|
1333
|
+
await fs2.mkdir(dir, { recursive: true });
|
|
1334
|
+
await fs2.writeFile(
|
|
1335
|
+
this.config.storagePath,
|
|
1336
|
+
JSON.stringify(state, null, 2),
|
|
1337
|
+
"utf-8"
|
|
1338
|
+
);
|
|
1339
|
+
}
|
|
1340
|
+
async deleteState() {
|
|
1341
|
+
this.cachedState = null;
|
|
1342
|
+
try {
|
|
1343
|
+
await fs2.unlink(this.config.storagePath);
|
|
1344
|
+
} catch (error) {
|
|
1345
|
+
if (error.code !== "ENOENT") {
|
|
1346
|
+
throw error;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
createEmptyState() {
|
|
1351
|
+
return {
|
|
1352
|
+
savedAt: Date.now()
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
generateRandomString(length) {
|
|
1356
|
+
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
1357
|
+
let result = "";
|
|
1358
|
+
const randomValues = new Uint8Array(length);
|
|
1359
|
+
crypto.getRandomValues(randomValues);
|
|
1360
|
+
for (let i = 0; i < length; i++) {
|
|
1361
|
+
const randomValue = randomValues[i] ?? 0;
|
|
1362
|
+
result += chars[randomValue % chars.length];
|
|
1363
|
+
}
|
|
1364
|
+
return result;
|
|
1365
|
+
}
|
|
1366
|
+
};
|
|
1367
|
+
async function generatePKCE() {
|
|
1368
|
+
const codeVerifier = oauth.generateRandomCodeVerifier();
|
|
1369
|
+
const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier);
|
|
1370
|
+
return {
|
|
1371
|
+
codeVerifier,
|
|
1372
|
+
codeChallenge
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
function generateState() {
|
|
1376
|
+
return oauth.generateRandomState();
|
|
1377
|
+
}
|
|
1378
|
+
function buildAuthorizationUrl(config) {
|
|
1379
|
+
const authorizationEndpoint = config.authServer.server.authorization_endpoint;
|
|
1380
|
+
if (!authorizationEndpoint) {
|
|
1381
|
+
throw new Error(
|
|
1382
|
+
"Authorization server does not have an authorization_endpoint"
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
1385
|
+
const authorizationUrl = new URL(authorizationEndpoint);
|
|
1386
|
+
authorizationUrl.searchParams.set("client_id", config.clientId);
|
|
1387
|
+
authorizationUrl.searchParams.set("redirect_uri", config.redirectUri);
|
|
1388
|
+
authorizationUrl.searchParams.set("response_type", "code");
|
|
1389
|
+
authorizationUrl.searchParams.set("scope", config.scopes.join(" "));
|
|
1390
|
+
authorizationUrl.searchParams.set("code_challenge", config.codeChallenge);
|
|
1391
|
+
authorizationUrl.searchParams.set("code_challenge_method", "S256");
|
|
1392
|
+
authorizationUrl.searchParams.set("state", config.state);
|
|
1393
|
+
if (config.resource) {
|
|
1394
|
+
authorizationUrl.searchParams.set("resource", config.resource);
|
|
1395
|
+
}
|
|
1396
|
+
return authorizationUrl;
|
|
1397
|
+
}
|
|
1398
|
+
async function exchangeCodeForTokens(config) {
|
|
1399
|
+
const client = {
|
|
1400
|
+
client_id: config.clientId,
|
|
1401
|
+
token_endpoint_auth_method: config.clientSecret ? "client_secret_basic" : "none"
|
|
1402
|
+
};
|
|
1403
|
+
const clientAuth = config.clientSecret ? oauth.ClientSecretBasic(config.clientSecret) : oauth.None();
|
|
1404
|
+
const callbackUrl = new URL(config.redirectUri);
|
|
1405
|
+
callbackUrl.searchParams.set("code", config.code);
|
|
1406
|
+
callbackUrl.searchParams.set("state", config.state);
|
|
1407
|
+
const validatedParams = oauth.validateAuthResponse(
|
|
1408
|
+
config.authServer.server,
|
|
1409
|
+
client,
|
|
1410
|
+
callbackUrl,
|
|
1411
|
+
config.state
|
|
1412
|
+
);
|
|
1413
|
+
const response = await oauth.authorizationCodeGrantRequest(
|
|
1414
|
+
config.authServer.server,
|
|
1415
|
+
client,
|
|
1416
|
+
clientAuth,
|
|
1417
|
+
validatedParams,
|
|
1418
|
+
config.redirectUri,
|
|
1419
|
+
config.codeVerifier
|
|
1420
|
+
);
|
|
1421
|
+
const result = await oauth.processAuthorizationCodeResponse(
|
|
1422
|
+
config.authServer.server,
|
|
1423
|
+
client,
|
|
1424
|
+
response
|
|
1425
|
+
);
|
|
1426
|
+
return {
|
|
1427
|
+
accessToken: result.access_token,
|
|
1428
|
+
tokenType: result.token_type,
|
|
1429
|
+
expiresIn: result.expires_in,
|
|
1430
|
+
refreshToken: result.refresh_token,
|
|
1431
|
+
scope: result.scope
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
async function refreshAccessToken(config) {
|
|
1435
|
+
const client = {
|
|
1436
|
+
client_id: config.clientId,
|
|
1437
|
+
token_endpoint_auth_method: config.clientSecret ? "client_secret_basic" : "none"
|
|
1438
|
+
};
|
|
1439
|
+
const clientAuth = config.clientSecret ? oauth.ClientSecretBasic(config.clientSecret) : oauth.None();
|
|
1440
|
+
const response = await oauth.refreshTokenGrantRequest(
|
|
1441
|
+
config.authServer.server,
|
|
1442
|
+
client,
|
|
1443
|
+
clientAuth,
|
|
1444
|
+
config.refreshToken
|
|
1445
|
+
);
|
|
1446
|
+
if (!response.ok) {
|
|
1447
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
1448
|
+
let errorMessage = `Token refresh failed: ${response.status} ${response.statusText}`;
|
|
1449
|
+
try {
|
|
1450
|
+
if (contentType.includes("application/json")) {
|
|
1451
|
+
const errorBody = await response.clone().json();
|
|
1452
|
+
if (errorBody.error) {
|
|
1453
|
+
errorMessage = `Token refresh failed: ${errorBody.error}`;
|
|
1454
|
+
if (errorBody.error_description) {
|
|
1455
|
+
errorMessage += ` - ${errorBody.error_description}`;
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
} else {
|
|
1459
|
+
const textBody = await response.clone().text();
|
|
1460
|
+
if (textBody) {
|
|
1461
|
+
errorMessage = `Token refresh failed: ${response.status} - ${textBody}`;
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
} catch {
|
|
1465
|
+
}
|
|
1466
|
+
throw new Error(errorMessage);
|
|
1467
|
+
}
|
|
1468
|
+
const result = await oauth.processRefreshTokenResponse(
|
|
1469
|
+
config.authServer.server,
|
|
1470
|
+
client,
|
|
1471
|
+
response
|
|
1472
|
+
);
|
|
1473
|
+
return {
|
|
1474
|
+
accessToken: result.access_token,
|
|
1475
|
+
tokenType: result.token_type,
|
|
1476
|
+
expiresIn: result.expires_in,
|
|
1477
|
+
refreshToken: result.refresh_token,
|
|
1478
|
+
scope: result.scope
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
var MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
1482
|
+
async function discoverProtectedResource(mcpServerUrl) {
|
|
1483
|
+
const url = new URL(mcpServerUrl);
|
|
1484
|
+
const origin = url.origin;
|
|
1485
|
+
const pathname = url.pathname;
|
|
1486
|
+
const pathAwareUrl = `${origin}/.well-known/oauth-protected-resource${pathname}`;
|
|
1487
|
+
try {
|
|
1488
|
+
const metadata = await fetchProtectedResourceMetadata(pathAwareUrl);
|
|
1489
|
+
return {
|
|
1490
|
+
metadata,
|
|
1491
|
+
discoveryUrl: pathAwareUrl,
|
|
1492
|
+
usedPathAwareDiscovery: true
|
|
1493
|
+
};
|
|
1494
|
+
} catch (error) {
|
|
1495
|
+
if (error instanceof DiscoveryError && error.status === 404) {
|
|
1496
|
+
const baseUrl = `${origin}/.well-known/oauth-protected-resource`;
|
|
1497
|
+
const metadata = await fetchProtectedResourceMetadata(baseUrl);
|
|
1498
|
+
return {
|
|
1499
|
+
metadata,
|
|
1500
|
+
discoveryUrl: baseUrl,
|
|
1501
|
+
usedPathAwareDiscovery: false
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
throw error;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
var DiscoveryError = class extends Error {
|
|
1508
|
+
constructor(message, status, url) {
|
|
1509
|
+
super(message);
|
|
1510
|
+
this.status = status;
|
|
1511
|
+
this.url = url;
|
|
1512
|
+
this.name = "DiscoveryError";
|
|
1513
|
+
}
|
|
1514
|
+
};
|
|
1515
|
+
async function fetchProtectedResourceMetadata(discoveryUrl) {
|
|
1516
|
+
const response = await fetch(discoveryUrl, {
|
|
1517
|
+
method: "GET",
|
|
1518
|
+
headers: {
|
|
1519
|
+
Accept: "application/json",
|
|
1520
|
+
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION
|
|
1521
|
+
}
|
|
1522
|
+
});
|
|
1523
|
+
if (!response.ok) {
|
|
1524
|
+
throw new DiscoveryError(
|
|
1525
|
+
`Protected resource discovery failed: ${response.status} ${response.statusText}`,
|
|
1526
|
+
response.status,
|
|
1527
|
+
discoveryUrl
|
|
1528
|
+
);
|
|
1529
|
+
}
|
|
1530
|
+
const metadata = await response.json();
|
|
1531
|
+
if (!metadata.resource) {
|
|
1532
|
+
throw new DiscoveryError(
|
|
1533
|
+
'Invalid protected resource metadata: missing required "resource" field',
|
|
1534
|
+
void 0,
|
|
1535
|
+
discoveryUrl
|
|
1536
|
+
);
|
|
1537
|
+
}
|
|
1538
|
+
return metadata;
|
|
1539
|
+
}
|
|
1540
|
+
async function discoverAuthorizationServer(authServerUrl) {
|
|
1541
|
+
const issuer = new URL(authServerUrl);
|
|
1542
|
+
const response = await oauth.discoveryRequest(issuer, {
|
|
1543
|
+
algorithm: "oauth2",
|
|
1544
|
+
headers: new Headers({
|
|
1545
|
+
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION
|
|
1546
|
+
})
|
|
1547
|
+
});
|
|
1548
|
+
const metadata = await oauth.processDiscoveryResponse(issuer, response);
|
|
1549
|
+
return {
|
|
1550
|
+
server: metadata,
|
|
1551
|
+
issuer: authServerUrl
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
var ENV_VAR_NAMES = {
|
|
1555
|
+
accessToken: "MCP_ACCESS_TOKEN",
|
|
1556
|
+
refreshToken: "MCP_REFRESH_TOKEN",
|
|
1557
|
+
tokenType: "MCP_TOKEN_TYPE",
|
|
1558
|
+
expiresAt: "MCP_TOKEN_EXPIRES_AT"
|
|
1559
|
+
};
|
|
1560
|
+
var DEFAULT_EXPIRY_BUFFER_MS = 6e4;
|
|
1561
|
+
function generateServerKey(serverUrl) {
|
|
1562
|
+
const url = new URL(serverUrl);
|
|
1563
|
+
let key = url.hostname;
|
|
1564
|
+
if (url.port) {
|
|
1565
|
+
key += `_${url.port}`;
|
|
1566
|
+
}
|
|
1567
|
+
if (url.pathname && url.pathname !== "/") {
|
|
1568
|
+
const cleanPath = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\//g, "_");
|
|
1569
|
+
if (cleanPath) {
|
|
1570
|
+
key += `_${cleanPath}`;
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
return key.replace(/[^a-zA-Z0-9_.-]/g, "_");
|
|
1574
|
+
}
|
|
1575
|
+
function getStateDir(serverUrl, customDir) {
|
|
1576
|
+
const serverKey = generateServerKey(serverUrl);
|
|
1577
|
+
if (customDir) {
|
|
1578
|
+
return path2.join(customDir, serverKey);
|
|
1579
|
+
}
|
|
1580
|
+
if (process.platform === "win32") {
|
|
1581
|
+
const localAppData = process.env.LOCALAPPDATA;
|
|
1582
|
+
if (localAppData) {
|
|
1583
|
+
return path2.join(localAppData, "mcp-tests", serverKey);
|
|
1584
|
+
}
|
|
1585
|
+
return path2.join(homedir(), "AppData", "Local", "mcp-tests", serverKey);
|
|
1586
|
+
}
|
|
1587
|
+
if (process.platform === "linux" && process.env.XDG_STATE_HOME) {
|
|
1588
|
+
return path2.join(process.env.XDG_STATE_HOME, "mcp-tests", serverKey);
|
|
1589
|
+
}
|
|
1590
|
+
return path2.join(homedir(), ".local", "state", "mcp-tests", serverKey);
|
|
1591
|
+
}
|
|
1592
|
+
function loadTokensFromEnv() {
|
|
1593
|
+
const accessToken = process.env[ENV_VAR_NAMES.accessToken];
|
|
1594
|
+
if (!accessToken) {
|
|
1595
|
+
return null;
|
|
1596
|
+
}
|
|
1597
|
+
const expiresAtStr = process.env[ENV_VAR_NAMES.expiresAt];
|
|
1598
|
+
const expiresAt = expiresAtStr ? parseInt(expiresAtStr, 10) : void 0;
|
|
1599
|
+
return {
|
|
1600
|
+
accessToken,
|
|
1601
|
+
refreshToken: process.env[ENV_VAR_NAMES.refreshToken],
|
|
1602
|
+
tokenType: process.env[ENV_VAR_NAMES.tokenType] ?? "Bearer",
|
|
1603
|
+
expiresAt: expiresAt && !isNaN(expiresAt) ? expiresAt : void 0
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
function createFileOAuthStorage(config) {
|
|
1607
|
+
return new FileOAuthStorage(config);
|
|
1608
|
+
}
|
|
1609
|
+
var FileOAuthStorage = class {
|
|
1610
|
+
stateDir;
|
|
1611
|
+
constructor(config) {
|
|
1612
|
+
this.stateDir = getStateDir(config.serverUrl, config.stateDir);
|
|
1613
|
+
}
|
|
1614
|
+
get serverMetadataPath() {
|
|
1615
|
+
return path2.join(this.stateDir, "server.json");
|
|
1616
|
+
}
|
|
1617
|
+
get clientPath() {
|
|
1618
|
+
return path2.join(this.stateDir, "client.json");
|
|
1619
|
+
}
|
|
1620
|
+
get tokensPath() {
|
|
1621
|
+
return path2.join(this.stateDir, "tokens.json");
|
|
1622
|
+
}
|
|
1623
|
+
async loadServerMetadata() {
|
|
1624
|
+
return this.loadFile(this.serverMetadataPath);
|
|
1625
|
+
}
|
|
1626
|
+
async saveServerMetadata(metadata) {
|
|
1627
|
+
await this.atomicWrite(this.serverMetadataPath, metadata);
|
|
1628
|
+
}
|
|
1629
|
+
async loadClient() {
|
|
1630
|
+
return this.loadFile(this.clientPath);
|
|
1631
|
+
}
|
|
1632
|
+
async saveClient(client) {
|
|
1633
|
+
await this.atomicWrite(this.clientPath, client);
|
|
1634
|
+
}
|
|
1635
|
+
async loadTokens() {
|
|
1636
|
+
return this.loadFile(this.tokensPath);
|
|
1637
|
+
}
|
|
1638
|
+
async saveTokens(tokens) {
|
|
1639
|
+
await this.atomicWrite(this.tokensPath, tokens);
|
|
1640
|
+
}
|
|
1641
|
+
async deleteTokens() {
|
|
1642
|
+
await this.deleteFile(this.tokensPath);
|
|
1643
|
+
}
|
|
1644
|
+
async hasValidToken(bufferMs = DEFAULT_EXPIRY_BUFFER_MS) {
|
|
1645
|
+
const tokens = await this.loadTokens();
|
|
1646
|
+
if (!tokens?.accessToken) {
|
|
1647
|
+
return false;
|
|
1648
|
+
}
|
|
1649
|
+
if (!tokens.expiresAt) {
|
|
1650
|
+
return true;
|
|
1651
|
+
}
|
|
1652
|
+
return tokens.expiresAt > Date.now() + bufferMs;
|
|
1653
|
+
}
|
|
1654
|
+
/**
|
|
1655
|
+
* Load a JSON file, returning null if not found
|
|
1656
|
+
*/
|
|
1657
|
+
async loadFile(filePath) {
|
|
1658
|
+
try {
|
|
1659
|
+
const content = await fs2.readFile(filePath, "utf-8");
|
|
1660
|
+
return JSON.parse(content);
|
|
1661
|
+
} catch (error) {
|
|
1662
|
+
if (error.code === "ENOENT") {
|
|
1663
|
+
return null;
|
|
1664
|
+
}
|
|
1665
|
+
throw error;
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
/**
|
|
1669
|
+
* Write data atomically: write to .tmp file, then rename
|
|
1670
|
+
* Files are created with 0o600 permissions (user read/write only)
|
|
1671
|
+
*/
|
|
1672
|
+
async atomicWrite(filePath, data) {
|
|
1673
|
+
await fs2.mkdir(this.stateDir, { recursive: true, mode: 448 });
|
|
1674
|
+
const tmpPath = `${filePath}.tmp`;
|
|
1675
|
+
const content = JSON.stringify(data, null, 2);
|
|
1676
|
+
await fs2.writeFile(tmpPath, content, { encoding: "utf-8", mode: 384 });
|
|
1677
|
+
await fs2.rename(tmpPath, filePath);
|
|
1678
|
+
}
|
|
1679
|
+
/**
|
|
1680
|
+
* Delete a file, ignoring errors if the file doesn't exist
|
|
1681
|
+
*/
|
|
1682
|
+
async deleteFile(filePath) {
|
|
1683
|
+
try {
|
|
1684
|
+
await fs2.unlink(filePath);
|
|
1685
|
+
} catch (error) {
|
|
1686
|
+
if (error.code !== "ENOENT") {
|
|
1687
|
+
throw error;
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
};
|
|
1692
|
+
|
|
1693
|
+
// src/auth/cli.ts
|
|
1694
|
+
var debug = createDebug("mcp-server-tester:cli-oauth");
|
|
1695
|
+
var DEFAULT_TIMEOUT_MS = 3e5;
|
|
1696
|
+
var DEFAULT_CLIENT_NAME = "@gleanwork/mcp-server-tester";
|
|
1697
|
+
var DEFAULT_METADATA_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
1698
|
+
var CLIOAuthClient = class {
|
|
1699
|
+
config;
|
|
1700
|
+
storage;
|
|
1701
|
+
constructor(config) {
|
|
1702
|
+
this.config = config;
|
|
1703
|
+
this.storage = createFileOAuthStorage({
|
|
1704
|
+
serverUrl: config.mcpServerUrl,
|
|
1705
|
+
stateDir: config.stateDir
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
1708
|
+
/**
|
|
1709
|
+
* Get a valid access token, authenticating if necessary
|
|
1710
|
+
*
|
|
1711
|
+
* Token resolution priority:
|
|
1712
|
+
* 1. Check environment variables (for CI/CD)
|
|
1713
|
+
* 2. Check file storage for cached tokens
|
|
1714
|
+
* 3. Try to refresh if expired but refresh token exists
|
|
1715
|
+
* 4. Run full OAuth flow if needed
|
|
1716
|
+
*/
|
|
1717
|
+
async getAccessToken() {
|
|
1718
|
+
const envTokens = loadTokensFromEnv();
|
|
1719
|
+
if (envTokens) {
|
|
1720
|
+
debug("Using tokens from environment variables");
|
|
1721
|
+
return {
|
|
1722
|
+
accessToken: envTokens.accessToken,
|
|
1723
|
+
tokenType: envTokens.tokenType,
|
|
1724
|
+
expiresAt: envTokens.expiresAt,
|
|
1725
|
+
refreshed: false,
|
|
1726
|
+
fromEnv: true
|
|
1727
|
+
};
|
|
1728
|
+
}
|
|
1729
|
+
const storedTokens = await this.storage.loadTokens();
|
|
1730
|
+
if (storedTokens?.accessToken) {
|
|
1731
|
+
const isValid = await this.storage.hasValidToken();
|
|
1732
|
+
if (isValid) {
|
|
1733
|
+
debug("Using cached tokens from storage");
|
|
1734
|
+
return {
|
|
1735
|
+
accessToken: storedTokens.accessToken,
|
|
1736
|
+
tokenType: storedTokens.tokenType,
|
|
1737
|
+
expiresAt: storedTokens.expiresAt,
|
|
1738
|
+
refreshed: false,
|
|
1739
|
+
fromEnv: false
|
|
1740
|
+
};
|
|
1741
|
+
}
|
|
1742
|
+
if (storedTokens.refreshToken) {
|
|
1743
|
+
debug("Token expired, attempting refresh");
|
|
1744
|
+
try {
|
|
1745
|
+
const refreshedTokens = await this.refreshStoredToken(storedTokens);
|
|
1746
|
+
return {
|
|
1747
|
+
accessToken: refreshedTokens.accessToken,
|
|
1748
|
+
tokenType: refreshedTokens.tokenType,
|
|
1749
|
+
expiresAt: refreshedTokens.expiresAt,
|
|
1750
|
+
refreshed: true,
|
|
1751
|
+
fromEnv: false
|
|
1752
|
+
};
|
|
1753
|
+
} catch (error) {
|
|
1754
|
+
debug("Token refresh failed, will re-authenticate:", error);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
debug("Performing full OAuth authentication");
|
|
1759
|
+
return this.authenticate();
|
|
1760
|
+
}
|
|
1761
|
+
/**
|
|
1762
|
+
* Try to get a valid access token without triggering browser auth
|
|
1763
|
+
*
|
|
1764
|
+
* Returns null if no valid token is available (no stored tokens,
|
|
1765
|
+
* expired without refresh token, or refresh failed). Unlike getAccessToken(),
|
|
1766
|
+
* this will NOT open a browser for authentication.
|
|
1767
|
+
*
|
|
1768
|
+
* Use this for CLI commands that should prompt the user to run `login`
|
|
1769
|
+
* instead of automatically starting the OAuth flow.
|
|
1770
|
+
*/
|
|
1771
|
+
async tryGetAccessToken() {
|
|
1772
|
+
const envTokens = loadTokensFromEnv();
|
|
1773
|
+
if (envTokens) {
|
|
1774
|
+
debug("Using tokens from environment variables");
|
|
1775
|
+
return {
|
|
1776
|
+
accessToken: envTokens.accessToken,
|
|
1777
|
+
tokenType: envTokens.tokenType,
|
|
1778
|
+
expiresAt: envTokens.expiresAt,
|
|
1779
|
+
refreshed: false,
|
|
1780
|
+
fromEnv: true
|
|
1781
|
+
};
|
|
1782
|
+
}
|
|
1783
|
+
const storedTokens = await this.storage.loadTokens();
|
|
1784
|
+
if (storedTokens?.accessToken) {
|
|
1785
|
+
const isValid = await this.storage.hasValidToken();
|
|
1786
|
+
if (isValid) {
|
|
1787
|
+
debug("Using cached tokens from storage");
|
|
1788
|
+
return {
|
|
1789
|
+
accessToken: storedTokens.accessToken,
|
|
1790
|
+
tokenType: storedTokens.tokenType,
|
|
1791
|
+
expiresAt: storedTokens.expiresAt,
|
|
1792
|
+
refreshed: false,
|
|
1793
|
+
fromEnv: false
|
|
1794
|
+
};
|
|
1795
|
+
}
|
|
1796
|
+
if (storedTokens.refreshToken) {
|
|
1797
|
+
debug("Token expired, attempting refresh");
|
|
1798
|
+
try {
|
|
1799
|
+
const refreshedTokens = await this.refreshStoredToken(storedTokens);
|
|
1800
|
+
return {
|
|
1801
|
+
accessToken: refreshedTokens.accessToken,
|
|
1802
|
+
tokenType: refreshedTokens.tokenType,
|
|
1803
|
+
expiresAt: refreshedTokens.expiresAt,
|
|
1804
|
+
refreshed: true,
|
|
1805
|
+
fromEnv: false
|
|
1806
|
+
};
|
|
1807
|
+
} catch (error) {
|
|
1808
|
+
debug("Token refresh failed:", error);
|
|
1809
|
+
return null;
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
debug("No valid token available");
|
|
1814
|
+
return null;
|
|
1815
|
+
}
|
|
1816
|
+
/**
|
|
1817
|
+
* Force a new authentication flow
|
|
1818
|
+
*/
|
|
1819
|
+
async authenticate() {
|
|
1820
|
+
const { protectedResource, authServer } = await this.discoverServers();
|
|
1821
|
+
const client = await this.getOrRegisterClient(authServer);
|
|
1822
|
+
const { tokens, requestedScopes } = await this.performOAuthFlow(
|
|
1823
|
+
authServer,
|
|
1824
|
+
client,
|
|
1825
|
+
protectedResource
|
|
1826
|
+
);
|
|
1827
|
+
return {
|
|
1828
|
+
accessToken: tokens.accessToken,
|
|
1829
|
+
tokenType: tokens.tokenType,
|
|
1830
|
+
expiresAt: tokens.expiresAt,
|
|
1831
|
+
refreshed: false,
|
|
1832
|
+
fromEnv: false,
|
|
1833
|
+
requestedScopes
|
|
1834
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
/**
|
|
1837
|
+
* Check if stored credentials exist (may be expired)
|
|
1838
|
+
*/
|
|
1839
|
+
async hasStoredCredentials() {
|
|
1840
|
+
const tokens = await this.storage.loadTokens();
|
|
1841
|
+
return tokens?.accessToken !== void 0;
|
|
1842
|
+
}
|
|
1843
|
+
/**
|
|
1844
|
+
* Clear stored credentials
|
|
1845
|
+
*/
|
|
1846
|
+
async clearCredentials() {
|
|
1847
|
+
await this.storage.deleteTokens();
|
|
1848
|
+
debug("Cleared stored credentials");
|
|
1849
|
+
}
|
|
1850
|
+
/**
|
|
1851
|
+
* Discover protected resource and authorization server
|
|
1852
|
+
*/
|
|
1853
|
+
async discoverServers() {
|
|
1854
|
+
const cachedMetadata = await this.storage.loadServerMetadata();
|
|
1855
|
+
if (cachedMetadata) {
|
|
1856
|
+
const age = Date.now() - cachedMetadata.discoveredAt;
|
|
1857
|
+
if (age < DEFAULT_METADATA_TTL_MS) {
|
|
1858
|
+
debug("Using cached server metadata (age: %dms)", age);
|
|
1859
|
+
debug(
|
|
1860
|
+
"Cached protected resource scopes: %O",
|
|
1861
|
+
cachedMetadata.protectedResource.scopes_supported
|
|
1862
|
+
);
|
|
1863
|
+
debug(
|
|
1864
|
+
"Cached auth server scopes: %O",
|
|
1865
|
+
cachedMetadata.authServer.server.scopes_supported
|
|
1866
|
+
);
|
|
1867
|
+
return {
|
|
1868
|
+
protectedResource: cachedMetadata.protectedResource,
|
|
1869
|
+
authServer: cachedMetadata.authServer
|
|
1870
|
+
};
|
|
1871
|
+
}
|
|
1872
|
+
debug("Cached server metadata is stale (age: %dms), re-discovering", age);
|
|
1873
|
+
}
|
|
1874
|
+
debug("Discovering protected resource:", this.config.mcpServerUrl);
|
|
1875
|
+
const prResult = await discoverProtectedResource(this.config.mcpServerUrl);
|
|
1876
|
+
debug("Found protected resource:", prResult.metadata.resource);
|
|
1877
|
+
debug(
|
|
1878
|
+
"Protected resource scopes_supported: %O",
|
|
1879
|
+
prResult.metadata.scopes_supported
|
|
1880
|
+
);
|
|
1881
|
+
const authServerUrl = prResult.metadata.authorization_servers?.[0];
|
|
1882
|
+
if (!authServerUrl) {
|
|
1883
|
+
throw new Error(
|
|
1884
|
+
"No authorization servers found in protected resource metadata"
|
|
1885
|
+
);
|
|
1886
|
+
}
|
|
1887
|
+
debug("Discovering authorization server:", authServerUrl);
|
|
1888
|
+
const authServer = await discoverAuthorizationServer(authServerUrl);
|
|
1889
|
+
debug("Found authorization server:", authServer.issuer);
|
|
1890
|
+
debug(
|
|
1891
|
+
"Auth server scopes_supported: %O",
|
|
1892
|
+
authServer.server.scopes_supported
|
|
1893
|
+
);
|
|
1894
|
+
const metadata = {
|
|
1895
|
+
authServer,
|
|
1896
|
+
protectedResource: prResult.metadata,
|
|
1897
|
+
discoveredAt: Date.now()
|
|
1898
|
+
};
|
|
1899
|
+
await this.storage.saveServerMetadata(metadata);
|
|
1900
|
+
return {
|
|
1901
|
+
protectedResource: prResult.metadata,
|
|
1902
|
+
authServer
|
|
1903
|
+
};
|
|
1904
|
+
}
|
|
1905
|
+
/**
|
|
1906
|
+
* Get existing client or register new one via DCR
|
|
1907
|
+
*/
|
|
1908
|
+
async getOrRegisterClient(authServer) {
|
|
1909
|
+
if (this.config.clientId) {
|
|
1910
|
+
debug("Using pre-configured client ID");
|
|
1911
|
+
return {
|
|
1912
|
+
clientId: this.config.clientId,
|
|
1913
|
+
clientSecret: this.config.clientSecret
|
|
1914
|
+
};
|
|
1915
|
+
}
|
|
1916
|
+
const cachedClient = await this.storage.loadClient();
|
|
1917
|
+
if (cachedClient?.clientId) {
|
|
1918
|
+
debug("Using cached client registration");
|
|
1919
|
+
return cachedClient;
|
|
1920
|
+
}
|
|
1921
|
+
debug("Registering new client via DCR");
|
|
1922
|
+
const client = await this.registerClient(authServer);
|
|
1923
|
+
await this.storage.saveClient(client);
|
|
1924
|
+
return client;
|
|
1925
|
+
}
|
|
1926
|
+
/**
|
|
1927
|
+
* Register a new client via Dynamic Client Registration
|
|
1928
|
+
*/
|
|
1929
|
+
async registerClient(authServer) {
|
|
1930
|
+
const registrationEndpoint = authServer.server.registration_endpoint;
|
|
1931
|
+
if (!registrationEndpoint) {
|
|
1932
|
+
throw new Error(
|
|
1933
|
+
"Authorization server does not support Dynamic Client Registration. Please provide a clientId in the configuration."
|
|
1934
|
+
);
|
|
1935
|
+
}
|
|
1936
|
+
const redirectUri = "http://127.0.0.1:0/callback";
|
|
1937
|
+
const response = await fetch(registrationEndpoint, {
|
|
1938
|
+
method: "POST",
|
|
1939
|
+
headers: {
|
|
1940
|
+
"Content-Type": "application/json",
|
|
1941
|
+
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION
|
|
1942
|
+
},
|
|
1943
|
+
body: JSON.stringify({
|
|
1944
|
+
redirect_uris: [redirectUri],
|
|
1945
|
+
token_endpoint_auth_method: "none",
|
|
1946
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
1947
|
+
response_types: ["code"],
|
|
1948
|
+
client_name: this.config.clientName ?? DEFAULT_CLIENT_NAME
|
|
1949
|
+
})
|
|
1950
|
+
});
|
|
1951
|
+
if (!response.ok) {
|
|
1952
|
+
const errorText = await response.text();
|
|
1953
|
+
throw new Error(
|
|
1954
|
+
`Dynamic Client Registration failed: ${response.status} ${response.statusText}
|
|
1955
|
+
${errorText}`
|
|
1956
|
+
);
|
|
1957
|
+
}
|
|
1958
|
+
const data = await response.json();
|
|
1959
|
+
debug("Client registered:", data.client_id);
|
|
1960
|
+
return {
|
|
1961
|
+
clientId: data.client_id,
|
|
1962
|
+
clientSecret: data.client_secret,
|
|
1963
|
+
clientIdIssuedAt: data.client_id_issued_at,
|
|
1964
|
+
clientSecretExpiresAt: data.client_secret_expires_at
|
|
1965
|
+
};
|
|
1966
|
+
}
|
|
1967
|
+
/**
|
|
1968
|
+
* Perform the full OAuth authorization flow
|
|
1969
|
+
*/
|
|
1970
|
+
async performOAuthFlow(authServer, client, protectedResource) {
|
|
1971
|
+
const pkce = await generatePKCE();
|
|
1972
|
+
const state = generateState();
|
|
1973
|
+
const { port, codePromise, close } = await this.startCallbackServer(state);
|
|
1974
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
1975
|
+
try {
|
|
1976
|
+
const requestedScopes = this.config.scopes ?? protectedResource.scopes_supported ?? authServer.server.scopes_supported ?? ["openid"];
|
|
1977
|
+
debug("Scope resolution:");
|
|
1978
|
+
debug(" - User config scopes: %O", this.config.scopes);
|
|
1979
|
+
debug(
|
|
1980
|
+
" - Protected resource scopes_supported: %O",
|
|
1981
|
+
protectedResource.scopes_supported
|
|
1982
|
+
);
|
|
1983
|
+
debug(
|
|
1984
|
+
" - Auth server scopes_supported: %O",
|
|
1985
|
+
authServer.server.scopes_supported
|
|
1986
|
+
);
|
|
1987
|
+
debug(" - Final requested scopes: %O", requestedScopes);
|
|
1988
|
+
const authUrl = buildAuthorizationUrl({
|
|
1989
|
+
authServer,
|
|
1990
|
+
clientId: client.clientId,
|
|
1991
|
+
redirectUri,
|
|
1992
|
+
scopes: requestedScopes,
|
|
1993
|
+
codeChallenge: pkce.codeChallenge,
|
|
1994
|
+
state,
|
|
1995
|
+
resource: protectedResource.resource
|
|
1996
|
+
});
|
|
1997
|
+
debug("Authorization URL: %s", authUrl.toString());
|
|
1998
|
+
debug("Authorization URL params:");
|
|
1999
|
+
debug(" - client_id: %s", authUrl.searchParams.get("client_id"));
|
|
2000
|
+
debug(" - redirect_uri: %s", authUrl.searchParams.get("redirect_uri"));
|
|
2001
|
+
debug(" - scope: %s", authUrl.searchParams.get("scope"));
|
|
2002
|
+
debug(" - resource: %s", authUrl.searchParams.get("resource"));
|
|
2003
|
+
await this.openBrowserOrPrintUrl(authUrl);
|
|
2004
|
+
debug("Waiting for OAuth callback...");
|
|
2005
|
+
const code = await codePromise;
|
|
2006
|
+
debug("Received authorization code");
|
|
2007
|
+
const tokenResult = await exchangeCodeForTokens({
|
|
2008
|
+
authServer,
|
|
2009
|
+
clientId: client.clientId,
|
|
2010
|
+
clientSecret: client.clientSecret,
|
|
2011
|
+
code,
|
|
2012
|
+
state,
|
|
2013
|
+
codeVerifier: pkce.codeVerifier,
|
|
2014
|
+
redirectUri
|
|
2015
|
+
});
|
|
2016
|
+
const tokens = this.tokenResultToStoredTokens(
|
|
2017
|
+
tokenResult,
|
|
2018
|
+
client.clientId
|
|
2019
|
+
);
|
|
2020
|
+
await this.storage.saveTokens(tokens);
|
|
2021
|
+
return { tokens, requestedScopes };
|
|
2022
|
+
} finally {
|
|
2023
|
+
close();
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
/**
|
|
2027
|
+
* Refresh an expired token
|
|
2028
|
+
*
|
|
2029
|
+
* Uses the clientId stored with the tokens (if available) to ensure
|
|
2030
|
+
* the refresh request uses the same client that obtained the original tokens.
|
|
2031
|
+
* This is important because refresh tokens are bound to the client_id.
|
|
2032
|
+
*/
|
|
2033
|
+
async refreshStoredToken(storedTokens) {
|
|
2034
|
+
if (!storedTokens.refreshToken) {
|
|
2035
|
+
throw new Error("No refresh token available");
|
|
2036
|
+
}
|
|
2037
|
+
const metadata = await this.storage.loadServerMetadata();
|
|
2038
|
+
if (!metadata) {
|
|
2039
|
+
throw new Error("No cached server metadata for refresh");
|
|
2040
|
+
}
|
|
2041
|
+
let clientId;
|
|
2042
|
+
let clientSecret;
|
|
2043
|
+
if (storedTokens.clientId) {
|
|
2044
|
+
debug("Using clientId from stored tokens for refresh");
|
|
2045
|
+
clientId = storedTokens.clientId;
|
|
2046
|
+
const storedClient = await this.storage.loadClient();
|
|
2047
|
+
if (storedClient?.clientId === clientId) {
|
|
2048
|
+
clientSecret = storedClient.clientSecret;
|
|
2049
|
+
}
|
|
2050
|
+
} else {
|
|
2051
|
+
debug(
|
|
2052
|
+
"No clientId in stored tokens, falling back to stored client (legacy behavior)"
|
|
2053
|
+
);
|
|
2054
|
+
const client = await this.getOrRegisterClient(metadata.authServer);
|
|
2055
|
+
clientId = client.clientId;
|
|
2056
|
+
clientSecret = client.clientSecret;
|
|
2057
|
+
}
|
|
2058
|
+
const tokenResult = await refreshAccessToken({
|
|
2059
|
+
authServer: metadata.authServer,
|
|
2060
|
+
clientId,
|
|
2061
|
+
clientSecret,
|
|
2062
|
+
refreshToken: storedTokens.refreshToken
|
|
2063
|
+
});
|
|
2064
|
+
const tokens = this.tokenResultToStoredTokens(tokenResult, clientId);
|
|
2065
|
+
await this.storage.saveTokens(tokens);
|
|
2066
|
+
return tokens;
|
|
2067
|
+
}
|
|
2068
|
+
/**
|
|
2069
|
+
* Start local callback server
|
|
2070
|
+
*/
|
|
2071
|
+
async startCallbackServer(expectedState) {
|
|
2072
|
+
const timeoutMs = this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
2073
|
+
return new Promise((resolve, reject) => {
|
|
2074
|
+
const server = http.createServer();
|
|
2075
|
+
const connections = /* @__PURE__ */ new Set();
|
|
2076
|
+
server.on("connection", (socket) => {
|
|
2077
|
+
connections.add(socket);
|
|
2078
|
+
socket.on("close", () => connections.delete(socket));
|
|
2079
|
+
});
|
|
2080
|
+
const forceClose = () => {
|
|
2081
|
+
for (const socket of connections) {
|
|
2082
|
+
socket.destroy();
|
|
2083
|
+
}
|
|
2084
|
+
server.close();
|
|
2085
|
+
};
|
|
2086
|
+
let codeResolve;
|
|
2087
|
+
let codeReject;
|
|
2088
|
+
const codePromise = new Promise((res, rej) => {
|
|
2089
|
+
codeResolve = res;
|
|
2090
|
+
codeReject = rej;
|
|
2091
|
+
});
|
|
2092
|
+
const timeout = setTimeout(() => {
|
|
2093
|
+
forceClose();
|
|
2094
|
+
codeReject(new Error(`OAuth flow timed out after ${timeoutMs}ms`));
|
|
2095
|
+
}, timeoutMs);
|
|
2096
|
+
server.on("request", (req, res) => {
|
|
2097
|
+
const url = new URL(
|
|
2098
|
+
req.url ?? "/",
|
|
2099
|
+
`http://127.0.0.1:${server.address().port}`
|
|
2100
|
+
);
|
|
2101
|
+
if (url.pathname !== "/callback") {
|
|
2102
|
+
res.writeHead(404);
|
|
2103
|
+
res.end("Not Found");
|
|
2104
|
+
return;
|
|
2105
|
+
}
|
|
2106
|
+
const error = url.searchParams.get("error");
|
|
2107
|
+
if (error) {
|
|
2108
|
+
const errorDescription = url.searchParams.get("error_description");
|
|
2109
|
+
clearTimeout(timeout);
|
|
2110
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
2111
|
+
res.end(this.errorHtml(error, errorDescription ?? void 0));
|
|
2112
|
+
codeReject(
|
|
2113
|
+
new Error(
|
|
2114
|
+
`OAuth error: ${error}${errorDescription ? ` - ${errorDescription}` : ""}`
|
|
2115
|
+
)
|
|
2116
|
+
);
|
|
2117
|
+
return;
|
|
2118
|
+
}
|
|
2119
|
+
const state = url.searchParams.get("state");
|
|
2120
|
+
if (state !== expectedState) {
|
|
2121
|
+
clearTimeout(timeout);
|
|
2122
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
2123
|
+
res.end(this.errorHtml("invalid_state", "State parameter mismatch"));
|
|
2124
|
+
codeReject(new Error("OAuth state mismatch - possible CSRF attack"));
|
|
2125
|
+
return;
|
|
2126
|
+
}
|
|
2127
|
+
const code = url.searchParams.get("code");
|
|
2128
|
+
if (!code) {
|
|
2129
|
+
clearTimeout(timeout);
|
|
2130
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
2131
|
+
res.end(
|
|
2132
|
+
this.errorHtml("missing_code", "No authorization code received")
|
|
2133
|
+
);
|
|
2134
|
+
codeReject(new Error("No authorization code in callback"));
|
|
2135
|
+
return;
|
|
2136
|
+
}
|
|
2137
|
+
clearTimeout(timeout);
|
|
2138
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
2139
|
+
res.end(this.successHtml());
|
|
2140
|
+
codeResolve(code);
|
|
2141
|
+
});
|
|
2142
|
+
const preferredPort = this.config.callbackPort ?? 0;
|
|
2143
|
+
server.listen(preferredPort, "127.0.0.1", () => {
|
|
2144
|
+
const address = server.address();
|
|
2145
|
+
debug("Callback server listening on port", address.port);
|
|
2146
|
+
resolve({ port: address.port, codePromise, close: forceClose });
|
|
2147
|
+
});
|
|
2148
|
+
server.on("error", (err) => {
|
|
2149
|
+
reject(err);
|
|
2150
|
+
});
|
|
2151
|
+
});
|
|
2152
|
+
}
|
|
2153
|
+
/**
|
|
2154
|
+
* Open browser or print URL for headless environments
|
|
2155
|
+
*/
|
|
2156
|
+
async openBrowserOrPrintUrl(url) {
|
|
2157
|
+
if (isHeadless()) {
|
|
2158
|
+
console.log("\n" + "=".repeat(60));
|
|
2159
|
+
console.log(
|
|
2160
|
+
"Please open the following URL in your browser to authenticate:"
|
|
2161
|
+
);
|
|
2162
|
+
console.log("\n" + url.toString() + "\n");
|
|
2163
|
+
console.log("=".repeat(60) + "\n");
|
|
2164
|
+
return;
|
|
2165
|
+
}
|
|
2166
|
+
try {
|
|
2167
|
+
const open = await import('open');
|
|
2168
|
+
await open.default(url.toString());
|
|
2169
|
+
debug("Opened browser for authentication");
|
|
2170
|
+
} catch (error) {
|
|
2171
|
+
debug("Failed to open browser:", error);
|
|
2172
|
+
console.log("\nFailed to open browser automatically.");
|
|
2173
|
+
console.log("Please open the following URL manually:\n");
|
|
2174
|
+
console.log(url.toString() + "\n");
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
/**
|
|
2178
|
+
* Convert TokenResult to StoredTokens
|
|
2179
|
+
*
|
|
2180
|
+
* @param result - Token result from exchange or refresh
|
|
2181
|
+
* @param clientId - Client ID that was used to obtain these tokens
|
|
2182
|
+
*/
|
|
2183
|
+
tokenResultToStoredTokens(result, clientId) {
|
|
2184
|
+
return {
|
|
2185
|
+
accessToken: result.accessToken,
|
|
2186
|
+
tokenType: result.tokenType,
|
|
2187
|
+
refreshToken: result.refreshToken,
|
|
2188
|
+
expiresAt: result.expiresIn ? Date.now() + result.expiresIn * 1e3 : void 0,
|
|
2189
|
+
clientId
|
|
2190
|
+
};
|
|
2191
|
+
}
|
|
2192
|
+
/**
|
|
2193
|
+
* HTML page for successful authentication
|
|
2194
|
+
*/
|
|
2195
|
+
successHtml() {
|
|
2196
|
+
return `
|
|
2197
|
+
<!DOCTYPE html>
|
|
2198
|
+
<html>
|
|
2199
|
+
<head>
|
|
2200
|
+
<meta charset="UTF-8">
|
|
2201
|
+
<title>Authentication Successful</title>
|
|
2202
|
+
<style>
|
|
2203
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
2204
|
+
display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;
|
|
2205
|
+
background: #f8fafc; }
|
|
2206
|
+
.container { text-align: center; background: white; padding: 48px 64px; border-radius: 8px;
|
|
2207
|
+
border: 1px solid #e2e8f0; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
|
2208
|
+
.icon { width: 48px; height: 48px; margin: 0 auto 24px; background: #dcfce7; border-radius: 50%;
|
|
2209
|
+
display: flex; align-items: center; justify-content: center; }
|
|
2210
|
+
.icon svg { width: 24px; height: 24px; color: #16a34a; }
|
|
2211
|
+
h1 { color: #0f172a; margin: 0 0 8px 0; font-size: 20px; font-weight: 600; }
|
|
2212
|
+
p { color: #64748b; margin: 0; font-size: 14px; }
|
|
2213
|
+
</style>
|
|
2214
|
+
</head>
|
|
2215
|
+
<body>
|
|
2216
|
+
<div class="container">
|
|
2217
|
+
<div class="icon">
|
|
2218
|
+
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
2219
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
|
2220
|
+
</svg>
|
|
2221
|
+
</div>
|
|
2222
|
+
<h1>Authentication Successful</h1>
|
|
2223
|
+
<p>You can close this window and return to the terminal.</p>
|
|
2224
|
+
</div>
|
|
2225
|
+
</body>
|
|
2226
|
+
</html>`;
|
|
2227
|
+
}
|
|
2228
|
+
/**
|
|
2229
|
+
* HTML page for authentication error
|
|
2230
|
+
*/
|
|
2231
|
+
errorHtml(error, description) {
|
|
2232
|
+
return `
|
|
2233
|
+
<!DOCTYPE html>
|
|
2234
|
+
<html>
|
|
2235
|
+
<head>
|
|
2236
|
+
<meta charset="UTF-8">
|
|
2237
|
+
<title>Authentication Failed</title>
|
|
2238
|
+
<style>
|
|
2239
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
2240
|
+
display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;
|
|
2241
|
+
background: #f8fafc; }
|
|
2242
|
+
.container { text-align: center; background: white; padding: 48px 64px; border-radius: 8px;
|
|
2243
|
+
border: 1px solid #e2e8f0; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
|
2244
|
+
.icon { width: 48px; height: 48px; margin: 0 auto 24px; background: #fee2e2; border-radius: 50%;
|
|
2245
|
+
display: flex; align-items: center; justify-content: center; }
|
|
2246
|
+
.icon svg { width: 24px; height: 24px; color: #dc2626; }
|
|
2247
|
+
h1 { color: #0f172a; margin: 0 0 8px 0; font-size: 20px; font-weight: 600; }
|
|
2248
|
+
p { color: #64748b; margin: 0 0 8px 0; font-size: 14px; }
|
|
2249
|
+
code { background: #f1f5f9; padding: 2px 8px; border-radius: 4px; color: #dc2626; font-size: 13px; }
|
|
2250
|
+
</style>
|
|
2251
|
+
</head>
|
|
2252
|
+
<body>
|
|
2253
|
+
<div class="container">
|
|
2254
|
+
<div class="icon">
|
|
2255
|
+
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
2256
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
2257
|
+
</svg>
|
|
2258
|
+
</div>
|
|
2259
|
+
<h1>Authentication Failed</h1>
|
|
2260
|
+
<p>Error: <code>${escapeHtml(error)}</code></p>
|
|
2261
|
+
${description ? `<p>${escapeHtml(description)}</p>` : ""}
|
|
2262
|
+
</div>
|
|
2263
|
+
</body>
|
|
2264
|
+
</html>`;
|
|
2265
|
+
}
|
|
2266
|
+
};
|
|
2267
|
+
function isHeadless() {
|
|
2268
|
+
if (process.env.CI) {
|
|
2269
|
+
return true;
|
|
2270
|
+
}
|
|
2271
|
+
if (!process.stdin.isTTY) {
|
|
2272
|
+
return true;
|
|
2273
|
+
}
|
|
2274
|
+
if (process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
|
|
2275
|
+
return true;
|
|
2276
|
+
}
|
|
2277
|
+
return false;
|
|
2278
|
+
}
|
|
2279
|
+
function escapeHtml(text) {
|
|
2280
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
// src/fixtures/mcp.ts
|
|
2284
|
+
var test = test$1.extend({
|
|
2285
|
+
/**
|
|
2286
|
+
* Internal fixture state - tracks resolved auth type between fixtures
|
|
2287
|
+
*/
|
|
2288
|
+
_mcpFixtureState: [
|
|
2289
|
+
// eslint-disable-next-line no-empty-pattern
|
|
2290
|
+
async ({}, use) => {
|
|
2291
|
+
const state = { resolvedAuthType: "none" };
|
|
2292
|
+
await use(state);
|
|
2293
|
+
},
|
|
2294
|
+
{ scope: "test" }
|
|
2295
|
+
],
|
|
2296
|
+
/**
|
|
2297
|
+
* mcpClient fixture: Creates and connects an MCP client
|
|
2298
|
+
*
|
|
2299
|
+
* The client configuration is read from the project's `use.mcpConfig`
|
|
2300
|
+
* setting in playwright.config.ts
|
|
2301
|
+
*
|
|
2302
|
+
* Authentication resolution order:
|
|
2303
|
+
* 1. Explicit authStatePath → uses PlaywrightOAuthClientProvider
|
|
2304
|
+
* 2. Explicit accessToken → uses static Bearer token
|
|
2305
|
+
* 3. HTTP transport with no auth → tries CLI-stored tokens (from `mcp-server-tester login`)
|
|
2306
|
+
* with automatic token refresh
|
|
2307
|
+
*/
|
|
2308
|
+
mcpClient: async ({ _mcpFixtureState }, use, testInfo) => {
|
|
2309
|
+
const useConfig = testInfo.project.use;
|
|
2310
|
+
const mcpConfig = useConfig.mcpConfig;
|
|
2311
|
+
if (!mcpConfig) {
|
|
2312
|
+
throw new Error(
|
|
2313
|
+
`Missing mcpConfig in project.use for project "${testInfo.project.name}". Please add mcpConfig to your project configuration in playwright.config.ts`
|
|
2314
|
+
);
|
|
2315
|
+
}
|
|
2316
|
+
let resolvedAuthType = "none";
|
|
2317
|
+
let authProvider;
|
|
2318
|
+
if (mcpConfig.auth?.oauth?.authStatePath) {
|
|
2319
|
+
authProvider = new PlaywrightOAuthClientProvider({
|
|
2320
|
+
storagePath: mcpConfig.auth.oauth.authStatePath,
|
|
2321
|
+
redirectUri: mcpConfig.auth.oauth.redirectUri ?? "http://localhost:3000/oauth/callback",
|
|
2322
|
+
clientId: mcpConfig.auth.oauth.clientId,
|
|
2323
|
+
clientSecret: mcpConfig.auth.oauth.clientSecret
|
|
2324
|
+
});
|
|
2325
|
+
resolvedAuthType = "oauth";
|
|
2326
|
+
}
|
|
2327
|
+
let effectiveConfig = mcpConfig;
|
|
2328
|
+
if (mcpConfig.auth?.accessToken) {
|
|
2329
|
+
resolvedAuthType = "api-token";
|
|
2330
|
+
}
|
|
2331
|
+
if (isHttpConfig(mcpConfig) && !mcpConfig.auth?.accessToken && !mcpConfig.auth?.oauth?.authStatePath) {
|
|
2332
|
+
const cliClient = new CLIOAuthClient({
|
|
2333
|
+
mcpServerUrl: mcpConfig.serverUrl
|
|
2334
|
+
});
|
|
2335
|
+
const tokenResult = await cliClient.tryGetAccessToken();
|
|
2336
|
+
if (tokenResult) {
|
|
2337
|
+
effectiveConfig = {
|
|
2338
|
+
...mcpConfig,
|
|
2339
|
+
auth: {
|
|
2340
|
+
...mcpConfig.auth,
|
|
2341
|
+
accessToken: tokenResult.accessToken
|
|
2342
|
+
}
|
|
2343
|
+
};
|
|
2344
|
+
resolvedAuthType = "oauth";
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
_mcpFixtureState.resolvedAuthType = resolvedAuthType;
|
|
2348
|
+
const client = await createMCPClientForConfig(effectiveConfig, {
|
|
2349
|
+
clientInfo: {
|
|
2350
|
+
name: "@gleanwork/mcp-server-tester",
|
|
2351
|
+
version: "0.1.0"
|
|
2352
|
+
},
|
|
2353
|
+
authProvider
|
|
2354
|
+
});
|
|
2355
|
+
try {
|
|
2356
|
+
await use(client);
|
|
2357
|
+
} finally {
|
|
2358
|
+
await closeMCPClient(client);
|
|
2359
|
+
}
|
|
2360
|
+
},
|
|
2361
|
+
/**
|
|
2362
|
+
* mcp fixture: High-level test API built on mcpClient
|
|
2363
|
+
*
|
|
2364
|
+
* Depends on mcpClient fixture
|
|
2365
|
+
* Automatically tracks all MCP operations for the reporter
|
|
2366
|
+
*/
|
|
2367
|
+
mcp: async ({ mcpClient, _mcpFixtureState }, use, testInfo) => {
|
|
2368
|
+
const api = createMCPFixture(mcpClient, testInfo, {
|
|
2369
|
+
authType: _mcpFixtureState.resolvedAuthType,
|
|
2370
|
+
project: testInfo.project.name
|
|
2371
|
+
});
|
|
2372
|
+
await use(api);
|
|
2373
|
+
}
|
|
2374
|
+
});
|
|
2375
|
+
|
|
2376
|
+
export { expect, test };
|
|
2377
|
+
//# sourceMappingURL=mcp.js.map
|
|
2378
|
+
//# sourceMappingURL=mcp.js.map
|