Server MCP custom per il tuo Ghost CMS: guida passo-passo
Costruisci un server MCP in TypeScript che espone il tuo Ghost CMS agli agenti AI: Admin API, sei tool, verifica finale.
Testato il: 2026-08-25 Ambiente: Node.js 20.20.2 · npm 10.8.2 · @modelcontextprotocol/server 2.0.0 · @tryghost/admin-api 1.14.11 · zod 4.4.3
Vuoi che un agente AI legga i tuoi post, ne crei uno in bozza o aggiorni un titolo direttamente nel tuo Ghost CMS? La strada pulita è un server MCP custom: un processo locale che parla il protocollo Model Context Protocol e che espone la Ghost Admin API come strumenti invocabili da Claude Code, VS Code, Cursor o qualsiasi host MCP.
Esistono pacchetti pronti che fanno questo lavoro (ne ho visti diversi su GitHub), ma un server tuo ha due vantaggi. Il primo: esponi solo le operazioni che vuoi, con gli schemi che decidi tu. Il secondo: la parte delicata, cioè l'autenticazione JWT verso la Admin API, la gestisce la libreria ufficiale di Ghost, quindi il codice che resta da scrivere è poco. In questa guida costruisco il server con sei tool operativi e nessuna logica di giudizio: le decisioni su cosa scrivere e come restano all'agente e ai tuoi prompt.
Prerequisiti
- Un'istanza Ghost CMS raggiungibile, self-hosted oppure Ghost(Pro)
- Node.js 20 o superiore e npm (verifico con Node.js 20.20.2 e npm 10.8.2)
- Una chiave Admin API di Ghost nel formato
id:secret(si crea al passo 1) - Un editor con supporto MCP, se vuoi provare il collegamento finale (VS Code con GitHub Copilot o Claude Desktop vanno bene)
La guida non usa la Content API. È read-only e non può creare o modificare post: per un server che deve anche scrivere ti serve la Admin API, punto.
1. Creare la Custom Integration su Ghost
Entra nel tuo Ghost Admin e vai su Settings → Integrations → Add custom integration. Dai un nome (per esempio mcp-server) e conferma: Ghost ti mostra subito la Admin API Key, una stringa nel formato id:secret dove id è di 24 caratteri esadecimali e secret di 64.
Copiala e tienila al sicuro: viene mostrata una sola volta.
Un dettaglio che ti evita un'ora di debug: la libreria @tryghost/admin-api valida il formato della chiave già alla creazione del client. Se id o secret non hanno la lunghezza giusta, il processo muore all'avvio con un messaggio chiaro, non con un 401 opaco alla prima chiamata. È un comportamento voluto, non un bug.
2. Inizializzare il progetto Node.js
Crea la cartella del progetto e inizializza il pacchetto:
mkdir ghost-mcp-server && cd ghost-mcp-server
npm init -yInstalla le dipendenze. Il server MCP usa lo SDK v2 del protocollo, che dal 2026-07-28 è diviso in pacchetti separati: @modelcontextprotocol/server per il lato server, niente più pacchetto monolitico @modelcontextprotocol/sdk:
npm install @modelcontextprotocol/server @tryghost/admin-api zod
npm install -D typescript @types/nodeImposta il progetto come modulo ESM e aggiungi gli script di build e avvio:
npm pkg set type="module"
npm pkg set scripts.build="tsc"
npm pkg set scripts.start="node build/index.js"Crea il tsconfig.json con questa configurazione:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src"]
}Dopo il comando npm install vedrai nella console la lista dei pacchetti installati e le versioni risolte: lo SDK MCP è alla 2.0.0, il client Ghost alla 1.14.11 e zod alla 4.4.3 con questa configurazione.
3. Scrivere il server MCP
Crea la cartella src e dentro due file. Il primo, src/types.d.ts, contiene una dichiarazione minima dei tipi per @tryghost/admin-api, che non ne include di propri. Senza questo file TypeScript in modalità strict si rifiuta di compilare:
declare module '@tryghost/admin-api' {
interface Post {
id: string;
slug: string;
title: string;
status: string;
custom_excerpt?: string;
url?: string;
updated_at: string;
}
interface Tag {
name: string;
slug: string;
}
interface Site {
title: string;
description: string;
url: string;
}
interface GhostAdminAPIOptions {
url: string;
key: string;
version: string;
}
class GhostAdminAPI {
constructor(options: GhostAdminAPIOptions);
posts: {
browse: (options?: Record<string, unknown>) => Promise<Post[]>;
read: (options: { id?: string; slug?: string }) => Promise<Post>;
add: (data: Record<string, unknown>) => Promise<Post>;
edit: (data: Record<string, unknown>) => Promise<Post>;
};
tags: {
browse: (options?: Record<string, unknown>) => Promise<Tag[]>;
};
site: {
read: () => Promise<Site>;
};
}
export default GhostAdminAPI;
}Il secondo file, src/index.ts, è il server vero e proprio. Legge le credenziali dalle variabili d'ambiente, costruisce il client Ghost e registra sei tool:
import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import GhostAdminAPI from '@tryghost/admin-api';
import * as z from 'zod/v4';
const GHOST_URL = process.env.GHOST_URL;
const GHOST_ADMIN_API_KEY = process.env.GHOST_ADMIN_API_KEY;
if (!GHOST_URL || !GHOST_ADMIN_API_KEY) {
console.error('GHOST_URL e GHOST_ADMIN_API_KEY sono obbligatorie. Esci.');
process.exit(1);
}
const api = new GhostAdminAPI({
url: GHOST_URL,
key: GHOST_ADMIN_API_KEY,
version: 'v5.0',
});
function buildServer() {
const server = new McpServer({ name: 'ghost-cms', version: '1.0.0' });
server.registerTool(
'list_posts',
{
description: 'Elenca i post del blog con paginazione.',
inputSchema: z.object({
limit: z.number().int().min(1).max(50).default(10),
page: z.number().int().min(1).default(1),
}),
},
async ({ limit, page }) => {
try {
const posts = await api.posts.browse({ limit, page, formats: 'lexical' });
const rows = posts.map(
(p) => `- ${p.title} [${p.status}] slug:${p.slug} id:${p.id}`,
);
return {
content: [{ type: 'text', text: rows.length ? rows.join('\n') : '(nessun post)' }],
};
} catch (err) {
return { content: [{ type: 'text', text: `Errore: ${(err as Error).message}` }] };
}
},
);
server.registerTool(
'get_post',
{
description: 'Legge un post esistente, per id oppure per slug.',
inputSchema: z.object({
id: z.string().optional(),
slug: z.string().optional(),
}),
},
async ({ id, slug }) => {
try {
const post = id
? await api.posts.read({ id })
: await api.posts.read({ slug });
return {
content: [
{
type: 'text',
text: `Titolo: ${post.title}\nSlug: ${post.slug}\nStato: ${post.status}\nExcerpt: ${post.custom_excerpt || ''}\nURL: ${post.url || ''}`,
},
],
};
} catch (err) {
return { content: [{ type: 'text', text: `Errore: ${(err as Error).message}` }] };
}
},
);
server.registerTool(
'create_post',
{
description:
'Crea un nuovo post. Lo stato di default è "draft": mai pubblicare senza richiesta esplicita.',
inputSchema: z.object({
title: z.string(),
lexical: z.string().optional(),
html: z.string().optional(),
status: z.enum(['draft', 'published']).default('draft'),
}),
},
async ({ title, lexical, html, status }) => {
try {
const post = await api.posts.add({ title, lexical, html, status });
return {
content: [
{
type: 'text',
text: `Creato post ${post.id} (${post.slug}) con stato ${post.status}`,
},
],
};
} catch (err) {
return { content: [{ type: 'text', text: `Errore: ${(err as Error).message}` }] };
}
},
);
server.registerTool(
'update_post',
{
description:
'Aggiorna un post esistente. Richiede updated_at letto in precedenza (locking ottimistico).',
inputSchema: z.object({
id: z.string(),
updated_at: z.string(),
title: z.string().optional(),
lexical: z.string().optional(),
html: z.string().optional(),
status: z.enum(['draft', 'published']).optional(),
}),
},
async ({ id, updated_at, title, lexical, html, status }) => {
try {
const post = await api.posts.edit({
id,
updated_at,
title,
lexical,
html,
status,
});
return {
content: [
{
type: 'text',
text: `Aggiornato post ${post.id} (${post.slug}), stato ${post.status}`,
},
],
};
} catch (err) {
return { content: [{ type: 'text', text: `Errore: ${(err as Error).message}` }] };
}
},
);
server.registerTool(
'list_tags',
{
description: 'Elenca i tag del blog.',
inputSchema: z.object({
limit: z.number().int().min(1).max(100).default(20),
}),
},
async ({ limit }) => {
try {
const tags = await api.tags.browse({ limit });
const rows = tags.map((t) => `- ${t.name} slug:${t.slug}`);
return {
content: [{ type: 'text', text: rows.length ? rows.join('\n') : '(nessun tag)' }],
};
} catch (err) {
return { content: [{ type: 'text', text: `Errore: ${(err as Error).message}` }] };
}
},
);
server.registerTool(
'get_site',
{
description: 'Legge le informazioni pubbliche del sito (titolo, descrizione, URL).',
inputSchema: z.object({}),
},
async () => {
try {
const site = await api.site.read();
return {
content: [
{
type: 'text',
text: `Titolo: ${site.title}\nDescrizione: ${site.description}\nURL: ${site.url}`,
},
],
};
} catch (err) {
return { content: [{ type: 'text', text: `Errore: ${(err as Error).message}` }] };
}
},
);
return server;
}
serveStdio(buildServer);Tre scelte vanno spiegate, perché non sono scontate: due sono di design, una è una regola ferrea.
Perché serveStdio e non server.connect(). Lo SDK v2 serve due generazioni del protocollo: i client del 2025 aprono con initialize, quelli della revisione 2026-07-28 negoziano con server/discover. serveStdio costruisce un'istanza del server per connessione e serve entrambe le generazioni dallo stesso codice. Se colleghi il server a mano con StdioServerTransport, parli solo la generazione 2025.
Il tool create_post nasce con stato draft. L'ho scelto di proposito: il server espone operazioni, non decisioni. Se un agente deve pubblicare, deve chiederlo esplicitamente passando status: "published". È la stessa regola che seguo per questo blog: tutto il giudizio sulla pubblicazione resta fuori dal server.
C'è poi update_post: richiede updated_at, il token di locking ottimistico di Ghost. L'agente deve prima leggere il post, poi passare il valore ricevuto. Se qualcun altro ha modificato il post nel frattempo, Ghost rifiuta la scrittura invece di sovrascrivere in silenzio. È una protezione che non disattiverei.
4. Compilare e avviare il server
Compila e avvia:
npm run build
node build/index.jsSe la compilazione va a buon fine non vedi output: tsc è silenzioso quando non ci sono errori. Il comando node build/index.js resta in attesa su stdin, senza stampare nulla: è il comportamento corretto. Premi Ctrl+C per uscire.
Due casi di errore da conoscere. Se le variabili d'ambiente mancano, il processo esce subito con il messaggio GHOST_URL e GHOST_ADMIN_API_KEY sono obbligatorie. Esci.. Se la chiave ha il formato sbagliato (per esempio abc:def), @tryghost/admin-api lancia un errore esplicito con il formato atteso: 24 caratteri esadecimali, due punti, 64 caratteri esadecimali.
5. Configurare il client MCP
Il server è pronto. Ora lo colleghi a un host MCP. Su VS Code con GitHub Copilot, crea un file .mcp.json nella radice del progetto (quello che contiene src/) e inserisci:
{
"servers": {
"ghost": {
"type": "stdio",
"command": "node",
"args": ["./build/index.js"],
"env": {
"GHOST_URL": "https://tuoblog.com",
"GHOST_ADMIN_API_KEY": "id:secret"
}
}
}
}Sostituisci https://tuoblog.com con l'URL reale del tuo blog e id:secret con la chiave del passo 1. Poi apri la palette comandi con Ctrl+Shift+P e lancia MCP: List Servers: il server ghost deve risultare attivo.
Su Claude Desktop la configurazione è analoga ma sta in claude_desktop_config.json e la chiave del JSON è mcpServers invece di servers. Il resto è identico: command, args ed env.
6. Verificare che i tool rispondano
Prima di tirare in mezzo un editor, verifica il server da terminale con una sessione JSON-RPC manuale. Lancia questo comando dalla cartella del progetto:
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
| GHOST_URL=https://example.com GHOST_ADMIN_API_KEY="aaaaaaaaaaaaaaaaaaaaaaaa:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" node build/index.jsLa chiave nel comando è finta ma ben formata: 24 caratteri esadecimali, due punti, 64 caratteri esadecimali. Basta e avanza perché il server risponda a tools/list senza chiamare Ghost (la prima richiesta alla Admin API arriva solo quando invochi un tool che la usa). Nello stdout devi vedere due risposte JSON-RPC: la prima all'initialize riporta "serverInfo":{"name":"ghost-cms","version":"1.0.0"}, la seconda a tools/list elenca i sei tool con i loro schemi di input. Se vedi questo, il server è sano e il problema semmai è nella configurazione del client.
Verifica
Ecco i controlli che confermano che tutto funziona:
npm run buildtermina senza errori e creabuild/index.jsnode build/index.jsresta in attesa su stdin senza stampare nulla (conCtrl+Cesci)- La sessione JSON-RPC del passo 6 risponde a
initializee atools/listcon sei tool - MCP: List Servers nell'editor mostra
ghostattivo - Una chiamata reale al tool
list_postsdal tuo host MCP restituisce l'elenco dei post del blog
Se vuoi un'interfaccia grafica per esplorare i tool, esiste l'ispettore ufficiale del protocollo (@modelcontextprotocol/inspector), ma richiede Node.js 22 o superiore: con la versione dichiarata in questa guida non parte, quindi non lo considero parte del percorso di verifica.
Limiti noti
- La chiave Admin API ha privilegi pieni su post, pagine, membri e impostazioni. Questo server la usa solo per i sei tool, ma chi controlla la macchina dove gira il client MCP controlla di fatto il blog. È accettabile per un blog personale, meno per un'organizzazione: lì servirebbe un layer di autorizzazione tra l'agente e Ghost.
- Nessun rate limiting. Gli strumenti chiamano la Admin API senza limiti: un agente che va in loop può generare centinaia di richieste. Per uso personale non è un problema pratico, ma se condividi il server tienilo a mente.
- Il server è locale per sessione. Con il trasporto stdio il processo vive sulla macchina del client e parte a ogni connessione. Se vuoi un server remoto condiviso, il passaggio naturale è lo Streamable HTTP, che lo stesso SDK supporta con
createMcpHandler, e lì servono autenticazione e validazione degli header. - Il set di tool è volutamente minimo. Membri, pagine, newsletter e upload di immagini non sono coperti. Estendere il server significa aggiungere
registerToolcon la chiamata corrispondente di@tryghost/admin-api: il pattern è sempre lo stesso.