Content
# magnolia-author-mcp
[](./LICENSE)
[](https://github.com/noicework/magnolia-author-mcp/actions/workflows/ci.yml)
[](https://www.python.org/downloads/)
[](https://modelcontextprotocol.io)
**An [MCP](https://modelcontextprotocol.io) server that lets an AI agent author
pages on [Magnolia CMS](https://www.magnolia-cms.com/) — discover templates,
components and dialog fields at runtime, then create, update, verify and
publish real content.**
Point Claude Desktop, Claude Code, Cursor, or any MCP client at your Magnolia
author instance and drive content authoring in natural language: *"Create an
events landing page using the hero banner and a three-card grid, then publish
it to UAT."* The server turns that into correct, JCR-typed writes against
Magnolia's REST APIs — with discovery, verification, and guard rails built in.
It wraps Magnolia's Nodes API (`/.rest/nodes/v1`), Definitions API
(`/.rest/registry-definitions`) and template registry
(`/.rest/template-definitions/v1`). Generic — no assumptions about specific
component libraries, page templates, or site conventions; everything is
discovered from the live instance. Multi-instance — one server can talk to
dev / UAT / prod tiers across multiple projects via `instances.json`.
> Built and maintained by [Noice](https://noice.net.au), a Magnolia Platinum
> Partner. See the [project page](https://noice.net.au/products/magnolia-author-mcp).
---
## Why this exists
Authoring in a headless/hybrid CMS programmatically is harder than it looks: an
agent that can't *see* the component library will write properties the renderer
silently ignores, invent DAM paths that 404, store dates as strings that break
sorting, or corrupt a switchable field by writing the wrong child-node shape. A
"successful" JCR write can still render a broken page.
`magnolia-author-mcp` closes that gap:
- It **discovers the real shape** of templates, components and dialogs from the
running instance, so the agent authors against ground truth, not a guess.
- It **serialises structured fields correctly** (switchable / composite /
multiField / typed JCR values) and tells you what it did.
- It **verifies the rendered HTML**, not just the JCR write.
- It **guards destructive and production actions** with dry-runs, snapshots, an
audit log, and explicit prod confirmation.
## Highlights
- **Runtime discovery, no source access needed.** Page templates, component
definitions (areas + allowed children), and dialog fields *with their allowed
values* all come from the live instance. `scan_components` crawls a site and
classifies every in-use component by authorability; `learn_component_shape`
reads a component's real on-disk shape from live usages.
- **Structured fields are plain JSON.** switchableField / compositeField values
are dicts, jcrMultiField values are lists of dicts, JCR-typed values use an
explicit `$type` form — the server serialises each to the child-node layout
Magnolia expects and reports what it did via `notices`.
- **Batched writes.** `build_page` creates a page and populates nested
component trees (grid → column → card) in one call; `add_components_bulk` /
`set_properties_bulk` cut N round-trips to one.
- **Truthful verification.** `verify_render` fetches the rendered HTML with auth
and reports broken DAM refs, genuinely-empty `<img src>` (JS-filled
lightbox/lazy placeholders are classified separately), suspiciously small
bodies, and pages missing `mgnl:template` — a JCR-clean write does not
guarantee a clean render.
- **Guard rails.** Dry-run deletes with pre-delete snapshots and
`restore_snapshot`, an audit log of every mutating call, and explicit
confirmation required for writes to `is_prod` instances.
## How it works
```
┌──────────────┐ MCP (stdio) ┌───────────────────────┐ HTTPS + Basic ┌──────────────────┐
│ MCP client │ ───────────────▶│ magnolia-author-mcp │ ──────────────────▶│ Magnolia author │
│ (Claude etc.)│ ◀─────────────── │ (this server) │ ◀────────────────── │ instance(s) │
└──────────────┘ tool calls └───────────────────────┘ Nodes/Defs API └──────────────────┘
│
instances.json (registry)
credentials: env / 1Password / literal
```
The server is a single Python file. It exposes ~45 tools over the standard MCP
stdio transport. Each tool resolves its target instance from `instances.json`
(re-read on every call, so credential edits take effect without a restart),
authenticates with HTTP Basic, and talks to Magnolia's REST endpoints.
## Requirements
- **Python 3.10+**
- A reachable **Magnolia author instance** (6.x) with the REST **Nodes API**
enabled. The **Definitions API** is optional but strongly recommended — it's
what powers template/component/dialog discovery. Without it, discovery tools
degrade gracefully (the server tells you).
- A Magnolia user with appropriate REST roles (read for discovery; an editor /
publishing role for writes and activation).
## Quick start
### 1. Install
```bash
git clone https://github.com/noicework/magnolia-author-mcp.git
cd magnolia-author-mcp
python3 -m venv mcp/.venv
mcp/.venv/bin/pip install -r mcp/requirements.txt
```
### 2. Configure your instances
```bash
cp instances.example.json instances.json
$EDITOR instances.json # set base_url + a credential source per instance
```
`instances.json` is **gitignored** — it holds your base URLs and (optionally)
credentials. See [Instances & credentials](#instances--credentials) below and
[`references/instances.md`](references/instances.md) for the full schema.
### 3. Register the server with your MCP client
The server speaks stdio, so any MCP client works. Use **absolute paths**.
**Claude Desktop** — add to `claude_desktop_config.json`
(`~/Library/Application Support/Claude/` on macOS,
`%APPDATA%\Claude\` on Windows):
```json
{
"mcpServers": {
"magnolia-author": {
"command": "/abs/path/to/magnolia-author-mcp/mcp/.venv/bin/python",
"args": ["/abs/path/to/magnolia-author-mcp/mcp/magnolia_author_mcp.py"],
"env": {
"MAGNOLIA_AUTHOR_INSTANCES": "/abs/path/to/magnolia-author-mcp/instances.json",
"MGNL_LOCAL_USER": "superuser",
"MGNL_LOCAL_PASS": "superuser"
}
}
}
}
```
**Claude Code** — from the repo root:
```bash
claude mcp add magnolia-author \
-e MAGNOLIA_AUTHOR_INSTANCES="$PWD/instances.json" \
-- "$PWD/mcp/.venv/bin/python" "$PWD/mcp/magnolia_author_mcp.py"
```
**Cursor** — add the same `mcpServers` block to `.cursor/mcp.json`.
### 4. Try it
Restart the client and ask: *"Use magnolia-author: test the connection to
`local-dev`, then list the page templates."* The agent should call
`test_connection` and `list_templates`.
## Instances & credentials
`instances.json` maps instance keys to connection + policy settings:
```jsonc
{
"instances": {
"local-dev": {
"label": "Local Magnolia Author",
"base_url": "http://localhost:8090", // no trailing slash
"site_root": null, // or "/mysite"
"is_prod": false, // true => writes need confirmation
"username_env": "MGNL_LOCAL_USER",
"password_env": "MGNL_LOCAL_PASS"
}
},
"aliases": { "local": "local-dev" }
}
```
Optional per-instance keys `header_areas` / `header_component_prefixes` tune
`set_page_image`'s hero auto-discovery. Full schema:
[`references/instances.md`](references/instances.md).
**Credential resolution** (per instance, in order):
1. **Env var** named by `username_env` / `password_env`.
2. **1Password** via `op_user_ref` / `op_pass_ref` (requires the `op` CLI and
`OP_SERVICE_ACCOUNT_TOKEN`).
3. **Literal** `username` / `password` in the entry — **dev only, never commit**.
`instances.json` is re-read on every call and the auth/session caches drop when
its mtime changes, so credential edits take effect immediately. (Code changes
need a restart.) Credentials are never logged — `test_connection` reports only
*which source* resolved, never the secret.
When auth fails, `test_connection` returns `auth_detail`: a 401 with a Basic
challenge means rejected credentials; a 403 means authenticated but missing a
REST role; a 401 **without** a challenge usually means a gateway/SSO layer is
intercepting auth before Magnolia sees it (common on Magnolia Cloud authors).
## Tool catalogue
**Discovery / introspection**
| Tool | What it answers |
|---|---|
| `list_instances` | which authors are configured + aliases |
| `test_connection` | is REST / Nodes API / Definitions API up; auth triage on 401/403 |
| `list_workspaces` | which JCR workspaces are reachable |
| `list_templates` | page template ids, optionally scoped to a site root |
| `get_template` | a PAGE template's areas + allowed components (registry fallback on dangling refs) |
| `get_component_definition` | a COMPONENT's areas, `max_components`, allowed children, and shaped dialog fields in one call |
| `get_dialog` / `get_dialogs` | dialog field schema; select fields include `options`, switchables include sub-fields |
| `list_components_for_area` | filtered view of one area |
| `scan_components` | crawl the site for the real component inventory + usage counts, classified safe / dict-authorable / hazard |
| `learn_component_shape` | merge live usages into a component's actual on-disk shape |
**Reads**
| Tool | What it does |
|---|---|
| `read_node` | read a JCR node (`properties_only` / `structure_only` slim modes, `depth`) |
| `list_pages` | pages under a path (`verbose` adds publication state) |
| `browse_workspace` / `search_workspace` | navigate / search any workspace (indexed query when available, BFS fallback) |
| `query_jcr` | run a JCR query where the endpoint exists |
| `get_publication_status` | published / modified / unpublished |
| `list_dam_folder` / `search_dam` / `get_asset` | find DAM assets + UUIDs |
| `download_dam_asset` | fetch a DAM binary with auth into the local cache (for vision tools) |
| `verify_render` | fetch the rendered `.html` with auth; report empty `<img>`, broken `/dam/` refs, thin bodies |
**Writes**
| Tool | What it does |
|---|---|
| `create_page` | create a page from a template |
| `build_page` | page + nested component trees in one verified call |
| `add_component` / `add_components_bulk` | add component(s); structured dict values auto-serialised |
| `insert_component` | positional insert (tail splice) |
| `reorder_components` | reorder within an area |
| `add_child_node` | create an arbitrary JCR node |
| `set_properties` / `set_properties_bulk` | write properties (same structured-value support) |
| `set_component_image` / `set_page_image` | wire a DAM image (+ alt) onto a component / page hero |
| `copy_node` / `move_node` | deep clone / relocate (move is dry-run by default, snapshots first) |
| `upload_asset` / `upload_asset_from_url` / `make_dam_image_ref` | add DAM assets / build image refs |
| `delete_node` | delete (dry-run by default; confirmed deletes snapshot first) |
| `restore_snapshot` | undo a delete / move / splice from its snapshot |
| `publish_page` / `unpublish_page` | activate / deactivate (command route auto-discovered) |
Per-tool signatures live in [`mcp/README.md`](mcp/README.md).
## Authoring conventions (special property values)
These value forms work in every property-writing tool (`create_page`,
`build_page`, `add_component(s_bulk)`, `add_child_node`,
`set_properties(_bulk)`):
**DAM image refs** — damLinkFields take `jcr:<UUID>`; a bare UUID is
auto-prefixed (with a notice). `image_alt` is a separate sibling textField;
`set_component_image` writes both atomically.
```json
{"image": "jcr:5ca6e45d-3c4b-4b3d-8f3b-f2488e939391", "image_alt": "People collaborating"}
```
**switchableField / compositeField** — pass a dict; it is stored as the child
node Magnolia expects. Switchable dicts need `field` set to the option name
(discover options + sub-fields via `get_component_definition` / `get_dialog`):
```json
{"iconLibrary": {"field": "enabled", "icon": "heart"},
"animation": {"field": "enabled", "animationStyle": "fade-up", "animationTiming": "stagger"}}
```
**jcrMultiField** — pass a list of dicts; it is stored as a child node named
after the property containing item nodes `<prop>0`, `<prop>1`, … Re-setting the
property replaces the whole container (stale items removed):
```json
{"images": [
{"image": "jcr:<uuid-1>", "image_alt": "one", "caption": "First"},
{"image": "jcr:<uuid-2>", "image_alt": "two", "caption": "Second"}
]}
```
**Typed JCR properties** — a `{"$type": ..., "value": ...}` dict writes a real
typed property (`Date | Long | Double | Boolean | String`). Use this for
content-app workspaces whose models declare typed fields (e.g. an events
workspace `date`) — a string looks right in AdminCentral but breaks date sorting
and filters:
```json
{"date": {"$type": "Date", "value": "2026-07-15T09:30:00+10:00"}}
```
ISO-8601 input is normalised for you: Magnolia's Nodes API only parses
`yyyy-MM-dd` and `yyyy-MM-ddTHH:mm:ss` (read as UTC) and **silently replaces
anything else with the current timestamp** — offset-aware values are converted
to UTC and milliseconds stripped so the instant survives.
**Lists of scalars** remain ordinary multi-value properties.
## Recommended workflow
1. `test_connection(instance)` once per unfamiliar instance.
2. **Discover:** `list_templates` → `get_template` for the page template's
areas → `get_component_definition` for each container you'll nest into →
`scan_components` / `learn_component_shape` when entering an unfamiliar
component library.
3. **Author:** `build_page` for new pages, `add_components_bulk` for additions
(entries process in order, so a grid created early can be the `parent_path`
of later entries). `copy_node` an existing page when cloning beats building
from a spec.
4. **Verify:** `verify_render(page_path)` — check `ok`, `warnings`,
`empty_img_tags`, `broken_dam_refs`. A clean JCR read does **not** guarantee
a clean render.
5. **Publish** via `publish_page` only where the instance's REST command
whitelist allows it; humans publish in AdminCentral otherwise.
## Safety model
- `delete_node` is dry-run unless `confirm=True`; the dry run reports the blast
radius (subtree child count).
- Confirmed deletes / moves / splices snapshot the subtree to `snapshots/`
first; `restore_snapshot` undoes them. Every mutating call is appended to
`audit.jsonl`. Both paths configurable via `MAGNOLIA_AUTHOR_SNAPSHOT_DIR` /
`MAGNOLIA_AUTHOR_AUDIT_LOG`.
- Prod instances (`is_prod: true`) require explicit user confirmation before
any write call.
- If your MCP client supports per-tool gating, exclude `delete_node` /
`publish_page` there too for defence in depth — the server-side gate is
independent of it.
- Credentials + HTTP sessions are cached per instance (1Password is hit once
per process). Definition caches expire after `MAGNOLIA_AUTHOR_DEF_CACHE_TTL`
seconds (default 1800).
## Environment variables
| Variable | Default | Purpose |
|---|---|---|
| `MAGNOLIA_AUTHOR_INSTANCES` | `../instances.json` | path to the instance registry |
| `MAGNOLIA_AUTHOR_CACHE_DIR` | `~/.cache/magnolia-author-mcp` | DAM download cache + allowlisted local-read root |
| `MAGNOLIA_AUTHOR_TIMEOUT` | `30` | per-request HTTP timeout (seconds) |
| `MAGNOLIA_AUTHOR_DEF_CACHE_TTL` | `1800` | definition cache TTL (seconds) |
| `MAGNOLIA_AUTHOR_SNAPSHOT_DIR` | `./snapshots` | pre-delete/move subtree snapshots |
| `MAGNOLIA_AUTHOR_AUDIT_LOG` | `./audit.jsonl` | JSONL audit log of mutating calls |
| `OP_SERVICE_ACCOUNT_TOKEN` | — | enables 1Password credential refs |
| per-instance `*_env` names | — | username/password env vars you choose |
## Using the agent skill
[`SKILL.md`](SKILL.md) is a portable [agent skill](https://modelcontextprotocol.io)
that teaches a model *how* to use these tools well — discovery-before-writing,
DAM handling, prod gating, reply tone — without baking in any site specifics.
Clients that support skills can load it directly. Everything site-specific (the
real component inventory, page-template ids, hero component, conventions) lives
in a per-instance guide at `references/<instance-key>.md`, started from
[`references/_instance-guide-template.md`](references/_instance-guide-template.md)
and built on first contact via `scan_components` + a few live reads.
## Known limitations
- **Definitions API lossiness.** The dialog endpoint drops
jcrMultiField/composite entries from its `properties` map, and the JSON-Schema
template endpoint 404s on templates with dangling component refs. The server
compensates (registry fallback with `hint`; structured values exempt from
unknown-property warnings) but multiField names can't be validated against the
dialog.
- **Ordering is a splice, not a move.** JCR `orderBefore` isn't exposed over
REST, so `insert_component` / `reorder_components` recreate the tail siblings
(new uuids). Responses include the snapshot file for recovery.
- **Renderer rules are invisible to introspection.** A dialog can declare a
field the FTL only renders for certain variants. `verify_render` + reading the
HTML is the ground truth.
- **Activation requires permissions.** `publish_page` can return success while
the user lacks the activator role — check Magnolia's activation monitor.
- `query_jcr` requires the optional nodes `@query` endpoint; tools degrade to
BFS search with a structured error when it's absent.
## Development
```bash
python3 -m venv mcp/.venv
mcp/.venv/bin/pip install -r mcp/requirements-dev.txt
mcp/.venv/bin/python -m pytest tests/test_unit.py -q # offline, mocked HTTP
mcp/.venv/bin/python tests/live_smoke.py # end-to-end vs a local author
```
The unit suite is fully mocked and runs anywhere. The live smoke test needs a
running author configured under `local-dev` in `instances.json`; it
creates/cleans its own `mcp-e2e-*` pages. Override its target site root and
template ids via the `SMOKE_*` environment variables (see
[`tests/live_smoke.py`](tests/live_smoke.py)).
Inspect the server interactively with the MCP dev runner:
```bash
cd mcp && MAGNOLIA_AUTHOR_INSTANCES=$PWD/../instances.json .venv/bin/mcp dev magnolia_author_mcp.py
```
## Repository layout
```
.
├── SKILL.md # portable agent skill (usage rules)
├── instances.example.json # registry schema — copy to instances.json
├── mcp/
│ ├── magnolia_author_mcp.py # the MCP server (single file)
│ ├── requirements.txt # mcp[cli], requests
│ ├── requirements-dev.txt # + pytest
│ └── README.md # per-tool signatures + notes
├── tests/
│ ├── test_unit.py # offline, mocked-HTTP unit tests
│ └── live_smoke.py # end-to-end against a local author
└── references/ # skill reference docs loaded on demand
├── components.md imagery.md instances.md moving-pages.md
├── project-document-layout.md troubleshooting.md
└── _instance-guide-template.md
```
## Contributing
Issues and PRs welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, the
test workflow, and PR expectations. Please review our
[Code of Conduct](CODE_OF_CONDUCT.md).
## Security
Found a vulnerability? Please follow the process in
[SECURITY.md](SECURITY.md) — do not open a public issue for security reports.
## License
Apache License 2.0 — see [LICENSE](LICENSE) and [NOTICE](NOTICE).
Built and maintained by [**Noice**](https://noice.net.au) — a Magnolia Platinum
Partner specialising in CMS migration and AI-assisted content operations.
> *Magnolia* is a trademark of Magnolia International Ltd. This is an
> independent, community-maintained project and is not affiliated with or
> endorsed by Magnolia International Ltd.
MCP Config
Below is the configuration for this MCP Server. You can copy it directly to Cursor or other MCP clients.
mcp.json
Connection Info
You Might Also Like
No recommendations available