Guide

MCP server — SSG for AI agents

ssg mcp is a Model Context Protocol server over stdio. An MCP-capable assistant connects to it and edits a real site: templates, theme assets and Markdown content, with the site rebuilding after every change so a broken template comes straight back as an error rather than shipping.

It is a development server. It edits the working tree in front of it and, if git is configured, can put those edits on a branch and open a pull request — but nothing merges without a human.

Register it

1{ "command": "ssg", "args": ["mcp"] }

Run from the site's root, or point at the config:

1ssg mcp --config=.ssg.yaml
Flag Meaning
--role=designer Expose the designer tools only
--role=content Expose the content tools only
--no-watch Do not rebuild after each change
--watch Also watch the filesystem, so edits made outside MCP rebuild too
--config=FILE Site config (default .ssg.yaml)

Both roles are exposed by default. Narrow with --role when an agent should not be able to touch the other half.

Call help first

The server has a help tool that describes the roles, the git workflow and every tool with its purpose. Every other tool's description states its own CAN and CANNOT. An agent that reads those before acting will not need this page.

The two roles

The split is deliberate: how the site looks and what the site says are different jobs with different blast radii, and an agent asked to fix a typo has no business rewriting a layout.

Designer — templates and theme assets

Tool Purpose
designer_list Every template and theme asset that may be edited
designer_read Read one before changing it
designer_write Create or replace one, full content (not a patch)
designer_edit Change one piece in place: exact oldnew, matched once
designer_find Where something lives — file and line range, without reading
designer_config_read The settings that may be changed — site name and description, theme, rendering — with current values
designer_config_set Change one of them

Writes are confined to the template and asset directories. The designer cannot edit content, delete files, or write outside those directories.

designer_config_set is deliberately narrow — an allow-list of presentation keys such as the theme, the Mermaid theme, the highlight style and minification. Secrets, deployment, server, endpoints, hooks and URL structure are neither readable nor writable. Call designer_config_read first: it returns the key names, the current values and what each one does.

It writes through the same editor as ssg config set (GO-101), so an assistant and a person editing the same file get the same result: one setting changed is one line changed, and the comments stay where the author put them. The allow-list is the difference — ssg config is the project owner's own tool and edits any key.

The browser editor (ssg --http --watch --edit, see EDITING) is a second client of this same server rather than a second server: it calls content_read, content_update and the git tools in-process, in the content role, so the path confinement and the refusals are decided here. It adds no tool.

Content manager — Markdown

Tool Purpose
content_list Every Markdown file
content_read Frontmatter and body of one file
content_create A new file — full document, including frontmatter
content_update Replace an existing file; fails if it does not exist
content_delete Remove a file
content_edit Change one passage in place: exact oldnew, matched once
content_find Which files mention something, and where

Media (media_*)

"Change the picture on the about page" is the most ordinary request after "fix this text", and until 1.8.51 the answer was that the assistant could not: the content tools handle Markdown, designer_write is text, and nothing touched a file. A hosted setup putting ssg mcp behind a token could not add a side channel for uploads without breaking the property that every change goes through the same audited tools.

Tool Purpose
media_list Every image the site serves, with size and format
media_upload Add a new one, from base64 bytes or a URL the server downloads
media_replace Replace one in place, keeping its path
media_delete Remove one — refused while anything still references it

Both roles get them: a photograph is neither purely presentation nor purely content, and the owner asking does not know the difference.

Address a file the way the site does, not by where it is stored: /media/images/team.jpg, the way a page links it. That matters because where media live differs between sites. A fresh project keeps them in static/; a site out of wpexporter keeps every picture under the content source (content/<site>/media/…, published at /media/…) and static/ holds only the theme's own. The tools cover every root the build publishes verbatim — the static directory, each static_sources entry, and the content source's media/ — and resolve a served path to whichever one holds it. A new file goes to the root that serves the prefix you addressed, so an upload to /media/… lands under the content source rather than creating a second media tree in static/.

A project-relative path is still accepted, since the tools took those when they shipped. A path belonging to no publishing root is refused and told where media actually live, rather than being quietly resolved into the first root that could hold it — a tool that silently relocates a file is worse than one that refuses.

media_replace is usually the tool you want. It keeps the path, so every page using that image changes at once with no content edits. media_upload followed by content_edit is for pointing one page somewhere new.

Three rules, each of which is a mistake this project has made once already:

  • The file's bytes decide what it is, not its name. A shell script called photo.png is refused. Extensions decide nothing — the image pipeline learned that as SEC-013, and the AVIF pass forgot it a release later (#198).
  • A URL is fetched through the same guard external sources use, which resolves the host itself and refuses private and loopback addresses, so a DNS answer that changes between check and connection cannot reach one either. A server that downloads whatever it is told to is a proxy into the network it runs on. Set mcp.media_allow_private: true only when the images genuinely come from a host on your own private network.
  • A file something references cannot be deleted. The refusal names the pages that still point at it; a broken image is a worse outcome than an unused file.

Only JPEG, PNG, GIF and WebP are stored — the formats the image pipeline can process, so an uploaded file is one the build can resize, convert and check. SVG is deliberately outside that set: it is XML that can carry script, served from the site's own origin, and "it is an image" is exactly the reasoning that makes it dangerous.

content_create and content_update are separate on purpose: creating over an existing file and updating a missing one are both mistakes, and splitting them turns each into an error instead of silent data loss. content_delete is destructive and should follow an explicit request, never an inference.

Site (site_*)

The other sections are file-shaped, which is right for editing and wrong for understanding. Seven read-only tools answer questions about the site's model — from the site-graph.json the last build wrote (site_graph: true) — and every answer names the build it comes from, so an agent can tell fresh from stale:

Tool Answers
site_pages every page: url, type, title, lang, date — filter type/lang, page with limit/offset
site_page one page in full, with every link out of it and into it
site_links the link graph — filter from, to, kind (page, asset, external)
site_taxonomies every taxonomy with its terms, archive URLs and counts
site_redirects every redirect rule
site_components which components the site defines, and which pages use each one
site_dependencies what a page was built from (url), or what editing a file rebuilds (path)

"Which pages link to /pricing/?" is site_links with to: /pricing/, not a grep across the output. The section appears for every role once the server knows the output directory; without a graph on disk each tool says what to turn on rather than answering with nothing. See AI-AGENTS for the artifact itself.

Before you change a file, ask what it changes

site_dependencies is the exception in that table: it reads the dependency graph from .ssg-cache/graph/, not the published site graph, so it works without site_graph: true and it is deliberately not part of the public artifact. Pages, links and taxonomies all follow from the published HTML. Which template rendered a page and which data file it read is the shape of the project, and an agent working on the project is the only reader that needs it.

1site_dependencies { "url": "/pricing/" }
2// → output/pricing/index.html, and its inputs grouped by kind:
3//   content: content/site/pages/pricing.md
4//   data:    data/plans.yaml
5
6site_dependencies { "path": "templates/theme/partials/nav.html" }
7// → full: true, "…is a template; every page rendered through it may differ"

With no argument it says how large the graph is and whether this site's builds can be narrowed at all — worth asking first, because a site fed by external sources or MDDB records fewer edges than it really has, and every answer that comes back carries that caveat.

Find, then edit — the cheap path

The tool list above has two shapes for changing a file, and the difference is measured in tokens rather than taste.

A full write (designer_write, content_update) costs the size of the file. Changing one CSS value on a real migrated site meant reading 4 812 bytes, writing 4 812 back and reading them again to check: about 4 300 tokens of file traffic to move one line — and that is before finding the right file at all.

An anchored edit costs the size of the change:

1designer_edit {
2  "path": "static/css/style.css",
3  "old":  "  background: #fff;",
4  "new":  "  background: #0b1220;"
5}
  • old is matched byte for byte, indentation included, and must appear exactly once. Zero matches or several is a refusal naming the count, so nothing fuzzy ever lands and nothing is half-applied.
  • The reply carries the change with its neighbours and line numbers. That is the verification — there is no re-read.
  • On a line too long to print — a minified stylesheet is one line — the window is measured in characters around the change instead, and reported as line:fromCol-toCol. Whatever is left out is stated rather than silently dropped. Line-based context is right for source that has lines; the reply should not grow to the file because the file has one.
  • An empty new deletes the anchored text.

designer_find / content_find supply the anchor without reading anything:

1designer_find { "query": "background" }
2 static/css/style.css:4-8
3    body {
4      background: #ffffff;
5      color: var(--ink);
6    }

The query is treated as a case-insensitive regular expression when it is valid syntax and literally when it is not, so pasting a CSS fragment with an unbalanced bracket returns an answer rather than a syntax error. Matches whose context windows overlap are reported as one region; files over 512 KB are skipped, because a minified bundle matches everything and helps nobody.

A match on a very long line comes back as a character window with a column range — static/css/site.css:1:9834-9859 — so a minified file gives a locus an edit can anchor to rather than the whole line, which in that file is the whole thing.

So the whole flow is find → edit, and the 10k-token background change becomes a few hundred. Reserve the full writes for new files and real rewrites.

An MDDB-backed search (optional)

A local scan matches text. It cannot answer a question phrased as a sentence — "where is the page background set?" — because that is a search problem. Point the find tools at an MDDB collection and it becomes answerable:

1mcp:
2  search:
3    mddb_url: http://localhost:11023
4    mddb_collection: theme
5    mddb_api_key: $MDDB_TOKEN   # optional; always $ENV, never a literal
6    mddb_lang: en               # query tokenisation
7    mddb_fuzzy: 1               # typo tolerance: 0 off, 1 or 2 edits
8    mddb_allow_http: true       # only if the URL is http:// on a private network

The API key travels in X-API-Key, which is the header MDDB validates keys from. Its bearer path is for JWTs — a key sent that way is parsed as a token and refused with 401 invalid token, which blames the key rather than the header. If mddb_api_key holds a JWT instead, it is sent as Authorization: Bearer and both work.

An API key is refused over plaintext http:// to anything but loopback. On a private container network — http://mddb:11023, never leaving the host — that is the same trust boundary as loopback spelled with a service name, so mddb_allow_http: true says so deliberately. Leave it off for anything routable.

Fill the collection with ssg mddb push-theme:

1ssg mddb push-theme            # upsert every template and asset, prune what vanished
2ssg mddb push-theme --dry      # show what it would do
3ssg mddb push-theme --lang=pl

Each file becomes one document keyed by its project-relative path, with kind (style / script / template / data / asset), size and a SHA-256 checksum in its metadata — so a search hit names the file to open, which is what makes it actionable rather than merely relevant. The sync reconciles: documents whose file no longer exists are deleted, and running it twice changes nothing the second time.

When the collection carries embeddings, the query is answered by two calls, and the reason is worth knowing before you tune anything:

  • /v1/hybrid-search decides which documents matter. That is what the vectors are for, and it is what lets "how the navigation looks on phones" find a template that never uses either word.
  • /v1/fts supplies the line ranges. Hybrid results carry none — no highlights, no positions — and a hit without a locus is a hit an anchored designer_edit cannot use.

A document the vectors ranked but the keywords did not match is still reported, with its line marked unknown rather than guessed. A collection without embeddings declines the first call once, is remembered, and behaves exactly as it did before.

The index is consulted first and never required. When it errors or finds nothing, the local scan still runs and answers. A search backend that is down degrades the answer; it does not take the ability to edit the site down with it.

Watch mode is the feedback loop

Unless --no-watch is passed, the site rebuilds after every mutation and build errors are returned to the calling tool. A template that fails to parse, a link that breaks, a validator that trips — the agent sees it immediately and can fix it before doing anything else.

This is the part worth relying on: an agent editing a static site otherwise has no way to tell whether its change was valid until someone looks at the output.

One process for preview, MCP and watch

1ssg mcp --http --watch --listen=7823

That one command serves the site preview, serves the MCP endpoint, and rebuilds on both kinds of change: a mutation arriving through MCP, and a file edited by anything else — a human editor open beside the agent, an rsync, a CMS export refreshing content. Live reload fires either way.

The distinction between the two watch flags is worth stating once:

Flag What it governs
(default on) Rebuild after every MCP mutation. Turn it off with --no-watch
--watch Also poll the filesystem — content, templates, data, content_sources, and the config file

Before 1.8.47 --watch was accepted here and read by nothing, so an edit that did not arrive through MCP left the preview stale indefinitely. The workaround — --watch-runner="ssg mcp --listen=…" — is one command line but still two processes, and worse: two independent builders over one output directory with nothing serialising them, which is a preview that intermittently serves half a site.

Every rebuild goes through one lock, whatever triggered it. That also closes a race that needed no watcher at all: the Streamable HTTP transport gives each request its own goroutine, so two concurrent tools/call were already able to rebuild the same output tree at the same time.

An edited config file is picked up here as it is under ssg --watch: the file is reloaded, endpoints: are republished onto the running preview without dropping the port, and the site rebuilds from the new settings. One boundary is fixed at startup and worth knowing: the MCP server's own surface — which roles are exposed, which directories its tools may write to, whether the git PR flow is armed — is read once when the process starts. Moving content_dir in a live config changes what is built, not what the assistant is allowed to edit; that needs a restart.

Git write-back

Optional. Without it the assistant edits files in place. With it, the changes travel a branch → commit → PR path with a human at the end:

Tool Purpose
git_status Branch and changed files, so both sides see what will be committed
git_new_branch Create and switch to a working branch
git_commit Stage and commit on that branch
git_open_pr Push and open a pull request against the default branch
1mcp:
2  git:
3    account: my-org
4    token: $GITHUB_TOKEN      # an environment variable, never a literal
5    repo: my-org/my-site      # optional; derived from the remote when empty
6    remote: origin            # default
7    default_branch: main      # PR base
8    branch_prefix: mcp/       # working-branch prefix

The token must reference an environment variable. A literal in a config file is a credential in version control.

git_commit never touches the base branch, and git_open_pr opens a pull request — it does not merge. The approval step stays with a person.

What this is not

It does not deploy, does not read or write secrets, and does not run arbitrary commands. The tools it exposes are the ones needed to change a site's appearance and its words, and nothing beyond that.

If an agent needs to build or deploy, that is the CLI's job (DEPLOYMENT) and belongs in CI, not in an editing session.

For an agent evaluating SSG

The properties that matter when a machine is doing the editing:

  • Errors come back to the caller. Rebuild-on-change means an invalid edit is reported at the point it was made, not discovered later.
  • The output is deterministic. Two builds of the same input produce byte-identical output regardless of worker count, which is verified in CI — so a diff means the change caused it.
  • Every tool states its limits. The CAN/CANNOT contract is in each tool's description, so the boundary does not have to be inferred from failures.
  • Structured data is first class. schema: in frontmatter is arbitrary JSON-LD, so any schema.org type — Recipe, Product, Event — is expressible without SSG knowing about it (CONFIGURATION).
  • Nothing is published without a person. The write-back path ends at a pull request.

See also

  • CONFIGURATION — the mcp: block and every other setting
  • TEMPLATES — what the designer role is editing
  • CONTENT — frontmatter and content structure