Ghost Styling MCP
# Ghost Styling MCP
An [MCP](https://modelcontextprotocol.io) server that builds your own Ghost blog
theme from your existing theme and real website data.
Most Ghost integrations manage content; this one handles how your blog *looks*. It
gives the model your real data to design against: the brand (colours, fonts, logo)
pulled from your live site, that page's rendered HTML and CSS, your current theme,
and your site settings. You get back a complete, custom theme to preview locally and
upload when you're ready.
Styling and vision come first. The authenticated client underneath is generic, so
the rest of the Ghost Admin API (posts, members, tags, and the other resources)
follows as thin tool wrappers, growing this into a full management server.
## Status
Working: auth, vision, theme generation/preview/upload, site settings, and content +
audience management (posts, tags, members, newsletters). Roadmap:
- [x] Authenticated Admin API client (generic browse/read/add/edit/delete)
- [x] **Vision**: `extract_brand` distils a live site's brand + navigation; `get_theme_structure` fetches its markup + CSS; `check_contrast` scores colour pairs against WCAG
- [x] **Themes**: generate, preview locally, upload, restyle, list, download, and (guarded) activate themes
- [x] **Site settings**: read/update brand + SEO metadata (title, description, accent, meta/OG/Twitter) and navigation menus
- [x] **Management**: posts, tags, members, and newsletters as CRUD tools
- [x] **Guided flow**: a `theme-a-site` prompt and a server instructions block encode the brand-first workflow
- [ ] **Management**: tiers, offers, and users as they're needed (next)
## Tools
The server exposes these tools to the model:
**Vision**
- `extract_brand`: distil a live site into clean brand tokens (colour palette, heading/body fonts, logo, button style) plus its navigation menus (header/footer content links, with login/sign-up/account links flagged separately) to design against.
- `get_theme_structure`: fetch a live page's HTML skeleton and linked CSS, so styling targets selectors that actually exist.
- `check_contrast`: WCAG contrast ratio (and AA/AAA pass levels) between two colours, so text-on-accent stays readable.
**Research** — *optional; registered only when `SERPER_API_KEY` is set*
- `search_serp`: see what currently ranks for a query and whether it's worth writing for. Cheap triage (1 API credit, no crawling).
- `expand_keywords`: expand a seed topic into the queries people actually search — "people also ask", related searches and autocomplete — grouped by intent (`alternatives` / `local` / `comparison` / `cost` / `how_to` / `commercial` / `informational`).
- `build_content_brief`: crawl the ranking pages and extract the structure they share: consensus sections, unique angles, target length, and whether they carry pricing tables or FAQs.
- `find_content_gaps`: match the SERP's consensus topics against posts already on *your* blog, so you extend an existing post instead of publishing a competitor to it.
- `plan_research_profile`: the questions to ask the user before building a profile — run this first, since a guessed profile fails silently.
- `list_research_profiles` / `set_research_profile`: inspect and switch the niche profile the verdicts are judged against.
- `add_incumbents` / `remove_incumbents`: record domains that keep ranking, so later verdicts account for them. Persisted to disk.
- `create_research_profile` / `delete_research_profile`: define a profile for a niche the bundled ones don't cover.
There's also a `set-up-research` prompt that runs the whole interview-and-configure
flow in one action.
### Research profiles
A verdict like "this keyword is unwinnable" only means something relative to a
niche. The sites that own *"booking software for salons"* (Capterra, G2, competing
vendors) are not the ones that own *"is red light therapy safe"* (hospitals, medical
publishers) — so the domain list is selectable rather than baked in.
Three profiles ship as starting points: **`general`** (the default — social platforms
and marketplaces only, no assumptions), **`saas`** (review aggregators and "best X
software" roundups) and **`wellness`** (hospitals, medical and lifestyle publishers,
booking directories). They're seeds, not a closed set: `create_research_profile`
defines your own, and a custom profile may reuse a bundled name to override it.
Each profile marks a **dominant** subset — domains so authoritative that three of them
ranking makes a keyword hopeless whatever you write. For `wellness` that's medical
authorities (Google treats health topics as YMYL and leans on institutional trust);
for `saas` it's the review aggregators.
The useful list is the one that grows. When a search turns up a competitor that isn't
flagged yet, `add_incumbents` records it, and every later verdict accounts for it.
Custom profiles and additions live in `incumbents.json` (see `GHOST_MCP_DATA_DIR`);
your own domain is always excluded, so your pages never count against you.
Keep it a *"cannot beat this"* list rather than a list of every competitor. Each domain
added makes verdicts more pessimistic, so logging weak rivals penalises you for
discovering that competition is thin.
### Verdicts
| Verdict | Meaning |
|---|---|
| `UNMET_DEMAND` | Forums rank, real pages don't — people are asking and nobody has answered. The best case. |
| `OPEN` | Little competition. Write it. |
| `CONTESTED` | Winnable, but only with a genuinely better first-party answer. |
| `SKIP` | Established pages own it, or three of the profile's dominant domains rank. Re-angle rather than abandon. |
| `LOCAL_INTENT` | Google showed a map pack: build a service or location page, not a post. |
| `UPDATE_EXISTING` | You already rank — extend that page instead of competing with yourself. |
Forum and social results (Reddit, Quora, Facebook, …) are scored **separately** from
competitor pages. A thread ranking usually means no good answer exists yet, so counting
it as competition inverts the strongest buying signal there is.
**Themes**
- `create_theme`: generate a complete, valid, previewable theme from a CSS design (and optional `index`/`post`/`page`/`default` template overrides).
- `preview_theme`: render a theme locally and serve it on localhost to review before publishing.
- `upload_theme`: package and upload a theme; it installs **inactive**, so the live site is untouched.
- `restyle_theme`: edit an installed theme's stylesheet (append or replace) and re-upload it, to iterate a theme without regenerating it.
- `list_themes`: list installed themes and which one is active.
- `download_theme`: download an installed theme's source as a zip.
- `activate_theme`: make an installed theme the live one — **outward-facing**; only on explicit user instruction, never an automatic follow-on to upload/restyle.
**Images**
- `upload_image`: upload a local image file; returns a hosted URL to use for a post's `feature_image`, the site logo/icon, or a newsletter header.
- `upload_image_from_url`: fetch a public image (under the same SSRF guard as vision) and re-host it on the blog.
**Site settings**
- `get_site_settings`: read brand and SEO settings.
- `update_site_metadata`: site title/description plus SEO and social metadata (`meta_*`, Open Graph, Twitter cards).
- `update_branding`: the brand accent colour.
- `update_navigation`: set the primary (header) and secondary (footer) navigation menus.
**Posts**
- `list_posts` / `get_post`: browse posts, or read one (with rendered HTML and a draft `preview_url`).
- `create_post` / `update_post` / `delete_post`: write posts from HTML; drafts by default.
- `publish_post`: publish a post **and email it** to a newsletter's members (outward-facing; a deliberate, explicit send, not a side effect of an edit).
**Pages**
- `list_pages` / `get_page`: browse standalone pages (about, contact, …), or read one (with rendered HTML and a `preview_url`).
- `create_page` / `update_page` / `delete_page`: write pages from HTML; drafts by default. Pages share post fields but have no tags or feed placement.
**Tags**
- `list_tags` / `get_tag`: browse tags (with post counts), or read one.
- `create_tag` / `update_tag` / `delete_tag`: manage tags.
**Members**
- `list_members` / `get_member`: browse members (filter by `status:paid`, `label:vip`, …) or read one, with labels and subscribed newsletters.
- `create_member` / `update_member`: add a member from an email; set name, note, labels, and newsletter subscriptions.
**Newsletters**
- `list_newsletters` / `get_newsletter`: browse newsletters or read one.
- `create_newsletter` / `update_newsletter`: create and configure newsletters; retire one with `status: archived` (the API has no delete).
**Tiers**
- `list_tiers` / `get_tier`: browse paid plans (with prices and benefits) or read one.
- `create_tier` / `update_tier`: create and configure tiers; no delete (retire with `active: false`).
**Offers**
- `list_offers` / `get_offer`: browse discount offers (each with its linked tier) or read one.
- `create_offer` / `update_offer`: create offers against a tier; no delete (Ghost only allows editing name/code/display fields after creation).
**Labels**
- `list_labels` / `get_label`: browse member labels or read one.
- `create_label` / `update_label` / `delete_label`: manage labels for member segmentation.
**Users**
- `list_users` / `get_user`: browse authors/staff or read one. **Read-only** — the Admin API forbids integrations from writing users.
Activating a theme is intentionally **not** a tool: it changes the live site, so it stays a manual step. The Admin API has no delete for members, newsletters, tiers, or offers, so neither does this server; users are read-only.
### Guided workflow
The server ships an `instructions` block (always in the model's context) encoding the
recommended order (extract the brand, confirm direction, build, preview, then upload
inactive), plus a **`theme-a-site` prompt** the user can invoke to start that guided
flow. Because those instructions are sent to the model on every connection, anyone who
installs the server can simply ask their assistant *"how do I use this?"* and get the
best-practice walkthrough.
- **[docs/theming-guide.md](docs/theming-guide.md)** — the user-facing best-practices
guide: how to use it, the end-to-end theming path, and design/colour/SEO best
practices. Start here.
- **[docs/theme-conventions.md](docs/theme-conventions.md)** — the full template and CSS
contract, for hand-writing templates.
## Requirements
- An **MCP client** to run it in, e.g. [Claude Desktop](https://claude.ai/download),
Cline, or Claude Code. This is an MCP *server*; it runs inside a client, not on its own.
- Python 3.13+
- [uv](https://docs.astral.sh/uv/)
- A Ghost site and a **staff access token** (from your user profile page in Ghost
Admin). Site-wide styling and management need a token with the **Owner or Admin**
role.
## Setup
```bash
git clone https://github.com/stemcreations/ghost-mcp.git && cd ghost-mcp
uv sync # creates .venv and installs everything
```
The server reads its configuration from environment variables:
| Variable | Required | Example |
|----------|----------|---------|
| `GHOST_ADMIN_URL` | yes | `https://yourblog.example.com` |
| `GHOST_STAFF_ACCESS_TOKEN` | yes | `<id>:<secret>` (from your Ghost user profile) |
| `GHOST_API_VERSION` | no | `v6.0` (default; match your Ghost major version) |
| `SERPER_API_KEY` | no | a [serper.dev](https://serper.dev) key; enables the research tools |
| `SERP_PROFILE` | no | starting research profile: `general` (default), `saas`, `wellness` |
| `SERP_INCUMBENTS` | no | extra competitor domains, comma-separated |
| `GHOST_MCP_DATA_DIR` | no | where `incumbents.json` lives (default `~/.ghost-mcp`) |
`SERPER_API_KEY` is genuinely optional. Without it the research tools are simply not
registered and everything else works unchanged — the key is read once at startup, so
adding it later needs a server restart.
The other three only matter if you use those tools. `SERP_PROFILE` picks the starting
[profile](#research-profiles), though `set_research_profile` persists its own choice
and takes precedence. `SERP_INCUMBENTS` is for deployments where the data directory
isn't writable; otherwise prefer `add_incumbents`, which persists. Set
`GHOST_MCP_DATA_DIR=.` to keep `incumbents.json` inside the project — it's gitignored
there, since the list is specific to your site rather than to the software.
Provide them **either** way:
- **In your MCP client**: put them in the server's `env` block (see [Running](#running)). No `.env` file is needed; this is the usual setup for Claude Desktop.
- **In a local `.env`**: handy for development and the connection check: `cp .env.example .env` and fill it in. (If both are set, the client's `env` values win.)
Confirm the credentials reach your site:
```bash
uv run python scripts/check_connection.py
```
## Running
Interactively, with the MCP Inspector:
```bash
uv run fastmcp dev src/ghost_mcp/server.py
```
### Connecting to Claude Desktop
Add the server to the config file below, then **fully restart Claude Desktop** (it
reads the config only at startup).
| OS | Config file |
|----|-------------|
| Windows | `%APPDATA%\Claude\claude_desktop_config.json` |
| macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` |
Use the **full path to `uv`** for `command`; clients often don't have it on their
PATH. Find it with `(Get-Command uv).Source` (Windows PowerShell) or `which uv`
(macOS/Linux). `--directory` points uv at the project, so the project's `.env` is
loaded automatically (or pass credentials with an `env` block instead, see below).
**Windows:**
```json
{
"mcpServers": {
"ghost": {
"command": "C:\\Users\\you\\.local\\bin\\uv.exe",
"args": ["run", "--directory", "C:\\path\\to\\ghost-mcp", "ghost-mcp"]
}
}
}
```
**macOS / Linux:**
```json
{
"mcpServers": {
"ghost": {
"command": "/home/you/.local/bin/uv",
"args": ["run", "--directory", "/home/you/ghost-mcp", "ghost-mcp"]
}
}
}
```
The same `command`/`args` work with any MCP client (Cline, Claude Code, …); only the
config-file location differs. To pass credentials through the client instead of a
`.env`, add an `env` block to the server entry:
```json
"env": {
"GHOST_ADMIN_URL": "https://yourblog.example.com",
"GHOST_STAFF_ACCESS_TOKEN": "<id>:<secret>",
"GHOST_API_VERSION": "v6.0"
}
```
### Run without cloning
To skip `git clone`, have `uvx` install and run the server straight from the repo.
Add this to the same config file (use the full path to `uvx` if your client doesn't
have it on PATH):
```json
{
"mcpServers": {
"ghost": {
"command": "uvx",
"args": ["--from", "git+https://github.com/stemcreations/ghost-mcp.git", "ghost-mcp"],
"env": {
"GHOST_ADMIN_URL": "https://yourblog.example.com",
"GHOST_STAFF_ACCESS_TOKEN": "<id>:<secret>",
"GHOST_API_VERSION": "v6.0"
}
}
}
}
```
`uvx` fetches and builds the package on first launch (git must be installed). With no
local `.env`, the credentials come from the `env` block above.
## Authentication, briefly
Ghost's Admin API never takes the token directly. Each request carries a JWT signed
from the staff token (`id:secret`): split on the colon, hex-decode the secret, sign
HS256 with a five-minute expiry. `ghost_mcp.admin.auth` handles this for you.
Site-wide styling (code injection via `/settings/`) requires the **Owner or Admin**
role; a standard integration key cannot reach those endpoints.
## Architecture
The package is layered so each piece has one job:
| Layer | Module | Responsibility |
|--------|--------------------|-------------------------------------------------------|
| Config | `ghost_mcp.config` | Load and validate environment configuration. |
| Errors | `ghost_mcp.errors` | The shared `GhostError` exception hierarchy. |
| Admin | `ghost_mcp.admin` | Authenticated Admin API: token signing, generic client, theme + settings helpers. |
| Vision | `ghost_mcp.vision` | Fetch the public rendered page + CSS (no auth). |
| Research | `ghost_mcp.research` | Search the SERP and analyse ranking pages (optional; needs a key). |
| Themes | `ghost_mcp.theme` | Generate, locally preview, and package themes. |
| Tools | `ghost_mcp.tools` | Thin MCP wrappers over the layers above. |
| Server | `ghost_mcp.server` | Assemble the layers into a runnable server. |
The Admin API is uniform: every resource shares the same browse/read/add/edit/
delete shape, so `GhostAdminClient` implements those operations generically. A new
resource is a thin tool module, not a new subsystem.
This server is intentionally **pure Python**. Ghost's own tooling is JavaScript, but
nothing here needs it: styling deals in CSS strings and theme zips, and post content
can be sent as HTML via the Admin API's `?source=html` conversion rather than
converting to Lexical client-side.
## Contributing
The most important convention: **put logic in a service module (`admin/`,
`vision/`, `theme/`) as a plain, typed, testable function, then expose it through a
thin wrapper in `tools/`.** Tools adapt and shape data; they don't hold business logic.
To add a group of tools:
1. Write the logic as a plain function in the relevant service module, and test it.
2. Add `tools/<name>.py` with a `register(mcp)` function that wraps it.
3. Call your `register` from `register_all` in `tools/__init__.py`.
Conventions:
- Type-hint everything.
- Docstrings go *inside* functions (FastMCP reads them to describe tools to the
model). Keep them concise; put longer context in the module docstring.
- Write docstrings for people reading the source: clear, no implementation noise.
Before opening a PR:
```bash
uv run ruff format # format
uv run ruff check # lint
uv run pytest # test
```
Or install the git hook to run all three automatically before each commit:
```bash
uv run pre-commit install
```
## Security
Ghost MCP runs locally and never exposes your staff token through any tool. See
[SECURITY.md](SECURITY.md) for the security model, the prompt-injection trust
boundary, and how to report a vulnerability.
## License
MIT. See [LICENSE](LICENSE).
TDQS
Scored across 55 tools
Each tool has a clearly distinct purpose, with detailed descriptions that prevent confusion between similar operations (e.g., publish_post vs update_post, create_theme vs upload_theme vs activate_theme). The boundaries between content types (post/page/member/newsletter) are well-defined.
All tools follow a consistent verb_noun snake_case pattern (e.g., create_post, list_members, update_theme). Even longer names like upload_image_from_url adhere to the same scheme. No mixing of casing or irregular verbs.
55 tools is excessive for a single MCP server, especially one focused on 'Styling'. The server includes full CRUD for many Ghost entities (members, offers, newsletters, etc.) well beyond styling, making the surface overwhelming and the scope unfocused.
The tool set covers the full styling workflow (extract brand → create/restyle/preview/upload/activate theme) and basic CRUD for most content types. Minor gaps exist (no delete_theme, delete_newsletter only via archive, no user management) but these are often API limitations.