The last two posts here were about the skills in this plugin — one learns my writing voice from my published posts, the other revises a draft section by section. Neither can do anything without a way to reach Ghost: to read what I've already posted, create a draft, update one that's already up. That's the rest of the plugin, a small MCP server wrapping the Ghost Admin API.
The server reaches Ghost with several tools create, read, update, images, and site info. Everything that takes judgment stays in the skills: the voice, the section-by-section pass, the rule that it never publishes.
A small tool surface
Seven tools: site info, list, get, create, update, tag list, and image upload. This is enough to read the posts I've published, read one back with its lexical, create a post or page, attach tags, and upload an image.
Because a page is the same object as a post in Ghost, the content tools take a type argument instead of a parallel page_create and page_update. The client picks the collection:
const resource = (type = "post") =>
type === "page" ? api.pages : api.posts;
Update reads before it writes. ghost_post_update fetches the post first and syncs all of it — title, tags, excerpt, and body — instead of overwriting the body and leaving the rest stale. Tags attach by slug; Ghost creates one it hasn't seen and matches one it has.
Markdown into Ghost's cards
Ghost stores a post as lexical: a JSON tree whose top level is an array of cards, each card an editable block in the editor. Send it a whole post as one card and it renders, but the editor shows a single giant block instead of sections you can edit and reorder. The Markdown that gets converted is the draft the other skills produce — draft-post writes it, revise-post refines it, and push-draft hands the file to the server, which builds the lexical and splits it into cards.
This is the same idea as revising section by section, carried into the editor: a small block is easier to change than the whole post, which is why it lands as cards. Each card is a section I can edit on its own.
Two things start a new card: a top-level <table>, which becomes its own html card, and an explicit <!-- card --> marker, which splits the prose around it. Prose with neither stays a single markdown card. The builder does this by hand, scanning the source for those two boundaries and slicing between them:
while ((m = TABLE_RE.exec(markdown)) !== null) {
pushMarkdownParts(parts, markdown.slice(lastIndex, m.index));
parts.push({ type: "html", content: m[0] });
lastIndex = m.index + m[0].length;
}
pushMarkdownParts(parts, markdown.slice(lastIndex));
Two strips run first: the leading --- ... --- frontmatter and a leading # Title. Ghost owns the title, so an H1 in the body would show up as a duplicate line of text.
Create and update return a cardCount and a one-line list of the card types, like markdown, html, markdown. The push-draft skill reads it back and warns me if the count is zero, the sign the conversion produced nothing.
Tables and diagrams
Because each block becomes its own card, a post's richer elements are editable on their own — the two that come up most here are tables and diagrams.
Markdown tables render badly in Ghost: cramped, no column alignment, hard to read on a phone. So the posts use inline-styled HTML tables, and the builder gives each <table> its own html card, which keeps the styling and stays editable.
A diagram is a fenced code block tagged mermaid — the builder passes it through in a markdown card and the theme renders it to SVG in the browser, so an architecture sketch is just a few lines of text in the draft.
Showing one of these in a post as an example works for the same reason: the builder masks fenced and inline code before it looks for card boundaries, so a <table> or a <!-- card --> written in prose — like the ones here — stays text instead of splitting the card around it.
Credentials from a file
The server authenticates with a Ghost Admin key — an id:secret pair from a custom integration. The @tryghost/admin-api library turns it into a short-lived JWT on each request, so the server never signs anything itself. It validates the key's shape on startup — 24 hex characters, a colon, 64 more — so a malformed key fails with a clear message instead of an opaque 401 on the first call.
The server reads the key from a gitignored ghost.creds.json, re-read on every spawn. Environment variables would be the obvious choice, but they resolve once when a session starts and go stale on resume; a file is read fresh each time. The path is safe to commit, so it lives in the plugin's .mcp.json and resolves against ${CLAUDE_PROJECT_DIR}, the project root Claude Code fills in for a plugin's config:
"env": {
"GHOST_CREDENTIALS_FILE":
"${CLAUDE_PROJECT_DIR}/.claude/ghost.creds.json"
}
The setup-ghost skill covers both getting the key and writing the file: it points you to the Ghost integration settings to create the key, reuses one already sitting in a local .env, and writes the gitignored file. Nobody edits JSON by hand.
Failing usefully
When the credentials are missing or malformed, the server stays up and reports the problem through its tools rather than exiting. A server that exits surfaces in Claude Code as -32000: Connection closed, which says nothing about what's wrong; a live one can hand back the real problem the moment you call a tool. So it lists all seven tools as usual and swaps in a client whose every method rejects with a clear message:
export function unconfiguredClient(message: string): GhostClient {
const fail = () => Promise.reject(new Error(message));
return { siteInfo: fail, listPosts: fail, /* …every op… */ };
}
ghost_site_info then comes back telling you to run setup-ghost, right there in the conversation, instead of a dead connection. A line to stderr at startup reports whether the credentials resolved and for which site, and never prints the key.
Delivered as its own package
The plugin's .mcp.json runs the server with npx -y ghost-blog-mcp@latest — a published npm package that Claude Code fetches and runs on demand. The alternative was to bundle a built .js file into the plugin and run it with node.
Bundling has one real advantage: node on a real path sidesteps the symlink entry-point problem, and there's nothing to fetch at spawn. But it means committing build output into the plugin, rebuilding it on every change, and tying the server to this one plugin.
Publishing it separately keeps the server standing on its own — a normal MCP server any client can run, not something welded to the plugin — and keeps the plugin a thin bit of config with no build step. The cost is that npx runs the binary through a symlink, which the entry-point check has to handle.
Starting under npx
The symlink lives in node_modules/.bin, and it breaks the usual ESM entry-point check: comparing import.meta.url against process.argv[1] doesn't match when one is the symlink and the other is the real file. The check resolves real paths on both sides instead:
function isEntryPoint() {
const argv1 = process.argv[1];
if (!argv1) return false;
try {
return realpathSync(argv1) ===
realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
A test spawns the entry point through a symlink, the way npx runs it.
Shipping a release
Publishing happens in CI, on a pushed tag, rather than from my machine. Pushing ghost-mcp-v0.1.4 starts a GitHub Action that builds the package, checks the tag against the version in package.json, and publishes to npm. There's no NPM_TOKEN anywhere in the repo — it publishes through trusted publishing, npm's OIDC-based path and now its recommended way to publish from CI. You set it up once on the package's page at npmjs.com, under Trusted Publisher: name the GitHub repository and the workflow file, and npm accepts publishes signed by that workflow and nothing else.
Which npm channel a build lands on comes from the shape of its version, decided in a few lines of the workflow:
on:
push:
tags: ["ghost-mcp-v*"]
# …
- name: Publish (dist-tag by version shape)
run: |
VERSION=$(node -p "require('./package.json').version")
case "$VERSION" in
*-*) NPM_TAG=dev ;; # prerelease → dev
*) NPM_TAG=latest ;; # clean release → latest
esac
npm publish --tag "$NPM_TAG"
The split also makes testing easy. A prerelease like 0.1.4-dev.0 goes to @dev instead of @latest, so I can publish a build, point a project's .mcp.json at it, and exercise it against a real Ghost site before the shipping version changes at all. A clean 0.1.4 goes to @latest — the one the bundled .mcp.json runs — so releasing to prod is just tagging a version without the -dev suffix.
Using it
The server ships with the ghost plugin, and npx pulls it on first use, so there's nothing to install separately. The earlier posts cover the setup; once the plugin is connected, the skills reach Ghost through the server. The repo is github.com/schuettc/claude-code-plugins, and this post was drafted and pushed with it.