frameshot-mcp 0.7.0 → 0.8.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/dist/cli.js CHANGED
@@ -1,14 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- BrowserPool,
4
- CatalogUseCase,
5
- DiffUseCase,
6
- EXT_TO_FRAMEWORK,
7
- HtmlBuilder,
8
- ImageComparator,
9
- RenderUseCase,
10
- ViteBundler
11
- } from "./chunk-Q7A3DLED.js";
3
+ createContainer
4
+ } from "./chunk-MA3FOIQY.js";
5
+ import {
6
+ EXT_TO_FRAMEWORK
7
+ } from "./chunk-PYWXJZTZ.js";
12
8
 
13
9
  // src/cli.ts
14
10
  import { execFileSync, execSync } from "child_process";
@@ -196,18 +192,8 @@ async function main() {
196
192
  log(`
197
193
  ${brand()} ${c.dim}v0.7.0${c.reset}
198
194
  `);
199
- const pool = new BrowserPool();
200
- const htmlBuilder = new HtmlBuilder();
201
- const imageComparator = new ImageComparator();
202
- const viteBundler = new ViteBundler();
203
- const renderUseCase = new RenderUseCase(
204
- pool,
205
- htmlBuilder,
206
- imageComparator,
207
- viteBundler
208
- );
209
- const catalogUseCase = new CatalogUseCase(renderUseCase);
210
- const diffUseCase = new DiffUseCase(renderUseCase, imageComparator);
195
+ const container = createContainer();
196
+ const { pool, renderUseCase, catalogUseCase, diffUseCase } = container;
211
197
  const outDir = resolve(flags.out);
212
198
  mkdirSync(outDir, { recursive: true });
213
199
  try {
@@ -228,8 +214,7 @@ async function main() {
228
214
  );
229
215
  }
230
216
  } finally {
231
- await viteBundler.shutdown();
232
- await pool.shutdown();
217
+ await container.shutdown();
233
218
  }
234
219
  log("");
235
220
  }
package/dist/index.js CHANGED
@@ -1,22 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- AuditUseCase,
4
- ScreenshotUseCase,
5
- SnapshotStore,
6
- SnapshotUseCase
7
- } from "./chunk-FTYTZW6D.js";
3
+ createContainer
4
+ } from "./chunk-MA3FOIQY.js";
8
5
  import {
9
- BrowserPool,
10
- CatalogUseCase,
11
6
  DEVICE_PRESETS,
12
- DiffUseCase,
13
7
  EXT_TO_FRAMEWORK,
14
- HtmlBuilder,
15
- ImageComparator,
16
- RenderUseCase,
17
- ViteBundler,
18
8
  __export
19
- } from "./chunk-Q7A3DLED.js";
9
+ } from "./chunk-PYWXJZTZ.js";
20
10
 
21
11
  // src/index.ts
22
12
  import { execSync } from "child_process";
@@ -15581,6 +15571,133 @@ ${lines.join("\n")}`
15581
15571
  );
15582
15572
  }
15583
15573
 
15574
+ // src/tools/watch-tools.ts
15575
+ function registerWatchTools(server2, useCase) {
15576
+ server2.tool(
15577
+ "watch_start",
15578
+ "Start watching component files for changes. On every save, the component is automatically rendered and you will receive a notification \u2014 call watch_get_latest(id) to retrieve the rendered screenshot. Use this to create a live feedback loop while editing UI \u2014 the AI sees each change without you needing to call render_file manually.",
15579
+ {
15580
+ patterns: external_exports.array(external_exports.string()).describe(
15581
+ "Glob patterns or absolute file paths to watch (e.g. ['src/components/Button.tsx'])"
15582
+ ),
15583
+ props: external_exports.record(external_exports.string(), external_exports.unknown()).optional().describe("Props to pass to the component on each render")
15584
+ },
15585
+ async ({ patterns, props }) => {
15586
+ const session = useCase.start(patterns, props, async (event) => {
15587
+ if (event.error) {
15588
+ await server2.server.notification({
15589
+ method: "notifications/message",
15590
+ params: {
15591
+ level: "error",
15592
+ logger: "frameshot/watch",
15593
+ data: `[${event.sessionId}] \u274C ${event.filePath}: ${event.error}`
15594
+ }
15595
+ });
15596
+ return;
15597
+ }
15598
+ const summary = `[${event.sessionId}] \u2713 ${event.filePath} \u2014 ${event.width}\xD7${event.height} \xB7 ${event.elapsedMs}ms \xB7 ${event.mode}` + (event.consoleErrors.length ? `
15599
+ \u26A0\uFE0F ${event.consoleErrors.join("\n")}` : "") + `
15600
+ Call watch_get_latest("${event.sessionId}") to see the render.`;
15601
+ await server2.server.notification({
15602
+ method: "notifications/message",
15603
+ params: {
15604
+ level: "info",
15605
+ logger: "frameshot/watch",
15606
+ data: summary
15607
+ }
15608
+ });
15609
+ });
15610
+ return {
15611
+ content: [
15612
+ {
15613
+ type: "text",
15614
+ text: `\u2713 Watch started (${session.id})
15615
+ Watching: ${patterns.join(", ")}
15616
+
15617
+ Every time a matched file is saved, it will be rendered automatically and a notification will appear in your MCP log stream. Call \`watch_get_latest\` with id "${session.id}" to retrieve the screenshot.
15618
+
15619
+ Call \`watch_stop\` with id "${session.id}" to stop.`
15620
+ }
15621
+ ]
15622
+ };
15623
+ }
15624
+ );
15625
+ server2.tool(
15626
+ "watch_stop",
15627
+ "Stop a running file watcher started with watch_start.",
15628
+ {
15629
+ id: external_exports.string().describe("Watch session ID returned by watch_start (e.g. 'watch-1')")
15630
+ },
15631
+ async ({ id }) => {
15632
+ const stopped = useCase.stop(id);
15633
+ return {
15634
+ content: [
15635
+ {
15636
+ type: "text",
15637
+ text: stopped ? `\u2713 Watch session "${id}" stopped.` : `No active watch session with id "${id}".`
15638
+ }
15639
+ ]
15640
+ };
15641
+ }
15642
+ );
15643
+ server2.tool(
15644
+ "watch_list",
15645
+ "List all currently active file watch sessions.",
15646
+ {},
15647
+ async () => {
15648
+ const sessions = useCase.activeSessions();
15649
+ if (sessions.length === 0) {
15650
+ return {
15651
+ content: [{ type: "text", text: "No active watch sessions." }]
15652
+ };
15653
+ }
15654
+ const lines = sessions.map(
15655
+ (s) => `${s.id}: watching ${s.patterns.join(", ")}`
15656
+ );
15657
+ return {
15658
+ content: [{ type: "text", text: lines.join("\n") }]
15659
+ };
15660
+ }
15661
+ );
15662
+ server2.tool(
15663
+ "watch_get_latest",
15664
+ "Get the latest rendered screenshot from a watch session. Call this after receiving a watch notification to see the current state of the component.",
15665
+ {
15666
+ id: external_exports.string().describe("Watch session ID from watch_start")
15667
+ },
15668
+ async ({ id }) => {
15669
+ const render = useCase.getLatestRender(id);
15670
+ if (!render) {
15671
+ return {
15672
+ content: [
15673
+ { type: "text", text: `No render yet for session "${id}".` }
15674
+ ]
15675
+ };
15676
+ }
15677
+ if (render.error) {
15678
+ return {
15679
+ content: [
15680
+ { type: "text", text: `Last render failed: ${render.error}` }
15681
+ ]
15682
+ };
15683
+ }
15684
+ return {
15685
+ content: [
15686
+ {
15687
+ type: "image",
15688
+ data: render.image,
15689
+ mimeType: "image/png"
15690
+ },
15691
+ {
15692
+ type: "text",
15693
+ text: `${render.filePath} \u2014 ${render.width}\xD7${render.height} \xB7 ${render.elapsedMs}ms \xB7 ${render.mode}`
15694
+ }
15695
+ ]
15696
+ };
15697
+ }
15698
+ );
15699
+ }
15700
+
15584
15701
  // src/tools/register.ts
15585
15702
  function registerAllTools(server2, useCases) {
15586
15703
  registerRenderTools(server2, useCases.render);
@@ -15589,36 +15706,18 @@ function registerAllTools(server2, useCases) {
15589
15706
  registerAuditTools(server2, useCases.audit);
15590
15707
  registerSnapshotTools(server2, useCases.snapshot);
15591
15708
  registerCatalogTools(server2, useCases.catalog);
15709
+ registerWatchTools(server2, useCases.watch);
15592
15710
  }
15593
15711
 
15594
15712
  // src/index.ts
15595
- var browserPool = new BrowserPool();
15596
- var htmlBuilder = new HtmlBuilder();
15597
- var imageComparator = new ImageComparator();
15598
- var snapshotStore = new SnapshotStore();
15599
- var viteBundler = new ViteBundler();
15600
- var renderUseCase = new RenderUseCase(
15601
- browserPool,
15602
- htmlBuilder,
15603
- imageComparator,
15604
- viteBundler
15605
- );
15606
- var screenshotUseCase = new ScreenshotUseCase(browserPool);
15607
- var diffUseCase = new DiffUseCase(renderUseCase, imageComparator);
15608
- var auditUseCase = new AuditUseCase(browserPool, htmlBuilder);
15609
- var snapshotUseCase = new SnapshotUseCase(
15610
- snapshotStore,
15611
- renderUseCase,
15612
- diffUseCase
15613
- );
15614
- var catalogUseCase = new CatalogUseCase(renderUseCase);
15713
+ var container = createContainer();
15615
15714
  async function ensureBrowser() {
15616
15715
  try {
15617
- await browserPool.warmup(["chromium"]);
15716
+ await container.pool.warmup(["chromium"]);
15618
15717
  } catch {
15619
15718
  try {
15620
15719
  execSync("npx playwright install chromium", { stdio: "pipe" });
15621
- await browserPool.warmup(["chromium"]);
15720
+ await container.pool.warmup(["chromium"]);
15622
15721
  } catch {
15623
15722
  }
15624
15723
  }
@@ -15626,25 +15725,24 @@ async function ensureBrowser() {
15626
15725
  await ensureBrowser();
15627
15726
  var server = new McpServer({
15628
15727
  name: "frameshot",
15629
- version: "0.5.0"
15728
+ version: "0.8.0"
15630
15729
  });
15631
15730
  registerAllTools(server, {
15632
- render: renderUseCase,
15633
- screenshot: screenshotUseCase,
15634
- diff: diffUseCase,
15635
- audit: auditUseCase,
15636
- snapshot: snapshotUseCase,
15637
- catalog: catalogUseCase
15731
+ render: container.renderUseCase,
15732
+ screenshot: container.screenshotUseCase,
15733
+ diff: container.diffUseCase,
15734
+ audit: container.auditUseCase,
15735
+ snapshot: container.snapshotUseCase,
15736
+ catalog: container.catalogUseCase,
15737
+ watch: container.watchUseCase
15638
15738
  });
15639
15739
  var transport = new StdioServerTransport();
15640
15740
  await server.connect(transport);
15641
15741
  process.on("SIGINT", async () => {
15642
- await viteBundler.shutdown();
15643
- await browserPool.shutdown();
15742
+ await container.shutdown();
15644
15743
  process.exit(0);
15645
15744
  });
15646
15745
  process.on("SIGTERM", async () => {
15647
- await viteBundler.shutdown();
15648
- await browserPool.shutdown();
15746
+ await container.shutdown();
15649
15747
  process.exit(0);
15650
15748
  });
@@ -90,6 +90,7 @@ interface CatalogEntry {
90
90
  width: number;
91
91
  height: number;
92
92
  consoleErrors: string[];
93
+ error?: string;
93
94
  }
94
95
  declare const DEVICE_PRESETS: Record<string, Viewport>;
95
96
  declare const EXT_TO_FRAMEWORK: Record<string, Framework>;
@@ -106,11 +107,18 @@ interface ProjectConfig {
106
107
 
107
108
  declare class BrowserPool {
108
109
  private pool;
110
+ private waiters;
111
+ private totalPages;
112
+ private readonly maxPages;
113
+ constructor(maxConcurrentPages?: number);
109
114
  warmup(engines: Engine[]): Promise<void>;
110
115
  getPage(engine: Engine): Promise<Page>;
111
- setViewport(engine: Engine, viewport: Viewport): Promise<void>;
116
+ releasePage(engine: Engine, page: Page): void;
117
+ private acquireSlot;
118
+ private releaseSlot;
112
119
  shutdown(): Promise<void>;
113
120
  private getSlot;
121
+ private createPage;
114
122
  }
115
123
 
116
124
  declare class HtmlBuilder {
@@ -129,7 +137,7 @@ interface DiffComparison {
129
137
  }
130
138
  declare class ImageComparator {
131
139
  diff(imageA: string, imageB: string, threshold?: number): DiffComparison;
132
- composite(images: Buffer[], columns: number, labelHeight?: number): {
140
+ composite(images: Buffer[], columns: number): {
133
141
  image: string;
134
142
  width: number;
135
143
  height: number;
@@ -169,7 +177,7 @@ declare class ViteBundler {
169
177
  private ensureServer;
170
178
  private importVite;
171
179
  private hasPackage;
172
- private generateEntry;
180
+ generateEntry(componentPath: string, framework: Framework, props?: Record<string, unknown>, projectRoot?: string): string;
173
181
  private findGlobalCss;
174
182
  private getOptimizeDepsInclude;
175
183
  }
@@ -226,8 +234,6 @@ declare class RenderUseCase {
226
234
  private renderUrl;
227
235
  private navigateAndWait;
228
236
  private resolveAutoFitViewport;
229
- private attachConsoleCapture;
230
- private takeScreenshot;
231
237
  private resolveOptions;
232
238
  }
233
239
 
package/dist/renderer.js CHANGED
@@ -1,10 +1,5 @@
1
1
  import {
2
2
  AuditUseCase,
3
- ScreenshotUseCase,
4
- SnapshotStore,
5
- SnapshotUseCase
6
- } from "./chunk-FTYTZW6D.js";
7
- import {
8
3
  BrowserPool,
9
4
  CatalogUseCase,
10
5
  DEVICE_PRESETS,
@@ -14,8 +9,11 @@ import {
14
9
  ImageComparator,
15
10
  ProjectDetector,
16
11
  RenderUseCase,
12
+ ScreenshotUseCase,
13
+ SnapshotStore,
14
+ SnapshotUseCase,
17
15
  ViteBundler
18
- } from "./chunk-Q7A3DLED.js";
16
+ } from "./chunk-PYWXJZTZ.js";
19
17
  export {
20
18
  AuditUseCase,
21
19
  BrowserPool,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frameshot-mcp",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Zero-config visual testing for AI agents. Render project components with full Vite dependency resolution — no stories, no config.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -67,6 +67,7 @@
67
67
  "dependencies": {
68
68
  "@modelcontextprotocol/sdk": "^1.12.1",
69
69
  "axe-core": "^4.12.1",
70
+ "chokidar": "^5.0.0",
70
71
  "pixelmatch": "^7.2.0",
71
72
  "playwright": "^1.52.0",
72
73
  "pngjs": "^7.0.0"
@@ -85,7 +86,7 @@
85
86
  "@types/pngjs": "^6.0.5",
86
87
  "tsup": "^8.0.0",
87
88
  "typescript": "^5.7.0",
88
- "vite": "^6.0.0",
89
+ "vite": "^8.0.16",
89
90
  "vitest": "^4.1.9"
90
91
  },
91
92
  "mcpName": "io.github.kamegoro/frameshot-mcp",
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { execSync } from "node:child_process";
4
- import { mkdirSync, writeFileSync } from "node:fs";
4
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
5
5
  import { basename, extname, join, relative } from "node:path";
6
6
 
7
7
  const {
8
8
  INPUT_PATHS = "",
9
+ INPUT_EXTENSIONS = ".jsx,.tsx,.vue,.svelte,.astro,.mdx",
10
+ INPUT_EXCLUDE = "*.test.*,*.spec.*,*.stories.*,*.story.*",
9
11
  INPUT_WIDTH = "",
10
12
  INPUT_HEIGHT = "",
11
13
  INPUT_BASE_REF = "",
@@ -20,19 +22,33 @@ const patterns = INPUT_PATHS.split(/[,\n]/)
20
22
  .map((p) => p.trim())
21
23
  .filter(Boolean);
22
24
 
23
- const COMPONENT_EXTS = new Set([
24
- ".jsx",
25
- ".tsx",
26
- ".vue",
27
- ".svelte",
28
- ]);
25
+ const COMPONENT_EXTS = new Set(
26
+ INPUT_EXTENSIONS.split(",").map((e) => e.trim().toLowerCase()).filter(Boolean),
27
+ );
28
+
29
+ const EXCLUDE_PATTERNS = INPUT_EXCLUDE.split(",")
30
+ .map((p) => p.trim())
31
+ .filter(Boolean)
32
+ .map((p) => {
33
+ // Convert glob pattern to regex: *.test.* → /[^/]*\.test\.[^/]*/
34
+ const escaped = p.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*");
35
+ return new RegExp(escaped);
36
+ });
37
+
38
+ function isExcluded(filePath) {
39
+ const base = filePath.split("/").pop() ?? "";
40
+ return EXCLUDE_PATTERNS.some((re) => re.test(base));
41
+ }
29
42
 
30
43
  function findFiles(workspace, patterns) {
31
44
  const allFiles = [];
32
45
  for (const pattern of patterns) {
33
46
  try {
47
+ const absPattern = pattern.startsWith("/")
48
+ ? pattern
49
+ : join(workspace, pattern.replace(/^\.\//, ""));
34
50
  const result = execSync(
35
- `find ${workspace} -path '${pattern}' -type f 2>/dev/null`,
51
+ `find "${workspace}" -path "${absPattern}" -type f 2>/dev/null`,
36
52
  { encoding: "utf-8" },
37
53
  );
38
54
  allFiles.push(...result.split("\n").filter(Boolean));
@@ -40,7 +56,23 @@ function findFiles(workspace, patterns) {
40
56
  // Pattern didn't match
41
57
  }
42
58
  }
43
- return allFiles.filter((f) => COMPONENT_EXTS.has(extname(f).toLowerCase()));
59
+ return allFiles
60
+ .filter((f) => COMPONENT_EXTS.has(extname(f).toLowerCase()))
61
+ .filter((f) => !isExcluded(f));
62
+ }
63
+
64
+ // When no paths specified, scan all component files in the workspace
65
+ function findAllComponentFiles(workspace) {
66
+ try {
67
+ const exts = [...COMPONENT_EXTS].map((e) => `-name "*${e}"`).join(" -o ");
68
+ const result = execSync(
69
+ `find "${workspace}" \\( ${exts} \\) -not -path "*/node_modules/*" -not -path "*/.git/*" -type f 2>/dev/null`,
70
+ { encoding: "utf-8" },
71
+ );
72
+ return result.split("\n").filter(Boolean).filter((f) => !isExcluded(f));
73
+ } catch {
74
+ return [];
75
+ }
44
76
  }
45
77
 
46
78
  function getChangedFiles(baseRef) {
@@ -78,8 +110,26 @@ function restoreFile(filePath) {
78
110
  }
79
111
 
80
112
  async function main() {
81
- const { BrowserPool, HtmlBuilder, ImageComparator, RenderUseCase, ViteBundler } =
82
- await import("frameshot-mcp/renderer");
113
+ // Try to resolve frameshot-mcp/renderer from multiple locations:
114
+ // 1. The action's own node_modules (when run as a GitHub Action)
115
+ // 2. Standard module resolution (when installed globally or in PATH)
116
+ let renderer;
117
+ const actionDir = new URL("..", import.meta.url).pathname;
118
+ const localPath = new URL(
119
+ "node_modules/frameshot-mcp/dist/renderer.js",
120
+ `file://${actionDir}/`,
121
+ ).href;
122
+ try {
123
+ renderer = await import(localPath);
124
+ } catch {
125
+ try {
126
+ renderer = await import("frameshot-mcp/renderer");
127
+ } catch {
128
+ console.error("Could not load frameshot-mcp. Run: npm install frameshot-mcp");
129
+ process.exit(1);
130
+ }
131
+ }
132
+ const { BrowserPool, HtmlBuilder, ImageComparator, RenderUseCase, ViteBundler } = renderer;
83
133
 
84
134
  const browserPool = new BrowserPool();
85
135
  const htmlBuilder = new HtmlBuilder();
@@ -94,16 +144,33 @@ async function main() {
94
144
 
95
145
  await browserPool.warmup(["chromium"]);
96
146
 
97
- const allFiles = findFiles(GITHUB_WORKSPACE, patterns);
147
+ // If no paths specified, use changed files detected from git diff
148
+ let allFiles;
149
+ if (patterns.length === 0) {
150
+ const changedFiles = getChangedFiles(INPUT_BASE_REF);
151
+ if (!changedFiles) {
152
+ // No base ref — render all component files in workspace
153
+ allFiles = findAllComponentFiles(GITHUB_WORKSPACE);
154
+ } else {
155
+ // Render only the changed component files
156
+ allFiles = [...changedFiles]
157
+ .filter((f) => COMPONENT_EXTS.has(extname(f).toLowerCase()))
158
+ .filter((f) => !isExcluded(f))
159
+ .map((f) => join(GITHUB_WORKSPACE, f))
160
+ .filter((f) => existsSync(f));
161
+ }
162
+ } else {
163
+ allFiles = findFiles(GITHUB_WORKSPACE, patterns);
164
+ }
98
165
 
99
166
  if (allFiles.length === 0) {
100
- console.log("No component files found matching the specified patterns.");
167
+ console.log("No changed component files to render.");
101
168
  await viteBundler.shutdown();
102
169
  await browserPool.shutdown();
103
170
  process.exit(0);
104
171
  }
105
172
 
106
- const changedFiles = getChangedFiles(INPUT_BASE_REF);
173
+ const changedFiles = patterns.length > 0 ? getChangedFiles(INPUT_BASE_REF) : null;
107
174
  const filesToRender = changedFiles
108
175
  ? allFiles.filter((f) => {
109
176
  const rel = relative(GITHUB_WORKSPACE, f);
@@ -123,8 +190,8 @@ async function main() {
123
190
  const autoFit = !INPUT_WIDTH || !INPUT_HEIGHT;
124
191
  const renderOpts = {
125
192
  viewport: {
126
- width: INPUT_WIDTH ? parseInt(INPUT_WIDTH) : 1280,
127
- height: INPUT_HEIGHT ? parseInt(INPUT_HEIGHT) : 800,
193
+ width: INPUT_WIDTH ? parseInt(INPUT_WIDTH, 10) : 1280,
194
+ height: INPUT_HEIGHT ? parseInt(INPUT_HEIGHT, 10) : 800,
128
195
  },
129
196
  autoFit,
130
197
  fullPage: true,