splay-mcp
v0.8.0
Published
MCP server that screenshots the screens you build locally and collects them in one shared gallery
Maintainers
Readme
splay-mcp
Collect the screens you build locally — automatically — in one shared gallery.
A design that only exists on your local dev server can't be shown to anyone, and it's gone the moment you move on. Splay screenshots it as you work, uploads it, and gives you a link.
Requirements
- Node.js 20.9+
- Google Chrome (reused if installed — no extra browser download)
- A Splay server URL and an API key
Get started
Nothing to install. From your project folder:
npx splay-mcp setup <API_KEY> --url https://your-splay.vercel.appThat one command does three things: saves the API key, scans your routes into
splay.config.json, and installs a commit hook.
Then, with your dev server running:
npx splay-mcp captureFrom now on every commit captures only the pages that commit touched.
Check that
baseUrlinsplay.config.jsonmatches the port your dev server actually runs on (defaults to 3000).
Prefer a global install? npm i -g splay-mcp, then drop the npx.
Use it from an agent
Add this to your MCP config (Claude Code, Cursor, …):
{
"mcpServers": {
"splay": { "command": "npx", "args": ["-y", "splay-mcp"] }
}
}The agent skill
The first time you run capture or init in a project, Splay writes
.claude/skills/splay-capture/SKILL.md. It tells coding agents how the two halves of
the job divide: listing the pages worth capturing in splay.config.json is a local
edit an agent can always make, while the upload leaves your machine — and in a repo
holding sensitive context, an agent may be refused permission to make that send.
Without the skill, a refused agent tends to stop at a list of local PNG paths, leaving
you to remember and re-enter the pages yourself. With it, the agent records the pages
and hands you one line — npx -y splay-mcp capture — which captures all of them.
Edit the file freely; Splay only overwrites what it wrote. --no-skill skips it.
If you already ran setup, credentials are read from ~/.splay/config.json. To skip
setup entirely, put them in the config instead:
{
"mcpServers": {
"splay": {
"command": "npx",
"args": ["-y", "splay-mcp"],
"env": {
"SPLAY_URL": "https://your-splay.vercel.app",
"SPLAY_API_KEY": "…"
}
}
}
}Running
npx splay-mcpwith no arguments in a terminal prints this help. It only starts the MCP server when stdin is a pipe, i.e. when a client connects. Force it with--stdio.
The agent gets these tools:
| Tool | What it does |
| ----------------------- | ------------------------------------------------------ |
| splay_capture | Capture the given pages and return a gallery link |
| splay_capture_changed | Capture only what the last commit changed |
| splay_comment | Leave a note on a screen already in the gallery |
| splay_doctor | Check every prerequisite and say exactly what to fix |
| splay_status | Report config, API key, and dev server state |
Adding a line like this to your project's CLAUDE.md makes the agent reach for it on its own:
After any noticeable UI change, capture the affected pages with splay_capture.
Say what changed in `comments` so the designer can reply on the frame.Comment while you capture
The moment you save a screen is the moment you still remember why. splay_capture takes
comments so the note goes up with the screenshot — one round trip, no second visit to
the gallery:
{
"pages": ["/pricing"],
"note": "Pricing table spacing",
"comments": [
{ "path": "/pricing", "body": "## Spacing pass\n\nRow gap 12 → 16. **Is the CTA still above the fold on 13\"?**" },
{ "path": "/pricing", "body": "This badge contrast is too light", "placement": "pin", "x": 0.62, "y": 0.28 }
]
}placement—below(default) orabovestacks a note under/over the frame.pindrops a marker inside the screen and needsx/y.x/y—0–1across and down the screen.0.5, 0.5is dead center.- Omit
pathto put the same note on every frame captured in that call. - Markdown renders — headings, lists, tables, task lists, code fences.
For a screen that is already in the gallery, use splay_comment (or the CLI):
npx splay-mcp comment /pricing "Row gap is 16 now — good?"
npx splay-mcp comment /pricing "This badge is too light" --pin 0.62,0.28
npx splay-mcp comment /pricing "Above-frame note" --aboveThe note lands on the most recent capture of that route. Whoever opens the gallery sees the pin on the frame and can reply to it — in the canvas or in the single-frame focus view.
Behind a corporate proxy or firewall
Locked-down environments (Coder workspaces, devcontainers, CI) usually reach the internet through a proxy and an egress allowlist. Two things follow:
Set the proxy. Node's fetch does not read HTTPS_PROXY on its own, so splay-mcp
reads it for you — set the usual variables and uploads will go through the proxy:
export HTTPS_PROXY=http://proxy.corp:3128
export NO_PROXY=.internal.corp # optionallocalhost and 127.0.0.1 never go through the proxy, so a local dev server is still
reached directly.
Ask for the allowlist. Only these hosts are needed:
| Host | Why |
| --- | --- |
| your Splay server (e.g. splay.example.app) | uploading captures and comments |
| registry.npmjs.org | npx splay-mcp |
| your staging host (only if baseUrl points at it) | loading the pages to capture |
Images are uploaded to object storage by the Splay server, so the client never talks to the storage host.
Not sure what is blocked? Run the doctor — it checks each prerequisite separately and prints the exact failure, including the proxy's own refusal:
npx splay-mcp doctor ✗ Splay 서버 도달
fetch failed ← UND_ERR_ABORTED: Proxy response (403) !== 200 when HTTP Tunneling
→ 방화벽/allowlist에 splay.example.app 허용 요청splay.config.json
setup writes this; edit it freely afterwards.
{
"project": "my-project", // identifier in the gallery
"baseUrl": "http://localhost:3000",
"viewports": [{ "w": 1440, "h": 900 }],
"pages": [
{ "path": "/", "name": "Home" },
{
"path": "/pricing",
"name": "Pricing",
"fullPage": true,
// Dependencies the commit hook can't infer from conventions or imports
"dependsOn": ["content/prices.json"]
}
]
}baseUrl must point at the port this project actually serves. If another app owns that
port, you'll silently capture that app instead.
Zero local config: store the config on the server
For someone who only captures — a designer pointing splay at a hosted preview — a local
splay.config.json is one more file to distribute and keep in sync. Store it on the server
instead:
# once, from the folder that has the config (anyone on the team, e.g. you):
npx splay-mcp config push
# the designer's one-time setup — no files created:
npx splay-mcp setup <key> --project my-project
# every capture after that:
npx splay-mcp capture --url https://that-days-preview.example.devcapture looks for a local splay.config.json first and falls back to the server-stored
config of the default project (--project <slug> picks one explicitly). Pages, viewports,
and the login section all come from the server, so changing them is a config push — the
designer doesn't touch anything.
config show --project <slug> prints what the server has (password masked). The stored
login password is encrypted at rest. Writing requires the project owner's key; reading
works with any valid API key of the gallery.
Capturing a hosted preview (--url)
When the app runs somewhere whose address changes per session — a Coder workspace, a cloud
dev environment, a tunnel — override the configured baseUrl at capture time:
npx splay-mcp capture --url https://my-branch--preview.example.devEverything else (project, pages, viewports, login) still comes from splay.config.json.
The MCP tool accepts the same thing as a url argument.
Capturing behind a login
If the app asks for a login before showing anything, add a login section:
{
"baseUrl": "https://staging.example.com",
"pages": [{ "path": "/dashboard" }],
"login": {
"url": "/login", // where the form lives (default "/")
"user": "[email protected]",
"password": "${STAGING_PASSWORD}" // ${VAR} reads from the environment
}
}Before capturing, splay opens the login page, fills the form, and saves the session to
~/.splay/sessions/ (chmod 600). Later runs reuse that session and only log in again when
it has expired — so you don't hammer the login endpoint on every capture.
The email/username field, password field, and submit button are detected automatically, which covers standard login forms. If yours is unusual, point at the elements yourself:
"login": {
"url": "/login",
"user": "[email protected]",
"password": "${STAGING_PASSWORD}",
"userSelector": "#account-id",
"passwordSelector": "#account-pw",
"submitSelector": "button.login-submit"
}Notes:
${VAR}substitution works inuserandpassword, so the config file itself can stay free of secrets and safe to commit.- Single-step forms only. SSO redirects, MFA, and captchas are out of scope — use a plain test account.
- A wrong password fails the run before anything is uploaded, quoting the error message the page showed.
The commit hook
setup installs a post-commit hook — deliberately not pre-commit:
- it can never block a commit (runs in the background, swallows every failure)
- the working tree matches the commit at capture time
If the dev server isn't running it skips silently. That's what keeps commits safe, but it
also means a missed capture is easy to overlook. npx splay-mcp status will tell you.
To remove it, delete the # >>> splay >>> block from .git/hooks/post-commit, or run
init --no-hook to never install it.
What counts as a "changed page"
Four rules, in order:
- Route conventions —
app/pricing/page.tsx→/pricing; alayout.tsxcovers everything beneath it - Import graph — edit
components/Button.tsxand every page importing it is captured dependsOn— explicit globs from the config- Global files —
globals.css, the root layout,next.config.*mean everything
Commits that only touch API routes or docs capture nothing. Projects without a detectable
framework capture every configured page whenever a visual file changes — narrow that with
dependsOn.
Environment variables
| Variable | Purpose |
| -------------------- | ---------------------------------------------------- |
| SPLAY_API_KEY | API key, as an alternative to login |
| SPLAY_URL | Splay server URL |
| SPLAY_CHROME_PATH | Point at Chrome if it can't be found automatically |
| SPLAY_HOOK_COMMAND | Command the hook runs (default npx --yes splay-mcp…) |
Notes
- Captures are deterministic. Rendering flags are pinned so sub-pixel antialiasing and GPU rasterisation don't drift, web fonts and images are awaited, and animations are settled to their final keyframe before the shot.
- Next.js is first-class. Route detection follows the App Router. Other frameworks work
fine — list your
pagesmanually. - Dynamic routes (
/blog/[slug]) need a concrete path in the config to be captured.
License
MIT
