@vinumcms/mcp
v0.2.0
Published
An MCP server over a Vinum install: read the content model, read and write pages, through the same sanitizers the admin uses.
Readme
@vinumcms/mcp
MCP tools over a Vinum install: read the site's content model, read and write its pages, without shelling out or reaching into the key-value store by hand.
// your MCP client's config
{
"mcpServers": {
"vinum": {
"command": "npx",
"args": ["-y", "@vinumcms/mcp", "--cwd=/path/to/your-site"]
}
}
}Read-only. Add "--write" to the args to allow content changes.
Why it holds your instance rather than building one
It imports your own app/vinum.server.ts and calls the same vinum.pages.* API
the admin screens call. There is no second path to the data and no privileged
one — which matters because the alternative, an agent editing ["page", slug]
directly, steps around every sanitizer and the first thing it stores is a block
shape the editor can never produce.
That also means it reads your store — and the store has to be a persistent
one, because this is a separate process. With memoryStore it opens its own
empty database beside your dev server's and every tool reports an empty site.
On Node that means fsStore, which ships in @vinumcms/adapters as of 0.6.0
and takes a lock file precisely so this server and your dev server can write the
same content without losing each other's edits:
import { fsStore } from "@vinumcms/adapters/fs";
store: fsStore({ path: "./data/store.json" }),Tools
| | |
|---|---|
| get_site | Name, origin, locales, and whether writes are allowed |
| list_block_types | Every block this site can use, with its fields. Read this first — the palette is per-site, because a host registers its own alongside Vinum's |
| list_page_types | Collections, their base path and structured fields |
| list_pages | Every page with route and status |
| get_page | One page in full, blocks included |
| scaffold_block | Generate a custom block — type, sanitizer, settings panel, renderer and a test — as source for you to write |
| save_draft | Create or update a draft — needs --write |
| publish_page | Publish a draft, snapshotting the outgoing version — needs --write |
list_block_types is the one that earns its place. Guessing a block's shape is
the failure mode Vinum's agent-experience notes are about: agents do not get
stuck, they guess confidently. This hands over the real registry, including the
blocks you wrote.
Scaffolding a block
scaffold_block takes a field list and returns source. It writes no files and
needs no --write — the agent holding this tool already has file tools, and
giving the server the repo would overload --write, which today means exactly
one thing, into meaning two.
{
"type": "testimonial",
"layout": "section",
"variants": ["tone"],
"fields": [
{ "name": "heading", "kind": "text", "placeholder": "What people say" },
{ "name": "quotes", "kind": "list", "of": [
{ "name": "body", "kind": "longText" },
{ "name": "name", "kind": "text" }
]}
]
}Field kinds: text, longText, image, link, boolean, number, select,
list. Text is editable in place; the rest get a settings panel. Images go
through safeSrc and links through safeHref, because a sanitizer is a trust
boundary whether a person or an agent wrote the block.
It returns a whole app/blocks.tsx when the site has no custom blocks yet, and
a definition to insert plus the imports it needs when it does — a distinction it
can make because it is reading your live registry rather than guessing at your
files. That is also why it refuses a type you have already registered, defaults
the palette group to one your site actually uses, and rejects a field called
id, type or variant (the system writes those around your sanitizer's
output, so such a field is silently overwritten on every save).
The generated test asserts the block reached BLOCKS, that create()
round-trips through sanitize(), and that hostile input coerces rather than
throwing. Invariant 1 asks for a sanitizer and a test in the same commit as a
new block type; this produces both.
Writes are a trust boundary
save_draft runs sanitizeBlocks before storing, exactly as the admin's own
route does. Unknown block types are dropped and unknown fields coerced — a CLI
or MCP write is untrusted like any other, and "it's a dev tool" has never been
an exemption.
A sanitizer drops silently, which is right for a form post where the editor
sees the result and wrong for an agent that would otherwise report success for
content that was never stored. So save_draft compares what it was given
against what survived and says so:
{
"saved": "about",
"blocks": 1,
"warning": "1 of 2 block(s) were dropped by the sanitizer — an unknown type, or a field it could not coerce. Check list_block_types."
}Refusals work the same way: with --write absent, a write tool returns an error
explaining the server is read-only rather than vanishing from the list. A tool an
agent cannot see is a tool it invents a workaround for.
Requirements
Node 18+, and a Vinum install of 0.7.0 or later — scaffold_block reads the
variant vocabulary, which core gained in 0.7.0. It runs under tsx, which is a
dependency of this package — Vinum ships TypeScript with no build step, and Node
refuses to strip types under node_modules, so a plain node process cannot
load the sanitizers this server depends on.
Licence
MIT
