@letsrunit/mcp-server 0.0.1 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+
2
+ export { }
package/dist/index.js ADDED
@@ -0,0 +1,314 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { Controller } from '@letsrunit/controller';
5
+ import { MemorySink, Journal } from '@letsrunit/journal';
6
+ import { join } from 'path';
7
+ import { z } from 'zod';
8
+ import { scrubHtml, screenshotElement, screenshot } from '@letsrunit/playwright';
9
+ import { mkdir, writeFile } from 'fs/promises';
10
+
11
+ var SESSION_TIMEOUT_MS = 30 * 60 * 1e3;
12
+ var BASE_ARTIFACT_DIR = process.env.LETSRUNIT_ARTIFACT_DIR ?? join(process.env.HOME ?? "/tmp", ".letsrunit", "artifacts");
13
+ function sessionId() {
14
+ return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
15
+ }
16
+ var SessionManager = class {
17
+ sessions = /* @__PURE__ */ new Map();
18
+ timers = /* @__PURE__ */ new Map();
19
+ async create(options = {}) {
20
+ const id = sessionId();
21
+ const artifactDir = join(BASE_ARTIFACT_DIR, id);
22
+ const sink = new MemorySink(artifactDir);
23
+ const journal = new Journal(sink);
24
+ const controller = await Controller.launch({ ...options, journal });
25
+ const session = {
26
+ id,
27
+ controller,
28
+ sink,
29
+ journal,
30
+ artifactDir,
31
+ createdAt: Date.now(),
32
+ lastActivity: Date.now(),
33
+ stepCount: 0
34
+ };
35
+ this.sessions.set(id, session);
36
+ this.resetTimer(id);
37
+ return session;
38
+ }
39
+ get(id) {
40
+ const session = this.sessions.get(id);
41
+ if (!session) throw new Error(`Session not found: ${id}`);
42
+ return session;
43
+ }
44
+ has(id) {
45
+ return this.sessions.has(id);
46
+ }
47
+ touch(id) {
48
+ const session = this.sessions.get(id);
49
+ if (session) {
50
+ session.lastActivity = Date.now();
51
+ this.resetTimer(id);
52
+ }
53
+ }
54
+ list() {
55
+ return Array.from(this.sessions.values());
56
+ }
57
+ async close(id) {
58
+ const session = this.sessions.get(id);
59
+ if (!session) return;
60
+ clearTimeout(this.timers.get(id));
61
+ this.timers.delete(id);
62
+ this.sessions.delete(id);
63
+ await session.controller.close();
64
+ }
65
+ resetTimer(id) {
66
+ clearTimeout(this.timers.get(id));
67
+ const timer = setTimeout(() => this.close(id), SESSION_TIMEOUT_MS);
68
+ if (typeof timer === "object" && "unref" in timer) timer.unref();
69
+ this.timers.set(id, timer);
70
+ }
71
+ };
72
+
73
+ // src/utility/response.ts
74
+ function text(content) {
75
+ return { content: [{ type: "text", text: content }] };
76
+ }
77
+ function err(message) {
78
+ return { content: [{ type: "text", text: message }], isError: true };
79
+ }
80
+
81
+ // src/tools/debug.ts
82
+ function registerDebug(server2, sessions2) {
83
+ server2.registerTool(
84
+ "letsrunit_debug",
85
+ {
86
+ description: "Evaluate JavaScript on the current page via Playwright page.evaluate(). Use for debugging \u2014 not for test logic.",
87
+ inputSchema: {
88
+ sessionId: z.string().describe("Session ID"),
89
+ script: z.string().describe("JavaScript expression or function body to evaluate in the page context")
90
+ }
91
+ },
92
+ async (input) => {
93
+ try {
94
+ const session = sessions2.get(input.sessionId);
95
+ sessions2.touch(input.sessionId);
96
+ const result = await session.controller.page.evaluate(input.script);
97
+ return text(JSON.stringify({ result }));
98
+ } catch (e) {
99
+ return text(JSON.stringify({ result: null, error: e.message }));
100
+ }
101
+ }
102
+ );
103
+ }
104
+
105
+ // src/tools/list-sessions.ts
106
+ function registerListSessions(server2, sessions2) {
107
+ server2.registerTool(
108
+ "letsrunit_list_sessions",
109
+ {
110
+ description: "List all active browser sessions.",
111
+ inputSchema: {}
112
+ },
113
+ async () => {
114
+ const list = sessions2.list().map((s) => ({
115
+ sessionId: s.id,
116
+ createdAt: s.createdAt,
117
+ lastActivity: s.lastActivity,
118
+ stepCount: s.stepCount,
119
+ artifactDir: s.artifactDir
120
+ }));
121
+ return text(JSON.stringify({ sessions: list }));
122
+ }
123
+ );
124
+ }
125
+
126
+ // src/utility/gherkin.ts
127
+ function normalizeGherkin(input) {
128
+ const trimmed = input.trim();
129
+ if (/^(Feature|Scenario|Background):/im.test(trimmed)) {
130
+ return trimmed;
131
+ }
132
+ return `Feature: MCP
133
+
134
+ Scenario: Steps
135
+ ${trimmed.split("\n").join("\n ")}`;
136
+ }
137
+
138
+ // src/tools/run.ts
139
+ function registerRun(server2, sessions2) {
140
+ server2.registerTool(
141
+ "letsrunit_run",
142
+ {
143
+ 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.",
144
+ inputSchema: {
145
+ sessionId: z.string().describe("Session ID returned by letsrunit_session_start"),
146
+ input: z.string().describe(
147
+ '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.'
148
+ )
149
+ }
150
+ },
151
+ async (input) => {
152
+ try {
153
+ const session = sessions2.get(input.sessionId);
154
+ sessions2.touch(input.sessionId);
155
+ const feature = normalizeGherkin(input.input);
156
+ session.sink.clear();
157
+ const result = await session.controller.run(feature);
158
+ session.stepCount += result.steps.length;
159
+ return text(
160
+ JSON.stringify({
161
+ status: result.status,
162
+ steps: result.steps,
163
+ reason: result.reason?.message,
164
+ journal: session.sink.getEntries()
165
+ })
166
+ );
167
+ } catch (e) {
168
+ return err(`Run failed: ${e.message}`);
169
+ }
170
+ }
171
+ );
172
+ }
173
+ function registerScreenshot(server2, sessions2) {
174
+ server2.registerTool(
175
+ "letsrunit_screenshot",
176
+ {
177
+ description: "Take a screenshot of the current page. Optionally crop to a specific element (selector) or highlight elements before capturing (mask).",
178
+ inputSchema: {
179
+ sessionId: z.string().describe("Session ID"),
180
+ selector: z.string().optional().describe("CSS selector \u2014 crop screenshot to the bounding box of this element"),
181
+ mask: z.array(z.string()).optional().describe("CSS selectors whose matching elements are highlighted (dark overlay, element spotlighted)"),
182
+ fullPage: z.boolean().optional().describe("Capture the full scrollable page (default: false)")
183
+ }
184
+ },
185
+ async (input) => {
186
+ try {
187
+ const session = sessions2.get(input.sessionId);
188
+ sessions2.touch(input.sessionId);
189
+ const page = session.controller.page;
190
+ let file;
191
+ if (input.selector) {
192
+ file = await screenshotElement(page, input.selector);
193
+ } else {
194
+ const masks = input.mask?.map((sel) => page.locator(sel)) ?? [];
195
+ file = await screenshot(page, {
196
+ fullPage: input.fullPage ?? false,
197
+ ...masks.length ? { mask: masks } : {}
198
+ });
199
+ }
200
+ await mkdir(session.artifactDir, { recursive: true });
201
+ const path = join(session.artifactDir, file.name);
202
+ await writeFile(path, await file.bytes());
203
+ return text(JSON.stringify({ path, mimeType: "image/png" }));
204
+ } catch (e) {
205
+ return err(`Screenshot failed: ${e.message}`);
206
+ }
207
+ }
208
+ );
209
+ }
210
+ function registerSessionClose(server2, sessions2) {
211
+ server2.registerTool(
212
+ "letsrunit_session_close",
213
+ {
214
+ description: "Close a browser session and release its resources.",
215
+ inputSchema: {
216
+ sessionId: z.string().describe("Session ID to close")
217
+ }
218
+ },
219
+ async (input) => {
220
+ try {
221
+ await sessions2.close(input.sessionId);
222
+ return text(JSON.stringify({ closed: true }));
223
+ } catch (e) {
224
+ return err(`Failed to close session: ${e.message}`);
225
+ }
226
+ }
227
+ );
228
+ }
229
+ function registerSessionStart(server2, sessions2) {
230
+ server2.registerTool(
231
+ "letsrunit_session_start",
232
+ {
233
+ description: "Launch a new browser session. Does not navigate anywhere \u2014 use letsrunit_run with a Given step to navigate.",
234
+ inputSchema: {
235
+ language: z.string().optional().describe("Browser language code, e.g. 'en', 'fr'"),
236
+ headless: z.boolean().optional().describe("Run browser in headless mode (default: true)"),
237
+ viewportWidth: z.number().int().optional().describe("Viewport width in pixels (default: 1280)"),
238
+ viewportHeight: z.number().int().optional().describe("Viewport height in pixels (default: 720)")
239
+ }
240
+ },
241
+ async (input) => {
242
+ try {
243
+ const viewport = input.viewportWidth || input.viewportHeight ? { width: input.viewportWidth ?? 1280, height: input.viewportHeight ?? 720 } : void 0;
244
+ const session = await sessions2.create({
245
+ headless: input.headless ?? true,
246
+ locale: input.language,
247
+ viewport
248
+ });
249
+ return text(JSON.stringify({ sessionId: session.id }));
250
+ } catch (e) {
251
+ return err(`Failed to start session: ${e.message}`);
252
+ }
253
+ }
254
+ );
255
+ }
256
+ function registerSnapshot(server2, sessions2) {
257
+ server2.registerTool(
258
+ "letsrunit_snapshot",
259
+ {
260
+ description: "Get the current page HTML, scrubbed for LLM consumption. Use selector to scope to a DOM subtree.",
261
+ inputSchema: {
262
+ sessionId: z.string().describe("Session ID"),
263
+ selector: z.string().optional().describe("CSS selector \u2014 return only the matching element's outer HTML instead of the full page"),
264
+ dropHidden: z.boolean().optional().describe("Remove hidden/inert nodes (default: true)"),
265
+ dropHead: z.boolean().optional().describe("Remove the <head> element (default: true)"),
266
+ pickMain: z.boolean().optional().describe("Keep only the <main> element (default: auto)"),
267
+ stripAttributes: z.union([z.literal(0), z.literal(1), z.literal(2)]).optional().describe("Attribute allowlist level: 0=none, 1=semantic (default), 2=aggressive")
268
+ }
269
+ },
270
+ async (input) => {
271
+ try {
272
+ const session = sessions2.get(input.sessionId);
273
+ sessions2.touch(input.sessionId);
274
+ const page = session.controller.page;
275
+ const url = page.url();
276
+ const opts = {
277
+ dropHidden: input.dropHidden,
278
+ dropHead: input.dropHead,
279
+ pickMain: input.pickMain,
280
+ stripAttributes: input.stripAttributes
281
+ };
282
+ let html;
283
+ if (input.selector) {
284
+ const rawHtml = await page.locator(input.selector).first().evaluate((el) => el.outerHTML);
285
+ html = await scrubHtml({ html: rawHtml, url }, opts);
286
+ } else {
287
+ html = await scrubHtml(page, opts);
288
+ }
289
+ return text(JSON.stringify({ url, html }));
290
+ } catch (e) {
291
+ return err(`Snapshot failed: ${e.message}`);
292
+ }
293
+ }
294
+ );
295
+ }
296
+
297
+ // src/index.ts
298
+ var sessions = new SessionManager();
299
+ var server = new McpServer({
300
+ name: "letsrunit",
301
+ version: "0.1.0",
302
+ websiteUrl: "https://letsrunit.ai"
303
+ });
304
+ registerSessionStart(server, sessions);
305
+ registerRun(server, sessions);
306
+ registerSnapshot(server, sessions);
307
+ registerScreenshot(server, sessions);
308
+ registerDebug(server, sessions);
309
+ registerSessionClose(server, sessions);
310
+ registerListSessions(server, sessions);
311
+ var transport = new StdioServerTransport();
312
+ await server.connect(transport);
313
+ //# sourceMappingURL=index.js.map
314
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/sessions.ts","../src/utility/response.ts","../src/tools/debug.ts","../src/tools/list-sessions.ts","../src/utility/gherkin.ts","../src/tools/run.ts","../src/tools/screenshot.ts","../src/tools/session-close.ts","../src/tools/session-start.ts","../src/tools/snapshot.ts","../src/index.ts"],"names":["server","sessions","z","join"],"mappings":"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","file":"index.js","sourcesContent":["import { Controller, type ControllerOptions } from '@letsrunit/controller';\nimport { Journal, MemorySink } from '@letsrunit/journal';\nimport { join } from 'node:path';\n\nconst SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes\n\nexport interface Session {\n id: string;\n controller: Controller;\n sink: MemorySink;\n journal: Journal<MemorySink>;\n artifactDir: string;\n createdAt: number;\n lastActivity: number;\n stepCount: number;\n}\n\nconst BASE_ARTIFACT_DIR = process.env.LETSRUNIT_ARTIFACT_DIR ?? join(process.env.HOME ?? '/tmp', '.letsrunit', 'artifacts');\n\nfunction sessionId(): string {\n return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);\n}\n\nexport class SessionManager {\n private readonly sessions = new Map<string, Session>();\n private readonly timers = new Map<string, ReturnType<typeof setTimeout>>();\n\n async create(options: ControllerOptions = {}): Promise<Session> {\n const id = sessionId();\n const artifactDir = join(BASE_ARTIFACT_DIR, id);\n\n const sink = new MemorySink(artifactDir);\n const journal = new Journal(sink);\n\n const controller = await Controller.launch({ ...options, journal });\n\n const session: Session = {\n id,\n controller,\n sink,\n journal,\n artifactDir,\n createdAt: Date.now(),\n lastActivity: Date.now(),\n stepCount: 0,\n };\n\n this.sessions.set(id, session);\n this.resetTimer(id);\n\n return session;\n }\n\n get(id: string): Session {\n const session = this.sessions.get(id);\n if (!session) throw new Error(`Session not found: ${id}`);\n return session;\n }\n\n has(id: string): boolean {\n return this.sessions.has(id);\n }\n\n touch(id: string): void {\n const session = this.sessions.get(id);\n if (session) {\n session.lastActivity = Date.now();\n this.resetTimer(id);\n }\n }\n\n list(): Session[] {\n return Array.from(this.sessions.values());\n }\n\n async close(id: string): Promise<void> {\n const session = this.sessions.get(id);\n if (!session) return;\n\n clearTimeout(this.timers.get(id));\n this.timers.delete(id);\n this.sessions.delete(id);\n\n await session.controller.close();\n }\n\n private resetTimer(id: string): void {\n clearTimeout(this.timers.get(id));\n const timer = setTimeout(() => this.close(id), SESSION_TIMEOUT_MS);\n // Allow Node.js to exit even if a timer is pending\n if (typeof timer === 'object' && 'unref' in timer) timer.unref();\n this.timers.set(id, timer);\n }\n}\n","export function text(content: string) {\n return { content: [{ type: 'text' as const, text: content }] };\n}\n\nexport function err(message: string) {\n return { content: [{ type: 'text' as const, text: message }], isError: true };\n}\n","import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { z } from 'zod';\nimport { type SessionManager } from '../sessions';\nimport { text } from '../utility/response';\n\nexport function registerDebug(server: McpServer, sessions: SessionManager): void {\n server.registerTool(\n 'letsrunit_debug',\n {\n description:\n 'Evaluate JavaScript on the current page via Playwright page.evaluate(). Use for debugging — not for test logic.',\n inputSchema: {\n sessionId: z.string().describe('Session ID'),\n script: z.string().describe('JavaScript expression or function body to evaluate in the page context'),\n },\n },\n async (input) => {\n try {\n const session = sessions.get(input.sessionId);\n sessions.touch(input.sessionId);\n\n const result = await session.controller.page.evaluate(input.script);\n return text(JSON.stringify({ result }));\n } catch (e) {\n return text(JSON.stringify({ result: null, error: (e as Error).message }));\n }\n },\n );\n}\n","import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { type SessionManager } from '../sessions';\nimport { text } from '../utility/response';\n\nexport function registerListSessions(server: McpServer, sessions: SessionManager): void {\n server.registerTool(\n 'letsrunit_list_sessions',\n {\n description: 'List all active browser sessions.',\n inputSchema: {},\n },\n async () => {\n const list = sessions.list().map((s) => ({\n sessionId: s.id,\n createdAt: s.createdAt,\n lastActivity: s.lastActivity,\n stepCount: s.stepCount,\n artifactDir: s.artifactDir,\n }));\n\n return text(JSON.stringify({ sessions: list }));\n },\n );\n}\n","export function normalizeGherkin(input: string): string {\n const trimmed = input.trim();\n\n if (/^(Feature|Scenario|Background):/im.test(trimmed)) {\n return trimmed;\n }\n\n return `Feature: MCP\\n\\nScenario: Steps\\n ${trimmed.split('\\n').join('\\n ')}`;\n}\n","import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { z } from 'zod';\nimport type { SessionManager } from '../sessions';\nimport { normalizeGherkin } from '../utility/gherkin';\nimport { err, text } from '../utility/response';\n\nexport function registerRun(server: McpServer, sessions: SessionManager): void {\n server.registerTool(\n 'letsrunit_run',\n {\n description:\n 'Execute Gherkin steps or a complete feature in the browser. ' +\n 'Accepts a single step line, multiple step lines, a full Scenario, or a full Feature. ' +\n 'Returns status, steps, reason on failure, and journal entries. Does not return a page snapshot — call letsrunit_snapshot explicitly if you need the DOM.',\n inputSchema: {\n sessionId: z.string().describe('Session ID returned by letsrunit_session_start'),\n input: z\n .string()\n .describe(\n '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.',\n ),\n },\n },\n async (input) => {\n try {\n const session = sessions.get(input.sessionId);\n sessions.touch(input.sessionId);\n\n const feature = normalizeGherkin(input.input);\n\n session.sink.clear();\n const result = await session.controller.run(feature);\n session.stepCount += result.steps.length;\n\n return text(\n JSON.stringify({\n status: result.status,\n steps: result.steps,\n reason: result.reason?.message,\n journal: session.sink.getEntries(),\n }),\n );\n } catch (e) {\n return err(`Run failed: ${(e as Error).message}`);\n }\n },\n );\n}\n","import { screenshot, screenshotElement } from '@letsrunit/playwright';\nimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { mkdir, writeFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { z } from 'zod';\nimport { type SessionManager } from '../sessions';\nimport { err, text } from '../utility/response';\n\nexport function registerScreenshot(server: McpServer, sessions: SessionManager): void {\n server.registerTool(\n 'letsrunit_screenshot',\n {\n description:\n 'Take a screenshot of the current page. Optionally crop to a specific element (selector) or highlight elements before capturing (mask).',\n inputSchema: {\n sessionId: z.string().describe('Session ID'),\n selector: z\n .string()\n .optional()\n .describe('CSS selector — crop screenshot to the bounding box of this element'),\n mask: z\n .array(z.string())\n .optional()\n .describe('CSS selectors whose matching elements are highlighted (dark overlay, element spotlighted)'),\n fullPage: z.boolean().optional().describe('Capture the full scrollable page (default: false)'),\n },\n },\n async (input) => {\n try {\n const session = sessions.get(input.sessionId);\n sessions.touch(input.sessionId);\n\n const page = session.controller.page;\n\n let file: File;\n\n if (input.selector) {\n file = await screenshotElement(page, input.selector);\n } else {\n const masks = input.mask?.map((sel) => page.locator(sel)) ?? [];\n file = await screenshot(page, {\n fullPage: input.fullPage ?? false,\n ...(masks.length ? { mask: masks } : {}),\n });\n }\n\n await mkdir(session.artifactDir, { recursive: true });\n const path = join(session.artifactDir, file.name);\n await writeFile(path, await file.bytes());\n\n return text(JSON.stringify({ path, mimeType: 'image/png' }));\n } catch (e) {\n return err(`Screenshot failed: ${(e as Error).message}`);\n }\n },\n );\n}\n","import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { z } from 'zod';\nimport type { SessionManager } from '../sessions';\nimport { err, text } from '../utility/response';\n\nexport function registerSessionClose(server: McpServer, sessions: SessionManager): void {\n server.registerTool(\n 'letsrunit_session_close',\n {\n description: 'Close a browser session and release its resources.',\n inputSchema: {\n sessionId: z.string().describe('Session ID to close'),\n },\n },\n async (input) => {\n try {\n await sessions.close(input.sessionId);\n return text(JSON.stringify({ closed: true }));\n } catch (e) {\n return err(`Failed to close session: ${(e as Error).message}`);\n }\n },\n );\n}\n","import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { z } from 'zod';\nimport type { SessionManager } from '../sessions';\nimport { err, text } from '../utility/response';\n\nexport function registerSessionStart(server: McpServer, sessions: SessionManager): void {\n server.registerTool(\n 'letsrunit_session_start',\n {\n description:\n 'Launch a new browser session. Does not navigate anywhere — use letsrunit_run with a Given step to navigate.',\n inputSchema: {\n language: z.string().optional().describe(\"Browser language code, e.g. 'en', 'fr'\"),\n headless: z.boolean().optional().describe('Run browser in headless mode (default: true)'),\n viewportWidth: z.number().int().optional().describe('Viewport width in pixels (default: 1280)'),\n viewportHeight: z.number().int().optional().describe('Viewport height in pixels (default: 720)'),\n },\n },\n async (input) => {\n try {\n const viewport =\n input.viewportWidth || input.viewportHeight\n ? { width: input.viewportWidth ?? 1280, height: input.viewportHeight ?? 720 }\n : undefined;\n\n const session = await sessions.create({\n headless: input.headless ?? true,\n locale: input.language,\n viewport,\n });\n\n return text(JSON.stringify({ sessionId: session.id }));\n } catch (e) {\n return err(`Failed to start session: ${(e as Error).message}`);\n }\n },\n );\n}\n","import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { scrubHtml } from '@letsrunit/playwright';\nimport { z } from 'zod';\nimport { type SessionManager } from '../sessions';\nimport { err, text } from '../utility/response';\n\nexport function registerSnapshot(server: McpServer, sessions: SessionManager): void {\n server.registerTool(\n 'letsrunit_snapshot',\n {\n description:\n 'Get the current page HTML, scrubbed for LLM consumption. Use selector to scope to a DOM subtree.',\n inputSchema: {\n sessionId: z.string().describe('Session ID'),\n selector: z\n .string()\n .optional()\n .describe(\"CSS selector — return only the matching element's outer HTML instead of the full page\"),\n dropHidden: z.boolean().optional().describe('Remove hidden/inert nodes (default: true)'),\n dropHead: z.boolean().optional().describe('Remove the <head> element (default: true)'),\n pickMain: z.boolean().optional().describe('Keep only the <main> element (default: auto)'),\n stripAttributes: z\n .union([z.literal(0), z.literal(1), z.literal(2)])\n .optional()\n .describe('Attribute allowlist level: 0=none, 1=semantic (default), 2=aggressive'),\n },\n },\n async (input) => {\n try {\n const session = sessions.get(input.sessionId);\n sessions.touch(input.sessionId);\n\n const page = session.controller.page;\n const url = page.url();\n\n const opts = {\n dropHidden: input.dropHidden,\n dropHead: input.dropHead,\n pickMain: input.pickMain,\n stripAttributes: input.stripAttributes,\n };\n\n let html: string;\n\n if (input.selector) {\n const rawHtml = await page\n .locator(input.selector)\n .first()\n .evaluate((el: Element) => el.outerHTML);\n html = await scrubHtml({ html: rawHtml, url }, opts);\n } else {\n html = await scrubHtml(page, opts);\n }\n\n return text(JSON.stringify({ url, html }));\n } catch (e) {\n return err(`Snapshot failed: ${(e as Error).message}`);\n }\n },\n );\n}\n","import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';\nimport { SessionManager } from './sessions';\nimport {\n registerDebug,\n registerListSessions,\n registerRun,\n registerScreenshot,\n registerSessionClose,\n registerSessionStart,\n registerSnapshot,\n} from './tools';\n\nconst sessions = new SessionManager();\n\nconst server = new McpServer({\n name: 'letsrunit',\n version: '0.1.0',\n websiteUrl: 'https://letsrunit.ai',\n});\n\nregisterSessionStart(server, sessions);\nregisterRun(server, sessions);\nregisterSnapshot(server, sessions);\nregisterScreenshot(server, sessions);\nregisterDebug(server, sessions);\nregisterSessionClose(server, sessions);\nregisterListSessions(server, sessions);\n\nconst transport = new StdioServerTransport();\nawait server.connect(transport);\n"]}
package/package.json CHANGED
@@ -1,4 +1,74 @@
1
1
  {
2
2
  "name": "@letsrunit/mcp-server",
3
- "version": "0.0.1"
4
- }
3
+ "version": "0.2.6",
4
+ "description": "MCP server for letsrunit — AI-agent browser test generation and execution",
5
+ "keywords": [
6
+ "testing",
7
+ "automation",
8
+ "mcp",
9
+ "playwright",
10
+ "letsrunit"
11
+ ],
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/letsrunit-hq/letsrunit.git",
16
+ "directory": "packages/mcp-server"
17
+ },
18
+ "bugs": "https://github.com/letsrunit-hq/letsrunit/issues",
19
+ "homepage": "https://github.com/letsrunit-hq/letsrunit#readme",
20
+ "type": "module",
21
+ "main": "./dist/index.js",
22
+ "bin": {
23
+ "letsrunit-mcp": "./dist/index.js"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public",
27
+ "main": "./dist/index.js",
28
+ "module": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "import": "./dist/index.js",
34
+ "default": "./dist/index.js"
35
+ }
36
+ }
37
+ },
38
+ "files": [
39
+ "dist",
40
+ "src",
41
+ "README.md"
42
+ ],
43
+ "scripts": {
44
+ "build": "../../node_modules/.bin/tsup",
45
+ "test": "vitest run",
46
+ "test:cov": "vitest run --coverage",
47
+ "typecheck": "tsc --noEmit"
48
+ },
49
+ "packageManager": "yarn@4.10.3",
50
+ "dependencies": {
51
+ "@letsrunit/controller": "0.2.6",
52
+ "@letsrunit/journal": "0.2.6",
53
+ "@letsrunit/playwright": "0.2.6",
54
+ "@letsrunit/utils": "0.2.6",
55
+ "@modelcontextprotocol/sdk": "^1.26.0",
56
+ "@playwright/test": "^1.57.0",
57
+ "zod": "^4.3.5"
58
+ },
59
+ "devDependencies": {
60
+ "@types/node": "^25.0.9",
61
+ "@vitest/coverage-v8": "4.0.17",
62
+ "typescript": "^5.9.3",
63
+ "vitest": "^4.0.17"
64
+ },
65
+ "module": "./dist/index.js",
66
+ "types": "./dist/index.d.ts",
67
+ "exports": {
68
+ ".": {
69
+ "types": "./dist/index.d.ts",
70
+ "import": "./dist/index.js",
71
+ "default": "./dist/index.js"
72
+ }
73
+ }
74
+ }
package/src/index.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { SessionManager } from './sessions';
4
+ import {
5
+ registerDebug,
6
+ registerListSessions,
7
+ registerRun,
8
+ registerScreenshot,
9
+ registerSessionClose,
10
+ registerSessionStart,
11
+ registerSnapshot,
12
+ } from './tools';
13
+
14
+ const sessions = new SessionManager();
15
+
16
+ const server = new McpServer({
17
+ name: 'letsrunit',
18
+ version: '0.1.0',
19
+ websiteUrl: 'https://letsrunit.ai',
20
+ });
21
+
22
+ registerSessionStart(server, sessions);
23
+ registerRun(server, sessions);
24
+ registerSnapshot(server, sessions);
25
+ registerScreenshot(server, sessions);
26
+ registerDebug(server, sessions);
27
+ registerSessionClose(server, sessions);
28
+ registerListSessions(server, sessions);
29
+
30
+ const transport = new StdioServerTransport();
31
+ await server.connect(transport);
@@ -0,0 +1,94 @@
1
+ import { Controller, type ControllerOptions } from '@letsrunit/controller';
2
+ import { Journal, MemorySink } from '@letsrunit/journal';
3
+ import { join } from 'node:path';
4
+
5
+ const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
6
+
7
+ export interface Session {
8
+ id: string;
9
+ controller: Controller;
10
+ sink: MemorySink;
11
+ journal: Journal<MemorySink>;
12
+ artifactDir: string;
13
+ createdAt: number;
14
+ lastActivity: number;
15
+ stepCount: number;
16
+ }
17
+
18
+ const BASE_ARTIFACT_DIR = process.env.LETSRUNIT_ARTIFACT_DIR ?? join(process.env.HOME ?? '/tmp', '.letsrunit', 'artifacts');
19
+
20
+ function sessionId(): string {
21
+ return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
22
+ }
23
+
24
+ export class SessionManager {
25
+ private readonly sessions = new Map<string, Session>();
26
+ private readonly timers = new Map<string, ReturnType<typeof setTimeout>>();
27
+
28
+ async create(options: ControllerOptions = {}): Promise<Session> {
29
+ const id = sessionId();
30
+ const artifactDir = join(BASE_ARTIFACT_DIR, id);
31
+
32
+ const sink = new MemorySink(artifactDir);
33
+ const journal = new Journal(sink);
34
+
35
+ const controller = await Controller.launch({ ...options, journal });
36
+
37
+ const session: Session = {
38
+ id,
39
+ controller,
40
+ sink,
41
+ journal,
42
+ artifactDir,
43
+ createdAt: Date.now(),
44
+ lastActivity: Date.now(),
45
+ stepCount: 0,
46
+ };
47
+
48
+ this.sessions.set(id, session);
49
+ this.resetTimer(id);
50
+
51
+ return session;
52
+ }
53
+
54
+ get(id: string): Session {
55
+ const session = this.sessions.get(id);
56
+ if (!session) throw new Error(`Session not found: ${id}`);
57
+ return session;
58
+ }
59
+
60
+ has(id: string): boolean {
61
+ return this.sessions.has(id);
62
+ }
63
+
64
+ touch(id: string): void {
65
+ const session = this.sessions.get(id);
66
+ if (session) {
67
+ session.lastActivity = Date.now();
68
+ this.resetTimer(id);
69
+ }
70
+ }
71
+
72
+ list(): Session[] {
73
+ return Array.from(this.sessions.values());
74
+ }
75
+
76
+ async close(id: string): Promise<void> {
77
+ const session = this.sessions.get(id);
78
+ if (!session) return;
79
+
80
+ clearTimeout(this.timers.get(id));
81
+ this.timers.delete(id);
82
+ this.sessions.delete(id);
83
+
84
+ await session.controller.close();
85
+ }
86
+
87
+ private resetTimer(id: string): void {
88
+ clearTimeout(this.timers.get(id));
89
+ const timer = setTimeout(() => this.close(id), SESSION_TIMEOUT_MS);
90
+ // Allow Node.js to exit even if a timer is pending
91
+ if (typeof timer === 'object' && 'unref' in timer) timer.unref();
92
+ this.timers.set(id, timer);
93
+ }
94
+ }
@@ -0,0 +1,29 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { z } from 'zod';
3
+ import { type SessionManager } from '../sessions';
4
+ import { text } from '../utility/response';
5
+
6
+ export function registerDebug(server: McpServer, sessions: SessionManager): void {
7
+ server.registerTool(
8
+ 'letsrunit_debug',
9
+ {
10
+ description:
11
+ 'Evaluate JavaScript on the current page via Playwright page.evaluate(). Use for debugging — not for test logic.',
12
+ inputSchema: {
13
+ sessionId: z.string().describe('Session ID'),
14
+ script: z.string().describe('JavaScript expression or function body to evaluate in the page context'),
15
+ },
16
+ },
17
+ async (input) => {
18
+ try {
19
+ const session = sessions.get(input.sessionId);
20
+ sessions.touch(input.sessionId);
21
+
22
+ const result = await session.controller.page.evaluate(input.script);
23
+ return text(JSON.stringify({ result }));
24
+ } catch (e) {
25
+ return text(JSON.stringify({ result: null, error: (e as Error).message }));
26
+ }
27
+ },
28
+ );
29
+ }
@@ -0,0 +1,7 @@
1
+ export { registerDebug } from './debug';
2
+ export { registerListSessions } from './list-sessions';
3
+ export { registerRun } from './run';
4
+ export { registerScreenshot } from './screenshot';
5
+ export { registerSessionClose } from './session-close';
6
+ export { registerSessionStart } from './session-start';
7
+ export { registerSnapshot } from './snapshot';
@@ -0,0 +1,24 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { type SessionManager } from '../sessions';
3
+ import { text } from '../utility/response';
4
+
5
+ export function registerListSessions(server: McpServer, sessions: SessionManager): void {
6
+ server.registerTool(
7
+ 'letsrunit_list_sessions',
8
+ {
9
+ description: 'List all active browser sessions.',
10
+ inputSchema: {},
11
+ },
12
+ async () => {
13
+ const list = sessions.list().map((s) => ({
14
+ sessionId: s.id,
15
+ createdAt: s.createdAt,
16
+ lastActivity: s.lastActivity,
17
+ stepCount: s.stepCount,
18
+ artifactDir: s.artifactDir,
19
+ }));
20
+
21
+ return text(JSON.stringify({ sessions: list }));
22
+ },
23
+ );
24
+ }
@@ -0,0 +1,48 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { z } from 'zod';
3
+ import type { SessionManager } from '../sessions';
4
+ import { normalizeGherkin } from '../utility/gherkin';
5
+ import { err, text } from '../utility/response';
6
+
7
+ export function registerRun(server: McpServer, sessions: SessionManager): void {
8
+ server.registerTool(
9
+ 'letsrunit_run',
10
+ {
11
+ description:
12
+ 'Execute Gherkin steps or a complete feature in the browser. ' +
13
+ 'Accepts a single step line, multiple step lines, a full Scenario, or a full Feature. ' +
14
+ 'Returns status, steps, reason on failure, and journal entries. Does not return a page snapshot — call letsrunit_snapshot explicitly if you need the DOM.',
15
+ inputSchema: {
16
+ sessionId: z.string().describe('Session ID returned by letsrunit_session_start'),
17
+ input: z
18
+ .string()
19
+ .describe(
20
+ '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.',
21
+ ),
22
+ },
23
+ },
24
+ async (input) => {
25
+ try {
26
+ const session = sessions.get(input.sessionId);
27
+ sessions.touch(input.sessionId);
28
+
29
+ const feature = normalizeGherkin(input.input);
30
+
31
+ session.sink.clear();
32
+ const result = await session.controller.run(feature);
33
+ session.stepCount += result.steps.length;
34
+
35
+ return text(
36
+ JSON.stringify({
37
+ status: result.status,
38
+ steps: result.steps,
39
+ reason: result.reason?.message,
40
+ journal: session.sink.getEntries(),
41
+ }),
42
+ );
43
+ } catch (e) {
44
+ return err(`Run failed: ${(e as Error).message}`);
45
+ }
46
+ },
47
+ );
48
+ }
@@ -0,0 +1,57 @@
1
+ import { screenshot, screenshotElement } from '@letsrunit/playwright';
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { mkdir, writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { z } from 'zod';
6
+ import { type SessionManager } from '../sessions';
7
+ import { err, text } from '../utility/response';
8
+
9
+ export function registerScreenshot(server: McpServer, sessions: SessionManager): void {
10
+ server.registerTool(
11
+ 'letsrunit_screenshot',
12
+ {
13
+ description:
14
+ 'Take a screenshot of the current page. Optionally crop to a specific element (selector) or highlight elements before capturing (mask).',
15
+ inputSchema: {
16
+ sessionId: z.string().describe('Session ID'),
17
+ selector: z
18
+ .string()
19
+ .optional()
20
+ .describe('CSS selector — crop screenshot to the bounding box of this element'),
21
+ mask: z
22
+ .array(z.string())
23
+ .optional()
24
+ .describe('CSS selectors whose matching elements are highlighted (dark overlay, element spotlighted)'),
25
+ fullPage: z.boolean().optional().describe('Capture the full scrollable page (default: false)'),
26
+ },
27
+ },
28
+ async (input) => {
29
+ try {
30
+ const session = sessions.get(input.sessionId);
31
+ sessions.touch(input.sessionId);
32
+
33
+ const page = session.controller.page;
34
+
35
+ let file: File;
36
+
37
+ if (input.selector) {
38
+ file = await screenshotElement(page, input.selector);
39
+ } else {
40
+ const masks = input.mask?.map((sel) => page.locator(sel)) ?? [];
41
+ file = await screenshot(page, {
42
+ fullPage: input.fullPage ?? false,
43
+ ...(masks.length ? { mask: masks } : {}),
44
+ });
45
+ }
46
+
47
+ await mkdir(session.artifactDir, { recursive: true });
48
+ const path = join(session.artifactDir, file.name);
49
+ await writeFile(path, await file.bytes());
50
+
51
+ return text(JSON.stringify({ path, mimeType: 'image/png' }));
52
+ } catch (e) {
53
+ return err(`Screenshot failed: ${(e as Error).message}`);
54
+ }
55
+ },
56
+ );
57
+ }
@@ -0,0 +1,24 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { z } from 'zod';
3
+ import type { SessionManager } from '../sessions';
4
+ import { err, text } from '../utility/response';
5
+
6
+ export function registerSessionClose(server: McpServer, sessions: SessionManager): void {
7
+ server.registerTool(
8
+ 'letsrunit_session_close',
9
+ {
10
+ description: 'Close a browser session and release its resources.',
11
+ inputSchema: {
12
+ sessionId: z.string().describe('Session ID to close'),
13
+ },
14
+ },
15
+ async (input) => {
16
+ try {
17
+ await sessions.close(input.sessionId);
18
+ return text(JSON.stringify({ closed: true }));
19
+ } catch (e) {
20
+ return err(`Failed to close session: ${(e as Error).message}`);
21
+ }
22
+ },
23
+ );
24
+ }
@@ -0,0 +1,38 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { z } from 'zod';
3
+ import type { SessionManager } from '../sessions';
4
+ import { err, text } from '../utility/response';
5
+
6
+ export function registerSessionStart(server: McpServer, sessions: SessionManager): void {
7
+ server.registerTool(
8
+ 'letsrunit_session_start',
9
+ {
10
+ description:
11
+ 'Launch a new browser session. Does not navigate anywhere — use letsrunit_run with a Given step to navigate.',
12
+ inputSchema: {
13
+ language: z.string().optional().describe("Browser language code, e.g. 'en', 'fr'"),
14
+ headless: z.boolean().optional().describe('Run browser in headless mode (default: true)'),
15
+ viewportWidth: z.number().int().optional().describe('Viewport width in pixels (default: 1280)'),
16
+ viewportHeight: z.number().int().optional().describe('Viewport height in pixels (default: 720)'),
17
+ },
18
+ },
19
+ async (input) => {
20
+ try {
21
+ const viewport =
22
+ input.viewportWidth || input.viewportHeight
23
+ ? { width: input.viewportWidth ?? 1280, height: input.viewportHeight ?? 720 }
24
+ : undefined;
25
+
26
+ const session = await sessions.create({
27
+ headless: input.headless ?? true,
28
+ locale: input.language,
29
+ viewport,
30
+ });
31
+
32
+ return text(JSON.stringify({ sessionId: session.id }));
33
+ } catch (e) {
34
+ return err(`Failed to start session: ${(e as Error).message}`);
35
+ }
36
+ },
37
+ );
38
+ }
@@ -0,0 +1,61 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { scrubHtml } from '@letsrunit/playwright';
3
+ import { z } from 'zod';
4
+ import { type SessionManager } from '../sessions';
5
+ import { err, text } from '../utility/response';
6
+
7
+ export function registerSnapshot(server: McpServer, sessions: SessionManager): void {
8
+ server.registerTool(
9
+ 'letsrunit_snapshot',
10
+ {
11
+ description:
12
+ 'Get the current page HTML, scrubbed for LLM consumption. Use selector to scope to a DOM subtree.',
13
+ inputSchema: {
14
+ sessionId: z.string().describe('Session ID'),
15
+ selector: z
16
+ .string()
17
+ .optional()
18
+ .describe("CSS selector — return only the matching element's outer HTML instead of the full page"),
19
+ dropHidden: z.boolean().optional().describe('Remove hidden/inert nodes (default: true)'),
20
+ dropHead: z.boolean().optional().describe('Remove the <head> element (default: true)'),
21
+ pickMain: z.boolean().optional().describe('Keep only the <main> element (default: auto)'),
22
+ stripAttributes: z
23
+ .union([z.literal(0), z.literal(1), z.literal(2)])
24
+ .optional()
25
+ .describe('Attribute allowlist level: 0=none, 1=semantic (default), 2=aggressive'),
26
+ },
27
+ },
28
+ async (input) => {
29
+ try {
30
+ const session = sessions.get(input.sessionId);
31
+ sessions.touch(input.sessionId);
32
+
33
+ const page = session.controller.page;
34
+ const url = page.url();
35
+
36
+ const opts = {
37
+ dropHidden: input.dropHidden,
38
+ dropHead: input.dropHead,
39
+ pickMain: input.pickMain,
40
+ stripAttributes: input.stripAttributes,
41
+ };
42
+
43
+ let html: string;
44
+
45
+ if (input.selector) {
46
+ const rawHtml = await page
47
+ .locator(input.selector)
48
+ .first()
49
+ .evaluate((el: Element) => el.outerHTML);
50
+ html = await scrubHtml({ html: rawHtml, url }, opts);
51
+ } else {
52
+ html = await scrubHtml(page, opts);
53
+ }
54
+
55
+ return text(JSON.stringify({ url, html }));
56
+ } catch (e) {
57
+ return err(`Snapshot failed: ${(e as Error).message}`);
58
+ }
59
+ },
60
+ );
61
+ }
@@ -0,0 +1,9 @@
1
+ export function normalizeGherkin(input: string): string {
2
+ const trimmed = input.trim();
3
+
4
+ if (/^(Feature|Scenario|Background):/im.test(trimmed)) {
5
+ return trimmed;
6
+ }
7
+
8
+ return `Feature: MCP\n\nScenario: Steps\n ${trimmed.split('\n').join('\n ')}`;
9
+ }
@@ -0,0 +1,7 @@
1
+ export function text(content: string) {
2
+ return { content: [{ type: 'text' as const, text: content }] };
3
+ }
4
+
5
+ export function err(message: string) {
6
+ return { content: [{ type: 'text' as const, text: message }], isError: true };
7
+ }