@gleanwork/mcp-server-tester 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +421 -0
- package/dist/cli/index.js +2785 -0
- package/dist/fixtures/mcp.d.ts +605 -0
- package/dist/fixtures/mcp.js +2378 -0
- package/dist/fixtures/mcp.js.map +1 -0
- package/dist/fixtures/mcpAuth.d.ts +31 -0
- package/dist/fixtures/mcpAuth.js +317 -0
- package/dist/fixtures/mcpAuth.js.map +1 -0
- package/dist/index.cjs +3658 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +3857 -0
- package/dist/index.d.ts +3857 -0
- package/dist/index.js +3582 -0
- package/dist/index.js.map +1 -0
- package/dist/reporters/mcpReporter.cjs +301 -0
- package/dist/reporters/mcpReporter.cjs.map +1 -0
- package/dist/reporters/mcpReporter.d.cts +85 -0
- package/dist/reporters/mcpReporter.d.ts +85 -0
- package/dist/reporters/mcpReporter.js +297 -0
- package/dist/reporters/mcpReporter.js.map +1 -0
- package/dist/reporters/ui-dist/app.js +174 -0
- package/dist/reporters/ui-dist/index.html +28 -0
- package/dist/reporters/ui-dist/styles.css +1 -0
- package/package.json +138 -0
- package/src/reporters/ui-dist/app.js +174 -0
- package/src/reporters/ui-dist/index.html +28 -0
- package/src/reporters/ui-dist/styles.css +1 -0
|
@@ -0,0 +1,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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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();
|