devlens-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PresenceLabs LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # DevLens
2
+
3
+ Real-time visual feedback for AI-assisted frontend development. A Claude Code plugin (MCP server) that gives Claude the ability to see what it's building — automatically, after every file write, in ~50ms.
4
+
5
+ ## How it works
6
+
7
+ DevLens keeps a single Chromium instance alive for the duration of your Claude Code session. After every frontend file change, Claude calls `dl_capture` and receives a screenshot of the affected route. No user prompting needed — the companion skill handles it automatically.
8
+
9
+ ## Tools
10
+
11
+ | Tool | Speed | Use |
12
+ |------|-------|-----|
13
+ | `dl_warmup` | ~500ms cold, once | Pre-warm browser at session start |
14
+ | `dl_capture` | ~50ms warm | Screenshot a route or element |
15
+ | `dl_diff` | ~60ms warm | Pixel diff vs last capture |
16
+ | `dl_snapshot` | ~10ms | DOM + styles, no pixels |
17
+
18
+ ## Setup
19
+
20
+ ```bash
21
+ npm install -g devlens-mcp
22
+ npx playwright install chromium
23
+ ```
24
+
25
+ Then in your project root:
26
+
27
+ ```bash
28
+ npx devlens-mcp init
29
+ ```
30
+
31
+ This creates `.mcp.json`, `.claude/skills/devlens.md`, and `devlens.config.ts` automatically.
32
+
33
+ Edit `devlens.config.ts` to map your source files to dev server routes, then restart Claude Code.
34
+
35
+ ```typescript
36
+ // devlens.config.ts
37
+ const config = {
38
+ devServerUrl: 'http://localhost:5173',
39
+ routes: [
40
+ { pattern: '**/pages/Home.tsx', route: '/' },
41
+ { pattern: '**/pages/About.tsx', route: '/about' },
42
+ { pattern: '**/components/**', route: null },
43
+ ],
44
+ };
45
+ export default config;
46
+ ```
47
+
48
+ ## What `init` sets up
49
+
50
+ - **`.mcp.json`** — registers the devlens MCP server so Claude Code can call the `dl_*` tools
51
+ - **`.claude/skills/devlens.md`** — the skill that tells Claude to automatically capture after every file write, diff before/after significant changes, and run a visual audit before deploying
52
+ - **`devlens.config.ts`** — maps file glob patterns to dev server routes
53
+
54
+ ## Performance
55
+
56
+ Warm captures target **<80ms**. Cold first-load is ~500ms (one-time per session). Element-level captures (`selector` param) are faster and produce smaller images.
57
+
58
+ ## Open source
59
+
60
+ MIT. Works with any MCP-compatible AI client.
package/SKILL.md ADDED
@@ -0,0 +1,65 @@
1
+ ---
2
+ name: devlens
3
+ description: Real-time visual QA for frontend development. Auto-invoked when editing frontend files.
4
+ triggers:
5
+ - frontend
6
+ - ui
7
+ - component
8
+ - tsx
9
+ - css
10
+ ---
11
+
12
+ # DevLens — Automatic Visual QA
13
+
14
+ You have access to four visual inspection tools: `dl_warmup`, `dl_capture`, `dl_diff`, `dl_snapshot`.
15
+
16
+ ## Rules — follow these automatically, no user prompting needed
17
+
18
+ ### Rule 1: Warmup at session start
19
+ The first time you are about to edit any file matching `apps/web/src/**`, call `dl_warmup` first.
20
+ This pre-warms the browser so subsequent captures cost ~50ms instead of ~500ms.
21
+ Only call it once per session.
22
+
23
+ ### Rule 2: Capture after every frontend file write
24
+ After every `Edit` or `Write` to a file matching `apps/web/src/**/*.tsx` or `apps/web/src/**/*.css`:
25
+ 1. Call `dl_capture` with either `route` (if you know it) or `filePath` (auto-resolved from devlens.config.ts)
26
+ 2. Add `selector` if you only changed a specific component (keeps the image small and fast)
27
+ 3. Look at the returned image — does it look right?
28
+ 4. If something looks wrong (layout broken, text missing, wrong color, overlapping elements), fix it before moving on
29
+
30
+ Do this every time, automatically. Do not wait to be asked.
31
+
32
+ ### Rule 3: Diff before/after for significant changes
33
+ When making a change you expect to visually alter the page (not just a bug fix):
34
+ 1. Call `dl_diff` before the change to set the baseline
35
+ 2. Make your code changes
36
+ 3. Call `dl_diff` again to see exactly what pixels changed
37
+ 4. Verify the diff matches your intent
38
+
39
+ ### Rule 4: Pre-deploy visual audit
40
+ Before running any deploy command (`wrangler pages deploy`, `npm run deploy`, etc.):
41
+ 1. Call `dl_capture` on the key routes that were changed during this session
42
+ 2. Review each screenshot
43
+ 3. Only proceed with deploy if all look correct
44
+
45
+ Routes to always check before deploy:
46
+ - `/` (Dashboard)
47
+ - Any route you edited during this session
48
+
49
+ ### Rule 5: Snap on request
50
+ If the user asks "does this look right?", "show me what it looks like", or "screenshot this",
51
+ call `dl_capture` immediately and show the result.
52
+
53
+ ## Performance expectations
54
+ - `dl_warmup`: ~500ms (cold), do once
55
+ - `dl_capture` warm: ~50–80ms
56
+ - `dl_snapshot`: ~10ms
57
+ - `dl_diff` warm: ~60ms
58
+
59
+ These are fast enough to call after every file write without slowing down development.
60
+
61
+ ## Selector tips
62
+ - Use `.classname` to target a specific component
63
+ - Use `[data-testid="x"]` for test-id targeting
64
+ - Omit selector for full-page captures (slower, larger image)
65
+ - Prefer element-level captures during iteration, full-page before deploy
package/dist/index.js ADDED
@@ -0,0 +1,485 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli/init.ts
4
+ import { writeFileSync, mkdirSync, existsSync, readFileSync } from "fs";
5
+ import { resolve, join } from "path";
6
+ var SKILL_CONTENT = `---
7
+ name: devlens
8
+ description: Real-time visual QA for frontend development. Auto-invoked when editing frontend files.
9
+ triggers:
10
+ - frontend
11
+ - ui
12
+ - component
13
+ - tsx
14
+ - css
15
+ ---
16
+
17
+ # DevLens \u2014 Automatic Visual QA
18
+
19
+ You have access to four visual inspection tools: \`dl_warmup\`, \`dl_capture\`, \`dl_diff\`, \`dl_snapshot\`.
20
+
21
+ ## Rules \u2014 follow these automatically, no user prompting needed
22
+
23
+ ### Rule 1: Warmup at session start
24
+ The first time you are about to edit any frontend file (\`.tsx\`, \`.jsx\`, \`.css\`, \`.vue\`, \`.svelte\`), call \`dl_warmup\` first.
25
+ This pre-warms the browser so subsequent captures cost ~50ms instead of ~500ms.
26
+ Only call it once per session.
27
+
28
+ ### Rule 2: Capture after every frontend file write
29
+ After every \`Edit\` or \`Write\` to a \`.tsx\`, \`.jsx\`, \`.css\`, \`.vue\`, or \`.svelte\` file:
30
+ 1. Call \`dl_capture\` with either \`route\` (if you know it) or \`filePath\` (auto-resolved from devlens.config.ts)
31
+ 2. Add \`selector\` if you only changed a specific component (keeps the image small and fast)
32
+ 3. Look at the returned image \u2014 does it look right?
33
+ 4. If something looks wrong (layout broken, text missing, wrong color, overlapping elements), fix it before moving on
34
+
35
+ Do this every time, automatically. Do not wait to be asked.
36
+
37
+ ### Rule 3: Diff before/after for significant changes
38
+ When making a change you expect to visually alter the page:
39
+ 1. Call \`dl_diff\` before the change to set the baseline
40
+ 2. Make your code changes
41
+ 3. Call \`dl_diff\` again to see exactly what pixels changed
42
+ 4. Verify the diff matches your intent
43
+
44
+ ### Rule 4: Pre-deploy visual audit
45
+ Before running any deploy command:
46
+ 1. Call \`dl_capture\` on the key routes that were changed during this session
47
+ 2. Review each screenshot
48
+ 3. Only proceed with deploy if all look correct
49
+
50
+ ### Rule 5: Snap on request
51
+ If the user asks "does this look right?", "show me what it looks like", or "screenshot this",
52
+ call \`dl_capture\` immediately and show the result.
53
+
54
+ ## Performance expectations
55
+ - \`dl_warmup\`: ~500ms (cold), do once
56
+ - \`dl_capture\` warm: ~50\u201380ms
57
+ - \`dl_snapshot\`: ~10ms
58
+ - \`dl_diff\` warm: ~60ms
59
+
60
+ ## Selector tips
61
+ - Use \`.classname\` to target a specific component
62
+ - Use \`[data-testid="x"]\` for test-id targeting
63
+ - Omit selector for full-page captures (slower, larger image)
64
+ - Prefer element-level captures during iteration, full-page before deploy
65
+ `;
66
+ var CONFIG_TEMPLATE = `// devlens.config.ts \u2014 map your source files to dev server routes
67
+ const config = {
68
+ devServerUrl: 'http://localhost:5173',
69
+ hmrDebounceMs: 150,
70
+ defaultViewport: { width: 1280, height: 900 },
71
+ routes: [
72
+ // { pattern: '**/pages/Home.tsx', route: '/' },
73
+ // { pattern: '**/pages/About.tsx', route: '/about' },
74
+ // { pattern: '**/components/**', route: null }, // null = no auto-route for components
75
+ ],
76
+ };
77
+
78
+ export default config;
79
+ `;
80
+ async function runInit() {
81
+ const cwd = process.cwd();
82
+ const mcpPath = resolve(cwd, ".mcp.json");
83
+ let mcpConfig = {};
84
+ if (existsSync(mcpPath)) {
85
+ try {
86
+ mcpConfig = JSON.parse(readFileSync(mcpPath, "utf8"));
87
+ } catch {
88
+ console.warn(" Warning: existing .mcp.json could not be parsed \u2014 overwriting");
89
+ }
90
+ }
91
+ mcpConfig.mcpServers = mcpConfig.mcpServers ?? {};
92
+ mcpConfig.mcpServers["devlens"] = { command: "npx", args: ["--yes", "devlens-mcp"] };
93
+ writeFileSync(mcpPath, JSON.stringify(mcpConfig, null, 2) + "\n");
94
+ console.log(" \u2713 .mcp.json updated");
95
+ const skillsDir = resolve(cwd, ".claude", "skills");
96
+ mkdirSync(skillsDir, { recursive: true });
97
+ writeFileSync(join(skillsDir, "devlens.md"), SKILL_CONTENT);
98
+ console.log(" \u2713 .claude/skills/devlens.md written");
99
+ const configPath = resolve(cwd, "devlens.config.ts");
100
+ if (!existsSync(configPath)) {
101
+ writeFileSync(configPath, CONFIG_TEMPLATE);
102
+ console.log(" \u2713 devlens.config.ts created (edit this to add your routes)");
103
+ } else {
104
+ console.log(" \u2713 devlens.config.ts already exists \u2014 skipped");
105
+ }
106
+ console.log("");
107
+ console.log("DevLens initialized. Next steps:");
108
+ console.log("");
109
+ console.log(" 1. Install Chromium (one-time):");
110
+ console.log(" npx playwright install chromium");
111
+ console.log("");
112
+ console.log(" 2. Edit devlens.config.ts to map your pages to routes");
113
+ console.log("");
114
+ console.log(" 3. Restart Claude Code");
115
+ console.log("");
116
+ console.log(" Claude will then automatically screenshot your UI after every file write.");
117
+ }
118
+
119
+ // src/server.ts
120
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
121
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
122
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
123
+
124
+ // src/config.ts
125
+ import { pathToFileURL } from "url";
126
+ import { resolve as resolve2 } from "path";
127
+ var defaults = {
128
+ defaultViewport: { width: 1280, height: 900 },
129
+ hmrDebounceMs: 150
130
+ };
131
+ async function loadConfig(cwd = process.cwd()) {
132
+ const configPath = resolve2(cwd, "devlens.config.ts");
133
+ try {
134
+ const jsPath = configPath.replace(/\.ts$/, ".js");
135
+ const mod = await import(pathToFileURL(jsPath).href);
136
+ return { ...defaults, ...mod.default };
137
+ } catch {
138
+ return {
139
+ ...defaults,
140
+ devServerUrl: "http://localhost:5173",
141
+ routes: []
142
+ };
143
+ }
144
+ }
145
+
146
+ // src/browser/manager.ts
147
+ import { chromium } from "playwright";
148
+ var BrowserManager = class {
149
+ static browser = null;
150
+ static page = null;
151
+ static async getPage() {
152
+ if (!this.browser) {
153
+ this.browser = await chromium.launch({ headless: true });
154
+ }
155
+ if (!this.page || this.page.isClosed()) {
156
+ this.page = await this.browser.newPage();
157
+ this.page.on("console", () => {
158
+ });
159
+ this.page.on("pageerror", () => {
160
+ });
161
+ }
162
+ return this.page;
163
+ }
164
+ static async close() {
165
+ if (this.page && !this.page.isClosed()) await this.page.close();
166
+ if (this.browser) await this.browser.close();
167
+ this.page = null;
168
+ this.browser = null;
169
+ }
170
+ static isWarm() {
171
+ return this.browser !== null && this.page !== null && !this.page.isClosed();
172
+ }
173
+ };
174
+
175
+ // src/utils/diff.ts
176
+ import { PNG } from "pngjs";
177
+ import pixelmatch from "pixelmatch";
178
+ var DiffStore = class {
179
+ baselines = /* @__PURE__ */ new Map();
180
+ async diffAndStore(routeKey, newImageBase64) {
181
+ const newBuf = Buffer.from(newImageBase64, "base64");
182
+ const previous = this.baselines.get(routeKey);
183
+ this.baselines.set(routeKey, newBuf);
184
+ if (!previous) return null;
185
+ const imgA = PNG.sync.read(previous);
186
+ const imgB = PNG.sync.read(newBuf);
187
+ if (imgA.width !== imgB.width || imgA.height !== imgB.height) {
188
+ return { diffImageBase64: newImageBase64, changedPercent: 100, changedPixels: -1, totalPixels: -1 };
189
+ }
190
+ const { width, height } = imgA;
191
+ const diff2 = new PNG({ width, height });
192
+ const changedPixels = pixelmatch(imgA.data, imgB.data, diff2.data, width, height, { threshold: 0.1 });
193
+ const totalPixels = width * height;
194
+ return {
195
+ diffImageBase64: PNG.sync.write(diff2).toString("base64"),
196
+ changedPercent: Math.round(changedPixels / totalPixels * 100 * 10) / 10,
197
+ changedPixels,
198
+ totalPixels
199
+ };
200
+ }
201
+ clear(routeKey) {
202
+ if (routeKey) this.baselines.delete(routeKey);
203
+ else this.baselines.clear();
204
+ }
205
+ };
206
+
207
+ // src/browser/capture.ts
208
+ function normaliseUrl(raw) {
209
+ try {
210
+ const u = new URL(raw);
211
+ if (u.pathname === "/") u.pathname = "";
212
+ return u.toString().replace(/\/$/, "");
213
+ } catch {
214
+ return raw.replace(/\/$/, "");
215
+ }
216
+ }
217
+ async function captureRoute(url, selector, viewport) {
218
+ const start = Date.now();
219
+ const page = await BrowserManager.getPage();
220
+ await page.setViewportSize(viewport);
221
+ if (normaliseUrl(page.url()) !== normaliseUrl(url)) {
222
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 1e4 });
223
+ }
224
+ const screenshot = await page.screenshot({ type: "png", fullPage: false });
225
+ return {
226
+ imageBase64: screenshot.toString("base64"),
227
+ durationMs: Date.now() - start,
228
+ url,
229
+ selector: void 0,
230
+ found: true
231
+ };
232
+ }
233
+ async function captureElement(url, selector, viewport) {
234
+ const start = Date.now();
235
+ const page = await BrowserManager.getPage();
236
+ await page.setViewportSize(viewport);
237
+ if (normaliseUrl(page.url()) !== normaliseUrl(url)) {
238
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 1e4 });
239
+ }
240
+ const locator = page.locator(selector).first();
241
+ const count = await locator.count();
242
+ if (count === 0) {
243
+ return { imageBase64: "", durationMs: Date.now() - start, url, selector, found: false };
244
+ }
245
+ const screenshot = await locator.screenshot({ type: "png" });
246
+ return {
247
+ imageBase64: screenshot.toString("base64"),
248
+ durationMs: Date.now() - start,
249
+ url,
250
+ selector,
251
+ found: true
252
+ };
253
+ }
254
+
255
+ // src/tools/warmup.ts
256
+ async function warmup(devServerUrl, config) {
257
+ const url = devServerUrl ?? config.devServerUrl;
258
+ const start = Date.now();
259
+ await captureRoute(url, void 0, config.defaultViewport ?? { width: 1280, height: 900 });
260
+ return { ok: true, durationMs: Date.now() - start, url };
261
+ }
262
+
263
+ // src/utils/route-mapper.ts
264
+ import { minimatch } from "minimatch";
265
+ function resolveRoute(filePath, config) {
266
+ const normalized = filePath.replace(/\\/g, "/");
267
+ for (const mapping of config.routes) {
268
+ if (minimatch(normalized, mapping.pattern, { matchBase: false })) {
269
+ return mapping.route;
270
+ }
271
+ }
272
+ return null;
273
+ }
274
+
275
+ // src/utils/wait-for-hmr.ts
276
+ function waitForHmr(ms) {
277
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
278
+ }
279
+
280
+ // src/tools/capture.ts
281
+ async function capture(input, config) {
282
+ let route = input.route;
283
+ if (!route && input.filePath) {
284
+ route = resolveRoute(input.filePath, config) ?? void 0;
285
+ }
286
+ if (!route) {
287
+ return { error: "No route resolved \u2014 pass route directly or add filePath to devlens.config.ts mappings" };
288
+ }
289
+ const url = `${config.devServerUrl}${route}`;
290
+ const debounce = input.waitMs ?? config.hmrDebounceMs ?? 150;
291
+ await waitForHmr(debounce);
292
+ const viewport = config.defaultViewport ?? { width: 1280, height: 900 };
293
+ if (input.selector) {
294
+ return captureElement(url, input.selector, viewport);
295
+ }
296
+ return captureRoute(url, void 0, viewport);
297
+ }
298
+
299
+ // src/tools/snapshot.ts
300
+ async function snapshot(route, selector, config) {
301
+ const url = `${config.devServerUrl}${route}`;
302
+ const page = await BrowserManager.getPage();
303
+ if (normaliseUrl(page.url()) !== normaliseUrl(url)) {
304
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 1e4 });
305
+ }
306
+ const target = selector ? page.locator(selector).first() : page.locator("body");
307
+ const found = await target.count() > 0;
308
+ if (!found) return { found: false, elements: [] };
309
+ const elements = await target.evaluate((el) => {
310
+ function extract(node) {
311
+ const style = window.getComputedStyle(node);
312
+ const rect = node.getBoundingClientRect();
313
+ return {
314
+ tag: node.tagName.toLowerCase(),
315
+ id: node.id || void 0,
316
+ classes: Array.from(node.classList).slice(0, 5),
317
+ text: node.textContent?.trim().slice(0, 120) || void 0,
318
+ role: node.getAttribute("role") || void 0,
319
+ ariaLabel: node.getAttribute("aria-label") || void 0,
320
+ visible: rect.width > 0 && rect.height > 0,
321
+ bounds: { x: Math.round(rect.x), y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) },
322
+ styles: {
323
+ display: style.display,
324
+ color: style.color,
325
+ backgroundColor: style.backgroundColor,
326
+ fontSize: style.fontSize,
327
+ fontWeight: style.fontWeight
328
+ },
329
+ children: Array.from(node.children).slice(0, 8).map(extract)
330
+ };
331
+ }
332
+ return extract(el);
333
+ });
334
+ return { found: true, url, selector, elements };
335
+ }
336
+
337
+ // src/tools/diff.ts
338
+ async function diff(route, selector, store, config) {
339
+ const result = await capture({ route, selector }, config);
340
+ if ("error" in result) return result;
341
+ if (!result.found) return { found: false };
342
+ const key = `${route}${selector ?? ""}`;
343
+ const diffResult = await store.diffAndStore(key, result.imageBase64);
344
+ if (!diffResult) {
345
+ return { ...result, diff: null, message: "Baseline set \u2014 call again after making changes to see the diff." };
346
+ }
347
+ return {
348
+ ...result,
349
+ diff: {
350
+ diffImageBase64: diffResult.diffImageBase64,
351
+ changedPercent: diffResult.changedPercent,
352
+ changedPixels: diffResult.changedPixels
353
+ }
354
+ };
355
+ }
356
+
357
+ // src/server.ts
358
+ var diffStore = new DiffStore();
359
+ async function startServer() {
360
+ const config = await loadConfig();
361
+ const server = new Server(
362
+ { name: "devlens", version: "0.1.0" },
363
+ { capabilities: { tools: {} } }
364
+ );
365
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
366
+ tools: [
367
+ {
368
+ name: "dl_warmup",
369
+ description: "Pre-warm the browser by loading the dev server. Call once at the start of a frontend task. First call takes ~500ms; subsequent captures cost ~50ms.",
370
+ inputSchema: {
371
+ type: "object",
372
+ properties: {
373
+ devServerUrl: { type: "string", description: 'Override the dev server URL from config (e.g. "http://localhost:5173")' }
374
+ }
375
+ }
376
+ },
377
+ {
378
+ name: "dl_capture",
379
+ description: 'Screenshot a dev server route and return a PNG image. Pass either `route` ("/dashboard") or `filePath` (auto-resolved via devlens.config.ts). Optionally pass `selector` to crop to a specific element. Call this after every frontend file write.',
380
+ inputSchema: {
381
+ type: "object",
382
+ properties: {
383
+ route: { type: "string", description: 'Dev server route, e.g. "/dashboard"' },
384
+ filePath: { type: "string", description: "The file you just edited \u2014 route resolved from devlens.config.ts mappings" },
385
+ selector: { type: "string", description: 'CSS selector to crop to (e.g. ".onboarding-banner"). Omit for full page.' },
386
+ waitMs: { type: "number", description: "ms to wait for HMR to settle (default: 150)" }
387
+ }
388
+ }
389
+ },
390
+ {
391
+ name: "dl_diff",
392
+ description: "Screenshot a route and compare to the last capture for that route. Returns a pixel diff image and the percentage of pixels changed. First call sets the baseline.",
393
+ inputSchema: {
394
+ type: "object",
395
+ properties: {
396
+ route: { type: "string" },
397
+ selector: { type: "string" }
398
+ },
399
+ required: ["route"]
400
+ }
401
+ },
402
+ {
403
+ name: "dl_snapshot",
404
+ description: "Fast structural snapshot \u2014 returns DOM tree, computed styles, and bounding boxes without a visual screenshot. ~10ms. Use for quick layout/content verification.",
405
+ inputSchema: {
406
+ type: "object",
407
+ properties: {
408
+ route: { type: "string", description: "Dev server route" },
409
+ selector: { type: "string", description: "CSS selector to scope snapshot to" }
410
+ },
411
+ required: ["route"]
412
+ }
413
+ }
414
+ ]
415
+ }));
416
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
417
+ const { name, arguments: args } = req.params;
418
+ try {
419
+ if (name === "dl_warmup") {
420
+ const result = await warmup(args.devServerUrl, config);
421
+ return {
422
+ content: [{ type: "text", text: `Warmed up in ${result.durationMs}ms \u2014 ${result.url}` }]
423
+ };
424
+ }
425
+ if (name === "dl_capture") {
426
+ const input = args;
427
+ const result = await capture(input, config);
428
+ if ("error" in result) {
429
+ return { content: [{ type: "text", text: `Error: ${result.error}` }] };
430
+ }
431
+ const content = [
432
+ { type: "text", text: `Captured in ${result.durationMs}ms \u2014 ${result.url}${result.selector ? ` (${result.selector})` : ""}` }
433
+ ];
434
+ if (result.imageBase64) {
435
+ content.push({ type: "image", data: result.imageBase64, mimeType: "image/png" });
436
+ }
437
+ return { content };
438
+ }
439
+ if (name === "dl_diff") {
440
+ const { route, selector } = args;
441
+ const result = await diff(route, selector, diffStore, config);
442
+ if ("error" in result) return { content: [{ type: "text", text: `Error: ${result.error}` }] };
443
+ const content = [
444
+ { type: "text", text: result.diff ? `Changed: ${result.diff.changedPercent}% (${result.diff.changedPixels} pixels)` : result.message ?? "Baseline set." }
445
+ ];
446
+ if (result.diff?.diffImageBase64) {
447
+ content.push({ type: "image", data: result.diff.diffImageBase64, mimeType: "image/png" });
448
+ }
449
+ return { content };
450
+ }
451
+ if (name === "dl_snapshot") {
452
+ const { route, selector } = args;
453
+ const result = await snapshot(route, selector, config);
454
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
455
+ }
456
+ return { content: [{ type: "text", text: `Unknown tool: ${name}` }] };
457
+ } catch (err) {
458
+ const msg = err instanceof Error ? err.message : String(err);
459
+ return { content: [{ type: "text", text: `Tool error: ${msg}` }], isError: true };
460
+ }
461
+ });
462
+ process.on("SIGINT", async () => {
463
+ await BrowserManager.close();
464
+ process.exit(0);
465
+ });
466
+ const transport = new StdioServerTransport();
467
+ await server.connect(transport);
468
+ console.error("[devlens] MCP server ready");
469
+ }
470
+
471
+ // src/index.ts
472
+ var command = process.argv[2];
473
+ if (command === "init") {
474
+ console.log("Initializing DevLens...");
475
+ console.log("");
476
+ runInit().catch((err) => {
477
+ console.error("Init failed:", err instanceof Error ? err.message : String(err));
478
+ process.exit(1);
479
+ });
480
+ } else {
481
+ startServer().catch((err) => {
482
+ console.error("[devlens] fatal:", err);
483
+ process.exit(1);
484
+ });
485
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "devlens-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Real-time visual feedback plugin for Claude Code frontend development",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "devlens-mcp": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "SKILL.md",
13
+ "README.md"
14
+ ],
15
+ "keywords": [
16
+ "claude",
17
+ "mcp",
18
+ "claude-code",
19
+ "playwright",
20
+ "visual-testing",
21
+ "frontend"
22
+ ],
23
+ "license": "MIT",
24
+ "scripts": {
25
+ "build": "tsup",
26
+ "dev": "tsup --watch",
27
+ "test": "vitest run",
28
+ "test:watch": "vitest",
29
+ "start": "node dist/index.js"
30
+ },
31
+ "dependencies": {
32
+ "@modelcontextprotocol/sdk": "^1.0.0",
33
+ "minimatch": "^10.2.5",
34
+ "pixelmatch": "^6.0.0",
35
+ "playwright": "^1.45.0",
36
+ "pngjs": "^7.0.0",
37
+ "zod": "^3.22.0"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^20.0.0",
41
+ "@types/pngjs": "^6.0.0",
42
+ "tsup": "^8.0.0",
43
+ "typescript": "^5.4.0",
44
+ "vitest": "^1.6.0"
45
+ }
46
+ }