@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.
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
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