@letsrunit/mcp-server 0.9.1 → 0.11.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.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letsrunit/mcp-server",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "description": "MCP server for letsrunit — AI-agent browser test generation and execution",
5
5
  "keywords": [
6
6
  "testing",
@@ -46,12 +46,13 @@
46
46
  },
47
47
  "packageManager": "yarn@4.10.3",
48
48
  "dependencies": {
49
- "@letsrunit/controller": "0.9.1",
50
- "@letsrunit/gherkin": "0.9.1",
51
- "@letsrunit/journal": "0.9.1",
52
- "@letsrunit/playwright": "0.9.1",
53
- "@letsrunit/store": "0.9.1",
54
- "@letsrunit/utils": "0.9.1",
49
+ "@cucumber/cucumber": "^12.7.0",
50
+ "@letsrunit/controller": "0.11.0",
51
+ "@letsrunit/gherkin": "0.11.0",
52
+ "@letsrunit/journal": "0.11.0",
53
+ "@letsrunit/playwright": "0.11.0",
54
+ "@letsrunit/store": "0.11.0",
55
+ "@letsrunit/utils": "0.11.0",
55
56
  "@modelcontextprotocol/sdk": "^1.26.0",
56
57
  "@playwright/test": "^1.57.0",
57
58
  "zod": "^4.3.5"
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ import { SessionManager } from './sessions';
6
6
  const { version } = createRequire(import.meta.url)('../package.json') as { version: string };
7
7
  import {
8
8
  registerDebug,
9
+ registerDiagnostics,
9
10
  registerDiff,
10
11
  registerListSteps,
11
12
  registerListSessions,
@@ -33,6 +34,9 @@ registerSessionClose(server, sessions);
33
34
  registerListSteps(server, sessions);
34
35
  registerListSessions(server, sessions);
35
36
  registerDiff(server, sessions);
37
+ if (process.env.LETSRUNIT_MCP_DIAGNOSTICS === 'enabled') {
38
+ registerDiagnostics(server);
39
+ }
36
40
 
37
41
  const transport = new StdioServerTransport();
38
42
  await server.connect(transport);
@@ -0,0 +1,22 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { collectSupportDiagnostics } from '../utility/support';
3
+ import { err, text } from '../utility/response';
4
+
5
+ export function registerDiagnostics(server: McpServer): void {
6
+ server.registerTool(
7
+ 'letsrunit_diagnostics',
8
+ {
9
+ description:
10
+ 'Return runtime diagnostics for MCP support-file loading (cwd resolution, cucumber config path, support entries). Available only when LETSRUNIT_MCP_DIAGNOSTICS=enabled.',
11
+ inputSchema: {},
12
+ },
13
+ async () => {
14
+ try {
15
+ const diagnostics = await collectSupportDiagnostics();
16
+ return text(JSON.stringify(diagnostics));
17
+ } catch (e) {
18
+ return err(`Diagnostics failed: ${(e as Error).message}`);
19
+ }
20
+ },
21
+ );
22
+ }
package/src/tools/diff.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { unifiedHtmlDiff } from '@letsrunit/playwright';
3
- import { openStore, findLastRun, findArtifacts } from '@letsrunit/store';
3
+ import { openStore, findLastTest, findArtifacts } from '@letsrunit/store';
4
4
  import { execSync } from 'node:child_process';
5
5
  import { readFileSync } from 'node:fs';
6
6
  import { dirname, join } from 'node:path';
@@ -28,17 +28,17 @@ export function registerDiff(server: McpServer, sessions: SessionManager): void
28
28
  'letsrunit_diff',
29
29
  {
30
30
  description:
31
- 'Diff the current live page against the HTML snapshot from the last passing run of a scenario. ' +
31
+ 'Diff the current live page against the HTML snapshot from the last passing test of a scenario. ' +
32
32
  'Pass the scenarioId returned by letsrunit_run. ' +
33
33
  'Returns a unified HTML diff and paths to baseline screenshots. ' +
34
- 'By default only considers baseline runs from the current git ancestry (gitTreeOnly: true).',
34
+ 'By default only considers baseline tests from the current git ancestry (gitTreeOnly: true).',
35
35
  inputSchema: {
36
36
  sessionId: z.string().describe('Session ID returned by letsrunit_session_start'),
37
37
  scenarioId: z.string().describe('Scenario UUID returned by letsrunit_run'),
38
38
  gitTreeOnly: z
39
39
  .boolean()
40
40
  .optional()
41
- .describe('Restrict baseline to runs from the current git ancestry (default: true)'),
41
+ .describe('Restrict baseline to tests from the current git ancestry (default: true)'),
42
42
  },
43
43
  },
44
44
  async (input) => {
@@ -55,20 +55,20 @@ export function registerDiff(server: McpServer, sessions: SessionManager): void
55
55
 
56
56
  const allowedCommits = (input.gitTreeOnly ?? true) ? resolveAllowedCommits() : undefined;
57
57
 
58
- const run = findLastRun(db, input.scenarioId, 'passed', allowedCommits ?? undefined);
59
- if (!run) {
58
+ const test = findLastTest(db, input.scenarioId, 'passed', allowedCommits ?? undefined);
59
+ if (!test) {
60
60
  return err(
61
61
  allowedCommits
62
- ? 'No passing run found for this scenario in the current git ancestry. Try gitTreeOnly: false or run cucumber first.'
63
- : 'No passing run found for this scenario.',
62
+ ? 'No passing test found for this scenario in the current git ancestry. Try gitTreeOnly: false or run cucumber first.'
63
+ : 'No passing test found for this scenario.',
64
64
  );
65
65
  }
66
66
 
67
- const artifacts = findArtifacts(db, run.id);
67
+ const artifacts = findArtifacts(db, test.id);
68
68
 
69
69
  const htmlArtifact = [...artifacts].reverse().find((a) => a.filename.endsWith('.html'));
70
70
  if (!htmlArtifact) {
71
- return err('No HTML snapshot found in the baseline run. Ensure the store formatter is configured.');
71
+ return err('No HTML snapshot found in the baseline test. Ensure the store formatter is configured.');
72
72
  }
73
73
 
74
74
  const storedHtml = readFileSync(join(artifactDir, htmlArtifact.filename), 'utf-8');
@@ -86,8 +86,8 @@ export function registerDiff(server: McpServer, sessions: SessionManager): void
86
86
  JSON.stringify({
87
87
  diff,
88
88
  baseline: {
89
- runId: run.id,
90
- commit: run.gitCommit,
89
+ testId: test.id,
90
+ commit: test.gitCommit,
91
91
  screenshots,
92
92
  },
93
93
  }),
@@ -1,4 +1,5 @@
1
1
  export { registerDebug } from './debug';
2
+ export { registerDiagnostics } from './diagnostics';
2
3
  export { registerDiff } from './diff';
3
4
  export { registerListSteps } from './list-steps';
4
5
  export { registerListSessions } from './list-sessions';
package/src/tools/run.ts CHANGED
@@ -1,17 +1,10 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod';
3
- import { parseFeature } from '@letsrunit/gherkin';
4
- import { computeStepId, computeScenarioId } from '@letsrunit/store';
3
+ import { scenarioIdFromGherkin } from '@letsrunit/gherkin';
5
4
  import type { SessionManager } from '../sessions';
6
5
  import { normalizeGherkin } from '../utility/gherkin';
7
6
  import { err, text } from '../utility/response';
8
7
 
9
- function scenarioIdFromGherkin(gherkin: string): string {
10
- const { steps } = parseFeature(gherkin);
11
- const stepIds = steps.map((s) => computeStepId(s));
12
- return computeScenarioId(stepIds);
13
- }
14
-
15
8
  export function registerRun(server: McpServer, sessions: SessionManager): void {
16
9
  server.registerTool(
17
10
  'letsrunit_run',
@@ -2,6 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod';
3
3
  import type { SessionManager } from '../sessions';
4
4
  import { err, text } from '../utility/response';
5
+ import { loadSupportFiles } from '../utility/support';
5
6
 
6
7
  export function registerSessionStart(server: McpServer, sessions: SessionManager): void {
7
8
  server.registerTool(
@@ -19,6 +20,8 @@ export function registerSessionStart(server: McpServer, sessions: SessionManager
19
20
  },
20
21
  async (input) => {
21
22
  try {
23
+ await loadSupportFiles();
24
+
22
25
  const viewport =
23
26
  input.viewportWidth || input.viewportHeight
24
27
  ? { width: input.viewportWidth ?? 1280, height: input.viewportHeight ?? 720 }
@@ -0,0 +1,238 @@
1
+ import { createRequire } from 'node:module';
2
+ import { loadConfiguration } from '@cucumber/cucumber/api';
3
+ import { registry } from '@letsrunit/bdd';
4
+ import { existsSync } from 'node:fs';
5
+ import { glob } from 'node:fs/promises';
6
+ import { isAbsolute, resolve } from 'node:path';
7
+ import { pathToFileURL } from 'node:url';
8
+
9
+ type CucumberConfig = {
10
+ require?: unknown;
11
+ import?: unknown;
12
+ letsrunit?: {
13
+ ignore?: unknown;
14
+ };
15
+ };
16
+
17
+ type SupportEntry =
18
+ | { kind: 'path'; value: string }
19
+ | { kind: 'module'; value: string };
20
+
21
+ const CUCUMBER_CONFIG_FILES = [
22
+ 'cucumber.js',
23
+ 'cucumber.mjs',
24
+ 'cucumber.cjs',
25
+ 'cucumber.ts',
26
+ 'cucumber.mts',
27
+ 'cucumber.cts',
28
+ ];
29
+
30
+ const loadedProjectRoots = new Set<string>();
31
+ const loadedSupportEntries = new Set<string>();
32
+
33
+ export type SupportDiagnostics = {
34
+ envProjectCwd: string | null;
35
+ processCwd: string;
36
+ inputCwd: string | null;
37
+ effectiveCwd: string;
38
+ projectRoot: string;
39
+ cucumberConfigPath: string | null;
40
+ supportPatterns: string[];
41
+ ignorePatterns: string[];
42
+ ignoredPaths: string[];
43
+ supportEntries: SupportEntry[];
44
+ loadedProjectRoots: string[];
45
+ loadedSupportEntries: string[];
46
+ moduleResolution: {
47
+ serverBddPath: string | null;
48
+ projectBddPath: string | null;
49
+ sameModule: boolean;
50
+ };
51
+ registry: {
52
+ total: number;
53
+ byType: {
54
+ Given: number;
55
+ When: number;
56
+ Then: number;
57
+ };
58
+ definitions: Array<{
59
+ type: 'Given' | 'When' | 'Then';
60
+ source: string;
61
+ comment?: string;
62
+ }>;
63
+ };
64
+ };
65
+
66
+ function toStrings(value: unknown): string[] {
67
+ if (!Array.isArray(value)) return [];
68
+ return value.filter((entry): entry is string => typeof entry === 'string');
69
+ }
70
+
71
+ function hasGlobMagic(input: string): boolean {
72
+ return /[*?[\]{}]/.test(input);
73
+ }
74
+
75
+ function isPathLike(input: string): boolean {
76
+ return input.startsWith('.') || input.startsWith('/') || /^[A-Za-z]:[\\/]/.test(input);
77
+ }
78
+
79
+ function toAbsolutePath(baseDir: string, input: string): string {
80
+ return isAbsolute(input) ? resolve(input) : resolve(baseDir, input);
81
+ }
82
+
83
+ function normalizeMatch(baseDir: string, match: string): string {
84
+ return isAbsolute(match) ? resolve(match) : resolve(baseDir, match);
85
+ }
86
+
87
+ async function expandPathPatterns(baseDir: string, patterns: string[]): Promise<Set<string>> {
88
+ const files = new Set<string>();
89
+
90
+ for (const pattern of patterns) {
91
+ if (hasGlobMagic(pattern)) {
92
+ for await (const match of glob(pattern, { cwd: baseDir, absolute: true, withFileTypes: false })) {
93
+ files.add(normalizeMatch(baseDir, match));
94
+ }
95
+ continue;
96
+ }
97
+
98
+ files.add(toAbsolutePath(baseDir, pattern));
99
+ }
100
+
101
+ return files;
102
+ }
103
+
104
+ async function resolveSupportEntries(baseDir: string, entries: string[]): Promise<SupportEntry[]> {
105
+ const resolved: SupportEntry[] = [];
106
+
107
+ for (const entry of entries) {
108
+ if (hasGlobMagic(entry)) {
109
+ for await (const match of glob(entry, { cwd: baseDir, absolute: true, withFileTypes: false })) {
110
+ resolved.push({ kind: 'path', value: normalizeMatch(baseDir, match) });
111
+ }
112
+ continue;
113
+ }
114
+
115
+ if (!isPathLike(entry)) {
116
+ resolved.push({ kind: 'module', value: entry });
117
+ continue;
118
+ }
119
+
120
+ resolved.push({ kind: 'path', value: toAbsolutePath(baseDir, entry) });
121
+ }
122
+
123
+ return resolved;
124
+ }
125
+
126
+ function findCucumberConfig(cwd: string): string | null {
127
+ for (const filename of CUCUMBER_CONFIG_FILES) {
128
+ const path = resolve(cwd, filename);
129
+ if (existsSync(path)) return path;
130
+ }
131
+
132
+ return null;
133
+ }
134
+
135
+ async function loadLetsrunitIgnorePatterns(cwd: string): Promise<string[]> {
136
+ const configPath = findCucumberConfig(cwd);
137
+ if (!configPath) return [];
138
+
139
+ const configModule = await import(pathToFileURL(configPath).href);
140
+ const config = (configModule.default ?? configModule) as CucumberConfig;
141
+
142
+ return toStrings(config.letsrunit?.ignore);
143
+ }
144
+
145
+ function resolveEffectiveCwd(cwd?: string): string {
146
+ return cwd ?? process.env.LETSRUNIT_PROJECT_CWD ?? process.cwd();
147
+ }
148
+
149
+ function resolveFrom(moduleId: string, fromPath: string): string | null {
150
+ try {
151
+ const req = createRequire(fromPath);
152
+ return req.resolve(moduleId);
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+
158
+ export async function collectSupportDiagnostics(cwd?: string): Promise<SupportDiagnostics> {
159
+ const effectiveCwd = resolveEffectiveCwd(cwd);
160
+ const projectRoot = resolve(effectiveCwd);
161
+ const cucumberConfigPath = findCucumberConfig(projectRoot);
162
+ const { useConfiguration } = await loadConfiguration({}, { cwd: projectRoot });
163
+ const supportPatterns = [...toStrings(useConfiguration.require), ...toStrings(useConfiguration.import)];
164
+ const ignorePatterns = await loadLetsrunitIgnorePatterns(projectRoot);
165
+ const ignoredPaths = await expandPathPatterns(projectRoot, ignorePatterns);
166
+ const supportEntries = await resolveSupportEntries(projectRoot, supportPatterns);
167
+ const serverBddPath = resolveFrom('@letsrunit/bdd', import.meta.url);
168
+ const projectBddPath = resolveFrom('@letsrunit/bdd', resolve(projectRoot, 'package.json'));
169
+ const registryDefinitions = registry.defs.map((def) => ({
170
+ type: def.type,
171
+ source: def.source,
172
+ comment: def.comment,
173
+ }));
174
+
175
+ return {
176
+ envProjectCwd: process.env.LETSRUNIT_PROJECT_CWD ?? null,
177
+ processCwd: process.cwd(),
178
+ inputCwd: cwd ?? null,
179
+ effectiveCwd,
180
+ projectRoot,
181
+ cucumberConfigPath,
182
+ supportPatterns,
183
+ ignorePatterns,
184
+ ignoredPaths: [...ignoredPaths].sort(),
185
+ supportEntries,
186
+ loadedProjectRoots: [...loadedProjectRoots].sort(),
187
+ loadedSupportEntries: [...loadedSupportEntries].sort(),
188
+ moduleResolution: {
189
+ serverBddPath,
190
+ projectBddPath,
191
+ sameModule: !!serverBddPath && !!projectBddPath && serverBddPath === projectBddPath,
192
+ },
193
+ registry: {
194
+ total: registryDefinitions.length,
195
+ byType: {
196
+ Given: registryDefinitions.filter((d) => d.type === 'Given').length,
197
+ When: registryDefinitions.filter((d) => d.type === 'When').length,
198
+ Then: registryDefinitions.filter((d) => d.type === 'Then').length,
199
+ },
200
+ definitions: registryDefinitions,
201
+ },
202
+ };
203
+ }
204
+
205
+ export async function loadSupportFiles(cwd?: string): Promise<void> {
206
+ const projectRoot = resolve(resolveEffectiveCwd(cwd));
207
+ if (loadedProjectRoots.has(projectRoot)) return;
208
+
209
+ const { useConfiguration } = await loadConfiguration({}, { cwd: projectRoot });
210
+ const supportPatterns = [...toStrings(useConfiguration.require), ...toStrings(useConfiguration.import)];
211
+ if (supportPatterns.length === 0) {
212
+ loadedProjectRoots.add(projectRoot);
213
+ return;
214
+ }
215
+
216
+ const ignorePatterns = await loadLetsrunitIgnorePatterns(projectRoot);
217
+ const ignoredPaths = await expandPathPatterns(projectRoot, ignorePatterns);
218
+ const supportEntries = await resolveSupportEntries(projectRoot, supportPatterns);
219
+
220
+ for (const entry of supportEntries) {
221
+ if (entry.kind === 'path' && ignoredPaths.has(entry.value)) {
222
+ continue;
223
+ }
224
+
225
+ const key = `${entry.kind}:${entry.value}`;
226
+ if (loadedSupportEntries.has(key)) continue;
227
+
228
+ if (entry.kind === 'path') {
229
+ await import(pathToFileURL(entry.value).href);
230
+ } else {
231
+ await import(entry.value);
232
+ }
233
+
234
+ loadedSupportEntries.add(key);
235
+ }
236
+
237
+ loadedProjectRoots.add(projectRoot);
238
+ }