@letsrunit/mcp-server 0.23.1 → 0.23.2
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/README.md +3 -6
- package/dist/index.js +788 -37
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/bootstrap.ts +2 -3
- package/src/index.ts +32 -29
- package/src/utility/diagnostics.ts +1 -2
- package/dist/chunk-4A4A2HLV.js +0 -82
- package/dist/chunk-4A4A2HLV.js.map +0 -1
- package/dist/sessions-BUPV6ET4.js +0 -72
- package/dist/sessions-BUPV6ET4.js.map +0 -1
- package/dist/tools-INA4ALAI.js +0 -638
- package/dist/tools-INA4ALAI.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,46 +1,797 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import * as __m from 'node:module';
|
|
3
|
-
import { bootstrapProjectServer } from './chunk-4A4A2HLV.js';
|
|
4
3
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
5
4
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
import { spawnSync, execSync } from 'child_process';
|
|
6
|
+
import { loadLetsrunitEnv } from '@letsrunit/utils';
|
|
7
|
+
import { readFileSync, realpathSync, existsSync } from 'fs';
|
|
8
|
+
import { createRequire } from 'module';
|
|
9
|
+
import { join, dirname, resolve, isAbsolute } from 'path';
|
|
10
|
+
import { Controller } from '@letsrunit/controller';
|
|
11
|
+
import { MemorySink, Journal } from '@letsrunit/journal';
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import { loadConfiguration } from '@cucumber/cucumber/api';
|
|
14
|
+
import { registry } from '@letsrunit/bdd';
|
|
15
|
+
import { mkdir, writeFile, glob } from 'fs/promises';
|
|
16
|
+
import { pathToFileURL } from 'url';
|
|
17
|
+
import { scrubHtml, screenshotElement, screenshot, unifiedHtmlDiff } from '@letsrunit/playwright';
|
|
18
|
+
import { openStore, findLastTest, findArtifacts } from '@letsrunit/store';
|
|
19
|
+
import { scenarioIdFromGherkin } from '@letsrunit/gherkin';
|
|
6
20
|
|
|
7
21
|
__m.createRequire(import.meta.url);
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
function resolveProjectRoot() {
|
|
23
|
+
return resolve(process.env.LETSRUNIT_PROJECT_CWD ?? process.cwd());
|
|
24
|
+
}
|
|
25
|
+
function resolveFromProject(moduleId, projectRoot) {
|
|
26
|
+
try {
|
|
27
|
+
const req = createRequire(resolve(projectRoot, "package.json"));
|
|
28
|
+
return req.resolve(moduleId);
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function toRealpath(path) {
|
|
34
|
+
if (!path) return null;
|
|
35
|
+
try {
|
|
36
|
+
return realpathSync(path);
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function sameEntrypoint(a, b) {
|
|
42
|
+
if (!a || !b) return false;
|
|
43
|
+
return a === b;
|
|
44
|
+
}
|
|
45
|
+
function resolveRuntimeModeOverride() {
|
|
46
|
+
const runtimeMode = process.env.LETSRUNIT_MCP_RUNTIME_MODE;
|
|
47
|
+
if (runtimeMode == null || runtimeMode === "") {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
if (runtimeMode === "project" || runtimeMode === "standalone") {
|
|
51
|
+
return runtimeMode;
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`Invalid LETSRUNIT_MCP_RUNTIME_MODE: ${runtimeMode}. Expected "project" or "standalone".`);
|
|
54
|
+
}
|
|
55
|
+
function decideHandoff(currentEntrypointPath, projectEntrypointPath, runtimeModeOverride) {
|
|
56
|
+
if (runtimeModeOverride) {
|
|
57
|
+
return { shouldHandoff: false, runtimeMode: runtimeModeOverride };
|
|
58
|
+
}
|
|
59
|
+
if (!projectEntrypointPath) {
|
|
60
|
+
return { shouldHandoff: false, runtimeMode: "standalone" };
|
|
61
|
+
}
|
|
62
|
+
if (sameEntrypoint(currentEntrypointPath, projectEntrypointPath)) {
|
|
63
|
+
return { shouldHandoff: false, runtimeMode: "project" };
|
|
64
|
+
}
|
|
65
|
+
return { shouldHandoff: true, runtimeMode: "project" };
|
|
66
|
+
}
|
|
67
|
+
function runProjectLocalServer(projectEntrypointPath) {
|
|
68
|
+
const result = spawnSync(process.execPath, [projectEntrypointPath, ...process.argv.slice(2)], {
|
|
69
|
+
stdio: "inherit",
|
|
70
|
+
env: {
|
|
71
|
+
...process.env,
|
|
72
|
+
LETSRUNIT_MCP_RUNTIME_MODE: "project"
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
if (result.error) throw result.error;
|
|
76
|
+
process.exit(result.status ?? 1);
|
|
77
|
+
}
|
|
78
|
+
function bootstrapProjectServer(currentEntrypointPath) {
|
|
79
|
+
const projectRoot = resolveProjectRoot();
|
|
80
|
+
loadLetsrunitEnv(projectRoot);
|
|
81
|
+
const runtimeModeOverride = resolveRuntimeModeOverride();
|
|
82
|
+
const currentEntryPath = toRealpath(currentEntrypointPath ?? process.argv[1] ?? null);
|
|
83
|
+
const projectEntryPath = toRealpath(resolveFromProject("@letsrunit/mcp-server", projectRoot));
|
|
84
|
+
const decision = decideHandoff(currentEntryPath, projectEntryPath, runtimeModeOverride);
|
|
85
|
+
if (decision.shouldHandoff && projectEntryPath) {
|
|
86
|
+
runProjectLocalServer(projectEntryPath);
|
|
87
|
+
}
|
|
88
|
+
return decision.runtimeMode;
|
|
89
|
+
}
|
|
90
|
+
var SESSION_TIMEOUT_MS = 30 * 60 * 1e3;
|
|
91
|
+
var BASE_ARTIFACT_DIR = process.env.LETSRUNIT_ARTIFACT_DIR ?? join(process.env.HOME ?? "/tmp", ".letsrunit", "artifacts");
|
|
92
|
+
function sessionId() {
|
|
93
|
+
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
|
94
|
+
}
|
|
95
|
+
var SessionManager = class {
|
|
96
|
+
sessions = /* @__PURE__ */ new Map();
|
|
97
|
+
timers = /* @__PURE__ */ new Map();
|
|
98
|
+
async create(options = {}) {
|
|
99
|
+
const id = sessionId();
|
|
100
|
+
const artifactDir = join(BASE_ARTIFACT_DIR, id);
|
|
101
|
+
const sink = new MemorySink(artifactDir);
|
|
102
|
+
const journal = new Journal(sink);
|
|
103
|
+
const controller = await Controller.launch({ ...options, journal, capture: false });
|
|
104
|
+
const session = {
|
|
105
|
+
id,
|
|
106
|
+
controller,
|
|
107
|
+
sink,
|
|
108
|
+
journal,
|
|
109
|
+
artifactDir,
|
|
110
|
+
createdAt: Date.now(),
|
|
111
|
+
lastActivity: Date.now(),
|
|
112
|
+
stepCount: 0
|
|
113
|
+
};
|
|
114
|
+
this.sessions.set(id, session);
|
|
115
|
+
this.resetTimer(id);
|
|
116
|
+
return session;
|
|
117
|
+
}
|
|
118
|
+
get(id) {
|
|
119
|
+
const session = this.sessions.get(id);
|
|
120
|
+
if (!session) throw new Error(`Session not found: ${id}`);
|
|
121
|
+
return session;
|
|
122
|
+
}
|
|
123
|
+
has(id) {
|
|
124
|
+
return this.sessions.has(id);
|
|
125
|
+
}
|
|
126
|
+
touch(id) {
|
|
127
|
+
const session = this.sessions.get(id);
|
|
128
|
+
if (session) {
|
|
129
|
+
session.lastActivity = Date.now();
|
|
130
|
+
this.resetTimer(id);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
list() {
|
|
134
|
+
return Array.from(this.sessions.values());
|
|
135
|
+
}
|
|
136
|
+
async close(id) {
|
|
137
|
+
const session = this.sessions.get(id);
|
|
138
|
+
if (!session) return;
|
|
139
|
+
clearTimeout(this.timers.get(id));
|
|
140
|
+
this.timers.delete(id);
|
|
141
|
+
this.sessions.delete(id);
|
|
142
|
+
await session.controller.close();
|
|
143
|
+
}
|
|
144
|
+
resetTimer(id) {
|
|
145
|
+
clearTimeout(this.timers.get(id));
|
|
146
|
+
const timer = setTimeout(() => this.close(id), SESSION_TIMEOUT_MS);
|
|
147
|
+
if (typeof timer === "object" && "unref" in timer) timer.unref();
|
|
148
|
+
this.timers.set(id, timer);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// src/utility/response.ts
|
|
153
|
+
function text(content) {
|
|
154
|
+
return { content: [{ type: "text", text: content }] };
|
|
155
|
+
}
|
|
156
|
+
function err(message) {
|
|
157
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// src/tools/debug.ts
|
|
161
|
+
function registerDebug(server, sessions) {
|
|
162
|
+
server.registerTool(
|
|
163
|
+
"letsrunit_debug",
|
|
164
|
+
{
|
|
165
|
+
description: "Evaluate JavaScript on the current page via Playwright page.evaluate(). Use for debugging \u2014 not for test logic.",
|
|
166
|
+
inputSchema: {
|
|
167
|
+
sessionId: z.string().describe("Session ID"),
|
|
168
|
+
script: z.string().describe("JavaScript expression or function body to evaluate in the page context")
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
async (input) => {
|
|
172
|
+
try {
|
|
173
|
+
const session = sessions.get(input.sessionId);
|
|
174
|
+
sessions.touch(input.sessionId);
|
|
175
|
+
const result = await session.controller.page.evaluate(input.script);
|
|
176
|
+
return text(JSON.stringify({ result }));
|
|
177
|
+
} catch (e) {
|
|
178
|
+
return text(JSON.stringify({ result: null, error: e.message }));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
var CUCUMBER_CONFIG_FILES = [
|
|
184
|
+
"cucumber.js",
|
|
185
|
+
"cucumber.mjs",
|
|
186
|
+
"cucumber.cjs",
|
|
187
|
+
"cucumber.ts",
|
|
188
|
+
"cucumber.mts",
|
|
189
|
+
"cucumber.cts"
|
|
190
|
+
];
|
|
191
|
+
var loadedProjectRoots = /* @__PURE__ */ new Set();
|
|
192
|
+
var loadedSupportEntries = /* @__PURE__ */ new Set();
|
|
193
|
+
function toStrings(value) {
|
|
194
|
+
if (!Array.isArray(value)) return [];
|
|
195
|
+
return value.filter((entry) => typeof entry === "string");
|
|
196
|
+
}
|
|
197
|
+
function hasGlobMagic(input) {
|
|
198
|
+
return /[*?[\]{}]/.test(input);
|
|
199
|
+
}
|
|
200
|
+
function isPathLike(input) {
|
|
201
|
+
return input.startsWith(".") || input.startsWith("/") || /^[A-Za-z]:[\\/]/.test(input);
|
|
202
|
+
}
|
|
203
|
+
function toAbsolutePath(baseDir, input) {
|
|
204
|
+
return isAbsolute(input) ? resolve(input) : resolve(baseDir, input);
|
|
205
|
+
}
|
|
206
|
+
function normalizeMatch(baseDir, match) {
|
|
207
|
+
return isAbsolute(match) ? resolve(match) : resolve(baseDir, match);
|
|
208
|
+
}
|
|
209
|
+
async function expandPathPatterns(baseDir, patterns) {
|
|
210
|
+
const files = /* @__PURE__ */ new Set();
|
|
211
|
+
for (const pattern of patterns) {
|
|
212
|
+
if (hasGlobMagic(pattern)) {
|
|
213
|
+
for await (const match of glob(pattern, { cwd: baseDir })) {
|
|
214
|
+
files.add(normalizeMatch(baseDir, match));
|
|
215
|
+
}
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
files.add(toAbsolutePath(baseDir, pattern));
|
|
219
|
+
}
|
|
220
|
+
return files;
|
|
221
|
+
}
|
|
222
|
+
async function resolveSupportEntries(baseDir, entries) {
|
|
223
|
+
const resolved = [];
|
|
224
|
+
for (const entry of entries) {
|
|
225
|
+
if (hasGlobMagic(entry)) {
|
|
226
|
+
for await (const match of glob(entry, { cwd: baseDir })) {
|
|
227
|
+
resolved.push({ kind: "path", value: normalizeMatch(baseDir, match) });
|
|
228
|
+
}
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (!isPathLike(entry)) {
|
|
232
|
+
resolved.push({ kind: "module", value: entry });
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
resolved.push({ kind: "path", value: toAbsolutePath(baseDir, entry) });
|
|
236
|
+
}
|
|
237
|
+
return resolved;
|
|
238
|
+
}
|
|
239
|
+
function findCucumberConfig(cwd) {
|
|
240
|
+
for (const filename of CUCUMBER_CONFIG_FILES) {
|
|
241
|
+
const path = resolve(cwd, filename);
|
|
242
|
+
if (existsSync(path)) return path;
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
async function loadLetsrunitIgnorePatterns(cwd) {
|
|
247
|
+
const configPath = findCucumberConfig(cwd);
|
|
248
|
+
if (!configPath) return [];
|
|
249
|
+
const configModule = await import(pathToFileURL(configPath).href);
|
|
250
|
+
const config = configModule.default ?? configModule;
|
|
251
|
+
return toStrings(config.letsrunit?.ignore);
|
|
252
|
+
}
|
|
253
|
+
function resolveEffectiveCwd(cwd) {
|
|
254
|
+
return cwd ?? process.env.LETSRUNIT_PROJECT_CWD ?? process.cwd();
|
|
255
|
+
}
|
|
256
|
+
function getSupportLoadState() {
|
|
257
|
+
return {
|
|
258
|
+
loadedProjectRoots: [...loadedProjectRoots].sort(),
|
|
259
|
+
loadedSupportEntries: [...loadedSupportEntries].sort()
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
function clearSupportLoadState() {
|
|
263
|
+
loadedProjectRoots.clear();
|
|
264
|
+
loadedSupportEntries.clear();
|
|
265
|
+
}
|
|
266
|
+
function buildReloadedFileUrl(path) {
|
|
267
|
+
const url = pathToFileURL(path);
|
|
268
|
+
url.searchParams.set("letsrunitReload", Date.now().toString(36));
|
|
269
|
+
return url.href;
|
|
270
|
+
}
|
|
271
|
+
async function loadSupportFiles(cwd, options) {
|
|
272
|
+
const projectRoot = resolve(resolveEffectiveCwd(cwd));
|
|
273
|
+
const forceReload = options?.forceReload === true;
|
|
274
|
+
if (!forceReload && loadedProjectRoots.has(projectRoot)) {
|
|
275
|
+
return { projectRoot, supportEntriesLoaded: 0, ignoredEntries: 0 };
|
|
276
|
+
}
|
|
277
|
+
const { useConfiguration } = await loadConfiguration({}, { cwd: projectRoot });
|
|
278
|
+
const supportPatterns = [...toStrings(useConfiguration.require), ...toStrings(useConfiguration.import)];
|
|
279
|
+
if (supportPatterns.length === 0) {
|
|
280
|
+
loadedProjectRoots.add(projectRoot);
|
|
281
|
+
return { projectRoot, supportEntriesLoaded: 0, ignoredEntries: 0 };
|
|
282
|
+
}
|
|
283
|
+
const ignorePatterns = await loadLetsrunitIgnorePatterns(projectRoot);
|
|
284
|
+
const ignoredPaths = await expandPathPatterns(projectRoot, ignorePatterns);
|
|
285
|
+
const supportEntries = await resolveSupportEntries(projectRoot, supportPatterns);
|
|
286
|
+
let supportEntriesLoaded = 0;
|
|
287
|
+
let ignoredEntries = 0;
|
|
288
|
+
for (const entry of supportEntries) {
|
|
289
|
+
if (entry.kind === "path" && ignoredPaths.has(entry.value)) {
|
|
290
|
+
ignoredEntries += 1;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const key = `${entry.kind}:${entry.value}`;
|
|
294
|
+
if (!forceReload && loadedSupportEntries.has(key)) continue;
|
|
295
|
+
if (entry.kind === "path") {
|
|
296
|
+
await (forceReload ? import(buildReloadedFileUrl(entry.value)) : import(pathToFileURL(entry.value).href));
|
|
297
|
+
} else {
|
|
298
|
+
await import(entry.value);
|
|
299
|
+
}
|
|
300
|
+
supportEntriesLoaded += 1;
|
|
301
|
+
loadedSupportEntries.add(key);
|
|
302
|
+
}
|
|
303
|
+
loadedProjectRoots.add(projectRoot);
|
|
304
|
+
return { projectRoot, supportEntriesLoaded, ignoredEntries };
|
|
305
|
+
}
|
|
306
|
+
async function reloadSupportFiles(cwd) {
|
|
307
|
+
clearSupportLoadState();
|
|
308
|
+
return loadSupportFiles(cwd, { forceReload: true });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// src/utility/diagnostics.ts
|
|
312
|
+
function resolveFrom(moduleId, fromPath) {
|
|
313
|
+
try {
|
|
314
|
+
const req = createRequire(fromPath);
|
|
315
|
+
return req.resolve(moduleId);
|
|
316
|
+
} catch {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
function toRealpath2(path) {
|
|
321
|
+
if (!path) return null;
|
|
322
|
+
try {
|
|
323
|
+
return realpathSync(path);
|
|
324
|
+
} catch {
|
|
325
|
+
return path;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
function pickLetsrunitEnv() {
|
|
329
|
+
return Object.fromEntries(
|
|
330
|
+
Object.entries(process.env).filter(([key, value]) => key.startsWith("LETSRUNIT_") && typeof value === "string").map(([key, value]) => [key, value])
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
async function collectDiagnostics(cwd) {
|
|
334
|
+
const effectiveCwd = resolveEffectiveCwd(cwd);
|
|
335
|
+
const projectRoot = resolve(effectiveCwd);
|
|
336
|
+
const cucumberConfigPath = findCucumberConfig(projectRoot);
|
|
337
|
+
const { useConfiguration } = await loadConfiguration({}, { cwd: projectRoot });
|
|
338
|
+
const supportPatterns = [...useConfiguration.require ?? [], ...useConfiguration.import ?? []];
|
|
339
|
+
const ignorePatterns = await loadLetsrunitIgnorePatterns(projectRoot);
|
|
340
|
+
const ignoredPaths = await expandPathPatterns(projectRoot, ignorePatterns);
|
|
341
|
+
const supportEntries = await resolveSupportEntries(projectRoot, supportPatterns);
|
|
342
|
+
const supportLoadState = getSupportLoadState();
|
|
343
|
+
const serverBddPath = toRealpath2(resolveFrom("@letsrunit/bdd", import.meta.url));
|
|
344
|
+
const projectBddPath = toRealpath2(resolveFrom("@letsrunit/bdd", resolve(projectRoot, "package.json")));
|
|
345
|
+
const projectMcpEntryPath = resolveFrom("@letsrunit/mcp-server", resolve(projectRoot, "package.json"));
|
|
346
|
+
const currentEntrypointPath = toRealpath2(process.argv[1] ?? null);
|
|
347
|
+
const projectEntrypointPath = toRealpath2(projectMcpEntryPath);
|
|
348
|
+
const handoffDecision = decideHandoff(
|
|
349
|
+
currentEntrypointPath,
|
|
350
|
+
projectEntrypointPath,
|
|
351
|
+
resolveRuntimeModeOverride()
|
|
352
|
+
);
|
|
353
|
+
const serverMcpPath = toRealpath2(resolveFrom("@letsrunit/mcp-server", import.meta.url));
|
|
354
|
+
const projectMcpPath = toRealpath2(projectMcpEntryPath);
|
|
355
|
+
const executablePath = toRealpath2(process.argv[1] ?? null);
|
|
356
|
+
const version2 = "0.23.2" ;
|
|
357
|
+
const registryDefinitions = registry.defs.map((def) => ({
|
|
358
|
+
type: def.type,
|
|
359
|
+
source: def.source,
|
|
360
|
+
comment: def.comment
|
|
361
|
+
}));
|
|
362
|
+
return {
|
|
363
|
+
envProjectCwd: process.env.LETSRUNIT_PROJECT_CWD ?? null,
|
|
364
|
+
processCwd: process.cwd(),
|
|
365
|
+
inputCwd: cwd ?? null,
|
|
366
|
+
effectiveCwd,
|
|
367
|
+
projectRoot,
|
|
368
|
+
cucumberConfigPath,
|
|
369
|
+
supportPatterns,
|
|
370
|
+
ignorePatterns,
|
|
371
|
+
ignoredPaths: [...ignoredPaths].sort(),
|
|
372
|
+
supportEntries,
|
|
373
|
+
loadedProjectRoots: supportLoadState.loadedProjectRoots,
|
|
374
|
+
loadedSupportEntries: supportLoadState.loadedSupportEntries,
|
|
375
|
+
mcpServer: {
|
|
376
|
+
version: version2,
|
|
377
|
+
executablePath,
|
|
378
|
+
projectServerUsed: handoffDecision.runtimeMode === "project",
|
|
379
|
+
handoffDecision: {
|
|
380
|
+
shouldHandoff: handoffDecision.shouldHandoff,
|
|
381
|
+
runtimeMode: handoffDecision.runtimeMode
|
|
382
|
+
},
|
|
383
|
+
serverMcpPath,
|
|
384
|
+
projectMcpPath
|
|
385
|
+
},
|
|
386
|
+
letsrunitEnv: pickLetsrunitEnv(),
|
|
387
|
+
moduleResolution: {
|
|
388
|
+
serverBddPath,
|
|
389
|
+
projectBddPath
|
|
390
|
+
},
|
|
391
|
+
registry: {
|
|
392
|
+
total: registryDefinitions.length,
|
|
393
|
+
byType: {
|
|
394
|
+
Given: registryDefinitions.filter((d) => d.type === "Given").length,
|
|
395
|
+
When: registryDefinitions.filter((d) => d.type === "When").length,
|
|
396
|
+
Then: registryDefinitions.filter((d) => d.type === "Then").length
|
|
397
|
+
},
|
|
398
|
+
definitions: registryDefinitions
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// src/tools/diagnostics.ts
|
|
404
|
+
function registerDiagnostics(server, sessions) {
|
|
405
|
+
server.registerTool(
|
|
406
|
+
"letsrunit_diagnostics",
|
|
407
|
+
{
|
|
408
|
+
description: "Return runtime diagnostics for MCP support-file loading (cwd resolution, cucumber config path, support entries). Available only when LETSRUNIT_MCP_DIAGNOSTICS=enabled.",
|
|
409
|
+
inputSchema: {
|
|
410
|
+
sessionId: z.string().describe("Session ID returned by letsrunit_session_start")
|
|
411
|
+
}
|
|
412
|
+
},
|
|
413
|
+
async (input) => {
|
|
414
|
+
try {
|
|
415
|
+
const diagnostics = await collectDiagnostics();
|
|
416
|
+
const session = sessions.get(input.sessionId);
|
|
417
|
+
const sessionInfo = {
|
|
418
|
+
sessionId: session.id,
|
|
419
|
+
createdAt: session.createdAt,
|
|
420
|
+
lastActivity: session.lastActivity,
|
|
421
|
+
stepCount: session.stepCount,
|
|
422
|
+
artifactDir: session.artifactDir,
|
|
423
|
+
pageUrl: session.controller.page.url()
|
|
424
|
+
};
|
|
425
|
+
return text(JSON.stringify({ ...diagnostics, session: sessionInfo }));
|
|
426
|
+
} catch (e) {
|
|
427
|
+
return err(`Diagnostics failed: ${e.message}`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
var DEFAULT_DB_PATH = join(process.cwd(), ".letsrunit", "letsrunit.db");
|
|
433
|
+
function getDbPath() {
|
|
434
|
+
return process.env.LETSRUNIT_DB_PATH ?? DEFAULT_DB_PATH;
|
|
435
|
+
}
|
|
436
|
+
function resolveAllowedCommits() {
|
|
437
|
+
try {
|
|
438
|
+
const output = execSync("git log --format=%H", { encoding: "utf8" });
|
|
439
|
+
return output.trim().split("\n").filter(Boolean);
|
|
440
|
+
} catch {
|
|
441
|
+
return void 0;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
function registerDiff(server, sessions) {
|
|
445
|
+
server.registerTool(
|
|
446
|
+
"letsrunit_diff",
|
|
447
|
+
{
|
|
448
|
+
description: "Diff the current live page against the HTML snapshot from the last passing test of a scenario. Pass the scenarioId returned by letsrunit_run. Returns a unified HTML diff and paths to baseline screenshots. By default only considers baseline tests from the current git ancestry (gitTreeOnly: true).",
|
|
449
|
+
inputSchema: {
|
|
450
|
+
sessionId: z.string().describe("Session ID returned by letsrunit_session_start"),
|
|
451
|
+
scenarioId: z.string().describe("Scenario UUID returned by letsrunit_run"),
|
|
452
|
+
gitTreeOnly: z.boolean().optional().describe("Restrict baseline to tests from the current git ancestry (default: true)")
|
|
453
|
+
}
|
|
454
|
+
},
|
|
455
|
+
async (input) => {
|
|
456
|
+
const dbPath = getDbPath();
|
|
457
|
+
const artifactDir = join(dirname(dbPath), "artifacts");
|
|
458
|
+
let db;
|
|
459
|
+
try {
|
|
460
|
+
try {
|
|
461
|
+
db = openStore(dbPath);
|
|
462
|
+
} catch {
|
|
463
|
+
return err("Could not open the letsrunit store. Run cucumber with the store formatter first.");
|
|
464
|
+
}
|
|
465
|
+
const allowedCommits = input.gitTreeOnly ?? true ? resolveAllowedCommits() : void 0;
|
|
466
|
+
const test = findLastTest(db, input.scenarioId, "passed", allowedCommits ?? void 0);
|
|
467
|
+
if (!test) {
|
|
468
|
+
return err(
|
|
469
|
+
allowedCommits ? "No passing test found for this scenario in the current git ancestry. Try gitTreeOnly: false or run cucumber first." : "No passing test found for this scenario."
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
const artifacts = findArtifacts(db, test.id);
|
|
473
|
+
const htmlArtifact = [...artifacts].reverse().find((a) => a.filename.endsWith(".html"));
|
|
474
|
+
if (!htmlArtifact) {
|
|
475
|
+
return err("No HTML snapshot found in the baseline test. Ensure the store formatter is configured.");
|
|
476
|
+
}
|
|
477
|
+
const storedHtml = readFileSync(join(artifactDir, htmlArtifact.filename), "utf-8");
|
|
478
|
+
const session = sessions.get(input.sessionId);
|
|
479
|
+
sessions.touch(input.sessionId);
|
|
480
|
+
const diff = await unifiedHtmlDiff({ html: storedHtml, url: "about:blank" }, session.controller.page);
|
|
481
|
+
const screenshots = artifacts.filter((a) => a.stepIdx === htmlArtifact.stepIdx && a.filename.endsWith(".png")).map((a) => join(artifactDir, a.filename));
|
|
482
|
+
return text(
|
|
483
|
+
JSON.stringify({
|
|
484
|
+
diff,
|
|
485
|
+
baseline: {
|
|
486
|
+
testId: test.id,
|
|
487
|
+
commit: test.gitCommit,
|
|
488
|
+
screenshots
|
|
489
|
+
}
|
|
490
|
+
})
|
|
491
|
+
);
|
|
492
|
+
} catch (e) {
|
|
493
|
+
return err(`Diff failed: ${e.message}`);
|
|
494
|
+
} finally {
|
|
495
|
+
db?.close();
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
var stepTypeSchema = z.enum(["Given", "When", "Then"]);
|
|
501
|
+
function registerListSteps(server, sessions) {
|
|
502
|
+
server.registerTool(
|
|
503
|
+
"letsrunit_list_steps",
|
|
504
|
+
{
|
|
505
|
+
description: "List available step definitions for a session. Optionally filter by step type (Given/When/Then).",
|
|
506
|
+
inputSchema: {
|
|
507
|
+
sessionId: z.string().describe("Session ID returned by letsrunit_session_start"),
|
|
508
|
+
type: stepTypeSchema.optional().describe("Optional step type filter")
|
|
509
|
+
}
|
|
510
|
+
},
|
|
511
|
+
async (input) => {
|
|
512
|
+
try {
|
|
513
|
+
const session = sessions.get(input.sessionId);
|
|
514
|
+
sessions.touch(input.sessionId);
|
|
515
|
+
const steps = session.controller.listSteps(input.type);
|
|
516
|
+
return text(JSON.stringify({ steps }));
|
|
517
|
+
} catch (e) {
|
|
518
|
+
return err(`List steps failed: ${e.message}`);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// src/tools/list-sessions.ts
|
|
525
|
+
function registerListSessions(server, sessions) {
|
|
526
|
+
server.registerTool(
|
|
527
|
+
"letsrunit_list_sessions",
|
|
528
|
+
{
|
|
529
|
+
description: "List all active browser sessions.",
|
|
530
|
+
inputSchema: {}
|
|
531
|
+
},
|
|
532
|
+
async () => {
|
|
533
|
+
const list = sessions.list().map((s) => ({
|
|
534
|
+
sessionId: s.id,
|
|
535
|
+
createdAt: s.createdAt,
|
|
536
|
+
lastActivity: s.lastActivity,
|
|
537
|
+
stepCount: s.stepCount,
|
|
538
|
+
artifactDir: s.artifactDir
|
|
539
|
+
}));
|
|
540
|
+
return text(JSON.stringify({ sessions: list }));
|
|
541
|
+
}
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// src/utility/gherkin.ts
|
|
546
|
+
function normalizeGherkin(input) {
|
|
547
|
+
const trimmed = input.trim();
|
|
548
|
+
if (/^(Feature|Scenario|Background):/im.test(trimmed)) {
|
|
549
|
+
return trimmed;
|
|
550
|
+
}
|
|
551
|
+
return `Feature: MCP
|
|
552
|
+
|
|
553
|
+
Scenario: Steps
|
|
554
|
+
${trimmed.split("\n").join("\n ")}`;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// src/tools/run.ts
|
|
558
|
+
function registerRun(server, sessions) {
|
|
559
|
+
server.registerTool(
|
|
560
|
+
"letsrunit_run",
|
|
561
|
+
{
|
|
562
|
+
description: "Execute Gherkin steps or a complete feature in the browser. Accepts a single step line, multiple step lines, a full Scenario, or a full Feature. Returns status, steps, reason on failure, and journal entries. Does not return a page snapshot \u2014 call letsrunit_snapshot explicitly if you need the DOM.",
|
|
563
|
+
inputSchema: {
|
|
564
|
+
sessionId: z.string().describe("Session ID returned by letsrunit_session_start"),
|
|
565
|
+
input: z.string().describe(
|
|
566
|
+
'Gherkin text to execute: one or more step lines (e.g. "Given I am on \\"https://example.com\\""), a Scenario block, or a full Feature block.'
|
|
567
|
+
)
|
|
568
|
+
}
|
|
569
|
+
},
|
|
570
|
+
async (input) => {
|
|
571
|
+
try {
|
|
572
|
+
const session = sessions.get(input.sessionId);
|
|
573
|
+
sessions.touch(input.sessionId);
|
|
574
|
+
const feature = normalizeGherkin(input.input);
|
|
575
|
+
session.sink.clear();
|
|
576
|
+
const result = await session.controller.run(feature);
|
|
577
|
+
session.stepCount += result.steps.length;
|
|
578
|
+
return text(
|
|
579
|
+
JSON.stringify({
|
|
580
|
+
status: result.status,
|
|
581
|
+
steps: result.steps,
|
|
582
|
+
reason: result.reason?.message,
|
|
583
|
+
journal: session.sink.getEntries(),
|
|
584
|
+
scenarioId: scenarioIdFromGherkin(input.input)
|
|
585
|
+
})
|
|
586
|
+
);
|
|
587
|
+
} catch (e) {
|
|
588
|
+
return err(`Run failed: ${e.message}`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
async function resetBuiltInStepRegistry() {
|
|
594
|
+
const bdd = await import('@letsrunit/bdd');
|
|
595
|
+
const reset = bdd.resetRegistryToBuiltInSteps;
|
|
596
|
+
if (typeof reset !== "function") {
|
|
597
|
+
throw new Error(
|
|
598
|
+
"Installed @letsrunit/bdd does not expose resetRegistryToBuiltInSteps. Update @letsrunit/bdd to a compatible version."
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
reset();
|
|
602
|
+
}
|
|
603
|
+
function registerReload(server, options) {
|
|
604
|
+
server.registerTool(
|
|
605
|
+
"letsrunit_reload",
|
|
606
|
+
{
|
|
607
|
+
description: "Reload built-in and project support step definitions without restarting the MCP server.",
|
|
608
|
+
inputSchema: {
|
|
609
|
+
cwd: z.string().optional().describe("Project directory to resolve cucumber support files from. Defaults to current project cwd.")
|
|
610
|
+
}
|
|
611
|
+
},
|
|
612
|
+
async (input) => {
|
|
613
|
+
if (options.runtimeMode !== "project") {
|
|
614
|
+
return err("Reload failed: letsrunit_reload is only available in project runtime mode.");
|
|
615
|
+
}
|
|
616
|
+
try {
|
|
617
|
+
await resetBuiltInStepRegistry();
|
|
618
|
+
const result = await reloadSupportFiles(input.cwd);
|
|
619
|
+
return text(
|
|
620
|
+
JSON.stringify({
|
|
621
|
+
reloaded: true,
|
|
622
|
+
projectRoot: result.projectRoot,
|
|
623
|
+
supportEntriesLoaded: result.supportEntriesLoaded,
|
|
624
|
+
ignoredEntries: result.ignoredEntries
|
|
625
|
+
})
|
|
626
|
+
);
|
|
627
|
+
} catch (e) {
|
|
628
|
+
return err(`Reload failed: ${e.message}`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
function registerScreenshot(server, sessions) {
|
|
634
|
+
server.registerTool(
|
|
635
|
+
"letsrunit_screenshot",
|
|
636
|
+
{
|
|
637
|
+
description: "Take a screenshot of the current page. Optionally crop to a specific element (selector) or highlight elements before capturing (mask).",
|
|
638
|
+
inputSchema: {
|
|
639
|
+
sessionId: z.string().describe("Session ID"),
|
|
640
|
+
selector: z.string().optional().describe("CSS selector \u2014 crop screenshot to the bounding box of this element"),
|
|
641
|
+
mask: z.array(z.string()).optional().describe("CSS selectors whose matching elements are highlighted (dark overlay, element spotlighted)."),
|
|
642
|
+
fullPage: z.boolean().optional().describe("Capture the full scrollable page (default: false)")
|
|
643
|
+
}
|
|
644
|
+
},
|
|
645
|
+
async (input) => {
|
|
646
|
+
try {
|
|
647
|
+
const session = sessions.get(input.sessionId);
|
|
648
|
+
sessions.touch(input.sessionId);
|
|
649
|
+
const page = session.controller.page;
|
|
650
|
+
let file;
|
|
651
|
+
if (input.selector) {
|
|
652
|
+
file = await screenshotElement(page, input.selector);
|
|
653
|
+
} else {
|
|
654
|
+
const masks = input.mask?.map((sel) => page.locator(sel)) ?? [];
|
|
655
|
+
file = await screenshot(page, {
|
|
656
|
+
fullPage: input.fullPage ?? false,
|
|
657
|
+
...masks.length ? { mask: masks } : {}
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
await mkdir(session.artifactDir, { recursive: true });
|
|
661
|
+
const path = join(session.artifactDir, file.name);
|
|
662
|
+
await writeFile(path, await file.bytes());
|
|
663
|
+
return text(JSON.stringify({ path, mimeType: "image/png" }));
|
|
664
|
+
} catch (e) {
|
|
665
|
+
return err(`Screenshot failed: ${e.message}`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
function registerSessionClose(server, sessions) {
|
|
671
|
+
server.registerTool(
|
|
672
|
+
"letsrunit_session_close",
|
|
673
|
+
{
|
|
674
|
+
description: "Close a browser session and release its resources.",
|
|
675
|
+
inputSchema: {
|
|
676
|
+
sessionId: z.string().describe("Session ID to close")
|
|
677
|
+
}
|
|
678
|
+
},
|
|
679
|
+
async (input) => {
|
|
680
|
+
try {
|
|
681
|
+
await sessions.close(input.sessionId);
|
|
682
|
+
return text(JSON.stringify({ closed: true }));
|
|
683
|
+
} catch (e) {
|
|
684
|
+
return err(`Failed to close session: ${e.message}`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
function registerSessionStart(server, sessions, opts) {
|
|
690
|
+
server.registerTool(
|
|
691
|
+
"letsrunit_session_start",
|
|
692
|
+
{
|
|
693
|
+
description: `Launch a new browser session. Does not navigate anywhere \u2014 use letsrunit_run with a Given step to navigate. Set baseURL to enable relative paths like "Given I'm on the homepage".`,
|
|
694
|
+
inputSchema: {
|
|
695
|
+
baseURL: z.string().optional().describe(
|
|
696
|
+
`Base URL for the session, e.g. "http://localhost:3000". Enables relative paths in Given steps like "Given I'm on the homepage" or "Given I'm on page \\"/login\\""`
|
|
697
|
+
),
|
|
698
|
+
language: z.string().optional().describe("Browser language code, e.g. 'en', 'fr'"),
|
|
699
|
+
headless: z.boolean().optional().describe("Run browser in headless mode (default: true)"),
|
|
700
|
+
viewportWidth: z.number().int().optional().describe("Viewport width in pixels (default: 1280)"),
|
|
701
|
+
viewportHeight: z.number().int().optional().describe("Viewport height in pixels (default: 720)")
|
|
702
|
+
}
|
|
703
|
+
},
|
|
704
|
+
async (input) => {
|
|
705
|
+
try {
|
|
706
|
+
if (opts?.runtimeMode === "project") {
|
|
707
|
+
await loadSupportFiles();
|
|
708
|
+
}
|
|
709
|
+
const viewport = input.viewportWidth || input.viewportHeight ? { width: input.viewportWidth ?? 1280, height: input.viewportHeight ?? 720 } : void 0;
|
|
710
|
+
const session = await sessions.create({
|
|
711
|
+
baseURL: input.baseURL,
|
|
712
|
+
headless: input.headless ?? true,
|
|
713
|
+
locale: input.language,
|
|
714
|
+
viewport
|
|
715
|
+
});
|
|
716
|
+
return text(JSON.stringify({ sessionId: session.id }));
|
|
717
|
+
} catch (e) {
|
|
718
|
+
return err(`Failed to start session: ${e.message}`);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
var stripAttributesSchema = z.nativeEnum({
|
|
724
|
+
none: 0,
|
|
725
|
+
semantic: 1,
|
|
726
|
+
aggressive: 2
|
|
29
727
|
});
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
728
|
+
function registerSnapshot(server, sessions) {
|
|
729
|
+
server.registerTool(
|
|
730
|
+
"letsrunit_snapshot",
|
|
731
|
+
{
|
|
732
|
+
description: "Get the current page HTML, scrubbed for LLM consumption. Use selector to scope to a DOM subtree.",
|
|
733
|
+
inputSchema: {
|
|
734
|
+
sessionId: z.string().describe("Session ID"),
|
|
735
|
+
selector: z.string().optional().describe("CSS selector \u2014 return only the matching element's outer HTML instead of the full page"),
|
|
736
|
+
dropHidden: z.boolean().optional().describe("Remove hidden/inert nodes (default: true)"),
|
|
737
|
+
dropHead: z.boolean().optional().describe("Remove the <head> element (default: true)"),
|
|
738
|
+
pickMain: z.boolean().optional().describe("Keep only the <main> element (default: auto)"),
|
|
739
|
+
stripAttributes: stripAttributesSchema.optional().describe("Attribute allowlist level: 0=none, 1=semantic (default), 2=aggressive")
|
|
740
|
+
}
|
|
741
|
+
},
|
|
742
|
+
async (input) => {
|
|
743
|
+
try {
|
|
744
|
+
const session = sessions.get(input.sessionId);
|
|
745
|
+
sessions.touch(input.sessionId);
|
|
746
|
+
const page = session.controller.page;
|
|
747
|
+
const url = page.url();
|
|
748
|
+
const opts = {
|
|
749
|
+
dropHidden: input.dropHidden,
|
|
750
|
+
dropHead: input.dropHead,
|
|
751
|
+
pickMain: input.pickMain,
|
|
752
|
+
stripAttributes: input.stripAttributes
|
|
753
|
+
};
|
|
754
|
+
let html;
|
|
755
|
+
if (input.selector) {
|
|
756
|
+
const rawHtml = await page.locator(input.selector).first().evaluate((el) => el.outerHTML);
|
|
757
|
+
html = await scrubHtml({ html: rawHtml, url }, opts);
|
|
758
|
+
} else {
|
|
759
|
+
html = await scrubHtml(page, opts);
|
|
760
|
+
}
|
|
761
|
+
return text(JSON.stringify({ url, html }));
|
|
762
|
+
} catch (e) {
|
|
763
|
+
return err(`Snapshot failed: ${e.message}`);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// src/index.ts
|
|
770
|
+
var version = "0.23.2" ;
|
|
771
|
+
async function main() {
|
|
772
|
+
const runtimeMode = bootstrapProjectServer(process.argv[1] ?? null);
|
|
773
|
+
const sessions = new SessionManager();
|
|
774
|
+
const server = new McpServer({
|
|
775
|
+
name: "letsrunit",
|
|
776
|
+
version,
|
|
777
|
+
websiteUrl: "https://letsrunit.ai"
|
|
778
|
+
});
|
|
779
|
+
registerSessionStart(server, sessions, { runtimeMode });
|
|
780
|
+
registerRun(server, sessions);
|
|
781
|
+
registerSnapshot(server, sessions);
|
|
782
|
+
registerScreenshot(server, sessions);
|
|
783
|
+
registerDebug(server, sessions);
|
|
784
|
+
registerSessionClose(server, sessions);
|
|
785
|
+
registerListSteps(server, sessions);
|
|
786
|
+
registerListSessions(server, sessions);
|
|
787
|
+
registerReload(server, { runtimeMode });
|
|
788
|
+
registerDiff(server, sessions);
|
|
789
|
+
if (process.env.LETSRUNIT_MCP_DIAGNOSTICS === "enabled") {
|
|
790
|
+
registerDiagnostics(server, sessions);
|
|
791
|
+
}
|
|
792
|
+
const transport = new StdioServerTransport();
|
|
793
|
+
await server.connect(transport);
|
|
794
|
+
}
|
|
795
|
+
void main();
|
|
45
796
|
//# sourceMappingURL=index.js.map
|
|
46
797
|
//# sourceMappingURL=index.js.map
|