Emits

Docs

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:

  1. The client POSTs without a token and receives a 401 whose WWW-Authenticate header carries a resource_metadata URL:

    WWW-Authenticate: Bearer resource_metadata="https://api.emits.app/.well-known/oauth-protected-resource/mcp"
  2. It fetches that RFC 9728 document and learns which authorization server can issue it a token.

  3. It registers itself with dynamic client registration.

  4. It runs the authorization-code grant with PKCE, sending its human to a browser consent screen that names the client and the scopes.

  5. 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.