MCP
Emits exposes its verbs as Model Context Protocol tools, so an agent client publishes by making tool calls instead of driving the CLI. There is one set of tools and two transports: a local server the client launches as a subprocess, and a hosted endpoint for clients that cannot.
The tools
| Tool | What it does | Scope |
|---|---|---|
publish |
Create a page from HTML passed as a string, or from a path on the local server; returns the live URL |
pages:write |
publish_bundle |
Create a linked multi-page site from a set of files, under one slug | pages:write |
update |
Replace a page's contents; the URL does not change | pages:write |
list |
List the account's pages | pages:read |
unpublish |
Take a page offline | pages:write |
restore |
Make an earlier version of a page the live one again | pages:write |
Both transports serve exactly these, with the same parameters: the tool
definitions are shared code, so a tool cannot exist locally and be missing
remotely. What they do not share is what a parameter says — publish_bundle
describes its dir, and publish/update their path, differently on each,
because only one of them has a filesystem to point them at. See
publishing a folder and
publishing a file already on disk.
publish_bundle takes files — each { path, html } — plus an optional
entryPath naming the one that serves at the root (index.html by default).
Every other file serves at the path you gave it, so relative links between
them resolve as written. Reach for it instead of calling publish once per
document: separate publishes land on separate subdomains, and the links
between them break. The file set is text only — .html, .htm, .css, .js,
.mjs, .json, .txt, .xml, .webmanifest and .svg. An image or a font
in files is refused by the tool itself, before a request is made, naming the
file; a format the platform cannot serve at all is refused by the API with a
400 naming it. A bundle is revised by publishing it
again with the same slug — update is refused on a bundle's entry page. See
multi-page publishes.
Publishing a folder, images included
files[].html is a JSON string, so a site with a logo or a webfont in it
cannot be sent that way — by any client, at any size. The local server closes
that gap with a second parameter: dir, a path on the machine the server is
running on. Give it instead of files and the server walks the folder, uploads
the binaries out of band exactly as emit publish --bundle does, and publishes
everything under one slug in a single call. The bytes never enter the
conversation; only the path does.
Point it at the folder that is the site. The walk is the CLI's, which means
it never descends into node_modules, dist, build, out, coverage,
vendor, target, __pycache__ or .git, and never picks up a dotfile — so
a project root with a stub index.html beside a built dist/ publishes the
stub and nothing else. Pass dist itself.
A skipped dist, build or out — and Nuxt's dotted .output, which the
dotfile rule would otherwise hide — comes back as a note on the publish, because
one of them may be the site that was meant. The note names the paths, and the
exact dir that would publish one when there is only one candidate; with
several it says to pick, because the list is sorted and guessing would choose on
spelling. It also names the bundleId to reuse as the slug, since republishing without
it leaves the first site live beside the second.
Where the entry document links into a skipped folder, the note stops suggesting and states it: that link is dead on the page just published. That is read off the HTML this call published, so it costs nothing and needs no guessing.
The rest of the skip list is dropped in silence: every JS project has a
node_modules, and a warning printed every time is one nobody reads.
emit publish --bundle prints the same build-output warning to stderr, --json
or not.
What the result also names is every file that was found and refused: a
format the platform cannot serve — a README.md, a .zip — any symlink, which
is never followed, and any file this deployment says it does not accept. Those
come back as notes on the same call, so an omission of that kind is visible
before a broken link is. The third reason is the one whose remedy is not on your
machine: the file is fine and the CLI would have sent it, so renaming it will
not help and neither will upgrading anything locally.
Only the stdio transport can honour dir. The hosted server has no filesystem
to point it at, so it advertises the parameter as unsupported and refuses it
rather than ignoring it — a folder path silently dropped would publish the site
with every image missing and report success. Asked for a binary on that
transport, it points at the CLI or the web uploader; asked
on the local one, it points at dir. dir and path (below) are the only
places the two transports differ, and they differ over a capability rather than
a location.
dir publishes whatever is in the folder it is given, to a URL that anyone
holding it can open. Name the folder the user named.
Publishing a file already on disk
dir publishes a folder. For the other case — one document, written to disk,
sitting among files that are not part of it — publish and update take a
path instead of html. The local server reads that file and publishes its
contents; nothing else in the folder goes with it.
Prefer it whenever the document is already on disk. Passing html for a file
you have just written sends the same text through the model's context a second
time, and on a long document that is where a publish comes back truncated.
Only .html and .htm are readable, and that is a boundary rather than a
convenience: path is chosen by the model and the result is a public URL, so a
parameter that read any file would be a way to publish one. Anything else is
refused by extension, without the server even checking whether it exists. For a
site — stylesheets, images, fonts — use publish_bundle with dir.
The file that was actually read is named in the result, by its resolved absolute path. The model gave a path; only the resolved one says where it landed.
path is not put to you for confirmation the way dir is, and that is a
decision rather than an oversight. The folder prompt exists because dir does
not disclose what it will publish — you are shown a path and a hundred files
arrive. Here the argument is the file: one document, named in the result by
the path it resolved to, and refused outright unless it is an ordinary .html
or .htm file. Symlinks are never followed, for the reason the extension rule
exists at all — a link called notes.html can point at anything.
Like dir, path is honoured only on the stdio transport, declared on both,
and refused by name on the hosted one rather than ignored — with html beside
it, a dropped path would publish the wrong document and report success. Send
one of the two, not both: they are alternatives, and a call carrying both is
refused rather than resolved in the tool's favour.
Confirming the folder
The model chooses that path. files carries bytes the model just wrote and you
can read in the transcript, but dir is a path inferred from a conversation,
and a wrong guess one level up publishes a whole project tree instead of a page.
path is the other parameter of that shape and is deliberately not prompted
for, as the section above says: the difference is that a wrong dir publishes
an unbounded set of files nobody named, and a wrong path publishes one
.html file, named back to you by the path it resolved to.
So before anything uploads, the stdio server shows you the folder it walked: the absolute path, how many files and how large, which one serves at the root, the first dozen by name, and what the site will be visible as. Decline and nothing is uploaded — not the HTML, not the images. The check happens after the walk, so the list you approve is the file set that ships; if the folder changes between the question and your answer (a watch build rewriting a page, say), you are asked again rather than publishing something you did not see.
If the walk declined a dist on the way, the dialog says so — the same
build-output warning the result carries, worded for a folder that has not been
published yet. This is the turn on which it is still worth reading: declining
costs nothing, where the identical warning in a result arrives with the site
already live.
New sites are unlisted by default: reachable only by their URL, never listed or
indexed. A new site you did not mean is recoverable — the result names the
slug, and unpublish takes it down. Replacing an existing one is not: pages it
served that are not in the folder stop being served, and republishing the old
folder is the only way back.
To turn the prompt off, add an env block to the Emits entry you already have
in your MCP client config — keep its command and args exactly as they are:
{
"mcpServers": {
"emits": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@mzedstudio/emits-cli", "mcp"],
"env": { "EMITS_MCP_CONFIRM_DIR": "0" }
}
}
}
It lives there, and not in a tool argument, on purpose: a switch the model can
set is not a guardrail. Only the exact string 0 disables it.
What this checks is the dir argument — the case where the model chose a path
you never saw and an unbounded set of files follows from it. It is not a general
lock on publishing: a model can still read files itself and publish them with
files, or name a single document with path. Neither is prompted for, and
neither can publish more than what it names.
The prompt needs a client that supports elicitation. One that does not is not
blocked — it publishes exactly as it did before, since there is no other route
through MCP to a site held back — so on those clients dir is still worth the
same care it always was.
Retrying without publishing twice
A tool call that times out or dies mid-flight leaves a client with no result
and no way to tell a request that never arrived from one that landed and lost
its response. publish, publish_bundle and update therefore take an
optional idempotencyKey: mint a fresh one — a uuid will do — for each
distinct call, and send it. If the call answers, that is the answer. If it does
not, repeat the same call with the same key: the retry replays the first
attempt's result — the same page, version and URL — instead of creating a
second copy. unpublish and restore take no key and need none; repeating
either lands the state it already landed.
Four things bound what the key promises.
It is a retry key, not a lock. It does not serialize two calls running at once, and a timeout does not stop the first one — so give a timed-out call a few seconds to finish before retrying it, or both may publish.
It covers a finished call. The key is recorded at the end of a publish, and
for publish_bundle only once the whole bundle has landed, so a call that
failed part-way leaves no key and its retry publishes from the top rather than
replaying.
It expires. A key's result is kept for 48 hours; past that the key is forgotten
and the call runs as a new one — update publishes a new version, publish
without a slug creates a second page at a new URL. Retry a call you never saw
the result of within minutes, which is the only retry there is; do not carry a
key across days.
It answers one call only. A key already spent is refused
(IDEMPOTENCY_KEY_REUSED) for anything that is not a retry of the call that
spent it — changed content, a different slug, a different page, a single-file
publish against a key spent on a bundle — and also for a faithful retry whose
page has since been deleted, since the URL it would hand back no longer
serves. The remedy is the same in every one of those: change nothing, mint a
fresh key, send again — under a different slug, because a deleted page keeps its
handle. Never hard-code a key or reuse one across edits; the second edit would
otherwise be refused while the old page kept serving.
A page that was taken down is the exception, and it is refused as the
takedown (PAGE_QUARANTINED) rather than as a key. Do not mint a fresh key and
do not publish the content elsewhere: that is the move read as evading a
takedown. Stop, and tell the user the case is at /dashboard/moderation.
The failure text the tools return says which of these you are in, and whether the call was a write at all — a read that fails changed nothing and is always safe to repeat.
The local server
emit mcp
runs the server on stdio, which every subprocess-capable client can launch with no port, no TLS, and no inbound network. A typical client configuration:
{
"mcpServers": {
"emits": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@mzedstudio/emits-cli", "mcp"]
}
}
}
Auth is the CLI's: the server reads the credential
emit login stored in ~/.emits/, re-reading it
on each call, so logging in from another terminal mid-session is picked up
and credentials never leave the machine or appear on a socket.
The remote server
Clients that cannot launch a subprocess (browser-hosted connectors, hosted agents) connect to:
https://api.emits.app/mcp
There is no key to mint and no client to register by hand; the endpoint bootstraps a conforming client from a single unauthenticated request:
The client
POSTs without a token and receives a401whoseWWW-Authenticateheader carries aresource_metadataURL:WWW-Authenticate: Bearer resource_metadata="https://api.emits.app/.well-known/oauth-protected-resource/mcp"It fetches that RFC 9728 document and learns which authorization server can issue it a token.
It registers itself with dynamic client registration.
It runs the authorization-code grant with PKCE, sending its human to a browser consent screen that names the client and the scopes.
It retries with the bearer token it was issued.
The token rides the Authorization header only: the MCP spec forbids tokens
in the query string, and so does the endpoint.
Which transport to choose
Prefer the local server whenever the client can launch one: the credential stays on the machine and there is no OAuth handshake to perform. The hosted endpoint deliberately makes the opposite trade: it is reachable from anywhere, and pays for that with the browser consent flow above. Approved clients appear in the console under Settings → API access, one entry per client, where access can be revoked.