frameshot-mcp 0.1.0 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +39 -125
  2. package/dist/index.js +512 -38
  3. package/package.json +10 -2
package/README.md CHANGED
@@ -1,57 +1,24 @@
1
1
  # frameshot
2
2
 
3
- > Instant cross-browser component preview for AI agents. One call, one screenshot — no dev server, no Storybook, no ceremony.
3
+ [![npm version](https://img.shields.io/npm/v/frameshot-mcp)](https://www.npmjs.com/package/frameshot-mcp) [![npm downloads](https://img.shields.io/npm/dm/frameshot-mcp)](https://www.npmjs.com/package/frameshot-mcp) [![GitHub stars](https://img.shields.io/github/stars/kamegoro/frameshot)](https://github.com/kamegoro/frameshot) [![CI](https://github.com/kamegoro/frameshot/actions/workflows/ci.yml/badge.svg)](https://github.com/kamegoro/frameshot/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
4
 
5
- **Works with:** Claude Code · Cursor · Codex · Windsurf · any MCP-compatible tool
5
+ > Give your AI agent eyes. Render UI components and get screenshots back — in 120ms.
6
6
 
7
- ## The Problem
7
+ <!-- TODO: demo GIF here -->
8
+ <!-- ![demo](docs/demo.gif) -->
8
9
 
9
- You're building UI with an AI agent. The agent writes a component but can't see it. You have to:
10
- 1. Start the dev server
11
- 2. Log in
12
- 3. Navigate to the right page
13
- 4. Scroll to the component
14
- 5. Tell the agent what's wrong
15
-
16
- **frameshot** skips all of that. The agent renders the component itself, sees the result, and fixes it.
17
-
18
- ## Quick Start
19
-
20
- ### Claude Code
21
-
22
- ```bash
23
- claude mcp add frameshot -- npx frameshot-mcp@latest
24
10
  ```
25
-
26
- That's it. Now your AI agent can see UI.
27
-
28
- ### Cursor (`.cursor/mcp.json`)
29
-
30
- ```json
31
- {
32
- "mcpServers": {
33
- "frameshot": {
34
- "command": "npx",
35
- "args": ["frameshot-mcp@latest"]
36
- }
37
- }
38
- }
11
+ AI writes code → AI calls frameshot → AI sees the result → AI self-corrects
39
12
  ```
40
13
 
41
- ### VS Code Copilot (`.vscode/mcp.json`)
14
+ ## Install
42
15
 
43
- ```json
44
- {
45
- "servers": {
46
- "frameshot": {
47
- "command": "npx",
48
- "args": ["frameshot-mcp@latest"]
49
- }
50
- }
51
- }
16
+ ```bash
17
+ claude mcp add frameshot -- npx frameshot-mcp@latest
52
18
  ```
53
19
 
54
- ### Manual setup
20
+ <details>
21
+ <summary>Cursor / VS Code / Other</summary>
55
22
 
56
23
  ```json
57
24
  {
@@ -64,115 +31,62 @@ That's it. Now your AI agent can see UI.
64
31
  }
65
32
  ```
66
33
 
67
- > **Note:** On first run, Playwright will download Chromium (~150MB). For additional engines: `npx playwright install firefox webkit`
34
+ </details>
68
35
 
69
36
  ## Tools
70
37
 
71
- ### `render_component`
38
+ | Tool | What it does |
39
+ |------|-------------|
40
+ | `render_component` | Render React/Vue/Svelte/HTML → screenshot. Tailwind built-in. |
41
+ | `render_responsive` | Render at mobile + tablet + desktop in one call. |
42
+ | `render_variants` | Render multiple prop/state variants in one call. |
43
+ | `screenshot_url` | Screenshot any URL (e.g. localhost:3000). |
44
+ | `audit_a11y` | Run axe-core accessibility audit on your component. |
45
+ | `diff_component` | Visual regression: compare before/after code, get pixel diff. |
46
+ | `capture_animation` | Capture CSS animation frames over time (multi-screenshot). |
72
47
 
73
- Render isolated component code → get screenshot back instantly.
48
+ ### Example
74
49
 
75
50
  ```typescript
76
51
  render_component({
77
- code: `
78
- function App() {
79
- return (
80
- <div className="p-8 bg-gradient-to-r from-blue-500 to-purple-600 rounded-xl">
81
- <h1 className="text-4xl font-bold text-white">Hello World</h1>
82
- </div>
83
- )
84
- }
85
- `,
52
+ code: `function App() {
53
+ return <div className="p-8 bg-blue-500 text-white rounded-xl">Hello</div>
54
+ }`,
86
55
  framework: "react",
56
+ darkMode: true,
87
57
  engines: ["chromium", "firefox", "webkit"]
88
58
  })
89
- // Returns 3 screenshots — one per browser engine
90
59
  ```
91
60
 
92
- | Parameter | Default | Description |
93
- |-----------|---------|-------------|
94
- | `code` | required | Component source code |
95
- | `framework` | `"react"` | `"react"` · `"vue"` · `"html"` |
96
- | `engines` | `["chromium"]` | Browser engines to render in |
97
- | `width` | `1280` | Viewport width |
98
- | `height` | `800` | Viewport height |
99
- | `fullPage` | `true` | Capture full scroll height |
100
-
101
- ### `screenshot_url`
102
-
103
- Screenshot a running app (e.g. localhost) across browsers.
104
-
105
- ```typescript
106
- screenshot_url({
107
- url: "http://localhost:3000/components/button",
108
- engines: ["chromium", "firefox", "webkit"]
109
- })
110
- ```
111
-
112
- ## Why Not Storybook?
61
+ ## Why frameshot?
113
62
 
114
63
  | | Storybook | frameshot |
115
64
  |---|-----------|-----------|
116
- | Setup | Write .stories.tsx, configure addons, run server | **Zero** — pass code, get image |
117
- | Speed | Heavy dev server startup | **~800ms** warm render |
118
- | Cross-browser | Chromatic ($149+/mo) | **Free** — Playwright built-in |
119
- | AI-native | Requires pre-written stories | **Works with any code snippet** |
120
- | Auth required? | Need full app context | **No** — isolated component render |
121
-
122
- frameshot is not a Storybook replacement. Storybook is for documentation and design systems. frameshot is for **seeing what you just wrote, right now**.
65
+ | Setup | .stories.tsx + addons + server | **Zero** |
66
+ | Speed | Dev server startup | **~120ms** |
67
+ | Cross-browser | Chromatic ($149+/mo) | **Free** |
68
+ | AI-native | Needs pre-written stories | **Any code snippet** |
123
69
 
124
70
  ## Performance
125
71
 
126
72
  | Scenario | Time |
127
73
  |----------|------|
128
- | Warm render (browser reused) | **~800ms** |
129
- | Cold start (first render) | ~3-5s |
130
- | Cross-browser (3 engines parallel) | ~1-2s warm |
131
-
132
- The browser stays warm between calls. Second render onwards is sub-second.
74
+ | Warm render | **~120ms** |
75
+ | Cold start | ~4s |
76
+ | 3 engines parallel | ~300ms |
133
77
 
134
- ## Framework Support
135
-
136
- ### React (JSX + Tailwind)
137
- ```jsx
138
- function App() {
139
- const [count, setCount] = React.useState(0)
140
- return <button onClick={() => setCount(c => c+1)} className="btn">{count}</button>
141
- }
142
- ```
143
-
144
- ### Vue 3 (Composition API + Tailwind)
145
- ```javascript
146
- const App = {
147
- setup() {
148
- const count = ref(0)
149
- return { count }
150
- },
151
- template: `<button @click="count++">{{ count }}</button>`
152
- }
153
- ```
154
-
155
- ### HTML (+ Tailwind)
156
- ```html
157
- <div class="flex gap-4 p-8">
158
- <button class="px-4 py-2 bg-blue-500 text-white rounded">Click me</button>
159
- </div>
160
- ```
78
+ Browser pool stays warm. Tailwind pre-cached. Sub-200ms after first run.
161
79
 
162
- ## Environment Variables
80
+ ## Recipes
163
81
 
164
- | Variable | Description |
165
- |----------|-------------|
166
- | `FRAMESHOT_BROWSER_PATH` | Custom Chromium executable path |
82
+ - [Claude Code skill setup](examples/claude-code-skill.md) — Auto-preview components with `/project:preview`
83
+ - [Cursor rules](examples/cursor-rules.md) — Auto-verify UI on every edit
167
84
 
168
85
  ## Development
169
86
 
170
87
  ```bash
171
- git clone https://github.com/kamegoro/frameshot.git
172
- cd frameshot
173
- npm install
174
- npx playwright install chromium
175
- npm run build
88
+ git clone https://github.com/kamegoro/frameshot.git && cd frameshot
89
+ npm install && npx playwright install chromium && npm run build
176
90
  ```
177
91
 
178
92
  ## License
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ var __export = (target, all) => {
6
6
  };
7
7
 
8
8
  // src/index.ts
9
+ import { execSync } from "child_process";
9
10
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
11
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
12
 
@@ -14524,7 +14525,9 @@ function date4(params) {
14524
14525
  config(en_default());
14525
14526
 
14526
14527
  // src/renderer.ts
14528
+ import pixelmatch from "pixelmatch";
14527
14529
  import { chromium, firefox, webkit } from "playwright";
14530
+ import { PNG } from "pngjs";
14528
14531
  var pool = /* @__PURE__ */ new Map();
14529
14532
  var ENGINES = ["chromium", "firefox", "webkit"];
14530
14533
  async function warmup(engines = ENGINES) {
@@ -14542,66 +14545,90 @@ async function getSlot(engine) {
14542
14545
  headless: true,
14543
14546
  ...engine === "chromium" ? { channel: "chrome" } : {}
14544
14547
  });
14545
- } catch (e) {
14546
- throw new Error(`${engine} is not installed. Run: npx playwright install ${engine}`);
14548
+ } catch (_e) {
14549
+ throw new Error(
14550
+ `${engine} is not installed. Run: npx playwright install ${engine}`
14551
+ );
14547
14552
  }
14548
14553
  const context = await browser.newContext({
14549
14554
  viewport: { width: 1280, height: 800 },
14550
14555
  deviceScaleFactor: 2
14551
14556
  });
14552
14557
  const page = await context.newPage();
14558
+ await page.setContent(
14559
+ '<html><head><script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio"></script></head><body></body></html>',
14560
+ { waitUntil: "networkidle" }
14561
+ );
14553
14562
  const slot = { browser, page, ready: true };
14554
14563
  pool.set(engine, slot);
14555
14564
  return slot;
14556
14565
  }
14557
- function wrapComponent(code, framework) {
14566
+ function wrapComponent(code, framework, darkMode = false, css = "") {
14558
14567
  const tailwind = '<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio"></script>';
14568
+ const tailwindConfig = `<script>tailwind.config={darkMode:'class'}</script>`;
14569
+ const customCss = css ? `<style>${css}</style>` : "";
14559
14570
  const baseStyle = `<style>*{margin:0;box-sizing:border-box}body{padding:16px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}</style>`;
14571
+ const htmlClass = darkMode ? ' class="dark"' : "";
14560
14572
  if (framework === "html") {
14561
14573
  if (code.includes("<html")) return code;
14562
- return `<!DOCTYPE html><html><head><meta charset="utf-8">${tailwind}${baseStyle}</head><body>${code}</body></html>`;
14574
+ return `<!DOCTYPE html><html${htmlClass}><head><meta charset="utf-8">${tailwind}${tailwindConfig}${baseStyle}${customCss}</head><body>${code}</body></html>`;
14563
14575
  }
14564
14576
  if (framework === "react") {
14565
- return `<!DOCTYPE html><html><head><meta charset="utf-8">${tailwind}
14577
+ const cleanedCode = code.replace(/['"]use client['"];?\n?/g, "").replace(/['"]use server['"];?\n?/g, "").replace(/import\s+.*?\s+from\s+['"]next\/image['"];?\n?/g, "").replace(/import\s+.*?\s+from\s+['"]next\/link['"];?\n?/g, "");
14578
+ return `<!DOCTYPE html><html${htmlClass}><head><meta charset="utf-8">${tailwind}${tailwindConfig}
14566
14579
  <script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
14567
14580
  <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
14568
14581
  <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
14569
- ${baseStyle}</head><body><div id="root"></div>
14582
+ ${baseStyle}${customCss}</head><body><div id="root"></div>
14570
14583
  <script type="text/babel">
14571
- ${code}
14584
+ const Image = (props) => React.createElement('img', {...props, src: props.src?.src || props.src});
14585
+ const Link = ({href, children, ...props}) => React.createElement('a', {href, ...props}, children);
14586
+ ${cleanedCode}
14572
14587
  const _C = typeof App!=='undefined'?App:typeof Component!=='undefined'?Component:typeof Default!=='undefined'?Default:null;
14573
14588
  if(_C)ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(_C));
14574
14589
  </script></body></html>`;
14575
14590
  }
14576
14591
  if (framework === "vue") {
14577
- return `<!DOCTYPE html><html><head><meta charset="utf-8">${tailwind}
14592
+ return `<!DOCTYPE html><html${htmlClass}><head><meta charset="utf-8">${tailwind}${tailwindConfig}
14578
14593
  <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
14579
- ${baseStyle}</head><body><div id="app"></div>
14594
+ ${baseStyle}${customCss}</head><body><div id="app"></div>
14580
14595
  <script>
14581
14596
  const{createApp,ref,reactive,computed,onMounted,watch,watchEffect}=Vue;
14582
14597
  ${code}
14583
14598
  const _C=typeof App!=='undefined'?App:typeof Component!=='undefined'?Component:null;
14584
14599
  if(_C)createApp(_C).mount('#app');
14600
+ </script></body></html>`;
14601
+ }
14602
+ if (framework === "svelte") {
14603
+ return `<!DOCTYPE html><html${htmlClass}><head><meta charset="utf-8">${tailwind}${tailwindConfig}
14604
+ <script src="https://unpkg.com/svelte@4/compiler.cjs"></script>
14605
+ ${baseStyle}${customCss}</head><body><div id="app"></div>
14606
+ <script type="module">
14607
+ import "https://unpkg.com/svelte@4/internal/index.mjs";
14608
+ ${code}
14609
+ const _C=typeof App!=='undefined'?App:typeof Component!=='undefined'?Component:null;
14610
+ if(_C)new _C({target:document.getElementById('app')});
14585
14611
  </script></body></html>`;
14586
14612
  }
14587
14613
  return code;
14588
14614
  }
14589
14615
  async function renderSingle(engine, html, options) {
14590
- const {
14591
- width = 1280,
14592
- height = 800,
14593
- fullPage = true,
14594
- waitFor = 0
14595
- } = options;
14616
+ const { width = 1280, height = 800, fullPage = true, waitFor = 0 } = options;
14596
14617
  const slot = await getSlot(engine);
14597
14618
  const { page } = slot;
14619
+ const consoleErrors = [];
14620
+ const onError = (msg) => {
14621
+ if (msg.type() === "error") {
14622
+ consoleErrors.push(msg.text());
14623
+ }
14624
+ };
14625
+ page.on("console", onError);
14626
+ page.on("pageerror", (err) => consoleErrors.push(err.message));
14598
14627
  const currentViewport = page.viewportSize();
14599
14628
  if (currentViewport?.width !== width || currentViewport?.height !== height) {
14600
14629
  await page.setViewportSize({ width, height });
14601
14630
  }
14602
- await page.setContent(html, { waitUntil: "domcontentloaded", timeout: 1e4 });
14603
- await page.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => {
14604
- });
14631
+ await page.setContent(html, { waitUntil: "load", timeout: 1e4 });
14605
14632
  if (waitFor > 0) {
14606
14633
  await page.waitForTimeout(waitFor);
14607
14634
  }
@@ -14610,16 +14637,23 @@ async function renderSingle(engine, html, options) {
14610
14637
  w: document.documentElement.scrollWidth,
14611
14638
  h: document.documentElement.scrollHeight
14612
14639
  }));
14640
+ page.removeListener("console", onError);
14613
14641
  return {
14614
14642
  engine,
14615
14643
  image: screenshot.toString("base64"),
14616
14644
  width: metrics.w,
14617
- height: metrics.h
14645
+ height: metrics.h,
14646
+ consoleErrors
14618
14647
  };
14619
14648
  }
14620
14649
  async function render(code, framework, options = {}) {
14621
14650
  const engines = options.engines ?? ["chromium"];
14622
- const html = wrapComponent(code, framework);
14651
+ const html = wrapComponent(
14652
+ code,
14653
+ framework,
14654
+ options.darkMode ?? false,
14655
+ options.css ?? ""
14656
+ );
14623
14657
  const results = await Promise.all(
14624
14658
  engines.map((e) => renderSingle(e, html, options))
14625
14659
  );
@@ -14629,9 +14663,22 @@ async function screenshotUrl(url2, options = {}) {
14629
14663
  const engines = options.engines ?? ["chromium"];
14630
14664
  const results = await Promise.all(
14631
14665
  engines.map(async (engine) => {
14632
- const { width = 1280, height = 800, fullPage = true, waitFor = 0 } = options;
14666
+ const {
14667
+ width = 1280,
14668
+ height = 800,
14669
+ fullPage = true,
14670
+ waitFor = 0
14671
+ } = options;
14633
14672
  const slot = await getSlot(engine);
14634
14673
  const { page } = slot;
14674
+ const consoleErrors = [];
14675
+ const onError = (msg) => {
14676
+ if (msg.type() === "error") {
14677
+ consoleErrors.push(msg.text());
14678
+ }
14679
+ };
14680
+ page.on("console", onError);
14681
+ page.on("pageerror", (err) => consoleErrors.push(err.message));
14635
14682
  const currentViewport = page.viewportSize();
14636
14683
  if (currentViewport?.width !== width || currentViewport?.height !== height) {
14637
14684
  await page.setViewportSize({ width, height });
@@ -14647,11 +14694,137 @@ async function screenshotUrl(url2, options = {}) {
14647
14694
  w: document.documentElement.scrollWidth,
14648
14695
  h: document.documentElement.scrollHeight
14649
14696
  }));
14650
- return { engine, image: screenshot.toString("base64"), width: metrics.w, height: metrics.h };
14697
+ page.removeListener("console", onError);
14698
+ return {
14699
+ engine,
14700
+ image: screenshot.toString("base64"),
14701
+ width: metrics.w,
14702
+ height: metrics.h,
14703
+ consoleErrors
14704
+ };
14651
14705
  })
14652
14706
  );
14653
14707
  return results;
14654
14708
  }
14709
+ async function auditA11y(code, framework, options = {}) {
14710
+ const html = wrapComponent(
14711
+ code,
14712
+ framework,
14713
+ options.darkMode ?? false,
14714
+ options.css ?? ""
14715
+ );
14716
+ const slot = await getSlot("chromium");
14717
+ const { page } = slot;
14718
+ const { width = 1280, height = 800 } = options;
14719
+ const currentViewport = page.viewportSize();
14720
+ if (currentViewport?.width !== width || currentViewport?.height !== height) {
14721
+ await page.setViewportSize({ width, height });
14722
+ }
14723
+ await page.setContent(html, { waitUntil: "load", timeout: 1e4 });
14724
+ const axeSource = await import("axe-core").then((m) => m.source);
14725
+ await page.addScriptTag({ content: axeSource });
14726
+ const results = await page.evaluate(() => {
14727
+ return window.axe.run();
14728
+ });
14729
+ return {
14730
+ violations: results.violations.map(
14731
+ (v) => ({
14732
+ id: v.id,
14733
+ impact: v.impact,
14734
+ description: v.description,
14735
+ helpUrl: v.helpUrl,
14736
+ nodes: v.nodes.map((n) => ({
14737
+ html: n.html,
14738
+ target: n.target
14739
+ }))
14740
+ })
14741
+ ),
14742
+ passes: results.passes.length,
14743
+ incomplete: results.incomplete.length
14744
+ };
14745
+ }
14746
+ async function diffComponent(before, after, framework, options = {}) {
14747
+ const [beforeResults, afterResults] = await Promise.all([
14748
+ render(before, framework, { ...options, engines: ["chromium"] }),
14749
+ render(after, framework, { ...options, engines: ["chromium"] })
14750
+ ]);
14751
+ const beforeBuf = Buffer.from(beforeResults[0].image, "base64");
14752
+ const afterBuf = Buffer.from(afterResults[0].image, "base64");
14753
+ const beforePng = PNG.sync.read(beforeBuf);
14754
+ const afterPng = PNG.sync.read(afterBuf);
14755
+ const width = Math.max(beforePng.width, afterPng.width);
14756
+ const height = Math.max(beforePng.height, afterPng.height);
14757
+ const normalizedBefore = new PNG({ width, height });
14758
+ const normalizedAfter = new PNG({ width, height });
14759
+ PNG.bitblt(
14760
+ beforePng,
14761
+ normalizedBefore,
14762
+ 0,
14763
+ 0,
14764
+ beforePng.width,
14765
+ beforePng.height,
14766
+ 0,
14767
+ 0
14768
+ );
14769
+ PNG.bitblt(
14770
+ afterPng,
14771
+ normalizedAfter,
14772
+ 0,
14773
+ 0,
14774
+ afterPng.width,
14775
+ afterPng.height,
14776
+ 0,
14777
+ 0
14778
+ );
14779
+ const diffPng = new PNG({ width, height });
14780
+ const diffPixels = pixelmatch(
14781
+ normalizedBefore.data,
14782
+ normalizedAfter.data,
14783
+ diffPng.data,
14784
+ width,
14785
+ height,
14786
+ { threshold: 0.1 }
14787
+ );
14788
+ const totalPixels = width * height;
14789
+ return {
14790
+ before: beforeResults[0].image,
14791
+ after: afterResults[0].image,
14792
+ diff: PNG.sync.write(diffPng).toString("base64"),
14793
+ diffPixels,
14794
+ totalPixels,
14795
+ diffPercentage: Math.round(diffPixels / totalPixels * 1e4) / 100
14796
+ };
14797
+ }
14798
+ async function captureAnimation(code, framework, options = {}) {
14799
+ const { frames = 5, duration: duration3 = 1e3 } = options;
14800
+ const html = wrapComponent(
14801
+ code,
14802
+ framework,
14803
+ options.darkMode ?? false,
14804
+ options.css ?? ""
14805
+ );
14806
+ const slot = await getSlot("chromium");
14807
+ const { page } = slot;
14808
+ const { width = 1280, height = 800 } = options;
14809
+ const currentViewport = page.viewportSize();
14810
+ if (currentViewport?.width !== width || currentViewport?.height !== height) {
14811
+ await page.setViewportSize({ width, height });
14812
+ }
14813
+ await page.setContent(html, { waitUntil: "load", timeout: 1e4 });
14814
+ const interval = duration3 / (frames - 1);
14815
+ const results = [];
14816
+ for (let i = 0; i < frames; i++) {
14817
+ if (i > 0) {
14818
+ await page.waitForTimeout(interval);
14819
+ }
14820
+ const screenshot = await page.screenshot({ type: "png", fullPage: false });
14821
+ results.push({
14822
+ timestamp: Math.round(i * interval),
14823
+ image: screenshot.toString("base64")
14824
+ });
14825
+ }
14826
+ return results;
14827
+ }
14655
14828
  async function shutdown() {
14656
14829
  for (const slot of pool.values()) {
14657
14830
  await slot.browser.close().catch(() => {
@@ -14661,6 +14834,18 @@ async function shutdown() {
14661
14834
  }
14662
14835
 
14663
14836
  // src/index.ts
14837
+ async function ensureBrowser() {
14838
+ try {
14839
+ await warmup(["chromium"]);
14840
+ } catch {
14841
+ try {
14842
+ execSync("npx playwright install chromium", { stdio: "pipe" });
14843
+ await warmup(["chromium"]);
14844
+ } catch {
14845
+ }
14846
+ }
14847
+ }
14848
+ await ensureBrowser();
14664
14849
  var server = new McpServer({
14665
14850
  name: "frameshot",
14666
14851
  version: "0.1.0"
@@ -14670,20 +14855,47 @@ server.tool(
14670
14855
  "Instantly render a React/Vue/HTML component and return screenshots across browser engines. Zero setup needed \u2014 just pass your code. Tailwind CSS is built-in. Use this to visually verify UI code without starting a dev server.",
14671
14856
  {
14672
14857
  code: external_exports.string().describe("Component code to render"),
14673
- framework: external_exports.enum(["html", "react", "vue"]).default("react").describe("Framework: html, react, or vue"),
14674
- engines: external_exports.array(external_exports.enum(["chromium", "firefox", "webkit"])).optional().default(["chromium"]).describe('Browser engines to render in. Use ["chromium","firefox","webkit"] for cross-browser check.'),
14858
+ framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework: html, react, vue, or svelte"),
14859
+ engines: external_exports.array(external_exports.enum(["chromium", "firefox", "webkit"])).optional().default(["chromium"]).describe(
14860
+ 'Browser engines to render in. Use ["chromium","firefox","webkit"] for cross-browser check.'
14861
+ ),
14675
14862
  width: external_exports.number().optional().default(1280).describe("Viewport width (px)"),
14676
14863
  height: external_exports.number().optional().default(800).describe("Viewport height (px)"),
14677
- fullPage: external_exports.boolean().optional().default(true).describe("Capture full scroll height")
14864
+ fullPage: external_exports.boolean().optional().default(true).describe("Capture full scroll height"),
14865
+ darkMode: external_exports.boolean().optional().default(false).describe("Render with Tailwind dark mode (adds 'dark' class to html)"),
14866
+ colorSchemes: external_exports.array(external_exports.enum(["light", "dark"])).optional().describe(
14867
+ 'Render both: ["light","dark"] returns 2 screenshots for comparison'
14868
+ ),
14869
+ css: external_exports.string().optional().describe("Custom CSS to inject (design tokens, variables, etc)")
14678
14870
  },
14679
- async ({ code, framework, engines, width, height, fullPage }) => {
14871
+ async ({
14872
+ code,
14873
+ framework,
14874
+ engines,
14875
+ width,
14876
+ height,
14877
+ fullPage,
14878
+ darkMode,
14879
+ colorSchemes,
14880
+ css
14881
+ }) => {
14680
14882
  try {
14681
- const results = await render(code, framework, {
14682
- width,
14683
- height,
14684
- fullPage,
14685
- engines
14686
- });
14883
+ const start = performance.now();
14884
+ const schemes = colorSchemes ?? (darkMode ? ["dark"] : ["light"]);
14885
+ const allResults = await Promise.all(
14886
+ schemes.map(
14887
+ (scheme) => render(code, framework, {
14888
+ width,
14889
+ height,
14890
+ fullPage,
14891
+ engines,
14892
+ darkMode: scheme === "dark",
14893
+ css
14894
+ })
14895
+ )
14896
+ );
14897
+ const elapsed = Math.round(performance.now() - start);
14898
+ const results = allResults.flat();
14687
14899
  const content = results.flatMap((r) => [
14688
14900
  {
14689
14901
  type: "image",
@@ -14692,13 +14904,18 @@ server.tool(
14692
14904
  },
14693
14905
  {
14694
14906
  type: "text",
14695
- text: `[${r.engine}] ${r.width}x${r.height}`
14907
+ text: `[${r.engine}] ${r.width}x${r.height} (${elapsed}ms)${r.consoleErrors.length ? `
14908
+ \u26A0\uFE0F Console errors:
14909
+ ${r.consoleErrors.join("\n")}` : ""}`
14696
14910
  }
14697
14911
  ]);
14698
14912
  return { content };
14699
14913
  } catch (error51) {
14700
14914
  const msg = error51 instanceof Error ? error51.message : String(error51);
14701
- return { content: [{ type: "text", text: `Render failed: ${msg}` }], isError: true };
14915
+ return {
14916
+ content: [{ type: "text", text: `Render failed: ${msg}` }],
14917
+ isError: true
14918
+ };
14702
14919
  }
14703
14920
  }
14704
14921
  );
@@ -14728,19 +14945,276 @@ server.tool(
14728
14945
  },
14729
14946
  {
14730
14947
  type: "text",
14731
- text: `[${r.engine}] ${r.width}x${r.height}`
14948
+ text: `[${r.engine}] ${r.width}x${r.height}${r.consoleErrors.length ? `
14949
+ \u26A0\uFE0F Console errors:
14950
+ ${r.consoleErrors.join("\n")}` : ""}`
14732
14951
  }
14733
14952
  ]);
14734
14953
  return { content };
14735
14954
  } catch (error51) {
14736
14955
  const msg = error51 instanceof Error ? error51.message : String(error51);
14737
- return { content: [{ type: "text", text: `Screenshot failed: ${msg}` }], isError: true };
14956
+ return {
14957
+ content: [{ type: "text", text: `Screenshot failed: ${msg}` }],
14958
+ isError: true
14959
+ };
14960
+ }
14961
+ }
14962
+ );
14963
+ var DEVICE_PRESETS = {
14964
+ mobile: { width: 375, height: 667 },
14965
+ tablet: { width: 768, height: 1024 },
14966
+ desktop: { width: 1280, height: 800 }
14967
+ };
14968
+ server.tool(
14969
+ "render_responsive",
14970
+ "Render a component at mobile, tablet, and desktop sizes in one call. Returns 3 screenshots for responsive verification.",
14971
+ {
14972
+ code: external_exports.string().describe("Component code to render"),
14973
+ framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework: html, react, vue, or svelte"),
14974
+ devices: external_exports.array(external_exports.enum(["mobile", "tablet", "desktop"])).optional().default(["mobile", "tablet", "desktop"]).describe("Device sizes to render")
14975
+ },
14976
+ async ({ code, framework, devices }) => {
14977
+ try {
14978
+ const results = await Promise.all(
14979
+ devices.map(async (device) => {
14980
+ const preset = DEVICE_PRESETS[device];
14981
+ const [result] = await render(code, framework, {
14982
+ width: preset.width,
14983
+ height: preset.height,
14984
+ fullPage: true,
14985
+ engines: ["chromium"]
14986
+ });
14987
+ return { device, ...result };
14988
+ })
14989
+ );
14990
+ const content = results.flatMap((r) => [
14991
+ {
14992
+ type: "image",
14993
+ data: r.image,
14994
+ mimeType: "image/png"
14995
+ },
14996
+ {
14997
+ type: "text",
14998
+ text: `[${r.device}] ${r.width}x${r.height}`
14999
+ }
15000
+ ]);
15001
+ return { content };
15002
+ } catch (error51) {
15003
+ const msg = error51 instanceof Error ? error51.message : String(error51);
15004
+ return {
15005
+ content: [
15006
+ { type: "text", text: `Responsive render failed: ${msg}` }
15007
+ ],
15008
+ isError: true
15009
+ };
15010
+ }
15011
+ }
15012
+ );
15013
+ server.tool(
15014
+ "render_variants",
15015
+ "Render multiple variants of a component (different props/states) in one call. Returns a screenshot for each variant. Use this to verify buttons in all states, theme variations, etc.",
15016
+ {
15017
+ code: external_exports.string().describe("Component code (must export a function that accepts props)"),
15018
+ variants: external_exports.array(
15019
+ external_exports.object({
15020
+ label: external_exports.string().describe("Label for this variant (e.g. 'disabled', 'loading')"),
15021
+ props: external_exports.string().describe("Props as JSON string to pass to the component")
15022
+ })
15023
+ ).describe("Array of variants to render"),
15024
+ framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework: html, react, vue, or svelte"),
15025
+ width: external_exports.number().optional().default(1280).describe("Viewport width (px)"),
15026
+ height: external_exports.number().optional().default(800).describe("Viewport height (px)")
15027
+ },
15028
+ async ({ code, variants, framework, width, height }) => {
15029
+ try {
15030
+ const results = await Promise.all(
15031
+ variants.map(async (variant) => {
15032
+ const wrappedCode = framework === "react" ? `${code}
15033
+ const _VARIANT_PROPS = ${variant.props};
15034
+ function _VariantWrapper() { return <App {..._VARIANT_PROPS} />; }` : code;
15035
+ const renderFramework = framework;
15036
+ const overrideCode = framework === "react" ? wrappedCode.replace(
15037
+ /const _C = typeof App/,
15038
+ "const _C = typeof _VariantWrapper!=='undefined'?_VariantWrapper:typeof App"
15039
+ ) : wrappedCode;
15040
+ const [result] = await render(overrideCode, renderFramework, {
15041
+ width,
15042
+ height,
15043
+ fullPage: true,
15044
+ engines: ["chromium"]
15045
+ });
15046
+ return { label: variant.label, ...result };
15047
+ })
15048
+ );
15049
+ const content = results.flatMap((r) => [
15050
+ {
15051
+ type: "image",
15052
+ data: r.image,
15053
+ mimeType: "image/png"
15054
+ },
15055
+ {
15056
+ type: "text",
15057
+ text: `[${r.label}] ${r.width}x${r.height}${r.consoleErrors.length ? `
15058
+ \u26A0\uFE0F Console errors:
15059
+ ${r.consoleErrors.join("\n")}` : ""}`
15060
+ }
15061
+ ]);
15062
+ return { content };
15063
+ } catch (error51) {
15064
+ const msg = error51 instanceof Error ? error51.message : String(error51);
15065
+ return {
15066
+ content: [
15067
+ { type: "text", text: `Variants render failed: ${msg}` }
15068
+ ],
15069
+ isError: true
15070
+ };
15071
+ }
15072
+ }
15073
+ );
15074
+ server.tool(
15075
+ "audit_a11y",
15076
+ "Run an accessibility audit (axe-core) on a rendered component. Returns WCAG violations with impact level, description, and affected HTML nodes. Use this to catch a11y issues before shipping.",
15077
+ {
15078
+ code: external_exports.string().describe("Component code to audit"),
15079
+ framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework: html, react, vue, or svelte"),
15080
+ width: external_exports.number().optional().default(1280).describe("Viewport width (px)"),
15081
+ height: external_exports.number().optional().default(800).describe("Viewport height (px)")
15082
+ },
15083
+ async ({ code, framework, width, height }) => {
15084
+ try {
15085
+ const result = await auditA11y(code, framework, { width, height });
15086
+ if (result.violations.length === 0) {
15087
+ return {
15088
+ content: [
15089
+ {
15090
+ type: "text",
15091
+ text: `\u2705 No accessibility violations found. (${result.passes} rules passed, ${result.incomplete} need review)`
15092
+ }
15093
+ ]
15094
+ };
15095
+ }
15096
+ const report = result.violations.map((v) => {
15097
+ const nodes = v.nodes.slice(0, 3).map((n) => ` ${n.target.join(" > ")}
15098
+ ${n.html}`).join("\n");
15099
+ return `[${v.impact?.toUpperCase()}] ${v.id}
15100
+ ${v.description}
15101
+ ${v.helpUrl}
15102
+ ${nodes}`;
15103
+ }).join("\n\n");
15104
+ return {
15105
+ content: [
15106
+ {
15107
+ type: "text",
15108
+ text: `Found ${result.violations.length} accessibility violation(s):
15109
+
15110
+ ${report}
15111
+
15112
+ (${result.passes} rules passed, ${result.incomplete} need review)`
15113
+ }
15114
+ ]
15115
+ };
15116
+ } catch (error51) {
15117
+ const msg = error51 instanceof Error ? error51.message : String(error51);
15118
+ return {
15119
+ content: [{ type: "text", text: `A11y audit failed: ${msg}` }],
15120
+ isError: true
15121
+ };
15122
+ }
15123
+ }
15124
+ );
15125
+ server.tool(
15126
+ "diff_component",
15127
+ "Visual regression test: render before/after code and return a pixel diff image with percentage changed. Use this during refactoring to catch unintended visual changes.",
15128
+ {
15129
+ before: external_exports.string().describe("Component code BEFORE the change"),
15130
+ after: external_exports.string().describe("Component code AFTER the change"),
15131
+ framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework: html, react, vue, or svelte"),
15132
+ width: external_exports.number().optional().default(1280).describe("Viewport width (px)"),
15133
+ height: external_exports.number().optional().default(800).describe("Viewport height (px)")
15134
+ },
15135
+ async ({ before, after, framework, width, height }) => {
15136
+ try {
15137
+ const result = await diffComponent(before, after, framework, {
15138
+ width,
15139
+ height
15140
+ });
15141
+ const content = [
15142
+ {
15143
+ type: "text",
15144
+ text: `Visual diff: ${result.diffPercentage}% pixels changed (${result.diffPixels}/${result.totalPixels})`
15145
+ },
15146
+ { type: "text", text: "Before:" },
15147
+ {
15148
+ type: "image",
15149
+ data: result.before,
15150
+ mimeType: "image/png"
15151
+ },
15152
+ { type: "text", text: "After:" },
15153
+ {
15154
+ type: "image",
15155
+ data: result.after,
15156
+ mimeType: "image/png"
15157
+ },
15158
+ { type: "text", text: "Diff (red = changed pixels):" },
15159
+ {
15160
+ type: "image",
15161
+ data: result.diff,
15162
+ mimeType: "image/png"
15163
+ }
15164
+ ];
15165
+ return { content };
15166
+ } catch (error51) {
15167
+ const msg = error51 instanceof Error ? error51.message : String(error51);
15168
+ return {
15169
+ content: [{ type: "text", text: `Diff failed: ${msg}` }],
15170
+ isError: true
15171
+ };
15172
+ }
15173
+ }
15174
+ );
15175
+ server.tool(
15176
+ "capture_animation",
15177
+ "Capture multiple frames of a CSS animation or transition over time. Returns sequential screenshots to verify animation behavior, timing, and smoothness.",
15178
+ {
15179
+ code: external_exports.string().describe("Component code with CSS animations/transitions"),
15180
+ framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework: html, react, vue, or svelte"),
15181
+ frames: external_exports.number().optional().default(5).describe("Number of frames to capture"),
15182
+ duration: external_exports.number().optional().default(1e3).describe("Total capture duration in ms"),
15183
+ width: external_exports.number().optional().default(1280).describe("Viewport width (px)"),
15184
+ height: external_exports.number().optional().default(800).describe("Viewport height (px)")
15185
+ },
15186
+ async ({ code, framework, frames, duration: duration3, width, height }) => {
15187
+ try {
15188
+ const results = await captureAnimation(code, framework, {
15189
+ frames,
15190
+ duration: duration3,
15191
+ width,
15192
+ height
15193
+ });
15194
+ const content = results.flatMap((r) => [
15195
+ {
15196
+ type: "image",
15197
+ data: r.image,
15198
+ mimeType: "image/png"
15199
+ },
15200
+ {
15201
+ type: "text",
15202
+ text: `[${r.timestamp}ms]`
15203
+ }
15204
+ ]);
15205
+ return { content };
15206
+ } catch (error51) {
15207
+ const msg = error51 instanceof Error ? error51.message : String(error51);
15208
+ return {
15209
+ content: [
15210
+ { type: "text", text: `Animation capture failed: ${msg}` }
15211
+ ],
15212
+ isError: true
15213
+ };
14738
15214
  }
14739
15215
  }
14740
15216
  );
14741
15217
  var transport = new StdioServerTransport();
14742
- warmup(["chromium"]).catch(() => {
14743
- });
14744
15218
  await server.connect(transport);
14745
15219
  process.on("SIGINT", async () => {
14746
15220
  await shutdown();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frameshot-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Instant cross-browser component preview for AI agents. One MCP call, one screenshot — no dev server, no Storybook, no ceremony.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -17,6 +17,9 @@
17
17
  "dev": "tsup src/index.ts --format esm --watch",
18
18
  "start": "node dist/index.js",
19
19
  "typecheck": "tsc --noEmit",
20
+ "lint": "biome check src/",
21
+ "lint:fix": "biome check --write src/",
22
+ "format": "biome format --write src/",
20
23
  "prepublishOnly": "npm run build"
21
24
  },
22
25
  "keywords": [
@@ -49,10 +52,15 @@
49
52
  },
50
53
  "dependencies": {
51
54
  "@modelcontextprotocol/sdk": "^1.12.1",
52
- "playwright": "^1.52.0"
55
+ "axe-core": "^4.12.1",
56
+ "pixelmatch": "^7.2.0",
57
+ "playwright": "^1.52.0",
58
+ "pngjs": "^7.0.0"
53
59
  },
54
60
  "devDependencies": {
61
+ "@biomejs/biome": "^2.5.0",
55
62
  "@types/node": "^22.0.0",
63
+ "@types/pngjs": "^6.0.5",
56
64
  "tsup": "^8.0.0",
57
65
  "typescript": "^5.7.0"
58
66
  },