Guide · Headless CMS Integration
MCP Server Ghost — Content API, plaintext format, filter syntax, pagination
Three Ghost behaviours catch MCP server authors off-guard: Ghost posts default to HTML — request formats=plaintext for agent-friendly output — the Ghost Content API returns post bodies as Mobiledoc JSON or HTML by default; for MCP tool handlers, &formats=plaintext requests the precomputed plain-text version of every post body, which is dramatically more token-efficient for agent reasoning than stripping HTML tags in code; Ghost's filter syntax is its own mini-language, not a query string convention — filters like tag:getting-started+featured:true use + for AND, , for OR, - for NOT, and property accessor syntax with [ for nested fields, which is distinct from every other CMS's filter format; and the Content API key must stay in query params, never in a header — Ghost's Content API authenticates by appending ?key=… to every request, not via Authorization: Bearer; the Admin API uses a different token format (id:secret) and must be sent as a signed JWT, not a plain bearer token.
TL;DR
Use @tryghost/content-api for read-only MCP tool handlers — it handles key injection and JSON parsing automatically. Always add formats: ['plaintext'] to post queries to get agent-friendly body text. Use include: 'authors,tags' to eagerly load related data. Ghost filters use + for AND and , for OR — different from every other CMS. Paginate with page and limit; check meta.pagination.pages for total page count. Use GET /ghost/api/content/settings/?key={key} as the health probe.
Content API setup and plaintext format
Ghost exposes two public APIs. The Content API is read-only and intended for displaying published content — it accepts a Content API key that is safe to embed in server-side tool handlers. The Admin API supports creating, editing, and deleting content — it requires signing every request with a JWT derived from the Admin API key (format: id:secret). For MCP server tool handlers that retrieve content for agent reasoning, use the Content API exclusively.
import GhostContentAPI from '@tryghost/content-api';
import { z } from 'zod';
const ghost = new GhostContentAPI({
url: process.env.GHOST_URL!, // e.g. https://blog.example.com
key: process.env.GHOST_CONTENT_KEY!, // Content API key from Ghost Admin → Integrations
version: 'v5.0',
});
// MCP tool — list published posts with plaintext body
server.tool(
'list_posts',
{
page: z.number().int().min(1).default(1),
limit: z.number().int().min(1).max(25).default(10),
tag: z.string().optional(),
author: z.string().optional(),
featured: z.boolean().optional(),
},
async ({ page, limit, tag, author, featured }) => {
// Build Ghost filter string
const filterParts: string[] = [];
if (tag) filterParts.push(`tag:${tag}`);
if (author) filterParts.push(`author:${author}`);
if (featured !== undefined) filterParts.push(`featured:${featured}`);
const filter = filterParts.join('+'); // + = AND in Ghost filter syntax
const posts = await ghost.posts.browse({
page,
limit,
filter: filter || undefined,
include: 'authors,tags' as any, // eagerly load related entities
fields: 'id,title,slug,excerpt,published_at,feature_image,reading_time' as any,
formats: ['plaintext'] as any, // add 'html' if markup is needed
order: 'published_at DESC',
});
// @tryghost/content-api adds a meta property to the array
const meta = (posts as any).meta;
return {
content: [{
type: 'text',
text: JSON.stringify({
posts: posts.map(p => ({
id: p.id,
title: p.title,
slug: p.slug,
excerpt: p.excerpt,
published_at: p.published_at,
reading_time: p.reading_time,
feature_image: p.feature_image,
authors: (p as any).authors?.map((a: any) => a.name) ?? [],
tags: (p as any).tags?.map((t: any) => t.slug) ?? [],
// plaintext body — only present when formats includes 'plaintext'
plaintext: (p as any).plaintext ?? null,
})),
pagination: meta?.pagination,
}, null, 2),
}],
};
}
);
The formats: ['plaintext'] option is the most impactful optimization for MCP tool handlers. A Ghost post with 3,000 words takes roughly 20,000 tokens as HTML (including all tags, attributes, and whitespace) vs roughly 4,000 tokens as plaintext. If your agent only needs to reason about the post content rather than render it, always request plaintext. You can request both simultaneously with formats: ['html', 'plaintext'] when you need to provide the agent a choice.
Ghost filter syntax
Ghost's filter syntax is a custom query language with its own operators and combining rules. It is not URL-encoded JSON, not SQL, and not GraphQL — it is a compact string designed for URL query parameters. Understanding the combining operators is essential because their characters conflict with intuitions from other tools: + means AND (not URL-encoded space), , means OR, and - negates a filter.
// Ghost filter syntax reference:
// Basic equality: tag:getting-started
// Not equal: tag:-getting-started (dash prefix = NOT)
// Numeric compare: reading_time:>5
// Boolean: featured:true
// AND (all must match): tag:news+featured:true
// OR (any can match): tag:news,tag:tutorials
// Grouped: (tag:news,tag:tutorials)+featured:true
// Nested property: authors.[slug]:alice (bracket notation for relations)
// IN list: tag:[news, tutorials, updates]
// MCP tool — filter examples
server.tool(
'search_posts',
{
keyword: z.string().optional(),
tags: z.array(z.string()).max(5).optional(),
author_slug: z.string().optional(),
min_reading_time: z.number().int().min(1).optional(),
max_reading_time: z.number().int().min(1).optional(),
page: z.number().int().min(1).default(1),
limit: z.number().int().min(1).max(20).default(10),
},
async ({ keyword, tags, author_slug, min_reading_time, max_reading_time, page, limit }) => {
const filterParts: string[] = [];
if (tags && tags.length > 0) {
if (tags.length === 1) {
filterParts.push(`tag:${tags[0]}`);
} else {
// IN syntax: tag:[tag1,tag2,tag3] (matches any of the tags — OR semantics)
filterParts.push(`tag:[${tags.join(',')}]`);
}
}
if (author_slug) {
filterParts.push(`authors.[slug]:${author_slug}`);
}
if (min_reading_time !== undefined) {
filterParts.push(`reading_time:>=${min_reading_time}`);
}
if (max_reading_time !== undefined) {
filterParts.push(`reading_time:<=${max_reading_time}`);
}
// Combine all parts with AND
const filter = filterParts.join('+');
const posts = await ghost.posts.browse({
page,
limit,
filter: filter || undefined,
include: 'authors,tags' as any,
formats: ['plaintext'] as any,
order: 'published_at DESC',
});
// Ghost's built-in full-text search (if needed)
// Note: Ghost does not expose full-text search via Content API
// Use filter for field-based matching; full-text search requires a search integration like Meilisearch
const meta = (posts as any).meta;
const results = keyword
? posts.filter(p =>
p.title?.toLowerCase().includes(keyword.toLowerCase()) ||
(p as any).plaintext?.toLowerCase().includes(keyword.toLowerCase())
)
: posts;
return {
content: [{
type: 'text',
text: JSON.stringify({
posts: results.map(p => ({
id: p.id,
title: p.title,
slug: p.slug,
published_at: p.published_at,
reading_time: p.reading_time,
excerpt: p.excerpt,
authors: (p as any).authors?.map((a: any) => a.slug) ?? [],
tags: (p as any).tags?.map((t: any) => t.slug) ?? [],
})),
pagination: meta?.pagination,
}, null, 2),
}],
};
}
);
Pagination — page-based with meta.pagination
Ghost Content API uses page-based pagination (not cursor-based). Every list response includes a meta.pagination object with page, limit, pages, total, next, and prev fields. The pages field tells you the total number of pages at the current limit, and next / prev are the adjacent page numbers (or null when at the boundary). Use these to implement robust pagination in MCP tools that may be called iteratively by agents stepping through large content sets.
// MCP tool — paginate posts with full pagination metadata
server.tool(
'get_posts_page',
{
page: z.number().int().min(1).default(1),
limit: z.number().int().min(1).max(15).default(10),
tag: z.string().optional(),
},
async ({ page, limit, tag }) => {
const posts = await ghost.posts.browse({
page,
limit,
filter: tag ? `tag:${tag}` : undefined,
formats: ['plaintext'] as any,
fields: 'id,title,slug,excerpt,published_at,reading_time' as any,
});
const pagination = (posts as any).meta?.pagination;
return {
content: [{
type: 'text',
text: JSON.stringify({
posts: posts.map(p => ({
id: p.id,
title: p.title,
slug: p.slug,
excerpt: p.excerpt,
published_at: p.published_at,
reading_time: p.reading_time,
plaintext: (p as any).plaintext,
})),
pagination: {
current_page: pagination?.page,
total_pages: pagination?.pages,
total_posts: pagination?.total,
has_next: pagination?.next !== null,
has_prev: pagination?.prev !== null,
next_page: pagination?.next ?? null,
prev_page: pagination?.prev ?? null,
},
instructions: pagination?.next
? `Call get_posts_page with page=${pagination.next} to get the next ${limit} posts.`
: 'This is the last page.',
}, null, 2),
}],
};
}
);
// Get single post by slug
server.tool(
'get_post',
{ slug: z.string() },
async ({ slug }) => {
const posts = await ghost.posts.browse({
filter: `slug:${slug}`,
limit: 1,
include: 'authors,tags' as any,
formats: ['plaintext'] as any,
});
const post = posts[0];
if (!post) {
return { content: [{ type: 'text', text: JSON.stringify({ error: 'Post not found' }) }] };
}
return {
content: [{
type: 'text',
text: JSON.stringify({
id: post.id,
title: post.title,
slug: post.slug,
published_at: post.published_at,
updated_at: post.updated_at,
reading_time: post.reading_time,
plaintext: (post as any).plaintext,
authors: (post as any).authors?.map((a: any) => ({ name: a.name, slug: a.slug })) ?? [],
tags: (post as any).tags?.map((t: any) => ({ name: t.name, slug: t.slug })) ?? [],
feature_image: post.feature_image,
}, null, 2),
}],
};
}
);
// Health probe — settings endpoint (no auth required beyond the API key)
server.resource('ghost_health', 'cms://ghost/health', async () => {
try {
const settings = await ghost.settings.browse();
return {
contents: [{
uri: 'cms://ghost/health',
text: JSON.stringify({
status: 'ok',
title: (settings as any).title,
url: (settings as any).url,
}),
}],
};
} catch (err: any) {
return {
contents: [{
uri: 'cms://ghost/health',
text: JSON.stringify({ status: 'error', message: err.message }),
}],
};
}
});
Admin API — creating content with JWT authentication
The Ghost Admin API requires every request to be signed with a short-lived JWT derived from your Admin API key. The key format is id:secret where id is the first part (hex) and secret is the second part (hex). The JWT must be generated fresh for each request with a 5-minute expiry. The @tryghost/admin-api package handles this signing automatically — prefer it over rolling your own JWT generation.
import GhostAdminAPI from '@tryghost/admin-api';
const ghostAdmin = new GhostAdminAPI({
url: process.env.GHOST_URL!,
key: process.env.GHOST_ADMIN_KEY!, // format: id:secret (NOT Content API key)
version: 'v5.0',
});
// MCP tool — create a draft post (requires Admin API)
server.tool(
'create_draft_post',
{
title: z.string().min(1),
plaintext: z.string().min(1),
tags: z.array(z.string()).optional(),
author_email: z.string().email().optional(),
},
async ({ title, plaintext, tags, author_email }) => {
// Ghost Admin API accepts Lexical (default), Mobiledoc, or HTML as content format
// Plaintext must be wrapped in a minimal Lexical JSON structure
const lexicalContent = JSON.stringify({
root: {
children: plaintext.split('\n\n').map(para => ({
children: [{ detail: 0, format: 0, mode: 'normal', style: '', text: para, type: 'text', version: 1 }],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
})),
direction: 'ltr',
format: '',
indent: 0,
type: 'root',
version: 1,
},
});
const postData: Record<string, unknown> = {
title,
lexical: lexicalContent,
status: 'draft',
};
if (tags) postData['tags'] = tags.map(t => ({ name: t }));
if (author_email) postData['authors'] = [{ email: author_email }];
const post = await ghostAdmin.posts.add(postData as any, { source: 'html' });
return {
content: [{
type: 'text',
text: JSON.stringify({
id: (post as any).id,
title: (post as any).title,
slug: (post as any).slug,
status: (post as any).status,
url: (post as any).url,
}, null, 2),
}],
};
}
);