@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,2785 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { render, useApp, useInput, Box, Text } from 'ink';
4
+ import React, { useState, useEffect, useCallback, useRef } from 'react';
5
+ import { TextInput, Select, ConfirmInput } from '@inkjs/ui';
6
+ import * as fs from 'fs/promises';
7
+ import { mkdir, writeFile, stat, readFile } from 'fs/promises';
8
+ import * as path from 'path';
9
+ import { resolve, join, dirname } from 'path';
10
+ import { spawn } from 'child_process';
11
+ import InkSpinner from 'ink-spinner';
12
+ import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
13
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
14
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
15
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
16
+ import { z } from 'zod';
17
+ import createDebug from 'debug';
18
+ import { homedir } from 'os';
19
+ import * as http from 'http';
20
+ import * as oauth from 'oauth4webapi';
21
+
22
+ function Spinner({ label }) {
23
+ return /* @__PURE__ */ jsxs(Box, { children: [
24
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: /* @__PURE__ */ jsx(InkSpinner, { type: "dots" }) }),
25
+ /* @__PURE__ */ jsxs(Text, { children: [
26
+ " ",
27
+ label
28
+ ] })
29
+ ] });
30
+ }
31
+ var icons = {
32
+ success: "\u2713",
33
+ // checkmark
34
+ error: "\u2717",
35
+ // x mark
36
+ info: "\u2139",
37
+ // info
38
+ warning: "\u26A0"
39
+ // warning
40
+ };
41
+ var colors = {
42
+ success: "green",
43
+ error: "red",
44
+ info: "cyan",
45
+ warning: "yellow"
46
+ };
47
+ function StatusMessage({ status, children }) {
48
+ return /* @__PURE__ */ jsxs(Text, { color: colors[status], children: [
49
+ icons[status],
50
+ " ",
51
+ children
52
+ ] });
53
+ }
54
+ function JsonPreview({ data, maxLines = 15 }) {
55
+ const formatted = JSON.stringify(data, null, 2);
56
+ const lines = formatted.split("\n");
57
+ const truncated = lines.length > maxLines;
58
+ const displayLines = truncated ? lines.slice(0, maxLines) : lines;
59
+ return /* @__PURE__ */ jsxs(
60
+ Box,
61
+ {
62
+ flexDirection: "column",
63
+ borderStyle: "round",
64
+ borderColor: "gray",
65
+ paddingX: 1,
66
+ children: [
67
+ displayLines.map((line, i) => /* @__PURE__ */ jsx(Text, { dimColor: true, children: line }, i)),
68
+ truncated && /* @__PURE__ */ jsxs(Text, { dimColor: true, italic: true, children: [
69
+ "... (",
70
+ lines.length - maxLines,
71
+ " more lines)"
72
+ ] })
73
+ ]
74
+ }
75
+ );
76
+ }
77
+
78
+ // src/cli/templates/index.ts
79
+ function getPlaywrightConfigTemplate(answers) {
80
+ const mcpConfig = answers.transport === "stdio" ? `{
81
+ transport: 'stdio' as const,
82
+ command: '${answers.serverCommand?.split(" ")[0] || "node"}',
83
+ args: [${answers.serverCommand?.split(" ").slice(1).map((arg) => `'${arg}'`).join(", ") || "'server.js'"}],
84
+ capabilities: {
85
+ roots: { listChanged: true },
86
+ },
87
+ }` : `{
88
+ transport: 'http' as const,
89
+ serverUrl: '${answers.serverUrl || "http://localhost:3000/mcp"}',
90
+ capabilities: {
91
+ roots: { listChanged: true },
92
+ },
93
+ }`;
94
+ return `import { defineConfig } from '@playwright/test';
95
+
96
+ /**
97
+ * Playwright configuration for MCP evaluation tests
98
+ *
99
+ * @see https://playwright.dev/docs/test-configuration
100
+ */
101
+ export default defineConfig({
102
+ testDir: './tests',
103
+ fullyParallel: true,
104
+ forbidOnly: !!process.env.CI,
105
+ retries: process.env.CI ? 2 : 0,
106
+ workers: process.env.CI ? 1 : undefined,
107
+
108
+ // Reporters: HTML and MCP Eval Reporter
109
+ reporter: [
110
+ ['html'],
111
+ ['@gleanwork/mcp-server-tester/reporters/mcpReporter', {
112
+ outputDir: '.mcp-test-results',
113
+ autoOpen: true,
114
+ historyLimit: 10
115
+ }]
116
+ ],
117
+
118
+ use: {
119
+ trace: 'on-first-retry',
120
+ },
121
+
122
+ projects: [
123
+ {
124
+ name: 'mcp-tests',
125
+ testMatch: /.*\\.spec\\.ts/,
126
+ use: {
127
+ // MCP server configuration
128
+ mcpConfig: ${mcpConfig},
129
+ },
130
+ },
131
+ ],
132
+ });
133
+ `;
134
+ }
135
+ function getTestFileTemplate(_answers) {
136
+ return `import { test, expect } from '@gleanwork/mcp-server-tester/fixtures/mcp';
137
+ import {
138
+ runConformanceChecks,
139
+ loadEvalDataset,
140
+ runEvalDataset,
141
+ } from '@gleanwork/mcp-server-tester';
142
+
143
+ test.describe('MCP Server Tests', () => {
144
+ test('should connect to MCP server', async ({ mcp }) => {
145
+ const serverInfo = mcp.getServerInfo();
146
+ expect(serverInfo).toBeTruthy();
147
+ });
148
+
149
+ test('should list available tools', async ({ mcp }) => {
150
+ const tools = await mcp.listTools();
151
+ expect(tools.length).toBeGreaterThan(0);
152
+ });
153
+
154
+ test('should run conformance checks', async ({ mcp }) => {
155
+ const result = await runConformanceChecks(mcp, {
156
+ validateSchemas: true,
157
+ checkServerInfo: true,
158
+ });
159
+
160
+ expect(result.pass).toBe(true);
161
+ });
162
+
163
+ test('should run eval dataset', async ({ mcp }, testInfo) => {
164
+ // Load dataset (the dataset JSON uses the 'expect' block for assertions)
165
+ const dataset = await loadEvalDataset('./data/example-dataset.json');
166
+
167
+ // Run evals - the runner uses validators internally based on 'expect' block
168
+ const result = await runEvalDataset(
169
+ { dataset },
170
+ { mcp, testInfo, expect }
171
+ );
172
+
173
+ expect(result.passed).toBeGreaterThan(0);
174
+ });
175
+ });
176
+ `;
177
+ }
178
+ function getDatasetTemplate(_answers) {
179
+ return `{
180
+ "name": "example-eval-dataset",
181
+ "description": "Example evaluation dataset for MCP server testing",
182
+ "cases": [
183
+ {
184
+ "id": "example-case-1",
185
+ "description": "Example test case - replace with your actual tool",
186
+ "toolName": "your_tool_name",
187
+ "args": {
188
+ "param1": "value1"
189
+ },
190
+ "expect": {
191
+ "containsText": ["expected text"],
192
+ "isError": false
193
+ }
194
+ }
195
+ ],
196
+ "metadata": {
197
+ "version": "1.0",
198
+ "author": "@gleanwork/mcp-server-tester",
199
+ "created": "${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}"
200
+ }
201
+ }
202
+ `;
203
+ }
204
+ function getGitignoreTemplate() {
205
+ return `# Dependencies
206
+ node_modules/
207
+
208
+ # Test results
209
+ test-results/
210
+ playwright-report/
211
+ playwright/.cache/
212
+ .mcp-test-results/
213
+
214
+ # Build output
215
+ dist/
216
+
217
+ # Environment variables
218
+ .env
219
+ .env.local
220
+
221
+ # OS files
222
+ .DS_Store
223
+ Thumbs.db
224
+
225
+ # IDE
226
+ .vscode/
227
+ .idea/
228
+ *.swp
229
+ *.swo
230
+ *~
231
+ `;
232
+ }
233
+ function getPackageJsonTemplate(projectName) {
234
+ return `{
235
+ "name": "${projectName}",
236
+ "version": "1.0.0",
237
+ "description": "MCP server evaluation tests",
238
+ "type": "module",
239
+ "scripts": {
240
+ "test": "playwright test",
241
+ "test:ui": "playwright test --ui",
242
+ "test:headed": "playwright test --headed",
243
+ "report": "playwright show-report"
244
+ },
245
+ "keywords": [
246
+ "mcp",
247
+ "playwright",
248
+ "testing",
249
+ "evals"
250
+ ],
251
+ "dependencies": {
252
+ "@modelcontextprotocol/sdk": "^1.0.4",
253
+ "@playwright/test": "^1.49.0",
254
+ "@gleanwork/mcp-server-tester": "^0.9.0",
255
+ "zod": "^3.24.1"
256
+ },
257
+ "devDependencies": {
258
+ "typescript": "^5.7.2"
259
+ }
260
+ }
261
+ `;
262
+ }
263
+ function getTsconfigTemplate() {
264
+ return `{
265
+ "compilerOptions": {
266
+ "target": "ES2022",
267
+ "module": "ESNext",
268
+ "lib": ["ES2022"],
269
+ "moduleResolution": "node",
270
+ "resolveJsonModule": true,
271
+ "allowJs": true,
272
+ "strict": true,
273
+ "esModuleInterop": true,
274
+ "skipLibCheck": true,
275
+ "forceConsistentCasingInFileNames": true,
276
+ "types": ["node", "@playwright/test"]
277
+ },
278
+ "include": ["tests/**/*"],
279
+ "exclude": ["node_modules"]
280
+ }
281
+ `;
282
+ }
283
+ function InitApp({ options }) {
284
+ const { exit } = useApp();
285
+ const [step, setStep] = useState("projectName");
286
+ const [projectName, setProjectName] = useState(
287
+ options.name || "my-mcp-tests"
288
+ );
289
+ const [transportType, setTransportType] = useState("stdio");
290
+ const [serverCommand, setServerCommand] = useState("node server.js");
291
+ const [serverUrl, setServerUrl] = useState("http://localhost:3000/mcp");
292
+ const [installDeps, setInstallDeps] = useState(true);
293
+ const [targetDir] = useState(resolve(options.dir || "."));
294
+ const [projectPath, setProjectPath] = useState("");
295
+ const [error, setError] = useState(null);
296
+ useEffect(() => {
297
+ setProjectPath(join(targetDir, projectName));
298
+ }, [targetDir, projectName]);
299
+ useInput((input, key) => {
300
+ if (key.ctrl && input === "c") {
301
+ exit();
302
+ }
303
+ });
304
+ useEffect(() => {
305
+ if (step === "done" || step === "error") {
306
+ exit();
307
+ }
308
+ }, [step, exit]);
309
+ const createProject = useCallback(async () => {
310
+ setStep("creating");
311
+ try {
312
+ await mkdir(projectPath, { recursive: true });
313
+ await mkdir(join(projectPath, "tests"), { recursive: true });
314
+ await mkdir(join(projectPath, "data"), { recursive: true });
315
+ const answers = {
316
+ projectName,
317
+ transport: transportType,
318
+ serverCommand: transportType === "stdio" ? serverCommand : void 0,
319
+ serverUrl: transportType === "http" ? serverUrl : void 0,
320
+ installDeps
321
+ };
322
+ const files = [
323
+ {
324
+ path: "playwright.config.ts",
325
+ content: getPlaywrightConfigTemplate(answers)
326
+ },
327
+ {
328
+ path: "tests/mcp.spec.ts",
329
+ content: getTestFileTemplate(answers)
330
+ },
331
+ {
332
+ path: "data/example-dataset.json",
333
+ content: getDatasetTemplate(answers)
334
+ },
335
+ {
336
+ path: ".gitignore",
337
+ content: getGitignoreTemplate()
338
+ },
339
+ {
340
+ path: "package.json",
341
+ content: getPackageJsonTemplate(projectName)
342
+ },
343
+ {
344
+ path: "tsconfig.json",
345
+ content: getTsconfigTemplate()
346
+ }
347
+ ];
348
+ for (const file of files) {
349
+ await writeFile(join(projectPath, file.path), file.content);
350
+ }
351
+ if (installDeps) {
352
+ setStep("installing");
353
+ await installDependencies();
354
+ } else {
355
+ setStep("done");
356
+ }
357
+ } catch (err) {
358
+ setError(err instanceof Error ? err.message : String(err));
359
+ setStep("error");
360
+ }
361
+ }, [
362
+ projectPath,
363
+ projectName,
364
+ transportType,
365
+ serverCommand,
366
+ serverUrl,
367
+ installDeps
368
+ ]);
369
+ const installDependencies = useCallback(async () => {
370
+ return new Promise((resolvePromise, reject) => {
371
+ const npm = spawn("npm", ["install"], {
372
+ cwd: projectPath,
373
+ stdio: "pipe"
374
+ });
375
+ let stderrOutput = "";
376
+ npm.stderr?.on("data", (data) => {
377
+ stderrOutput += data.toString();
378
+ });
379
+ npm.on("close", (code) => {
380
+ if (code === 0) {
381
+ setStep("done");
382
+ resolvePromise();
383
+ } else {
384
+ const errorMsg = stderrOutput.trim() ? `npm install failed: ${stderrOutput.trim().split("\n")[0]}` : `npm install exited with code ${code}`;
385
+ setError(errorMsg);
386
+ setStep("error");
387
+ reject(new Error(errorMsg));
388
+ }
389
+ });
390
+ npm.on("error", (err) => {
391
+ setError(err.message);
392
+ setStep("error");
393
+ reject(err);
394
+ });
395
+ });
396
+ }, [projectPath]);
397
+ const checkAndCreate = useCallback(async () => {
398
+ let dirExists = false;
399
+ try {
400
+ await stat(projectPath);
401
+ dirExists = true;
402
+ } catch {
403
+ }
404
+ if (dirExists) {
405
+ setStep("confirmOverwrite");
406
+ } else {
407
+ await createProject();
408
+ }
409
+ }, [projectPath, createProject]);
410
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", padding: 1, children: [
411
+ step === "projectName" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
412
+ /* @__PURE__ */ jsx(Text, { children: "Project name:" }),
413
+ /* @__PURE__ */ jsx(
414
+ TextInput,
415
+ {
416
+ defaultValue: projectName,
417
+ onSubmit: (value) => {
418
+ if (value.length === 0) return;
419
+ setProjectName(value);
420
+ setStep("transportType");
421
+ }
422
+ }
423
+ )
424
+ ] }),
425
+ step === "transportType" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
426
+ /* @__PURE__ */ jsx(Text, { children: "MCP transport type:" }),
427
+ /* @__PURE__ */ jsx(
428
+ Select,
429
+ {
430
+ options: [
431
+ { label: "stdio (local server process)", value: "stdio" },
432
+ { label: "http (remote server)", value: "http" }
433
+ ],
434
+ onChange: (value) => {
435
+ setTransportType(value);
436
+ setStep(value === "stdio" ? "serverCommand" : "serverUrl");
437
+ }
438
+ }
439
+ )
440
+ ] }),
441
+ step === "serverCommand" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
442
+ /* @__PURE__ */ jsx(Text, { children: "Server command (for stdio):" }),
443
+ /* @__PURE__ */ jsx(
444
+ TextInput,
445
+ {
446
+ defaultValue: serverCommand,
447
+ onSubmit: (value) => {
448
+ setServerCommand(value);
449
+ setStep("installDeps");
450
+ }
451
+ }
452
+ )
453
+ ] }),
454
+ step === "serverUrl" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
455
+ /* @__PURE__ */ jsx(Text, { children: "Server URL (for http):" }),
456
+ /* @__PURE__ */ jsx(
457
+ TextInput,
458
+ {
459
+ defaultValue: serverUrl,
460
+ onSubmit: (value) => {
461
+ setServerUrl(value);
462
+ setStep("installDeps");
463
+ }
464
+ }
465
+ )
466
+ ] }),
467
+ step === "installDeps" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
468
+ /* @__PURE__ */ jsx(Text, { children: "Install dependencies now?" }),
469
+ /* @__PURE__ */ jsx(
470
+ ConfirmInput,
471
+ {
472
+ onConfirm: () => {
473
+ setInstallDeps(true);
474
+ checkAndCreate();
475
+ },
476
+ onCancel: () => {
477
+ setInstallDeps(false);
478
+ checkAndCreate();
479
+ }
480
+ }
481
+ )
482
+ ] }),
483
+ step === "confirmOverwrite" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
484
+ /* @__PURE__ */ jsxs(Text, { color: "yellow", children: [
485
+ "Directory ",
486
+ projectName,
487
+ " already exists. Overwrite?"
488
+ ] }),
489
+ /* @__PURE__ */ jsx(
490
+ ConfirmInput,
491
+ {
492
+ onConfirm: () => createProject(),
493
+ onCancel: () => {
494
+ exit();
495
+ }
496
+ }
497
+ )
498
+ ] }),
499
+ step === "creating" && /* @__PURE__ */ jsx(Spinner, { label: "Creating project structure..." }),
500
+ step === "installing" && /* @__PURE__ */ jsx(Spinner, { label: "Installing dependencies..." }),
501
+ step === "done" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
502
+ /* @__PURE__ */ jsx(StatusMessage, { status: "success", children: "Project initialized successfully!" }),
503
+ /* @__PURE__ */ jsx(Text, { children: " " }),
504
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: "Next steps:" }),
505
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
506
+ " cd ",
507
+ projectName
508
+ ] }),
509
+ !installDeps && /* @__PURE__ */ jsx(Text, { dimColor: true, children: " npm install" }),
510
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " npm test" }),
511
+ /* @__PURE__ */ jsx(Text, { children: " " }),
512
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: "To generate a dataset:" }),
513
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " npx mcp-server-tester generate" })
514
+ ] }),
515
+ step === "error" && error && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
516
+ /* @__PURE__ */ jsx(StatusMessage, { status: "error", children: error }),
517
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Press Ctrl+C to exit" })
518
+ ] })
519
+ ] });
520
+ }
521
+
522
+ // src/cli/commands/init/index.ts
523
+ async function init(options) {
524
+ const { waitUntilExit } = render(React.createElement(InitApp, { options }));
525
+ await waitUntilExit();
526
+ }
527
+ var MCPHostCapabilitiesSchema = z.object({
528
+ sampling: z.record(z.unknown()).optional(),
529
+ roots: z.object({
530
+ listChanged: z.boolean()
531
+ }).optional()
532
+ });
533
+ var MCPOAuthConfigSchema = z.object({
534
+ serverUrl: z.string().url("serverUrl must be a valid URL"),
535
+ scopes: z.array(z.string()).optional(),
536
+ resource: z.string().url().optional(),
537
+ authStatePath: z.string().optional(),
538
+ clientId: z.string().optional(),
539
+ clientSecret: z.string().optional(),
540
+ redirectUri: z.string().url().optional()
541
+ });
542
+ var MCPAuthConfigSchema = z.object({
543
+ accessToken: z.string().optional(),
544
+ oauth: MCPOAuthConfigSchema.optional()
545
+ }).refine(
546
+ (data) => !(data.accessToken && data.oauth),
547
+ "Cannot specify both accessToken and oauth configuration"
548
+ );
549
+ var StdioConfigSchema = z.object({
550
+ transport: z.literal("stdio"),
551
+ command: z.string().min(1, "command is required for stdio transport"),
552
+ args: z.array(z.string()).optional(),
553
+ cwd: z.string().optional(),
554
+ capabilities: MCPHostCapabilitiesSchema.optional(),
555
+ connectTimeoutMs: z.number().positive().optional(),
556
+ requestTimeoutMs: z.number().positive().optional(),
557
+ quiet: z.boolean().optional()
558
+ });
559
+ var HttpConfigSchema = z.object({
560
+ transport: z.literal("http"),
561
+ serverUrl: z.string().url("serverUrl must be a valid URL"),
562
+ headers: z.record(z.string()).optional(),
563
+ capabilities: MCPHostCapabilitiesSchema.optional(),
564
+ connectTimeoutMs: z.number().positive().optional(),
565
+ requestTimeoutMs: z.number().positive().optional(),
566
+ auth: MCPAuthConfigSchema.optional()
567
+ });
568
+ var MCPConfigSchema = z.discriminatedUnion("transport", [
569
+ StdioConfigSchema,
570
+ HttpConfigSchema
571
+ ]);
572
+ function validateMCPConfig(config) {
573
+ return MCPConfigSchema.parse(config);
574
+ }
575
+ function isStdioConfig(config) {
576
+ return config.transport === "stdio" && typeof config.command === "string";
577
+ }
578
+ function isHttpConfig(config) {
579
+ return config.transport === "http" && typeof config.serverUrl === "string";
580
+ }
581
+ var NAMESPACE = "mcp-server-tester";
582
+ var debugClient = createDebug(`${NAMESPACE}:client`);
583
+ createDebug(`${NAMESPACE}:oauth`);
584
+ createDebug(`${NAMESPACE}:eval`);
585
+
586
+ // src/mcp/clientFactory.ts
587
+ async function createMCPClientForConfig(config, options) {
588
+ const validatedConfig = validateMCPConfig(config);
589
+ const client = new Client(
590
+ {
591
+ name: "@gleanwork/mcp-server-tester",
592
+ version: "0.1.0"
593
+ },
594
+ {
595
+ capabilities: validatedConfig.capabilities ?? {}
596
+ }
597
+ );
598
+ if (isStdioConfig(validatedConfig)) {
599
+ const transport = new StdioClientTransport({
600
+ command: validatedConfig.command,
601
+ args: validatedConfig.args ?? [],
602
+ ...validatedConfig.cwd && { cwd: validatedConfig.cwd },
603
+ // Suppress server stderr when quiet mode is enabled
604
+ ...validatedConfig.quiet && { stderr: "ignore" }
605
+ });
606
+ debugClient("Connecting via stdio: %O", {
607
+ command: validatedConfig.command,
608
+ args: validatedConfig.args,
609
+ cwd: validatedConfig.cwd
610
+ });
611
+ await client.connect(transport);
612
+ } else if (isHttpConfig(validatedConfig)) {
613
+ const headers = { ...validatedConfig.headers };
614
+ if (validatedConfig.auth?.accessToken && true) {
615
+ headers.Authorization = `Bearer ${validatedConfig.auth.accessToken}`;
616
+ }
617
+ const transport = new StreamableHTTPClientTransport(
618
+ new URL(validatedConfig.serverUrl),
619
+ {
620
+ requestInit: Object.keys(headers).length > 0 ? { headers } : void 0,
621
+ // Pass auth provider for OAuth flow - MCP SDK handles it automatically
622
+ authProvider: options?.authProvider
623
+ }
624
+ );
625
+ debugClient("Connecting via HTTP: %O", {
626
+ serverUrl: validatedConfig.serverUrl,
627
+ headers: Object.keys(headers).length > 0 ? Object.keys(headers) : void 0,
628
+ hasAuthProvider: false
629
+ });
630
+ await client.connect(transport);
631
+ }
632
+ debugClient("Connected successfully");
633
+ const serverInfo = client.getServerVersion();
634
+ if (serverInfo) {
635
+ debugClient("Server info: %O", serverInfo);
636
+ }
637
+ return client;
638
+ }
639
+ async function closeMCPClient(client) {
640
+ try {
641
+ await client.close();
642
+ } catch (error) {
643
+ console.error("[MCP] Error closing client:", error);
644
+ throw error;
645
+ }
646
+ }
647
+ var ENV_VAR_NAMES = {
648
+ accessToken: "MCP_ACCESS_TOKEN",
649
+ refreshToken: "MCP_REFRESH_TOKEN",
650
+ tokenType: "MCP_TOKEN_TYPE",
651
+ expiresAt: "MCP_TOKEN_EXPIRES_AT"
652
+ };
653
+ var DEFAULT_EXPIRY_BUFFER_MS = 6e4;
654
+ function generateServerKey(serverUrl) {
655
+ const url = new URL(serverUrl);
656
+ let key = url.hostname;
657
+ if (url.port) {
658
+ key += `_${url.port}`;
659
+ }
660
+ if (url.pathname && url.pathname !== "/") {
661
+ const cleanPath = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\//g, "_");
662
+ if (cleanPath) {
663
+ key += `_${cleanPath}`;
664
+ }
665
+ }
666
+ return key.replace(/[^a-zA-Z0-9_.-]/g, "_");
667
+ }
668
+ function getStateDir(serverUrl, customDir) {
669
+ const serverKey = generateServerKey(serverUrl);
670
+ if (customDir) {
671
+ return path.join(customDir, serverKey);
672
+ }
673
+ if (process.platform === "win32") {
674
+ const localAppData = process.env.LOCALAPPDATA;
675
+ if (localAppData) {
676
+ return path.join(localAppData, "mcp-tests", serverKey);
677
+ }
678
+ return path.join(homedir(), "AppData", "Local", "mcp-tests", serverKey);
679
+ }
680
+ if (process.platform === "linux" && process.env.XDG_STATE_HOME) {
681
+ return path.join(process.env.XDG_STATE_HOME, "mcp-tests", serverKey);
682
+ }
683
+ return path.join(homedir(), ".local", "state", "mcp-tests", serverKey);
684
+ }
685
+ function getBaseStateDir() {
686
+ if (process.platform === "win32") {
687
+ const localAppData = process.env.LOCALAPPDATA;
688
+ if (localAppData) {
689
+ return path.join(localAppData, "mcp-tests");
690
+ }
691
+ return path.join(homedir(), "AppData", "Local", "mcp-tests");
692
+ }
693
+ if (process.platform === "linux" && process.env.XDG_STATE_HOME) {
694
+ return path.join(process.env.XDG_STATE_HOME, "mcp-tests");
695
+ }
696
+ return path.join(homedir(), ".local", "state", "mcp-tests");
697
+ }
698
+ async function listKnownServers() {
699
+ const baseDir = getBaseStateDir();
700
+ const servers = [];
701
+ try {
702
+ const entries = await fs.readdir(baseDir, { withFileTypes: true });
703
+ for (const entry of entries) {
704
+ if (!entry.isDirectory()) continue;
705
+ const serverKey = entry.name;
706
+ const tokensPath = path.join(baseDir, serverKey, "tokens.json");
707
+ let hasTokens = false;
708
+ try {
709
+ await fs.access(tokensPath);
710
+ hasTokens = true;
711
+ } catch {
712
+ }
713
+ const parts = serverKey.split("_");
714
+ const hostname = parts[0];
715
+ const rest = parts.slice(1).join("/");
716
+ const url = `https://${hostname}/${rest}`;
717
+ servers.push({ key: serverKey, url, hasTokens });
718
+ }
719
+ } catch {
720
+ }
721
+ return servers;
722
+ }
723
+ function loadTokensFromEnv() {
724
+ const accessToken = process.env[ENV_VAR_NAMES.accessToken];
725
+ if (!accessToken) {
726
+ return null;
727
+ }
728
+ const expiresAtStr = process.env[ENV_VAR_NAMES.expiresAt];
729
+ const expiresAt = expiresAtStr ? parseInt(expiresAtStr, 10) : void 0;
730
+ return {
731
+ accessToken,
732
+ refreshToken: process.env[ENV_VAR_NAMES.refreshToken],
733
+ tokenType: process.env[ENV_VAR_NAMES.tokenType] ?? "Bearer",
734
+ expiresAt: expiresAt && !isNaN(expiresAt) ? expiresAt : void 0
735
+ };
736
+ }
737
+ function createFileOAuthStorage(config) {
738
+ return new FileOAuthStorage(config);
739
+ }
740
+ var FileOAuthStorage = class {
741
+ stateDir;
742
+ constructor(config) {
743
+ this.stateDir = getStateDir(config.serverUrl, config.stateDir);
744
+ }
745
+ get serverMetadataPath() {
746
+ return path.join(this.stateDir, "server.json");
747
+ }
748
+ get clientPath() {
749
+ return path.join(this.stateDir, "client.json");
750
+ }
751
+ get tokensPath() {
752
+ return path.join(this.stateDir, "tokens.json");
753
+ }
754
+ async loadServerMetadata() {
755
+ return this.loadFile(this.serverMetadataPath);
756
+ }
757
+ async saveServerMetadata(metadata) {
758
+ await this.atomicWrite(this.serverMetadataPath, metadata);
759
+ }
760
+ async loadClient() {
761
+ return this.loadFile(this.clientPath);
762
+ }
763
+ async saveClient(client) {
764
+ await this.atomicWrite(this.clientPath, client);
765
+ }
766
+ async loadTokens() {
767
+ return this.loadFile(this.tokensPath);
768
+ }
769
+ async saveTokens(tokens) {
770
+ await this.atomicWrite(this.tokensPath, tokens);
771
+ }
772
+ async deleteTokens() {
773
+ await this.deleteFile(this.tokensPath);
774
+ }
775
+ async hasValidToken(bufferMs = DEFAULT_EXPIRY_BUFFER_MS) {
776
+ const tokens = await this.loadTokens();
777
+ if (!tokens?.accessToken) {
778
+ return false;
779
+ }
780
+ if (!tokens.expiresAt) {
781
+ return true;
782
+ }
783
+ return tokens.expiresAt > Date.now() + bufferMs;
784
+ }
785
+ /**
786
+ * Load a JSON file, returning null if not found
787
+ */
788
+ async loadFile(filePath) {
789
+ try {
790
+ const content = await fs.readFile(filePath, "utf-8");
791
+ return JSON.parse(content);
792
+ } catch (error) {
793
+ if (error.code === "ENOENT") {
794
+ return null;
795
+ }
796
+ throw error;
797
+ }
798
+ }
799
+ /**
800
+ * Write data atomically: write to .tmp file, then rename
801
+ * Files are created with 0o600 permissions (user read/write only)
802
+ */
803
+ async atomicWrite(filePath, data) {
804
+ await fs.mkdir(this.stateDir, { recursive: true, mode: 448 });
805
+ const tmpPath = `${filePath}.tmp`;
806
+ const content = JSON.stringify(data, null, 2);
807
+ await fs.writeFile(tmpPath, content, { encoding: "utf-8", mode: 384 });
808
+ await fs.rename(tmpPath, filePath);
809
+ }
810
+ /**
811
+ * Delete a file, ignoring errors if the file doesn't exist
812
+ */
813
+ async deleteFile(filePath) {
814
+ try {
815
+ await fs.unlink(filePath);
816
+ } catch (error) {
817
+ if (error.code !== "ENOENT") {
818
+ throw error;
819
+ }
820
+ }
821
+ }
822
+ };
823
+ async function generatePKCE() {
824
+ const codeVerifier = oauth.generateRandomCodeVerifier();
825
+ const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier);
826
+ return {
827
+ codeVerifier,
828
+ codeChallenge
829
+ };
830
+ }
831
+ function generateState() {
832
+ return oauth.generateRandomState();
833
+ }
834
+ function buildAuthorizationUrl(config) {
835
+ const authorizationEndpoint = config.authServer.server.authorization_endpoint;
836
+ if (!authorizationEndpoint) {
837
+ throw new Error(
838
+ "Authorization server does not have an authorization_endpoint"
839
+ );
840
+ }
841
+ const authorizationUrl = new URL(authorizationEndpoint);
842
+ authorizationUrl.searchParams.set("client_id", config.clientId);
843
+ authorizationUrl.searchParams.set("redirect_uri", config.redirectUri);
844
+ authorizationUrl.searchParams.set("response_type", "code");
845
+ authorizationUrl.searchParams.set("scope", config.scopes.join(" "));
846
+ authorizationUrl.searchParams.set("code_challenge", config.codeChallenge);
847
+ authorizationUrl.searchParams.set("code_challenge_method", "S256");
848
+ authorizationUrl.searchParams.set("state", config.state);
849
+ if (config.resource) {
850
+ authorizationUrl.searchParams.set("resource", config.resource);
851
+ }
852
+ return authorizationUrl;
853
+ }
854
+ async function exchangeCodeForTokens(config) {
855
+ const client = {
856
+ client_id: config.clientId,
857
+ token_endpoint_auth_method: config.clientSecret ? "client_secret_basic" : "none"
858
+ };
859
+ const clientAuth = config.clientSecret ? oauth.ClientSecretBasic(config.clientSecret) : oauth.None();
860
+ const callbackUrl = new URL(config.redirectUri);
861
+ callbackUrl.searchParams.set("code", config.code);
862
+ callbackUrl.searchParams.set("state", config.state);
863
+ const validatedParams = oauth.validateAuthResponse(
864
+ config.authServer.server,
865
+ client,
866
+ callbackUrl,
867
+ config.state
868
+ );
869
+ const response = await oauth.authorizationCodeGrantRequest(
870
+ config.authServer.server,
871
+ client,
872
+ clientAuth,
873
+ validatedParams,
874
+ config.redirectUri,
875
+ config.codeVerifier
876
+ );
877
+ const result = await oauth.processAuthorizationCodeResponse(
878
+ config.authServer.server,
879
+ client,
880
+ response
881
+ );
882
+ return {
883
+ accessToken: result.access_token,
884
+ tokenType: result.token_type,
885
+ expiresIn: result.expires_in,
886
+ refreshToken: result.refresh_token,
887
+ scope: result.scope
888
+ };
889
+ }
890
+ async function refreshAccessToken(config) {
891
+ const client = {
892
+ client_id: config.clientId,
893
+ token_endpoint_auth_method: config.clientSecret ? "client_secret_basic" : "none"
894
+ };
895
+ const clientAuth = config.clientSecret ? oauth.ClientSecretBasic(config.clientSecret) : oauth.None();
896
+ const response = await oauth.refreshTokenGrantRequest(
897
+ config.authServer.server,
898
+ client,
899
+ clientAuth,
900
+ config.refreshToken
901
+ );
902
+ if (!response.ok) {
903
+ const contentType = response.headers.get("content-type") ?? "";
904
+ let errorMessage = `Token refresh failed: ${response.status} ${response.statusText}`;
905
+ try {
906
+ if (contentType.includes("application/json")) {
907
+ const errorBody = await response.clone().json();
908
+ if (errorBody.error) {
909
+ errorMessage = `Token refresh failed: ${errorBody.error}`;
910
+ if (errorBody.error_description) {
911
+ errorMessage += ` - ${errorBody.error_description}`;
912
+ }
913
+ }
914
+ } else {
915
+ const textBody = await response.clone().text();
916
+ if (textBody) {
917
+ errorMessage = `Token refresh failed: ${response.status} - ${textBody}`;
918
+ }
919
+ }
920
+ } catch {
921
+ }
922
+ throw new Error(errorMessage);
923
+ }
924
+ const result = await oauth.processRefreshTokenResponse(
925
+ config.authServer.server,
926
+ client,
927
+ response
928
+ );
929
+ return {
930
+ accessToken: result.access_token,
931
+ tokenType: result.token_type,
932
+ expiresIn: result.expires_in,
933
+ refreshToken: result.refresh_token,
934
+ scope: result.scope
935
+ };
936
+ }
937
+ var MCP_PROTOCOL_VERSION = "2025-06-18";
938
+ async function discoverProtectedResource(mcpServerUrl) {
939
+ const url = new URL(mcpServerUrl);
940
+ const origin = url.origin;
941
+ const pathname = url.pathname;
942
+ const pathAwareUrl = `${origin}/.well-known/oauth-protected-resource${pathname}`;
943
+ try {
944
+ const metadata = await fetchProtectedResourceMetadata(pathAwareUrl);
945
+ return {
946
+ metadata,
947
+ discoveryUrl: pathAwareUrl,
948
+ usedPathAwareDiscovery: true
949
+ };
950
+ } catch (error) {
951
+ if (error instanceof DiscoveryError && error.status === 404) {
952
+ const baseUrl = `${origin}/.well-known/oauth-protected-resource`;
953
+ const metadata = await fetchProtectedResourceMetadata(baseUrl);
954
+ return {
955
+ metadata,
956
+ discoveryUrl: baseUrl,
957
+ usedPathAwareDiscovery: false
958
+ };
959
+ }
960
+ throw error;
961
+ }
962
+ }
963
+ var DiscoveryError = class extends Error {
964
+ constructor(message, status, url) {
965
+ super(message);
966
+ this.status = status;
967
+ this.url = url;
968
+ this.name = "DiscoveryError";
969
+ }
970
+ };
971
+ async function fetchProtectedResourceMetadata(discoveryUrl) {
972
+ const response = await fetch(discoveryUrl, {
973
+ method: "GET",
974
+ headers: {
975
+ Accept: "application/json",
976
+ "MCP-Protocol-Version": MCP_PROTOCOL_VERSION
977
+ }
978
+ });
979
+ if (!response.ok) {
980
+ throw new DiscoveryError(
981
+ `Protected resource discovery failed: ${response.status} ${response.statusText}`,
982
+ response.status,
983
+ discoveryUrl
984
+ );
985
+ }
986
+ const metadata = await response.json();
987
+ if (!metadata.resource) {
988
+ throw new DiscoveryError(
989
+ 'Invalid protected resource metadata: missing required "resource" field',
990
+ void 0,
991
+ discoveryUrl
992
+ );
993
+ }
994
+ return metadata;
995
+ }
996
+ async function discoverAuthorizationServer(authServerUrl) {
997
+ const issuer = new URL(authServerUrl);
998
+ const response = await oauth.discoveryRequest(issuer, {
999
+ algorithm: "oauth2",
1000
+ headers: new Headers({
1001
+ "MCP-Protocol-Version": MCP_PROTOCOL_VERSION
1002
+ })
1003
+ });
1004
+ const metadata = await oauth.processDiscoveryResponse(issuer, response);
1005
+ return {
1006
+ server: metadata,
1007
+ issuer: authServerUrl
1008
+ };
1009
+ }
1010
+
1011
+ // src/auth/cli.ts
1012
+ var debug = createDebug("mcp-server-tester:cli-oauth");
1013
+ var DEFAULT_TIMEOUT_MS = 3e5;
1014
+ var DEFAULT_CLIENT_NAME = "@gleanwork/mcp-server-tester";
1015
+ var DEFAULT_METADATA_TTL_MS = 24 * 60 * 60 * 1e3;
1016
+ var CLIOAuthClient = class {
1017
+ config;
1018
+ storage;
1019
+ constructor(config) {
1020
+ this.config = config;
1021
+ this.storage = createFileOAuthStorage({
1022
+ serverUrl: config.mcpServerUrl,
1023
+ stateDir: config.stateDir
1024
+ });
1025
+ }
1026
+ /**
1027
+ * Get a valid access token, authenticating if necessary
1028
+ *
1029
+ * Token resolution priority:
1030
+ * 1. Check environment variables (for CI/CD)
1031
+ * 2. Check file storage for cached tokens
1032
+ * 3. Try to refresh if expired but refresh token exists
1033
+ * 4. Run full OAuth flow if needed
1034
+ */
1035
+ async getAccessToken() {
1036
+ const envTokens = loadTokensFromEnv();
1037
+ if (envTokens) {
1038
+ debug("Using tokens from environment variables");
1039
+ return {
1040
+ accessToken: envTokens.accessToken,
1041
+ tokenType: envTokens.tokenType,
1042
+ expiresAt: envTokens.expiresAt,
1043
+ refreshed: false,
1044
+ fromEnv: true
1045
+ };
1046
+ }
1047
+ const storedTokens = await this.storage.loadTokens();
1048
+ if (storedTokens?.accessToken) {
1049
+ const isValid = await this.storage.hasValidToken();
1050
+ if (isValid) {
1051
+ debug("Using cached tokens from storage");
1052
+ return {
1053
+ accessToken: storedTokens.accessToken,
1054
+ tokenType: storedTokens.tokenType,
1055
+ expiresAt: storedTokens.expiresAt,
1056
+ refreshed: false,
1057
+ fromEnv: false
1058
+ };
1059
+ }
1060
+ if (storedTokens.refreshToken) {
1061
+ debug("Token expired, attempting refresh");
1062
+ try {
1063
+ const refreshedTokens = await this.refreshStoredToken(storedTokens);
1064
+ return {
1065
+ accessToken: refreshedTokens.accessToken,
1066
+ tokenType: refreshedTokens.tokenType,
1067
+ expiresAt: refreshedTokens.expiresAt,
1068
+ refreshed: true,
1069
+ fromEnv: false
1070
+ };
1071
+ } catch (error) {
1072
+ debug("Token refresh failed, will re-authenticate:", error);
1073
+ }
1074
+ }
1075
+ }
1076
+ debug("Performing full OAuth authentication");
1077
+ return this.authenticate();
1078
+ }
1079
+ /**
1080
+ * Try to get a valid access token without triggering browser auth
1081
+ *
1082
+ * Returns null if no valid token is available (no stored tokens,
1083
+ * expired without refresh token, or refresh failed). Unlike getAccessToken(),
1084
+ * this will NOT open a browser for authentication.
1085
+ *
1086
+ * Use this for CLI commands that should prompt the user to run `login`
1087
+ * instead of automatically starting the OAuth flow.
1088
+ */
1089
+ async tryGetAccessToken() {
1090
+ const envTokens = loadTokensFromEnv();
1091
+ if (envTokens) {
1092
+ debug("Using tokens from environment variables");
1093
+ return {
1094
+ accessToken: envTokens.accessToken,
1095
+ tokenType: envTokens.tokenType,
1096
+ expiresAt: envTokens.expiresAt,
1097
+ refreshed: false,
1098
+ fromEnv: true
1099
+ };
1100
+ }
1101
+ const storedTokens = await this.storage.loadTokens();
1102
+ if (storedTokens?.accessToken) {
1103
+ const isValid = await this.storage.hasValidToken();
1104
+ if (isValid) {
1105
+ debug("Using cached tokens from storage");
1106
+ return {
1107
+ accessToken: storedTokens.accessToken,
1108
+ tokenType: storedTokens.tokenType,
1109
+ expiresAt: storedTokens.expiresAt,
1110
+ refreshed: false,
1111
+ fromEnv: false
1112
+ };
1113
+ }
1114
+ if (storedTokens.refreshToken) {
1115
+ debug("Token expired, attempting refresh");
1116
+ try {
1117
+ const refreshedTokens = await this.refreshStoredToken(storedTokens);
1118
+ return {
1119
+ accessToken: refreshedTokens.accessToken,
1120
+ tokenType: refreshedTokens.tokenType,
1121
+ expiresAt: refreshedTokens.expiresAt,
1122
+ refreshed: true,
1123
+ fromEnv: false
1124
+ };
1125
+ } catch (error) {
1126
+ debug("Token refresh failed:", error);
1127
+ return null;
1128
+ }
1129
+ }
1130
+ }
1131
+ debug("No valid token available");
1132
+ return null;
1133
+ }
1134
+ /**
1135
+ * Force a new authentication flow
1136
+ */
1137
+ async authenticate() {
1138
+ const { protectedResource, authServer } = await this.discoverServers();
1139
+ const client = await this.getOrRegisterClient(authServer);
1140
+ const { tokens, requestedScopes } = await this.performOAuthFlow(
1141
+ authServer,
1142
+ client,
1143
+ protectedResource
1144
+ );
1145
+ return {
1146
+ accessToken: tokens.accessToken,
1147
+ tokenType: tokens.tokenType,
1148
+ expiresAt: tokens.expiresAt,
1149
+ refreshed: false,
1150
+ fromEnv: false,
1151
+ requestedScopes
1152
+ };
1153
+ }
1154
+ /**
1155
+ * Check if stored credentials exist (may be expired)
1156
+ */
1157
+ async hasStoredCredentials() {
1158
+ const tokens = await this.storage.loadTokens();
1159
+ return tokens?.accessToken !== void 0;
1160
+ }
1161
+ /**
1162
+ * Clear stored credentials
1163
+ */
1164
+ async clearCredentials() {
1165
+ await this.storage.deleteTokens();
1166
+ debug("Cleared stored credentials");
1167
+ }
1168
+ /**
1169
+ * Discover protected resource and authorization server
1170
+ */
1171
+ async discoverServers() {
1172
+ const cachedMetadata = await this.storage.loadServerMetadata();
1173
+ if (cachedMetadata) {
1174
+ const age = Date.now() - cachedMetadata.discoveredAt;
1175
+ if (age < DEFAULT_METADATA_TTL_MS) {
1176
+ debug("Using cached server metadata (age: %dms)", age);
1177
+ debug(
1178
+ "Cached protected resource scopes: %O",
1179
+ cachedMetadata.protectedResource.scopes_supported
1180
+ );
1181
+ debug(
1182
+ "Cached auth server scopes: %O",
1183
+ cachedMetadata.authServer.server.scopes_supported
1184
+ );
1185
+ return {
1186
+ protectedResource: cachedMetadata.protectedResource,
1187
+ authServer: cachedMetadata.authServer
1188
+ };
1189
+ }
1190
+ debug("Cached server metadata is stale (age: %dms), re-discovering", age);
1191
+ }
1192
+ debug("Discovering protected resource:", this.config.mcpServerUrl);
1193
+ const prResult = await discoverProtectedResource(this.config.mcpServerUrl);
1194
+ debug("Found protected resource:", prResult.metadata.resource);
1195
+ debug(
1196
+ "Protected resource scopes_supported: %O",
1197
+ prResult.metadata.scopes_supported
1198
+ );
1199
+ const authServerUrl = prResult.metadata.authorization_servers?.[0];
1200
+ if (!authServerUrl) {
1201
+ throw new Error(
1202
+ "No authorization servers found in protected resource metadata"
1203
+ );
1204
+ }
1205
+ debug("Discovering authorization server:", authServerUrl);
1206
+ const authServer = await discoverAuthorizationServer(authServerUrl);
1207
+ debug("Found authorization server:", authServer.issuer);
1208
+ debug(
1209
+ "Auth server scopes_supported: %O",
1210
+ authServer.server.scopes_supported
1211
+ );
1212
+ const metadata = {
1213
+ authServer,
1214
+ protectedResource: prResult.metadata,
1215
+ discoveredAt: Date.now()
1216
+ };
1217
+ await this.storage.saveServerMetadata(metadata);
1218
+ return {
1219
+ protectedResource: prResult.metadata,
1220
+ authServer
1221
+ };
1222
+ }
1223
+ /**
1224
+ * Get existing client or register new one via DCR
1225
+ */
1226
+ async getOrRegisterClient(authServer) {
1227
+ if (this.config.clientId) {
1228
+ debug("Using pre-configured client ID");
1229
+ return {
1230
+ clientId: this.config.clientId,
1231
+ clientSecret: this.config.clientSecret
1232
+ };
1233
+ }
1234
+ const cachedClient = await this.storage.loadClient();
1235
+ if (cachedClient?.clientId) {
1236
+ debug("Using cached client registration");
1237
+ return cachedClient;
1238
+ }
1239
+ debug("Registering new client via DCR");
1240
+ const client = await this.registerClient(authServer);
1241
+ await this.storage.saveClient(client);
1242
+ return client;
1243
+ }
1244
+ /**
1245
+ * Register a new client via Dynamic Client Registration
1246
+ */
1247
+ async registerClient(authServer) {
1248
+ const registrationEndpoint = authServer.server.registration_endpoint;
1249
+ if (!registrationEndpoint) {
1250
+ throw new Error(
1251
+ "Authorization server does not support Dynamic Client Registration. Please provide a clientId in the configuration."
1252
+ );
1253
+ }
1254
+ const redirectUri = "http://127.0.0.1:0/callback";
1255
+ const response = await fetch(registrationEndpoint, {
1256
+ method: "POST",
1257
+ headers: {
1258
+ "Content-Type": "application/json",
1259
+ "MCP-Protocol-Version": MCP_PROTOCOL_VERSION
1260
+ },
1261
+ body: JSON.stringify({
1262
+ redirect_uris: [redirectUri],
1263
+ token_endpoint_auth_method: "none",
1264
+ grant_types: ["authorization_code", "refresh_token"],
1265
+ response_types: ["code"],
1266
+ client_name: this.config.clientName ?? DEFAULT_CLIENT_NAME
1267
+ })
1268
+ });
1269
+ if (!response.ok) {
1270
+ const errorText = await response.text();
1271
+ throw new Error(
1272
+ `Dynamic Client Registration failed: ${response.status} ${response.statusText}
1273
+ ${errorText}`
1274
+ );
1275
+ }
1276
+ const data = await response.json();
1277
+ debug("Client registered:", data.client_id);
1278
+ return {
1279
+ clientId: data.client_id,
1280
+ clientSecret: data.client_secret,
1281
+ clientIdIssuedAt: data.client_id_issued_at,
1282
+ clientSecretExpiresAt: data.client_secret_expires_at
1283
+ };
1284
+ }
1285
+ /**
1286
+ * Perform the full OAuth authorization flow
1287
+ */
1288
+ async performOAuthFlow(authServer, client, protectedResource) {
1289
+ const pkce = await generatePKCE();
1290
+ const state = generateState();
1291
+ const { port, codePromise, close } = await this.startCallbackServer(state);
1292
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
1293
+ try {
1294
+ const requestedScopes = this.config.scopes ?? protectedResource.scopes_supported ?? authServer.server.scopes_supported ?? ["openid"];
1295
+ debug("Scope resolution:");
1296
+ debug(" - User config scopes: %O", this.config.scopes);
1297
+ debug(
1298
+ " - Protected resource scopes_supported: %O",
1299
+ protectedResource.scopes_supported
1300
+ );
1301
+ debug(
1302
+ " - Auth server scopes_supported: %O",
1303
+ authServer.server.scopes_supported
1304
+ );
1305
+ debug(" - Final requested scopes: %O", requestedScopes);
1306
+ const authUrl = buildAuthorizationUrl({
1307
+ authServer,
1308
+ clientId: client.clientId,
1309
+ redirectUri,
1310
+ scopes: requestedScopes,
1311
+ codeChallenge: pkce.codeChallenge,
1312
+ state,
1313
+ resource: protectedResource.resource
1314
+ });
1315
+ debug("Authorization URL: %s", authUrl.toString());
1316
+ debug("Authorization URL params:");
1317
+ debug(" - client_id: %s", authUrl.searchParams.get("client_id"));
1318
+ debug(" - redirect_uri: %s", authUrl.searchParams.get("redirect_uri"));
1319
+ debug(" - scope: %s", authUrl.searchParams.get("scope"));
1320
+ debug(" - resource: %s", authUrl.searchParams.get("resource"));
1321
+ await this.openBrowserOrPrintUrl(authUrl);
1322
+ debug("Waiting for OAuth callback...");
1323
+ const code = await codePromise;
1324
+ debug("Received authorization code");
1325
+ const tokenResult = await exchangeCodeForTokens({
1326
+ authServer,
1327
+ clientId: client.clientId,
1328
+ clientSecret: client.clientSecret,
1329
+ code,
1330
+ state,
1331
+ codeVerifier: pkce.codeVerifier,
1332
+ redirectUri
1333
+ });
1334
+ const tokens = this.tokenResultToStoredTokens(
1335
+ tokenResult,
1336
+ client.clientId
1337
+ );
1338
+ await this.storage.saveTokens(tokens);
1339
+ return { tokens, requestedScopes };
1340
+ } finally {
1341
+ close();
1342
+ }
1343
+ }
1344
+ /**
1345
+ * Refresh an expired token
1346
+ *
1347
+ * Uses the clientId stored with the tokens (if available) to ensure
1348
+ * the refresh request uses the same client that obtained the original tokens.
1349
+ * This is important because refresh tokens are bound to the client_id.
1350
+ */
1351
+ async refreshStoredToken(storedTokens) {
1352
+ if (!storedTokens.refreshToken) {
1353
+ throw new Error("No refresh token available");
1354
+ }
1355
+ const metadata = await this.storage.loadServerMetadata();
1356
+ if (!metadata) {
1357
+ throw new Error("No cached server metadata for refresh");
1358
+ }
1359
+ let clientId;
1360
+ let clientSecret;
1361
+ if (storedTokens.clientId) {
1362
+ debug("Using clientId from stored tokens for refresh");
1363
+ clientId = storedTokens.clientId;
1364
+ const storedClient = await this.storage.loadClient();
1365
+ if (storedClient?.clientId === clientId) {
1366
+ clientSecret = storedClient.clientSecret;
1367
+ }
1368
+ } else {
1369
+ debug(
1370
+ "No clientId in stored tokens, falling back to stored client (legacy behavior)"
1371
+ );
1372
+ const client = await this.getOrRegisterClient(metadata.authServer);
1373
+ clientId = client.clientId;
1374
+ clientSecret = client.clientSecret;
1375
+ }
1376
+ const tokenResult = await refreshAccessToken({
1377
+ authServer: metadata.authServer,
1378
+ clientId,
1379
+ clientSecret,
1380
+ refreshToken: storedTokens.refreshToken
1381
+ });
1382
+ const tokens = this.tokenResultToStoredTokens(tokenResult, clientId);
1383
+ await this.storage.saveTokens(tokens);
1384
+ return tokens;
1385
+ }
1386
+ /**
1387
+ * Start local callback server
1388
+ */
1389
+ async startCallbackServer(expectedState) {
1390
+ const timeoutMs = this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1391
+ return new Promise((resolve3, reject) => {
1392
+ const server = http.createServer();
1393
+ const connections = /* @__PURE__ */ new Set();
1394
+ server.on("connection", (socket) => {
1395
+ connections.add(socket);
1396
+ socket.on("close", () => connections.delete(socket));
1397
+ });
1398
+ const forceClose = () => {
1399
+ for (const socket of connections) {
1400
+ socket.destroy();
1401
+ }
1402
+ server.close();
1403
+ };
1404
+ let codeResolve;
1405
+ let codeReject;
1406
+ const codePromise = new Promise((res, rej) => {
1407
+ codeResolve = res;
1408
+ codeReject = rej;
1409
+ });
1410
+ const timeout = setTimeout(() => {
1411
+ forceClose();
1412
+ codeReject(new Error(`OAuth flow timed out after ${timeoutMs}ms`));
1413
+ }, timeoutMs);
1414
+ server.on("request", (req, res) => {
1415
+ const url = new URL(
1416
+ req.url ?? "/",
1417
+ `http://127.0.0.1:${server.address().port}`
1418
+ );
1419
+ if (url.pathname !== "/callback") {
1420
+ res.writeHead(404);
1421
+ res.end("Not Found");
1422
+ return;
1423
+ }
1424
+ const error = url.searchParams.get("error");
1425
+ if (error) {
1426
+ const errorDescription = url.searchParams.get("error_description");
1427
+ clearTimeout(timeout);
1428
+ res.writeHead(400, { "Content-Type": "text/html" });
1429
+ res.end(this.errorHtml(error, errorDescription ?? void 0));
1430
+ codeReject(
1431
+ new Error(
1432
+ `OAuth error: ${error}${errorDescription ? ` - ${errorDescription}` : ""}`
1433
+ )
1434
+ );
1435
+ return;
1436
+ }
1437
+ const state = url.searchParams.get("state");
1438
+ if (state !== expectedState) {
1439
+ clearTimeout(timeout);
1440
+ res.writeHead(400, { "Content-Type": "text/html" });
1441
+ res.end(this.errorHtml("invalid_state", "State parameter mismatch"));
1442
+ codeReject(new Error("OAuth state mismatch - possible CSRF attack"));
1443
+ return;
1444
+ }
1445
+ const code = url.searchParams.get("code");
1446
+ if (!code) {
1447
+ clearTimeout(timeout);
1448
+ res.writeHead(400, { "Content-Type": "text/html" });
1449
+ res.end(
1450
+ this.errorHtml("missing_code", "No authorization code received")
1451
+ );
1452
+ codeReject(new Error("No authorization code in callback"));
1453
+ return;
1454
+ }
1455
+ clearTimeout(timeout);
1456
+ res.writeHead(200, { "Content-Type": "text/html" });
1457
+ res.end(this.successHtml());
1458
+ codeResolve(code);
1459
+ });
1460
+ const preferredPort = this.config.callbackPort ?? 0;
1461
+ server.listen(preferredPort, "127.0.0.1", () => {
1462
+ const address = server.address();
1463
+ debug("Callback server listening on port", address.port);
1464
+ resolve3({ port: address.port, codePromise, close: forceClose });
1465
+ });
1466
+ server.on("error", (err) => {
1467
+ reject(err);
1468
+ });
1469
+ });
1470
+ }
1471
+ /**
1472
+ * Open browser or print URL for headless environments
1473
+ */
1474
+ async openBrowserOrPrintUrl(url) {
1475
+ if (isHeadless()) {
1476
+ console.log("\n" + "=".repeat(60));
1477
+ console.log(
1478
+ "Please open the following URL in your browser to authenticate:"
1479
+ );
1480
+ console.log("\n" + url.toString() + "\n");
1481
+ console.log("=".repeat(60) + "\n");
1482
+ return;
1483
+ }
1484
+ try {
1485
+ const open = await import('open');
1486
+ await open.default(url.toString());
1487
+ debug("Opened browser for authentication");
1488
+ } catch (error) {
1489
+ debug("Failed to open browser:", error);
1490
+ console.log("\nFailed to open browser automatically.");
1491
+ console.log("Please open the following URL manually:\n");
1492
+ console.log(url.toString() + "\n");
1493
+ }
1494
+ }
1495
+ /**
1496
+ * Convert TokenResult to StoredTokens
1497
+ *
1498
+ * @param result - Token result from exchange or refresh
1499
+ * @param clientId - Client ID that was used to obtain these tokens
1500
+ */
1501
+ tokenResultToStoredTokens(result, clientId) {
1502
+ return {
1503
+ accessToken: result.accessToken,
1504
+ tokenType: result.tokenType,
1505
+ refreshToken: result.refreshToken,
1506
+ expiresAt: result.expiresIn ? Date.now() + result.expiresIn * 1e3 : void 0,
1507
+ clientId
1508
+ };
1509
+ }
1510
+ /**
1511
+ * HTML page for successful authentication
1512
+ */
1513
+ successHtml() {
1514
+ return `
1515
+ <!DOCTYPE html>
1516
+ <html>
1517
+ <head>
1518
+ <meta charset="UTF-8">
1519
+ <title>Authentication Successful</title>
1520
+ <style>
1521
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1522
+ display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;
1523
+ background: #f8fafc; }
1524
+ .container { text-align: center; background: white; padding: 48px 64px; border-radius: 8px;
1525
+ border: 1px solid #e2e8f0; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
1526
+ .icon { width: 48px; height: 48px; margin: 0 auto 24px; background: #dcfce7; border-radius: 50%;
1527
+ display: flex; align-items: center; justify-content: center; }
1528
+ .icon svg { width: 24px; height: 24px; color: #16a34a; }
1529
+ h1 { color: #0f172a; margin: 0 0 8px 0; font-size: 20px; font-weight: 600; }
1530
+ p { color: #64748b; margin: 0; font-size: 14px; }
1531
+ </style>
1532
+ </head>
1533
+ <body>
1534
+ <div class="container">
1535
+ <div class="icon">
1536
+ <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
1537
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
1538
+ </svg>
1539
+ </div>
1540
+ <h1>Authentication Successful</h1>
1541
+ <p>You can close this window and return to the terminal.</p>
1542
+ </div>
1543
+ </body>
1544
+ </html>`;
1545
+ }
1546
+ /**
1547
+ * HTML page for authentication error
1548
+ */
1549
+ errorHtml(error, description) {
1550
+ return `
1551
+ <!DOCTYPE html>
1552
+ <html>
1553
+ <head>
1554
+ <meta charset="UTF-8">
1555
+ <title>Authentication Failed</title>
1556
+ <style>
1557
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1558
+ display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;
1559
+ background: #f8fafc; }
1560
+ .container { text-align: center; background: white; padding: 48px 64px; border-radius: 8px;
1561
+ border: 1px solid #e2e8f0; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
1562
+ .icon { width: 48px; height: 48px; margin: 0 auto 24px; background: #fee2e2; border-radius: 50%;
1563
+ display: flex; align-items: center; justify-content: center; }
1564
+ .icon svg { width: 24px; height: 24px; color: #dc2626; }
1565
+ h1 { color: #0f172a; margin: 0 0 8px 0; font-size: 20px; font-weight: 600; }
1566
+ p { color: #64748b; margin: 0 0 8px 0; font-size: 14px; }
1567
+ code { background: #f1f5f9; padding: 2px 8px; border-radius: 4px; color: #dc2626; font-size: 13px; }
1568
+ </style>
1569
+ </head>
1570
+ <body>
1571
+ <div class="container">
1572
+ <div class="icon">
1573
+ <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
1574
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
1575
+ </svg>
1576
+ </div>
1577
+ <h1>Authentication Failed</h1>
1578
+ <p>Error: <code>${escapeHtml(error)}</code></p>
1579
+ ${description ? `<p>${escapeHtml(description)}</p>` : ""}
1580
+ </div>
1581
+ </body>
1582
+ </html>`;
1583
+ }
1584
+ };
1585
+ function isHeadless() {
1586
+ if (process.env.CI) {
1587
+ return true;
1588
+ }
1589
+ if (!process.stdin.isTTY) {
1590
+ return true;
1591
+ }
1592
+ if (process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
1593
+ return true;
1594
+ }
1595
+ return false;
1596
+ }
1597
+ function escapeHtml(text) {
1598
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
1599
+ }
1600
+
1601
+ // src/mcp/response.ts
1602
+ function normalizeToolResponse(result) {
1603
+ const isError = result.isError ?? false;
1604
+ const contentBlocks = [];
1605
+ const textParts = [];
1606
+ if (Array.isArray(result.content)) {
1607
+ for (const block of result.content) {
1608
+ if (block == null || typeof block !== "object") {
1609
+ continue;
1610
+ }
1611
+ const b = block;
1612
+ const contentBlock = {
1613
+ type: typeof b.type === "string" ? b.type : "unknown"
1614
+ };
1615
+ if (typeof b.text === "string") {
1616
+ contentBlock.text = b.text;
1617
+ textParts.push(b.text);
1618
+ }
1619
+ if (b.data !== void 0) {
1620
+ contentBlock.data = b.data;
1621
+ }
1622
+ if (typeof b.mimeType === "string") {
1623
+ contentBlock.mimeType = b.mimeType;
1624
+ }
1625
+ contentBlocks.push(contentBlock);
1626
+ }
1627
+ }
1628
+ let structuredContent = null;
1629
+ if (result.structuredContent !== void 0) {
1630
+ structuredContent = result.structuredContent;
1631
+ if (textParts.length === 0) {
1632
+ if (typeof result.structuredContent === "string") {
1633
+ textParts.push(result.structuredContent);
1634
+ } else if (result.structuredContent != null) {
1635
+ textParts.push(JSON.stringify(result.structuredContent));
1636
+ }
1637
+ }
1638
+ }
1639
+ const text = textParts.join("\n");
1640
+ return {
1641
+ text,
1642
+ raw: result,
1643
+ isError,
1644
+ contentBlocks,
1645
+ structuredContent
1646
+ };
1647
+ }
1648
+ function extractText(response) {
1649
+ if (response == null) {
1650
+ return "";
1651
+ }
1652
+ if (typeof response === "string") {
1653
+ return response;
1654
+ }
1655
+ if (isNormalizedResponse(response)) {
1656
+ return response.text;
1657
+ }
1658
+ if (isCallToolResult(response)) {
1659
+ return normalizeToolResponse(response).text;
1660
+ }
1661
+ if (Array.isArray(response)) {
1662
+ return extractTextFromContentArray(response);
1663
+ }
1664
+ if (typeof response === "object") {
1665
+ const r = response;
1666
+ if (Array.isArray(r.content)) {
1667
+ return extractTextFromContentArray(r.content);
1668
+ }
1669
+ if (typeof r.content === "string") {
1670
+ return r.content;
1671
+ }
1672
+ if (r.structuredContent !== void 0) {
1673
+ if (typeof r.structuredContent === "string") {
1674
+ return r.structuredContent;
1675
+ }
1676
+ return JSON.stringify(r.structuredContent);
1677
+ }
1678
+ if (typeof r.text === "string") {
1679
+ return r.text;
1680
+ }
1681
+ return JSON.stringify(r);
1682
+ }
1683
+ if (typeof response === "number" || typeof response === "boolean" || typeof response === "bigint") {
1684
+ return String(response);
1685
+ }
1686
+ return "";
1687
+ }
1688
+ function isNormalizedResponse(value) {
1689
+ if (value == null || typeof value !== "object") {
1690
+ return false;
1691
+ }
1692
+ const v = value;
1693
+ return typeof v.text === "string" && typeof v.isError === "boolean" && Array.isArray(v.contentBlocks) && v.raw !== void 0;
1694
+ }
1695
+ function isCallToolResult(value) {
1696
+ if (value == null || typeof value !== "object") {
1697
+ return false;
1698
+ }
1699
+ const v = value;
1700
+ return Array.isArray(v.content) || typeof v.isError === "boolean";
1701
+ }
1702
+ function extractTextFromContentArray(content) {
1703
+ const textParts = [];
1704
+ for (const block of content) {
1705
+ if (block == null || typeof block !== "object") {
1706
+ continue;
1707
+ }
1708
+ const b = block;
1709
+ if (b.type === "text" && typeof b.text === "string") {
1710
+ textParts.push(b.text);
1711
+ }
1712
+ }
1713
+ if (textParts.length > 0) {
1714
+ return textParts.join("\n");
1715
+ }
1716
+ return JSON.stringify(content);
1717
+ }
1718
+
1719
+ // src/cli/utils/expectationSuggester.ts
1720
+ function suggestExpectations(response, tool) {
1721
+ const suggestions = {
1722
+ textContains: [],
1723
+ regex: []
1724
+ };
1725
+ const text = extractText(response);
1726
+ suggestions.textContains = suggestTextContains(text);
1727
+ suggestions.regex = suggestRegexPatterns(text);
1728
+ return suggestions;
1729
+ }
1730
+ function suggestTextContains(text, _tool) {
1731
+ const suggestions = [];
1732
+ const headerMatches = text.matchAll(/^(#{1,6})\s+(.+)$/gm);
1733
+ for (const match of headerMatches) {
1734
+ suggestions.push(`${match[1]} ${match[2]}`);
1735
+ }
1736
+ const boldMatches = text.matchAll(/\*\*([^*]+)\*\*/g);
1737
+ const boldTexts = Array.from(boldMatches, (m) => m[0]);
1738
+ if (boldTexts.length > 0 && boldTexts.length <= 5) {
1739
+ suggestions.push(...boldTexts.slice(0, 3));
1740
+ }
1741
+ const kvMatches = text.matchAll(/^([A-Z][a-z\s]+):\s*(.+)$/gm);
1742
+ const kvPairs = Array.from(kvMatches, (m) => m[0]);
1743
+ if (kvPairs.length > 0 && kvPairs.length <= 5) {
1744
+ suggestions.push(...kvPairs.slice(0, 3));
1745
+ }
1746
+ if (suggestions.length === 0 && text.length > 10) {
1747
+ const firstLine = text.split("\n")[0];
1748
+ if (firstLine && firstLine.length > 0) {
1749
+ suggestions.push(firstLine.substring(0, 50));
1750
+ }
1751
+ }
1752
+ return [...new Set(suggestions)];
1753
+ }
1754
+ function suggestRegexPatterns(text, _tool) {
1755
+ const patterns = [];
1756
+ if (/^#{1,6}\s+/m.test(text)) {
1757
+ patterns.push("^#{1,6}\\s+\\w+");
1758
+ }
1759
+ if (/\d{4}-\d{2}-\d{2}/.test(text)) {
1760
+ patterns.push("\\d{4}-\\d{2}-\\d{2}");
1761
+ }
1762
+ if (/\d{1,2}:\d{2}(:\d{2})?/.test(text)) {
1763
+ patterns.push("\\d{1,2}:\\d{2}");
1764
+ }
1765
+ if (/\d+°[CF]/.test(text)) {
1766
+ patterns.push("\\d+\xB0[CF]");
1767
+ }
1768
+ if (/https?:\/\/[^\s]+/.test(text)) {
1769
+ patterns.push("https?://[\\w.-]+");
1770
+ }
1771
+ if (/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/.test(text)) {
1772
+ patterns.push("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}");
1773
+ }
1774
+ if (/\d+(\.\d+)?%/.test(text)) {
1775
+ patterns.push("\\d+(\\.\\d+)?%");
1776
+ }
1777
+ if (/[$£€]\d+(\.\d{2})?/.test(text)) {
1778
+ patterns.push("[$\xA3\u20AC]\\d+(\\.\\d{2})?");
1779
+ }
1780
+ if (/\*\*[^*]+\*\*/.test(text)) {
1781
+ patterns.push("\\*\\*\\w+:\\*\\*");
1782
+ }
1783
+ if (/^[-*]\s+/m.test(text)) {
1784
+ patterns.push("^[-*]\\s+[\\w\\s]+");
1785
+ }
1786
+ if (/^\d+\.\s+/m.test(text)) {
1787
+ patterns.push("^\\d+\\.\\s+");
1788
+ }
1789
+ if (/\(\d{3}\)\s*\d{3}-\d{4}|\d{3}-\d{3}-\d{4}|\d{10}/.test(text)) {
1790
+ patterns.push("\\d{3}[-.]?\\d{3}[-.]?\\d{4}");
1791
+ }
1792
+ if (/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/.test(text)) {
1793
+ patterns.push("\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}");
1794
+ }
1795
+ return [...new Set(patterns)];
1796
+ }
1797
+ function isAuthError(error) {
1798
+ const message = error instanceof Error ? error.message : String(error);
1799
+ return message.includes("401") || message.includes("Authorization") || message.includes("Unauthorized") || message.includes("authentication required");
1800
+ }
1801
+ function GenerateApp({ options }) {
1802
+ const { exit } = useApp();
1803
+ const [step, setStep] = useState(
1804
+ options.config ? "connecting" : "loadingServers"
1805
+ );
1806
+ const [knownServers, setKnownServers] = useState([]);
1807
+ const [mcpConfig, setMcpConfig] = useState(null);
1808
+ const [client, setClient] = useState(null);
1809
+ const [tools, setTools] = useState([]);
1810
+ const [selectedTool, setSelectedTool] = useState(null);
1811
+ const [response, setResponse] = useState(null);
1812
+ const [callError, setCallError] = useState(null);
1813
+ const [schemaProperties, setSchemaProperties] = useState(
1814
+ []
1815
+ );
1816
+ const [currentPropertyIndex, setCurrentPropertyIndex] = useState(0);
1817
+ const [argValues, setArgValues] = useState({});
1818
+ const [dataset, setDataset] = useState({
1819
+ name: "my-mcp-evals",
1820
+ description: "Generated eval dataset",
1821
+ cases: []
1822
+ });
1823
+ const [outputPath] = useState(resolve(options.output || "data/dataset.json"));
1824
+ const [currentCase, setCurrentCase] = useState({});
1825
+ const [suggestions, setSuggestions] = useState({ textContains: [], regex: [] });
1826
+ const [error, setError] = useState(null);
1827
+ const isMountedRef = useRef(true);
1828
+ useEffect(() => {
1829
+ if (step === "loadingServers") {
1830
+ loadKnownServers();
1831
+ }
1832
+ async function loadKnownServers() {
1833
+ const servers = await listKnownServers();
1834
+ const authenticatedServers = servers.filter((s) => s.hasTokens);
1835
+ setKnownServers(authenticatedServers);
1836
+ if (authenticatedServers.length > 0) {
1837
+ setStep("selectServer");
1838
+ } else {
1839
+ setStep("configTransport");
1840
+ }
1841
+ }
1842
+ }, [step]);
1843
+ useEffect(() => {
1844
+ if (options.config) {
1845
+ loadConfig(options.config);
1846
+ }
1847
+ }, [options.config]);
1848
+ async function loadConfig(configPath) {
1849
+ try {
1850
+ const content = await readFile(resolve(configPath), "utf-8");
1851
+ const config = JSON.parse(content);
1852
+ setMcpConfig(validateMCPConfig(config));
1853
+ setStep("connecting");
1854
+ } catch (err) {
1855
+ setError(
1856
+ `Failed to load config: ${err instanceof Error ? err.message : String(err)}`
1857
+ );
1858
+ setStep("error");
1859
+ }
1860
+ }
1861
+ useEffect(() => {
1862
+ if (step === "connecting" && mcpConfig) {
1863
+ connectToServer();
1864
+ }
1865
+ async function connectToServer() {
1866
+ if (!mcpConfig) return;
1867
+ try {
1868
+ let configWithAuth = mcpConfig;
1869
+ if (isHttpConfig(mcpConfig)) {
1870
+ const oauthClient = new CLIOAuthClient({
1871
+ mcpServerUrl: mcpConfig.serverUrl
1872
+ });
1873
+ const tokenResult = await oauthClient.tryGetAccessToken();
1874
+ if (tokenResult) {
1875
+ configWithAuth = {
1876
+ ...mcpConfig,
1877
+ auth: { accessToken: tokenResult.accessToken }
1878
+ };
1879
+ }
1880
+ }
1881
+ const c = await createMCPClientForConfig(configWithAuth);
1882
+ if (!isMountedRef.current) {
1883
+ await closeMCPClient(c);
1884
+ return;
1885
+ }
1886
+ const result = await c.listTools();
1887
+ if (!isMountedRef.current) {
1888
+ await closeMCPClient(c);
1889
+ return;
1890
+ }
1891
+ setClient(c);
1892
+ setTools(result.tools || []);
1893
+ let fileExists = false;
1894
+ try {
1895
+ await stat(outputPath);
1896
+ fileExists = true;
1897
+ } catch {
1898
+ }
1899
+ if (fileExists) {
1900
+ setStep("appendPrompt");
1901
+ } else {
1902
+ setStep("datasetName");
1903
+ }
1904
+ } catch (err) {
1905
+ if (isMountedRef.current) {
1906
+ if (isAuthError(err)) {
1907
+ setStep("authRequired");
1908
+ } else {
1909
+ setError(
1910
+ `Failed to connect: ${err instanceof Error ? err.message : String(err)}`
1911
+ );
1912
+ setStep("error");
1913
+ }
1914
+ }
1915
+ }
1916
+ }
1917
+ }, [step, mcpConfig, outputPath]);
1918
+ async function callTool(finalArgs) {
1919
+ if (!client || !selectedTool) return;
1920
+ try {
1921
+ const result = await client.callTool({
1922
+ name: selectedTool.name,
1923
+ arguments: finalArgs
1924
+ });
1925
+ const responseData = result.structuredContent ?? result.content;
1926
+ setResponse(responseData);
1927
+ setCallError(null);
1928
+ const sugg = suggestExpectations(responseData, selectedTool);
1929
+ setSuggestions(sugg);
1930
+ setCurrentCase({
1931
+ toolName: selectedTool.name,
1932
+ args: finalArgs
1933
+ });
1934
+ setStep("reviewResponse");
1935
+ } catch (err) {
1936
+ setCallError(err instanceof Error ? err.message : String(err));
1937
+ setStep("reviewResponse");
1938
+ }
1939
+ }
1940
+ async function saveDataset() {
1941
+ try {
1942
+ const serialized = {
1943
+ name: dataset.name,
1944
+ description: dataset.description,
1945
+ cases: dataset.cases,
1946
+ metadata: {
1947
+ version: "1.0",
1948
+ created: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
1949
+ }
1950
+ };
1951
+ await mkdir(dirname(outputPath), { recursive: true });
1952
+ await writeFile(outputPath, JSON.stringify(serialized, null, 2));
1953
+ setStep("done");
1954
+ } catch (err) {
1955
+ setError(
1956
+ `Failed to save: ${err instanceof Error ? err.message : String(err)}`
1957
+ );
1958
+ setStep("error");
1959
+ }
1960
+ }
1961
+ useEffect(() => {
1962
+ return () => {
1963
+ isMountedRef.current = false;
1964
+ if (client) {
1965
+ closeMCPClient(client).catch(() => {
1966
+ });
1967
+ }
1968
+ };
1969
+ }, [client]);
1970
+ const handleExit = useCallback(() => {
1971
+ if (client) {
1972
+ closeMCPClient(client).then(() => exit()).catch(() => exit());
1973
+ } else {
1974
+ exit();
1975
+ }
1976
+ }, [client, exit]);
1977
+ useInput((input, key) => {
1978
+ if (key.ctrl && input === "c") {
1979
+ handleExit();
1980
+ }
1981
+ });
1982
+ useEffect(() => {
1983
+ if (step === "done" || step === "error" || step === "authRequired") {
1984
+ handleExit();
1985
+ }
1986
+ }, [step, handleExit]);
1987
+ useEffect(() => {
1988
+ if (step === "useTextContains" && suggestions.textContains.length === 0) {
1989
+ setStep("useRegex");
1990
+ }
1991
+ }, [step, suggestions.textContains.length]);
1992
+ useEffect(() => {
1993
+ if (step === "useRegex" && suggestions.regex.length === 0) {
1994
+ setStep("useExact");
1995
+ }
1996
+ }, [step, suggestions.regex.length]);
1997
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", padding: 1, children: [
1998
+ step === "loadingServers" && /* @__PURE__ */ jsx(Spinner, { label: "Loading known servers..." }),
1999
+ step === "selectServer" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
2000
+ /* @__PURE__ */ jsx(Text, { children: "Select MCP server:" }),
2001
+ /* @__PURE__ */ jsx(
2002
+ Select,
2003
+ {
2004
+ visibleOptionCount: 10,
2005
+ options: [
2006
+ ...knownServers.map((s) => ({
2007
+ label: s.url,
2008
+ value: s.url
2009
+ })),
2010
+ { label: "Other (configure manually)", value: "__other__" }
2011
+ ],
2012
+ onChange: (value) => {
2013
+ if (value === "__other__") {
2014
+ setStep("configTransport");
2015
+ } else {
2016
+ setMcpConfig(
2017
+ validateMCPConfig({
2018
+ transport: "http",
2019
+ serverUrl: value,
2020
+ capabilities: { roots: { listChanged: true } }
2021
+ })
2022
+ );
2023
+ setStep("connecting");
2024
+ }
2025
+ }
2026
+ }
2027
+ )
2028
+ ] }),
2029
+ step === "configTransport" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
2030
+ /* @__PURE__ */ jsx(Text, { children: "Select MCP transport type:" }),
2031
+ /* @__PURE__ */ jsx(
2032
+ Select,
2033
+ {
2034
+ options: [
2035
+ { label: "stdio (local server process)", value: "stdio" },
2036
+ { label: "http (remote server)", value: "http" }
2037
+ ],
2038
+ onChange: (value) => {
2039
+ setStep(value === "stdio" ? "configStdio" : "configHttp");
2040
+ }
2041
+ }
2042
+ )
2043
+ ] }),
2044
+ step === "configStdio" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
2045
+ /* @__PURE__ */ jsx(Text, { children: "Server command (e.g., node server.js):" }),
2046
+ /* @__PURE__ */ jsx(
2047
+ TextInput,
2048
+ {
2049
+ defaultValue: "node server.js",
2050
+ onSubmit: (value) => {
2051
+ const [command, ...cmdArgs] = value.split(" ");
2052
+ setMcpConfig(
2053
+ validateMCPConfig({
2054
+ transport: "stdio",
2055
+ command,
2056
+ args: cmdArgs,
2057
+ capabilities: { roots: { listChanged: true } }
2058
+ })
2059
+ );
2060
+ setStep("connecting");
2061
+ }
2062
+ }
2063
+ )
2064
+ ] }),
2065
+ step === "configHttp" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
2066
+ /* @__PURE__ */ jsx(Text, { children: "Server URL:" }),
2067
+ /* @__PURE__ */ jsx(
2068
+ TextInput,
2069
+ {
2070
+ defaultValue: "http://localhost:3000/mcp",
2071
+ onSubmit: (value) => {
2072
+ setMcpConfig(
2073
+ validateMCPConfig({
2074
+ transport: "http",
2075
+ serverUrl: value,
2076
+ capabilities: { roots: { listChanged: true } }
2077
+ })
2078
+ );
2079
+ setStep("connecting");
2080
+ }
2081
+ }
2082
+ )
2083
+ ] }),
2084
+ step === "connecting" && /* @__PURE__ */ jsx(Spinner, { label: "Connecting to MCP server..." }),
2085
+ step === "authRequired" && mcpConfig && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
2086
+ /* @__PURE__ */ jsx(StatusMessage, { status: "error", children: "Authentication required" }),
2087
+ /* @__PURE__ */ jsx(Text, { children: " " }),
2088
+ /* @__PURE__ */ jsx(Text, { children: "This server requires OAuth authentication." }),
2089
+ "serverUrl" in mcpConfig && /* @__PURE__ */ jsxs(Fragment, { children: [
2090
+ /* @__PURE__ */ jsxs(Text, { children: [
2091
+ "Run:",
2092
+ " ",
2093
+ /* @__PURE__ */ jsxs(Text, { color: "cyan", children: [
2094
+ "mcp-server-tester login ",
2095
+ mcpConfig.serverUrl
2096
+ ] })
2097
+ ] }),
2098
+ /* @__PURE__ */ jsx(Text, { children: " " }),
2099
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Then retry: mcp-server-tester generate" })
2100
+ ] }),
2101
+ "command" in mcpConfig && /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Note: stdio servers typically don't require OAuth authentication." })
2102
+ ] }),
2103
+ step === "appendPrompt" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
2104
+ /* @__PURE__ */ jsxs(StatusMessage, { status: "success", children: [
2105
+ "Connected! Found ",
2106
+ tools.length,
2107
+ " tools"
2108
+ ] }),
2109
+ /* @__PURE__ */ jsx(Text, { children: " " }),
2110
+ /* @__PURE__ */ jsxs(Text, { children: [
2111
+ "Dataset file exists at ",
2112
+ outputPath,
2113
+ ". Append to it?"
2114
+ ] }),
2115
+ /* @__PURE__ */ jsx(
2116
+ ConfirmInput,
2117
+ {
2118
+ onConfirm: async () => {
2119
+ try {
2120
+ const content = await readFile(outputPath, "utf-8");
2121
+ const existing = JSON.parse(content);
2122
+ setDataset({
2123
+ name: existing.name,
2124
+ description: existing.description,
2125
+ cases: existing.cases,
2126
+ metadata: existing.metadata
2127
+ });
2128
+ setStep("selectTool");
2129
+ } catch {
2130
+ setStep("datasetName");
2131
+ }
2132
+ },
2133
+ onCancel: () => setStep("datasetName")
2134
+ }
2135
+ )
2136
+ ] }),
2137
+ step === "datasetName" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
2138
+ client && /* @__PURE__ */ jsxs(StatusMessage, { status: "success", children: [
2139
+ "Connected! Found ",
2140
+ tools.length,
2141
+ " tools"
2142
+ ] }),
2143
+ /* @__PURE__ */ jsx(Text, { children: " " }),
2144
+ /* @__PURE__ */ jsx(Text, { children: "Dataset name:" }),
2145
+ /* @__PURE__ */ jsx(
2146
+ TextInput,
2147
+ {
2148
+ defaultValue: "my-mcp-evals",
2149
+ onSubmit: (value) => {
2150
+ setDataset((d) => ({ ...d, name: value }));
2151
+ setStep("selectTool");
2152
+ }
2153
+ }
2154
+ )
2155
+ ] }),
2156
+ step === "selectTool" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
2157
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "--- New Test Case ---" }),
2158
+ /* @__PURE__ */ jsx(Text, { children: " " }),
2159
+ /* @__PURE__ */ jsx(Text, { children: "Select tool to test:" }),
2160
+ /* @__PURE__ */ jsx(
2161
+ Select,
2162
+ {
2163
+ visibleOptionCount: 15,
2164
+ options: tools.map((t) => ({
2165
+ label: t.name,
2166
+ value: t.name
2167
+ })),
2168
+ onChange: (value) => {
2169
+ const tool = tools.find((t) => t.name === value);
2170
+ setSelectedTool(tool || null);
2171
+ if (tool?.inputSchema) {
2172
+ const schema = tool.inputSchema;
2173
+ const props = schema.properties ?? {};
2174
+ const required = schema.required ?? [];
2175
+ const properties = Object.entries(props).map(
2176
+ ([name, prop]) => ({
2177
+ name,
2178
+ type: prop.type ?? "string",
2179
+ description: prop.description,
2180
+ required: required.includes(name)
2181
+ })
2182
+ );
2183
+ setSchemaProperties(properties);
2184
+ setCurrentPropertyIndex(0);
2185
+ setArgValues({});
2186
+ if (properties.length > 0) {
2187
+ setStep("enterArgField");
2188
+ } else {
2189
+ setStep("enterRawArgs");
2190
+ }
2191
+ } else {
2192
+ setSchemaProperties([]);
2193
+ setArgValues({});
2194
+ setStep("enterRawArgs");
2195
+ }
2196
+ }
2197
+ }
2198
+ )
2199
+ ] }),
2200
+ step === "enterArgField" && schemaProperties.length > 0 && /* @__PURE__ */ jsx(Box, { flexDirection: "column", children: (() => {
2201
+ const prop = schemaProperties[currentPropertyIndex];
2202
+ if (!prop) return null;
2203
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
2204
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
2205
+ "Field ",
2206
+ currentPropertyIndex + 1,
2207
+ " of ",
2208
+ schemaProperties.length
2209
+ ] }),
2210
+ /* @__PURE__ */ jsxs(Text, { children: [
2211
+ /* @__PURE__ */ jsx(Text, { bold: true, children: prop.name }),
2212
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
2213
+ " (",
2214
+ prop.type,
2215
+ ")"
2216
+ ] }),
2217
+ prop.required && /* @__PURE__ */ jsx(Text, { color: "red", children: "*" })
2218
+ ] }),
2219
+ prop.description && /* @__PURE__ */ jsx(Text, { dimColor: true, children: prop.description }),
2220
+ /* @__PURE__ */ jsx(
2221
+ TextInput,
2222
+ {
2223
+ defaultValue: "",
2224
+ onSubmit: (value) => {
2225
+ if (prop.required && value.trim() === "") {
2226
+ return;
2227
+ }
2228
+ let parsedValue = value;
2229
+ if (prop.type === "number" || prop.type === "integer") {
2230
+ parsedValue = value === "" ? void 0 : Number(value);
2231
+ } else if (prop.type === "boolean") {
2232
+ parsedValue = value.toLowerCase() === "true";
2233
+ } else if (prop.type === "array" || prop.type === "object") {
2234
+ try {
2235
+ parsedValue = value === "" ? void 0 : JSON.parse(value);
2236
+ } catch {
2237
+ parsedValue = value;
2238
+ }
2239
+ } else {
2240
+ parsedValue = value === "" ? void 0 : value;
2241
+ }
2242
+ const finalArgs = { ...argValues };
2243
+ if (parsedValue !== void 0) {
2244
+ finalArgs[prop.name] = parsedValue;
2245
+ }
2246
+ setArgValues(finalArgs);
2247
+ if (currentPropertyIndex < schemaProperties.length - 1) {
2248
+ setCurrentPropertyIndex(currentPropertyIndex + 1);
2249
+ } else {
2250
+ setStep("callingTool");
2251
+ setTimeout(() => callTool(finalArgs), 0);
2252
+ }
2253
+ }
2254
+ },
2255
+ prop.name
2256
+ )
2257
+ ] });
2258
+ })() }),
2259
+ step === "enterRawArgs" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
2260
+ /* @__PURE__ */ jsx(Text, { children: "Tool arguments (JSON):" }),
2261
+ selectedTool?.description && /* @__PURE__ */ jsx(Text, { dimColor: true, children: selectedTool.description }),
2262
+ /* @__PURE__ */ jsx(
2263
+ TextInput,
2264
+ {
2265
+ defaultValue: "{}",
2266
+ onSubmit: (value) => {
2267
+ try {
2268
+ const parsed = JSON.parse(value);
2269
+ setArgValues(parsed);
2270
+ setStep("callingTool");
2271
+ setTimeout(() => callTool(parsed), 0);
2272
+ } catch {
2273
+ }
2274
+ }
2275
+ }
2276
+ )
2277
+ ] }),
2278
+ step === "callingTool" && /* @__PURE__ */ jsx(Spinner, { label: `Calling ${selectedTool?.name}...` }),
2279
+ step === "reviewResponse" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
2280
+ callError ? /* @__PURE__ */ jsxs(StatusMessage, { status: "error", children: [
2281
+ "Tool call failed: ",
2282
+ callError
2283
+ ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
2284
+ /* @__PURE__ */ jsx(StatusMessage, { status: "success", children: "Tool called successfully" }),
2285
+ /* @__PURE__ */ jsx(Text, { children: " " }),
2286
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Response preview:" }),
2287
+ /* @__PURE__ */ jsx(JsonPreview, { data: response, maxLines: 10 }),
2288
+ suggestions.textContains.length > 0 && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginTop: 1, children: [
2289
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: "Suggested expectations:" }),
2290
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
2291
+ "Text contains:",
2292
+ " ",
2293
+ suggestions.textContains.map((t) => `"${t}"`).join(", ")
2294
+ ] })
2295
+ ] })
2296
+ ] }),
2297
+ /* @__PURE__ */ jsx(Text, { children: " " }),
2298
+ /* @__PURE__ */ jsx(Text, { children: "Press Enter to continue..." }),
2299
+ /* @__PURE__ */ jsx(
2300
+ TextInput,
2301
+ {
2302
+ defaultValue: "",
2303
+ onSubmit: () => {
2304
+ if (callError) {
2305
+ setStep("askContinue");
2306
+ } else {
2307
+ setStep("caseId");
2308
+ }
2309
+ }
2310
+ }
2311
+ )
2312
+ ] }),
2313
+ step === "caseId" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
2314
+ /* @__PURE__ */ jsx(Text, { children: "Test case ID:" }),
2315
+ /* @__PURE__ */ jsx(
2316
+ TextInput,
2317
+ {
2318
+ defaultValue: `${selectedTool?.name}-${dataset.cases.length + 1}`,
2319
+ onSubmit: (value) => {
2320
+ setCurrentCase((c) => ({ ...c, id: value }));
2321
+ setStep("caseDescription");
2322
+ }
2323
+ }
2324
+ )
2325
+ ] }),
2326
+ step === "caseDescription" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
2327
+ /* @__PURE__ */ jsx(Text, { children: "Description (optional, press Enter to skip):" }),
2328
+ /* @__PURE__ */ jsx(
2329
+ TextInput,
2330
+ {
2331
+ defaultValue: "",
2332
+ onSubmit: (value) => {
2333
+ setCurrentCase((c) => ({
2334
+ ...c,
2335
+ description: value || void 0
2336
+ }));
2337
+ if (options.snapshot) {
2338
+ const newCase = {
2339
+ id: currentCase.id,
2340
+ description: value || void 0,
2341
+ toolName: currentCase.toolName,
2342
+ args: currentCase.args,
2343
+ expect: { snapshot: currentCase.id }
2344
+ };
2345
+ setDataset((d) => ({ ...d, cases: [...d.cases, newCase] }));
2346
+ setStep("askContinue");
2347
+ } else {
2348
+ setStep("useTextContains");
2349
+ }
2350
+ }
2351
+ }
2352
+ )
2353
+ ] }),
2354
+ step === "useTextContains" && suggestions.textContains.length > 0 && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
2355
+ /* @__PURE__ */ jsx(Text, { children: "Add text contains expectations?" }),
2356
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
2357
+ "(",
2358
+ suggestions.textContains.map((t) => `"${t}"`).join(", "),
2359
+ ")"
2360
+ ] }),
2361
+ /* @__PURE__ */ jsx(
2362
+ ConfirmInput,
2363
+ {
2364
+ onConfirm: () => {
2365
+ setCurrentCase((c) => ({
2366
+ ...c,
2367
+ expect: {
2368
+ ...c.expect,
2369
+ containsText: suggestions.textContains
2370
+ }
2371
+ }));
2372
+ setStep("useRegex");
2373
+ },
2374
+ onCancel: () => setStep("useRegex")
2375
+ }
2376
+ )
2377
+ ] }),
2378
+ step === "useRegex" && suggestions.regex.length > 0 && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
2379
+ /* @__PURE__ */ jsx(Text, { children: "Add regex expectations?" }),
2380
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
2381
+ "(",
2382
+ suggestions.regex.map((r) => `/${r}/`).join(", "),
2383
+ ")"
2384
+ ] }),
2385
+ /* @__PURE__ */ jsx(
2386
+ ConfirmInput,
2387
+ {
2388
+ onConfirm: () => {
2389
+ setCurrentCase((c) => ({
2390
+ ...c,
2391
+ expect: {
2392
+ ...c.expect,
2393
+ matchesPattern: suggestions.regex
2394
+ }
2395
+ }));
2396
+ setStep("useExact");
2397
+ },
2398
+ onCancel: () => setStep("useExact")
2399
+ }
2400
+ )
2401
+ ] }),
2402
+ step === "useExact" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
2403
+ /* @__PURE__ */ jsx(Text, { children: "Add exact match expectation?" }),
2404
+ /* @__PURE__ */ jsx(
2405
+ ConfirmInput,
2406
+ {
2407
+ onConfirm: () => {
2408
+ setCurrentCase((c) => ({
2409
+ ...c,
2410
+ expect: {
2411
+ ...c.expect,
2412
+ response
2413
+ }
2414
+ }));
2415
+ setStep("useSnapshot");
2416
+ },
2417
+ onCancel: () => setStep("useSnapshot")
2418
+ }
2419
+ )
2420
+ ] }),
2421
+ step === "useSnapshot" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
2422
+ /* @__PURE__ */ jsx(Text, { children: "Use Playwright snapshot testing?" }),
2423
+ /* @__PURE__ */ jsx(
2424
+ ConfirmInput,
2425
+ {
2426
+ onConfirm: () => {
2427
+ const newCase = {
2428
+ id: currentCase.id,
2429
+ description: currentCase.description,
2430
+ toolName: currentCase.toolName,
2431
+ args: currentCase.args,
2432
+ expect: {
2433
+ ...currentCase.expect,
2434
+ snapshot: currentCase.id
2435
+ }
2436
+ };
2437
+ setDataset((d) => ({ ...d, cases: [...d.cases, newCase] }));
2438
+ setStep("askContinue");
2439
+ },
2440
+ onCancel: () => {
2441
+ const newCase = {
2442
+ id: currentCase.id,
2443
+ description: currentCase.description,
2444
+ toolName: currentCase.toolName,
2445
+ args: currentCase.args,
2446
+ expect: currentCase.expect
2447
+ };
2448
+ setDataset((d) => ({ ...d, cases: [...d.cases, newCase] }));
2449
+ setStep("askContinue");
2450
+ }
2451
+ }
2452
+ )
2453
+ ] }),
2454
+ step === "askContinue" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
2455
+ /* @__PURE__ */ jsxs(StatusMessage, { status: "success", children: [
2456
+ 'Added test case "',
2457
+ currentCase.id,
2458
+ '"'
2459
+ ] }),
2460
+ /* @__PURE__ */ jsxs(Text, { children: [
2461
+ "Total cases: ",
2462
+ dataset.cases.length
2463
+ ] }),
2464
+ /* @__PURE__ */ jsx(Text, { children: " " }),
2465
+ /* @__PURE__ */ jsx(Text, { children: "Add another test case?" }),
2466
+ /* @__PURE__ */ jsx(
2467
+ ConfirmInput,
2468
+ {
2469
+ onConfirm: () => {
2470
+ setCurrentCase({});
2471
+ setSelectedTool(null);
2472
+ setSchemaProperties([]);
2473
+ setCurrentPropertyIndex(0);
2474
+ setArgValues({});
2475
+ setResponse(null);
2476
+ setCallError(null);
2477
+ setSuggestions({ textContains: [], regex: [] });
2478
+ setStep("selectTool");
2479
+ },
2480
+ onCancel: () => {
2481
+ setStep("saving");
2482
+ setTimeout(() => saveDataset(), 0);
2483
+ }
2484
+ }
2485
+ )
2486
+ ] }),
2487
+ step === "saving" && /* @__PURE__ */ jsx(Spinner, { label: "Saving dataset..." }),
2488
+ step === "done" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
2489
+ /* @__PURE__ */ jsx(StatusMessage, { status: "success", children: "Dataset generation complete!" }),
2490
+ /* @__PURE__ */ jsx(Text, { children: " " }),
2491
+ /* @__PURE__ */ jsxs(Text, { color: "cyan", children: [
2492
+ "Total test cases: ",
2493
+ dataset.cases.length
2494
+ ] }),
2495
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
2496
+ "Output: ",
2497
+ outputPath
2498
+ ] }),
2499
+ /* @__PURE__ */ jsx(Text, { children: " " }),
2500
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: "Next steps:" }),
2501
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " npx playwright test" }),
2502
+ dataset.cases.some((c) => c.expect?.snapshot) && /* @__PURE__ */ jsxs(Fragment, { children: [
2503
+ /* @__PURE__ */ jsx(Text, { children: " " }),
2504
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: "Snapshot testing:" }),
2505
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " First run will capture snapshots" }),
2506
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
2507
+ " ",
2508
+ "Update: npx playwright test --update-snapshots"
2509
+ ] })
2510
+ ] })
2511
+ ] }),
2512
+ step === "error" && error && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
2513
+ /* @__PURE__ */ jsx(StatusMessage, { status: "error", children: error }),
2514
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Press Ctrl+C to exit" })
2515
+ ] })
2516
+ ] });
2517
+ }
2518
+
2519
+ // src/cli/commands/generate/index.ts
2520
+ async function generate(options) {
2521
+ const { waitUntilExit } = render(
2522
+ React.createElement(GenerateApp, { options })
2523
+ );
2524
+ await waitUntilExit();
2525
+ }
2526
+ function LoginApp({ serverUrl, options }) {
2527
+ const { exit } = useApp();
2528
+ const [step, setStep] = useState("validating");
2529
+ const [result, setResult] = useState(null);
2530
+ const [error, setError] = useState(null);
2531
+ const [stateDir, setStateDir] = useState("");
2532
+ useInput((input, key) => {
2533
+ if (key.ctrl && input === "c") {
2534
+ exit();
2535
+ }
2536
+ });
2537
+ const authenticate = useCallback(async () => {
2538
+ try {
2539
+ new URL(serverUrl);
2540
+ } catch {
2541
+ setError(`Invalid URL: ${serverUrl}`);
2542
+ setStep("error");
2543
+ return;
2544
+ }
2545
+ const scopes = options.scopes ? options.scopes.split(",").map((s) => s.trim()) : void 0;
2546
+ const client = new CLIOAuthClient({
2547
+ mcpServerUrl: serverUrl,
2548
+ stateDir: options.stateDir,
2549
+ scopes
2550
+ });
2551
+ try {
2552
+ if (options.force) {
2553
+ setStep("clearing");
2554
+ await client.clearCredentials();
2555
+ }
2556
+ setStep("authenticating");
2557
+ const authResult = await client.getAccessToken();
2558
+ setResult({
2559
+ fromEnv: authResult.fromEnv,
2560
+ refreshed: authResult.refreshed,
2561
+ requestedScopes: authResult.requestedScopes,
2562
+ expiresAt: authResult.expiresAt
2563
+ });
2564
+ setStateDir(getStateDir(serverUrl, options.stateDir));
2565
+ setStep("done");
2566
+ } catch (err) {
2567
+ setError(err instanceof Error ? err.message : String(err));
2568
+ setStep("error");
2569
+ }
2570
+ }, [serverUrl, options]);
2571
+ useEffect(() => {
2572
+ authenticate();
2573
+ }, [authenticate]);
2574
+ useEffect(() => {
2575
+ if (step === "done" || step === "error") {
2576
+ exit();
2577
+ }
2578
+ }, [step, exit]);
2579
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", padding: 1, children: [
2580
+ step === "validating" && /* @__PURE__ */ jsx(Spinner, { label: "Validating server URL..." }),
2581
+ step === "clearing" && /* @__PURE__ */ jsx(Spinner, { label: "Clearing existing credentials..." }),
2582
+ step === "authenticating" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
2583
+ /* @__PURE__ */ jsx(Spinner, { label: `Authenticating with ${serverUrl}...` }),
2584
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "A browser window may open for OAuth login" })
2585
+ ] }),
2586
+ step === "done" && result && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
2587
+ result.fromEnv ? /* @__PURE__ */ jsx(StatusMessage, { status: "info", children: "Using token from environment variables." }) : result.refreshed ? /* @__PURE__ */ jsx(StatusMessage, { status: "success", children: "Token refreshed successfully." }) : /* @__PURE__ */ jsx(StatusMessage, { status: "success", children: "Authentication successful!" }),
2588
+ result.requestedScopes && result.requestedScopes.length > 0 && /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
2589
+ "Scopes: ",
2590
+ result.requestedScopes.join(", ")
2591
+ ] }),
2592
+ result.expiresAt ? /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
2593
+ "Token expires: ",
2594
+ new Date(result.expiresAt).toLocaleString()
2595
+ ] }) : /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Token has no expiration." }),
2596
+ !result.fromEnv && stateDir && /* @__PURE__ */ jsxs(Fragment, { children: [
2597
+ /* @__PURE__ */ jsx(Text, { children: " " }),
2598
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
2599
+ "Tokens stored in: ",
2600
+ stateDir
2601
+ ] })
2602
+ ] })
2603
+ ] }),
2604
+ step === "error" && error && /* @__PURE__ */ jsx(Box, { flexDirection: "column", children: /* @__PURE__ */ jsxs(StatusMessage, { status: "error", children: [
2605
+ "Authentication failed: ",
2606
+ error
2607
+ ] }) })
2608
+ ] });
2609
+ }
2610
+
2611
+ // src/cli/commands/login/index.ts
2612
+ async function login(serverUrl, options) {
2613
+ const { waitUntilExit } = render(
2614
+ React.createElement(LoginApp, { serverUrl, options })
2615
+ );
2616
+ await waitUntilExit();
2617
+ }
2618
+ function TokenApp({ serverUrl, options }) {
2619
+ const { exit } = useApp();
2620
+ const [step, setStep] = useState("loading");
2621
+ const [tokens, setTokens] = useState(null);
2622
+ const [error, setError] = useState(null);
2623
+ const [stateDir, setStateDir] = useState("");
2624
+ useEffect(() => {
2625
+ async function loadTokens() {
2626
+ try {
2627
+ new URL(serverUrl);
2628
+ } catch {
2629
+ setError(`Invalid URL: ${serverUrl}`);
2630
+ setStep("error");
2631
+ return;
2632
+ }
2633
+ const storage = createFileOAuthStorage({
2634
+ serverUrl,
2635
+ stateDir: options.stateDir
2636
+ });
2637
+ const loadedTokens = await storage.loadTokens();
2638
+ if (!loadedTokens) {
2639
+ setStateDir(getStateDir(serverUrl, options.stateDir));
2640
+ setError("No tokens found");
2641
+ setStep("error");
2642
+ return;
2643
+ }
2644
+ setTokens(loadedTokens);
2645
+ setStep("success");
2646
+ }
2647
+ loadTokens();
2648
+ }, [serverUrl, options.stateDir]);
2649
+ useEffect(() => {
2650
+ if (step === "success" || step === "error") {
2651
+ exit();
2652
+ }
2653
+ }, [step, exit]);
2654
+ const format = options.format ?? "env";
2655
+ if (step === "loading") {
2656
+ return null;
2657
+ }
2658
+ if (step === "error") {
2659
+ if (error === "No tokens found") {
2660
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", padding: 1, children: [
2661
+ /* @__PURE__ */ jsxs(StatusMessage, { status: "error", children: [
2662
+ "No tokens found for ",
2663
+ serverUrl
2664
+ ] }),
2665
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
2666
+ "Expected location: ",
2667
+ stateDir,
2668
+ "/tokens.json"
2669
+ ] }),
2670
+ /* @__PURE__ */ jsx(Text, { children: " " }),
2671
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
2672
+ "Run 'mcp-server-tester login ",
2673
+ serverUrl,
2674
+ "' to authenticate first."
2675
+ ] })
2676
+ ] });
2677
+ }
2678
+ return /* @__PURE__ */ jsx(Box, { flexDirection: "column", padding: 1, children: /* @__PURE__ */ jsx(StatusMessage, { status: "error", children: error }) });
2679
+ }
2680
+ if (step === "success" && tokens) {
2681
+ switch (format) {
2682
+ case "env":
2683
+ return /* @__PURE__ */ jsx(EnvFormat, { tokens });
2684
+ case "json":
2685
+ return /* @__PURE__ */ jsx(JsonFormat, { tokens });
2686
+ case "gh":
2687
+ return /* @__PURE__ */ jsx(GhFormat, { tokens });
2688
+ }
2689
+ }
2690
+ return null;
2691
+ }
2692
+ function EnvFormat({ tokens }) {
2693
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
2694
+ /* @__PURE__ */ jsxs(Text, { children: [
2695
+ ENV_VAR_NAMES.accessToken,
2696
+ "=",
2697
+ tokens.accessToken
2698
+ ] }),
2699
+ tokens.refreshToken && /* @__PURE__ */ jsxs(Text, { children: [
2700
+ ENV_VAR_NAMES.refreshToken,
2701
+ "=",
2702
+ tokens.refreshToken
2703
+ ] }),
2704
+ /* @__PURE__ */ jsxs(Text, { children: [
2705
+ ENV_VAR_NAMES.tokenType,
2706
+ "=",
2707
+ tokens.tokenType
2708
+ ] }),
2709
+ tokens.expiresAt && /* @__PURE__ */ jsxs(Text, { children: [
2710
+ ENV_VAR_NAMES.expiresAt,
2711
+ "=",
2712
+ tokens.expiresAt
2713
+ ] })
2714
+ ] });
2715
+ }
2716
+ function JsonFormat({ tokens }) {
2717
+ const output = {
2718
+ [ENV_VAR_NAMES.accessToken]: tokens.accessToken,
2719
+ [ENV_VAR_NAMES.tokenType]: tokens.tokenType
2720
+ };
2721
+ if (tokens.refreshToken) {
2722
+ output[ENV_VAR_NAMES.refreshToken] = tokens.refreshToken;
2723
+ }
2724
+ if (tokens.expiresAt) {
2725
+ output[ENV_VAR_NAMES.expiresAt] = tokens.expiresAt;
2726
+ }
2727
+ return /* @__PURE__ */ jsx(Text, { children: JSON.stringify(output, null, 2) });
2728
+ }
2729
+ function GhFormat({ tokens }) {
2730
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
2731
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "# Run these commands to set GitHub Actions secrets:" }),
2732
+ /* @__PURE__ */ jsxs(Text, { children: [
2733
+ "gh secret set ",
2734
+ ENV_VAR_NAMES.accessToken,
2735
+ ' --body "',
2736
+ tokens.accessToken,
2737
+ '"'
2738
+ ] }),
2739
+ tokens.refreshToken && /* @__PURE__ */ jsxs(Text, { children: [
2740
+ "gh secret set ",
2741
+ ENV_VAR_NAMES.refreshToken,
2742
+ ' --body "',
2743
+ tokens.refreshToken,
2744
+ '"'
2745
+ ] }),
2746
+ /* @__PURE__ */ jsxs(Text, { children: [
2747
+ "gh secret set ",
2748
+ ENV_VAR_NAMES.tokenType,
2749
+ ' --body "',
2750
+ tokens.tokenType,
2751
+ '"'
2752
+ ] }),
2753
+ tokens.expiresAt && /* @__PURE__ */ jsxs(Text, { children: [
2754
+ "gh secret set ",
2755
+ ENV_VAR_NAMES.expiresAt,
2756
+ ' --body "',
2757
+ tokens.expiresAt,
2758
+ '"'
2759
+ ] })
2760
+ ] });
2761
+ }
2762
+
2763
+ // src/cli/commands/token/index.ts
2764
+ async function token(serverUrl, options) {
2765
+ const { waitUntilExit } = render(
2766
+ React.createElement(TokenApp, { serverUrl, options })
2767
+ );
2768
+ await waitUntilExit();
2769
+ }
2770
+
2771
+ // src/cli/index.ts
2772
+ var program = new Command();
2773
+ program.name("mcp-server-tester").description("CLI tools for MCP server evaluation and testing").version("0.1.0");
2774
+ program.command("init").description("Initialize a new MCP evaluation project").option("-n, --name <name>", "Project name").option("-d, --dir <directory>", "Target directory", ".").action(init);
2775
+ program.command("generate").alias("gen").description("Generate eval dataset by interacting with MCP server").option("-c, --config <path>", "Path to MCP config").option("-o, --output <path>", "Output dataset path", "data/dataset.json").option("-s, --snapshot", "Use Playwright snapshot testing for all cases").action(generate);
2776
+ program.command("login").description("Authenticate with an MCP server via OAuth").argument("<server-url>", "MCP server URL to authenticate with").option("--force", "Force re-authentication even if valid token exists").option("--state-dir <dir>", "Custom directory for token storage").option(
2777
+ "--scopes <scopes>",
2778
+ "Comma-separated list of scopes to request (default: all from server)"
2779
+ ).action(login);
2780
+ program.command("token").description("Output stored OAuth tokens for CI/CD use").argument("<server-url>", "MCP server URL to get tokens for").option(
2781
+ "-f, --format <format>",
2782
+ "Output format: env, json, or gh (default: env)",
2783
+ "env"
2784
+ ).option("--state-dir <dir>", "Custom directory for token storage").action(token);
2785
+ program.parse();