From 15e8474b91d28cd0a2e3cef47ed68dca9d6b4a97 Mon Sep 17 00:00:00 2001 From: Devlin Liles Date: Mon, 8 Jun 2026 18:10:52 -0500 Subject: [PATCH 1/9] feat: add full pages CRUD and images upload tools Adds pages_browse, pages_read, pages_add, pages_edit, pages_delete mirroring the posts tool set, plus images_upload for uploading local image files to Ghost. --- src/models.ts | 13 +++++ src/server.ts | 4 ++ src/tools/images.ts | 25 +++++++++ src/tools/pages.ts | 129 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 171 insertions(+) create mode 100644 src/tools/images.ts create mode 100644 src/tools/pages.ts diff --git a/src/models.ts b/src/models.ts index fb4cdf6..e001522 100644 --- a/src/models.ts +++ b/src/models.ts @@ -10,6 +10,19 @@ export interface Post { excerpt?: string | null; } +// Ghost page data model. +export interface Page { + id: string; + title: string; + status: string; + url: string; + created_at: string; + html?: string | null; + plaintext?: string | null; + excerpt?: string | null; + show_title_and_feature_image?: boolean | null; +} + // Ghost user data model. export interface User { id: string; diff --git a/src/server.ts b/src/server.ts index b7cee5d..4b90f55 100644 --- a/src/server.ts +++ b/src/server.ts @@ -57,6 +57,10 @@ import { registerRoleTools } from "./tools/roles"; registerRoleTools(server); import { registerWebhookTools } from "./tools/webhooks"; registerWebhookTools(server); +import { registerPageTools } from "./tools/pages"; +registerPageTools(server); +import { registerImageTools } from "./tools/images"; +registerImageTools(server); import { registerPrompts } from "./prompts"; registerPrompts(server); diff --git a/src/tools/images.ts b/src/tools/images.ts new file mode 100644 index 0000000..cd0ca81 --- /dev/null +++ b/src/tools/images.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { ghostApiClient } from "../ghostApi"; + +const uploadParams = { + file: z.string().describe("Absolute path to the image file on disk"), + purpose: z + .enum(["image", "profile_image", "icon"]) + .optional() + .describe("Upload purpose; defaults to 'image'"), + ref: z.string().optional().describe("Optional reference string returned in the response"), +}; + +export function registerImageTools(server: McpServer) { + server.tool( + "images_upload", + uploadParams, + async (args, _extra) => { + const result = await ghostApiClient.images.upload(args); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } + ); +} diff --git a/src/tools/pages.ts b/src/tools/pages.ts new file mode 100644 index 0000000..f20c785 --- /dev/null +++ b/src/tools/pages.ts @@ -0,0 +1,129 @@ +import { z } from "zod"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { ghostApiClient } from "../ghostApi"; + +const browseParams = { + filter: z.string().optional(), + limit: z.number().optional(), + page: z.number().optional(), + order: z.string().optional(), +}; +const readParams = { + id: z.string().optional(), + slug: z.string().optional(), +}; +const tagRef = z.union([ + z.string(), + z.object({ + id: z.string().optional(), + slug: z.string().optional(), + name: z.string().optional(), + }), +]); +const authorRef = z.union([ + z.string(), + z.object({ + id: z.string().optional(), + slug: z.string().optional(), + email: z.string().optional(), + }), +]); +const pageMutableFields = { + html: z.string().optional(), + lexical: z.string().optional(), + status: z.string().optional(), + slug: z.string().optional(), + visibility: z.string().optional(), + featured: z.boolean().optional(), + show_title_and_feature_image: z.boolean().optional(), + published_at: z.string().optional(), + custom_excerpt: z.string().optional(), + feature_image: z.string().optional(), + feature_image_alt: z.string().optional(), + feature_image_caption: z.string().optional(), + meta_title: z.string().optional(), + meta_description: z.string().optional(), + og_title: z.string().optional(), + og_description: z.string().optional(), + og_image: z.string().optional(), + twitter_title: z.string().optional(), + twitter_description: z.string().optional(), + twitter_image: z.string().optional(), + codeinjection_head: z.string().optional(), + codeinjection_foot: z.string().optional(), + canonical_url: z.string().optional(), + tags: z.array(tagRef).optional(), + authors: z.array(authorRef).optional(), +}; +const addParams = { + title: z.string(), + ...pageMutableFields, +}; +const editParams = { + id: z.string(), + updated_at: z.string(), + title: z.string().optional(), + ...pageMutableFields, +}; +const deleteParams = { + id: z.string(), +}; + +export function registerPageTools(server: McpServer) { + server.tool( + "pages_browse", + browseParams, + async (args, _extra) => { + const pages = await ghostApiClient.pages.browse(args); + return { + content: [{ type: "text", text: JSON.stringify(pages, null, 2) }], + }; + } + ); + + server.tool( + "pages_read", + readParams, + async (args, _extra) => { + const page = await ghostApiClient.pages.read(args); + return { + content: [{ type: "text", text: JSON.stringify(page, null, 2) }], + }; + } + ); + + server.tool( + "pages_add", + addParams, + async (args, _extra) => { + const options = args.html ? { source: "html" } : undefined; + const page = await ghostApiClient.pages.add(args, options); + return { + content: [{ type: "text", text: JSON.stringify(page, null, 2) }], + }; + } + ); + + server.tool( + "pages_edit", + editParams, + async (args, _extra) => { + const options = args.html ? { source: "html" } : undefined; + const page = await ghostApiClient.pages.edit(args, options); + return { + content: [{ type: "text", text: JSON.stringify(page, null, 2) }], + }; + } + ); + + server.tool( + "pages_delete", + deleteParams, + async (args, _extra) => { + await ghostApiClient.pages.delete(args); + return { + content: [{ type: "text", text: `Page with id ${args.id} deleted.` }], + }; + } + ); +} From 73dc9ab70b0ddb17f26c9ce07f46241b832ea559 Mon Sep 17 00:00:00 2001 From: Devlin Liles Date: Fri, 12 Jun 2026 08:11:02 -0500 Subject: [PATCH 2/9] expanded the mcp for support of additional edits like pages --- package-lock.json | 11 +++++++---- src/tools/images.ts | 1 + src/tools/pages.ts | 12 +++++++----- src/tools/posts.ts | 12 +++++++----- 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 40ddc42..ed2ded2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "ghost-mcp-ts", - "version": "1.0.0", + "name": "@fanyangmeng/ghost-mcp", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "ghost-mcp-ts", - "version": "1.0.0", + "name": "@fanyangmeng/ghost-mcp", + "version": "0.2.0", "license": "ISC", "dependencies": { "@modelcontextprotocol/sdk": "^1.10.1", @@ -14,6 +14,9 @@ "axios": "^1.8.4", "zod": "^3.24.3" }, + "bin": { + "ghost-mcp": "build/server.js" + }, "devDependencies": { "@types/axios": "^0.9.36", "@types/node": "^22.14.1", diff --git a/src/tools/images.ts b/src/tools/images.ts index cd0ca81..93bd020 100644 --- a/src/tools/images.ts +++ b/src/tools/images.ts @@ -14,6 +14,7 @@ const uploadParams = { export function registerImageTools(server: McpServer) { server.tool( "images_upload", + "Upload an image file to Ghost storage. Returns a 'url' field — use that URL as the value for feature_image, og_image, twitter_image, or src attributes in HTML content when creating or editing posts and pages. Images must be uploaded before a post referencing them is published.", uploadParams, async (args, _extra) => { const result = await ghostApiClient.images.upload(args); diff --git a/src/tools/pages.ts b/src/tools/pages.ts index f20c785..e756d6a 100644 --- a/src/tools/pages.ts +++ b/src/tools/pages.ts @@ -29,26 +29,26 @@ const authorRef = z.union([ }), ]); const pageMutableFields = { - html: z.string().optional(), + html: z.string().optional().describe("HTML content for the page. Any src values must be publicly accessible URLs — upload local images via images_upload first and use the returned URL."), lexical: z.string().optional(), - status: z.string().optional(), + status: z.string().optional().describe("Page status: 'draft', 'published', or 'scheduled'. All image URLs referenced in the page must be uploaded and accessible before setting status to 'published'."), slug: z.string().optional(), visibility: z.string().optional(), featured: z.boolean().optional(), show_title_and_feature_image: z.boolean().optional(), published_at: z.string().optional(), custom_excerpt: z.string().optional(), - feature_image: z.string().optional(), + feature_image: z.string().url().optional().describe("Publicly accessible HTTPS URL of the feature image. Use the URL returned by images_upload for Ghost-hosted images."), feature_image_alt: z.string().optional(), feature_image_caption: z.string().optional(), meta_title: z.string().optional(), meta_description: z.string().optional(), og_title: z.string().optional(), og_description: z.string().optional(), - og_image: z.string().optional(), + og_image: z.string().url().optional().describe("Publicly accessible HTTPS URL for the Open Graph image. Use the URL returned by images_upload for Ghost-hosted images."), twitter_title: z.string().optional(), twitter_description: z.string().optional(), - twitter_image: z.string().optional(), + twitter_image: z.string().url().optional().describe("Publicly accessible HTTPS URL for the Twitter card image. Use the URL returned by images_upload for Ghost-hosted images."), codeinjection_head: z.string().optional(), codeinjection_foot: z.string().optional(), canonical_url: z.string().optional(), @@ -94,6 +94,7 @@ export function registerPageTools(server: McpServer) { server.tool( "pages_add", + "Create a new Ghost page. If the page includes images (feature_image, og_image, twitter_image, or tags in html), upload them first using images_upload and use the returned URLs. Setting status to 'published' immediately makes the page live.", addParams, async (args, _extra) => { const options = args.html ? { source: "html" } : undefined; @@ -106,6 +107,7 @@ export function registerPageTools(server: McpServer) { server.tool( "pages_edit", + "Update an existing Ghost page. Requires the current updated_at timestamp to prevent conflicting edits. If adding or changing images, upload them via images_upload first and use the returned URLs. Changing status to 'published' immediately makes the page live.", editParams, async (args, _extra) => { const options = args.html ? { source: "html" } : undefined; diff --git a/src/tools/posts.ts b/src/tools/posts.ts index 07f5220..f660618 100644 --- a/src/tools/posts.ts +++ b/src/tools/posts.ts @@ -34,26 +34,26 @@ const authorRef = z.union([ }), ]); const postMutableFields = { - html: z.string().optional(), + html: z.string().optional().describe("HTML content for the post. Any src values must be publicly accessible URLs — upload local images via images_upload first and use the returned URL."), lexical: z.string().optional(), - status: z.string().optional(), + status: z.string().optional().describe("Post status: 'draft', 'published', 'scheduled', or 'sent'. All image URLs referenced in the post must be uploaded and accessible before setting status to 'published'."), slug: z.string().optional(), visibility: z.string().optional(), featured: z.boolean().optional(), email_only: z.boolean().optional(), published_at: z.string().optional(), custom_excerpt: z.string().optional(), - feature_image: z.string().optional(), + feature_image: z.string().url().optional().describe("Publicly accessible HTTPS URL of the feature image. Use the URL returned by images_upload for Ghost-hosted images."), feature_image_alt: z.string().optional(), feature_image_caption: z.string().optional(), meta_title: z.string().optional(), meta_description: z.string().optional(), og_title: z.string().optional(), og_description: z.string().optional(), - og_image: z.string().optional(), + og_image: z.string().url().optional().describe("Publicly accessible HTTPS URL for the Open Graph image. Use the URL returned by images_upload for Ghost-hosted images."), twitter_title: z.string().optional(), twitter_description: z.string().optional(), - twitter_image: z.string().optional(), + twitter_image: z.string().url().optional().describe("Publicly accessible HTTPS URL for the Twitter card image. Use the URL returned by images_upload for Ghost-hosted images."), codeinjection_head: z.string().optional(), codeinjection_foot: z.string().optional(), canonical_url: z.string().optional(), @@ -112,6 +112,7 @@ export function registerPostTools(server: McpServer) { // Add post server.tool( "posts_add", + "Create a new Ghost post. If the post includes images (feature_image, og_image, twitter_image, or tags in html), upload them first using images_upload and use the returned URLs. Setting status to 'published' immediately makes the post live.", addParams, async (args, _extra) => { // If html is present, use source: "html" to ensure Ghost uses the html content @@ -131,6 +132,7 @@ export function registerPostTools(server: McpServer) { // Edit post server.tool( "posts_edit", + "Update an existing Ghost post. Requires the current updated_at timestamp to prevent conflicting edits. If adding or changing images, upload them via images_upload first and use the returned URLs. Changing status to 'published' immediately makes the post live.", editParams, async (args, _extra) => { // If html is present, use source: "html" to ensure Ghost uses the html content for updates From 302c46ada578c652e807bd480c6e3411eaf9b144 Mon Sep 17 00:00:00 2001 From: Devlin Liles Date: Fri, 12 Jun 2026 09:00:41 -0500 Subject: [PATCH 3/9] feat: add macOS install script Installs prerequisites (Homebrew, Node.js), builds from source, collects Ghost credentials interactively, and registers the MCP server in ~/.claude.json. Preserves all existing Claude Code config on upsert; idempotent on re-run. --- install.sh | 182 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100755 install.sh diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..d29949a --- /dev/null +++ b/install.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +BOLD='\033[1m' +RESET='\033[0m' + +info() { echo -e "${BLUE}[ghost-mcp]${RESET} $*"; } +success() { echo -e "${GREEN}✓${RESET} $*"; } +warn() { echo -e "${YELLOW}⚠${RESET} $*"; } +fatal() { echo -e "${RED}✗ $*${RESET}" >&2; exit 1; } + +# ─── macOS gate ────────────────────────────────────────────────────────────── + +[[ "$(uname)" == "Darwin" ]] || fatal "This install script supports macOS only." + +echo "" +echo -e "${BOLD}ghost-mcp installer${RESET}" +echo "──────────────────────────────────────────" +echo "" + +# ─── Homebrew ──────────────────────────────────────────────────────────────── + +if ! command -v brew &>/dev/null; then + info "Homebrew not found — installing..." + /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + # Apple Silicon puts Homebrew at /opt/homebrew; add it to this session's PATH + if [[ -f /opt/homebrew/bin/brew ]]; then + eval "$(/opt/homebrew/bin/brew shellenv)" + elif [[ -f /usr/local/bin/brew ]]; then + eval "$(/usr/local/bin/brew shellenv)" + fi + success "Homebrew installed" +else + success "Homebrew found ($(brew --version | head -1))" +fi + +# ─── Node.js ───────────────────────────────────────────────────────────────── + +NODE_MIN_MAJOR=20 + +check_node() { + if command -v node &>/dev/null; then + local major + major=$(node -e "process.stdout.write(String(process.versions.node.split('.')[0]))") + [[ "$major" -ge "$NODE_MIN_MAJOR" ]] + else + return 1 + fi +} + +if ! check_node; then + info "Node.js v${NODE_MIN_MAJOR}+ not found — installing node@22 via Homebrew..." + brew install node@22 + # node@22 is keg-only, so we need to prepend its bin for this session + NODE22_BIN="$(brew --prefix node@22)/bin" + export PATH="${NODE22_BIN}:$PATH" + if ! check_node; then + fatal "Node.js v${NODE_MIN_MAJOR}+ could not be installed. Install it manually and re-run." + fi + success "Node.js installed ($(node --version))" +else + success "Node.js found ($(node --version))" +fi + +# ─── Install dependencies ───────────────────────────────────────────────────── + +info "Installing npm dependencies..." +cd "$SCRIPT_DIR" +npm install 2>&1 | grep -v '^npm warn' || true +success "Dependencies installed" + +# ─── Build ─────────────────────────────────────────────────────────────────── + +info "Building TypeScript..." +cd "$SCRIPT_DIR" +npm run build + +[[ -f "${SCRIPT_DIR}/build/server.js" ]] || fatal "Build completed but build/server.js is missing — check tsconfig.json." +success "Build complete" + +# ─── Ghost credentials ─────────────────────────────────────────────────────── + +echo "" +echo -e "${BOLD}Ghost CMS Configuration${RESET}" +echo "" + +read -rp " Ghost API URL (e.g. https://yourblog.com): " GHOST_API_URL +while [[ -z "$GHOST_API_URL" ]]; do + warn "GHOST_API_URL cannot be empty." + read -rp " Ghost API URL: " GHOST_API_URL +done + +read -rsp " Ghost Admin API Key: " GHOST_ADMIN_API_KEY +echo "" +while [[ -z "$GHOST_ADMIN_API_KEY" ]]; do + warn "GHOST_ADMIN_API_KEY cannot be empty." + read -rsp " Ghost Admin API Key: " GHOST_ADMIN_API_KEY + echo "" +done + +read -rp " Ghost API Version [v5.0]: " GHOST_API_VERSION_INPUT +GHOST_API_VERSION="${GHOST_API_VERSION_INPUT:-v5.0}" + +# ─── Update ~/.claude.json ─────────────────────────────────────────────────── + +info "Registering ghost-mcp in ~/.claude.json..." + +# JSON-encode every value before interpolating into the heredoc so that special +# characters (quotes, backslashes, unicode) in user input can't break the script. +serverPath="${SCRIPT_DIR}/build/server.js" +ghostApiUrlJson=$(node -e "process.stdout.write(JSON.stringify(process.argv[1]))" "$GHOST_API_URL") +ghostAdminApiKeyJson=$(node -e "process.stdout.write(JSON.stringify(process.argv[1]))" "$GHOST_ADMIN_API_KEY") +ghostApiVersionJson=$(node -e "process.stdout.write(JSON.stringify(process.argv[1]))" "$GHOST_API_VERSION") +serverPathJson=$(node -e "process.stdout.write(JSON.stringify(process.argv[1]))" "$serverPath") + +result=$(node << NODEJS +const fs = require('fs'); +const claudeJsonPath = process.env.HOME + '/.claude.json'; + +// All values arrive as JSON literals (pre-encoded by bash above) +const actualServerPath = ${serverPathJson}; +const ghostApiUrl = ${ghostApiUrlJson}; +const ghostAdminApiKey = ${ghostAdminApiKeyJson}; +const ghostApiVersion = ${ghostApiVersionJson}; + +// Load existing config (full object) or start fresh +let config = {}; +if (fs.existsSync(claudeJsonPath)) { + const raw = fs.readFileSync(claudeJsonPath, 'utf8'); + try { + config = JSON.parse(raw); + } catch (e) { + process.stderr.write('ERROR: ~/.claude.json exists but is not valid JSON. Fix it manually and re-run.\n'); + process.exit(1); + } +} + +// Ensure mcpServers key exists without disturbing any other top-level keys +if (!config.mcpServers) config.mcpServers = {}; + +// Upsert only the ghost-mcp entry — all other mcpServers entries are untouched +config.mcpServers['ghost-mcp'] = { + command: 'node', + args: [actualServerPath], + env: { + GHOST_API_URL: ghostApiUrl, + GHOST_ADMIN_API_KEY: ghostAdminApiKey, + GHOST_API_VERSION: ghostApiVersion, + } +}; + +// Write the full config back (pretty-printed, trailing newline, owner-only permissions) +fs.writeFileSync(claudeJsonPath, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 }); +process.stdout.write('OK'); +NODEJS +) + +[[ "$result" == "OK" ]] || fatal "Failed to update ~/.claude.json" +success "~/.claude.json updated (ghost-mcp entry registered)" + +# ─── Done ──────────────────────────────────────────────────────────────────── + +echo "" +echo -e "${GREEN}${BOLD}Setup complete!${RESET}" +echo "" +echo -e " ${BOLD}Server path:${RESET} ${SCRIPT_DIR}/build/server.js" +echo -e " ${BOLD}Config file:${RESET} ~/.claude.json (mcpServers.ghost-mcp)" +echo "" +echo -e "${BLUE}Next steps:${RESET}" +echo " 1. Restart Claude Code (quit and reopen, or run: claude mcp restart)" +echo " 2. Verify the server is active: /mcp" +echo " 3. Try: \"List my Ghost posts\"" +echo "" +echo -e "${YELLOW}To update your Ghost credentials, re-run:${RESET} ./install.sh" +echo "" From b981a891e79388487ee1da025ea21193b33349ac Mon Sep 17 00:00:00 2001 From: Devlin Liles Date: Fri, 12 Jun 2026 09:04:02 -0500 Subject: [PATCH 4/9] feat: add browse summaries and tool descriptions All *_browse tools now return lightweight summary projections instead of full objects, reducing token usage when listing. Added descriptive strings to each tool registration so the model knows what fields to expect and when to use *_read for full detail. Summaries extracted into src/utils/summaries.ts. --- src/tools/invites.ts | 4 +- src/tools/members.ts | 4 +- src/tools/newsletters.ts | 4 +- src/tools/offers.ts | 4 +- src/tools/pages.ts | 4 +- src/tools/posts.ts | 4 +- src/tools/roles.ts | 4 +- src/tools/tags.ts | 4 +- src/tools/tiers.ts | 4 +- src/tools/users.ts | 4 +- src/utils/summaries.ts | 128 +++++++++++++++++++++++++++++++++++++++ 11 files changed, 158 insertions(+), 10 deletions(-) create mode 100644 src/utils/summaries.ts diff --git a/src/tools/invites.ts b/src/tools/invites.ts index e021218..d6846b6 100644 --- a/src/tools/invites.ts +++ b/src/tools/invites.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ghostApiClient } from "../ghostApi"; +import { toInviteSummary } from "../utils/summaries"; // Parameter schemas as ZodRawShape (object literals) const browseParams = { @@ -22,6 +23,7 @@ export function registerInviteTools(server: McpServer) { // Browse invites server.tool( "invites_browse", + "Returns a summary list of pending invites (id, email, role_id, status, created_at).", browseParams, async (args, _extra) => { const invites = await ghostApiClient.invites.browse(args); @@ -29,7 +31,7 @@ export function registerInviteTools(server: McpServer) { content: [ { type: "text", - text: JSON.stringify(invites, null, 2), + text: JSON.stringify(invites.map(toInviteSummary), null, 2), }, ], }; diff --git a/src/tools/members.ts b/src/tools/members.ts index 3930e1a..6681595 100644 --- a/src/tools/members.ts +++ b/src/tools/members.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ghostApiClient } from "../ghostApi"; +import { toMemberSummary } from "../utils/summaries"; // Parameter schemas as ZodRawShape (object literals) const browseParams = { @@ -37,6 +38,7 @@ export function registerMemberTools(server: McpServer) { // Browse members server.tool( "members_browse", + "Returns a summary list of members (id, name, email, status, created_at, last_seen_at, email stats). Use members_read with an id or email to fetch full detail including subscriptions, labels, newsletters, and notes.", browseParams, async (args, _extra) => { const members = await ghostApiClient.members.browse(args); @@ -44,7 +46,7 @@ export function registerMemberTools(server: McpServer) { content: [ { type: "text", - text: JSON.stringify(members, null, 2), + text: JSON.stringify(members.map(toMemberSummary), null, 2), }, ], }; diff --git a/src/tools/newsletters.ts b/src/tools/newsletters.ts index 58de729..45b5eb5 100644 --- a/src/tools/newsletters.ts +++ b/src/tools/newsletters.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ghostApiClient } from "../ghostApi"; +import { toNewsletterSummary } from "../utils/summaries"; // Parameter schemas as ZodRawShape (object literals) const browseParams = { @@ -60,6 +61,7 @@ export function registerNewsletterTools(server: McpServer) { // Browse newsletters server.tool( "newsletters_browse", + "Returns a summary list of newsletters (id, name, status, visibility, subscribe_on_signup, sort_order). Use newsletters_read with an id or slug to fetch full detail including sender settings, display options, and font configuration.", browseParams, async (args, _extra) => { const newsletters = await ghostApiClient.newsletters.browse(args); @@ -67,7 +69,7 @@ export function registerNewsletterTools(server: McpServer) { content: [ { type: "text", - text: JSON.stringify(newsletters, null, 2), + text: JSON.stringify(newsletters.map(toNewsletterSummary), null, 2), }, ], }; diff --git a/src/tools/offers.ts b/src/tools/offers.ts index 2f66709..9aefc6a 100644 --- a/src/tools/offers.ts +++ b/src/tools/offers.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ghostApiClient } from "../ghostApi"; +import { toOfferSummary } from "../utils/summaries"; // Parameter schemas as ZodRawShape (object literals) const browseParams = { @@ -44,6 +45,7 @@ export function registerOfferTools(server: McpServer) { // Browse offers server.tool( "offers_browse", + "Returns a summary list of offers (id, name, code, status, type, amount, cadence, currency, redemption_count). Use offers_read with an id or code to fetch full detail including display title, description, duration, and tier.", browseParams, async (args, _extra) => { const offers = await ghostApiClient.offers.browse(args); @@ -51,7 +53,7 @@ export function registerOfferTools(server: McpServer) { content: [ { type: "text", - text: JSON.stringify(offers, null, 2), + text: JSON.stringify(offers.map(toOfferSummary), null, 2), }, ], }; diff --git a/src/tools/pages.ts b/src/tools/pages.ts index e756d6a..4053b53 100644 --- a/src/tools/pages.ts +++ b/src/tools/pages.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ghostApiClient } from "../ghostApi"; +import { toPageSummary } from "../utils/summaries"; const browseParams = { filter: z.string().optional(), @@ -72,11 +73,12 @@ const deleteParams = { export function registerPageTools(server: McpServer) { server.tool( "pages_browse", + "Returns a summary list of pages (id, title, slug, status, dates, url, tags, authors). Use pages_read with an id or slug to fetch full content including html, SEO fields, and all metadata.", browseParams, async (args, _extra) => { const pages = await ghostApiClient.pages.browse(args); return { - content: [{ type: "text", text: JSON.stringify(pages, null, 2) }], + content: [{ type: "text", text: JSON.stringify(pages.map(toPageSummary), null, 2) }], }; } ); diff --git a/src/tools/posts.ts b/src/tools/posts.ts index f660618..fa20161 100644 --- a/src/tools/posts.ts +++ b/src/tools/posts.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ghostApiClient } from "../ghostApi"; +import { toPostSummary } from "../utils/summaries"; // Parameter schemas as ZodRawShape (object literals) const browseParams = { @@ -78,6 +79,7 @@ export function registerPostTools(server: McpServer) { // Browse posts server.tool( "posts_browse", + "Returns a summary list of posts (id, title, slug, status, dates, url, tags, authors). Use posts_read with an id or slug to fetch full content including html, SEO fields, and all metadata.", browseParams, async (args, _extra) => { const posts = await ghostApiClient.posts.browse(args); @@ -85,7 +87,7 @@ export function registerPostTools(server: McpServer) { content: [ { type: "text", - text: JSON.stringify(posts, null, 2), + text: JSON.stringify(posts.map(toPostSummary), null, 2), }, ], }; diff --git a/src/tools/roles.ts b/src/tools/roles.ts index e1b92ba..2600b86 100644 --- a/src/tools/roles.ts +++ b/src/tools/roles.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ghostApiClient } from "../ghostApi"; +import { toRoleSummary } from "../utils/summaries"; // Parameter schemas as ZodRawShape (object literals) const browseParams = { @@ -19,6 +20,7 @@ export function registerRoleTools(server: McpServer) { // Browse roles server.tool( "roles_browse", + "Returns a summary list of roles (id, name, description). Use roles_read with an id or name to fetch full detail.", browseParams, async (args, _extra) => { const roles = await ghostApiClient.roles.browse(args); @@ -26,7 +28,7 @@ export function registerRoleTools(server: McpServer) { content: [ { type: "text", - text: JSON.stringify(roles, null, 2), + text: JSON.stringify(roles.map(toRoleSummary), null, 2), }, ], }; diff --git a/src/tools/tags.ts b/src/tools/tags.ts index 2c16f55..6bb81a9 100644 --- a/src/tools/tags.ts +++ b/src/tools/tags.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ghostApiClient } from "../ghostApi"; +import { toTagSummary } from "../utils/summaries"; // Parameter schemas as ZodRawShape (object literals) const browseParams = { @@ -35,6 +36,7 @@ export function registerTagTools(server: McpServer) { // Browse tags server.tool( "tags_browse", + "Returns a summary list of tags (id, name, slug, description, created_at). Use tags_read with an id or slug to fetch full detail including meta and OG fields.", browseParams, async (args, _extra) => { const tags = await ghostApiClient.tags.browse(args); @@ -42,7 +44,7 @@ export function registerTagTools(server: McpServer) { content: [ { type: "text", - text: JSON.stringify(tags, null, 2), + text: JSON.stringify(tags.map(toTagSummary), null, 2), }, ], }; diff --git a/src/tools/tiers.ts b/src/tools/tiers.ts index 08a5e7b..16e75e8 100644 --- a/src/tools/tiers.ts +++ b/src/tools/tiers.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ghostApiClient } from "../ghostApi"; +import { toTierSummary } from "../utils/summaries"; // Parameter schemas as ZodRawShape (object literals) const browseParams = { @@ -47,6 +48,7 @@ export function registerTierTools(server: McpServer) { // Browse tiers server.tool( "tiers_browse", + "Returns a summary list of tiers (id, name, type, active, monthly_price, yearly_price, currency). Use tiers_read with an id or slug to fetch full detail including benefits, welcome_page_url, and description.", browseParams, async (args, _extra) => { const tiers = await ghostApiClient.tiers.browse(args); @@ -54,7 +56,7 @@ export function registerTierTools(server: McpServer) { content: [ { type: "text", - text: JSON.stringify(tiers, null, 2), + text: JSON.stringify(tiers.map(toTierSummary), null, 2), }, ], }; diff --git a/src/tools/users.ts b/src/tools/users.ts index 93b06b1..0866f40 100644 --- a/src/tools/users.ts +++ b/src/tools/users.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ghostApiClient } from "../ghostApi"; +import { toUserSummary } from "../utils/summaries"; // Parameter schemas as ZodRawShape (object literals) const browseParams = { @@ -35,6 +36,7 @@ export function registerUserTools(server: McpServer) { // Browse users server.tool( "users_browse", + "Returns a summary list of users (id, name, email, slug, status, created_at, roles). Use users_read with an id, email, or slug to fetch full detail including bio, location, social links, and profile images.", browseParams, async (args, _extra) => { const users = await ghostApiClient.users.browse(args); @@ -42,7 +44,7 @@ export function registerUserTools(server: McpServer) { content: [ { type: "text", - text: JSON.stringify(users, null, 2), + text: JSON.stringify(users.map(toUserSummary), null, 2), }, ], }; diff --git a/src/utils/summaries.ts b/src/utils/summaries.ts new file mode 100644 index 0000000..b7a66d7 --- /dev/null +++ b/src/utils/summaries.ts @@ -0,0 +1,128 @@ +// Projection functions for browse (list) operations. +// Each function strips large content fields, returning only identifying +// and navigational metadata. Use the corresponding *_read tool to fetch +// a full object once you have the id or slug. + +export function toPostSummary(post: any) { + return { + id: post.id, + title: post.title, + slug: post.slug, + status: post.status, + featured: post.featured, + created_at: post.created_at, + updated_at: post.updated_at, + published_at: post.published_at, + url: post.url, + custom_excerpt: post.custom_excerpt, + tags: post.tags?.map((t: any) => ({ id: t.id, name: t.name, slug: t.slug })), + authors: post.authors?.map((a: any) => ({ id: a.id, name: a.name, slug: a.slug })), + }; +} + +export function toPageSummary(page: any) { + return { + id: page.id, + title: page.title, + slug: page.slug, + status: page.status, + featured: page.featured, + created_at: page.created_at, + updated_at: page.updated_at, + published_at: page.published_at, + url: page.url, + custom_excerpt: page.custom_excerpt, + tags: page.tags?.map((t: any) => ({ id: t.id, name: t.name, slug: t.slug })), + authors: page.authors?.map((a: any) => ({ id: a.id, name: a.name, slug: a.slug })), + }; +} + +export function toMemberSummary(member: any) { + return { + id: member.id, + name: member.name, + email: member.email, + status: member.status, + created_at: member.created_at, + last_seen_at: member.last_seen_at, + email_count: member.email_count, + email_open_rate: member.email_open_rate, + }; +} + +export function toUserSummary(user: any) { + return { + id: user.id, + name: user.name, + email: user.email, + slug: user.slug, + status: user.status, + created_at: user.created_at, + roles: user.roles?.map((r: any) => ({ id: r.id, name: r.name })), + }; +} + +export function toTagSummary(tag: any) { + return { + id: tag.id, + name: tag.name, + slug: tag.slug, + description: tag.description, + created_at: tag.created_at, + }; +} + +export function toNewsletterSummary(newsletter: any) { + return { + id: newsletter.id, + name: newsletter.name, + status: newsletter.status, + visibility: newsletter.visibility, + subscribe_on_signup: newsletter.subscribe_on_signup, + sort_order: newsletter.sort_order, + }; +} + +export function toTierSummary(tier: any) { + return { + id: tier.id, + name: tier.name, + type: tier.type, + active: tier.active, + monthly_price: tier.monthly_price, + yearly_price: tier.yearly_price, + currency: tier.currency, + }; +} + +export function toOfferSummary(offer: any) { + return { + id: offer.id, + name: offer.name, + code: offer.code, + status: offer.status, + type: offer.type, + amount: offer.amount, + cadence: offer.cadence, + currency: offer.currency, + redemption_count: offer.redemption_count, + }; +} + +export function toInviteSummary(invite: any) { + return { + id: invite.id, + email: invite.email, + role_id: invite.role_id, + status: invite.status, + created_at: invite.created_at, + }; +} + +export function toRoleSummary(role: any) { + return { + id: role.id, + name: role.name, + description: role.description, + }; +} From dd570377371ba2ab59dbe6e5015b71b560b60bdb Mon Sep 17 00:00:00 2001 From: Devlin Liles Date: Fri, 12 Jun 2026 09:15:28 -0500 Subject: [PATCH 5/9] fix: use claude mcp add for server registration Replace manual ~/.claude.json editing with the official Claude Code CLI (claude mcp add --scope user). Removes any existing ghost-mcp entry first so re-runs are idempotent, and adds a check that the claude CLI is present before attempting registration. --- install.sh | 71 ++++++++++++++---------------------------------------- 1 file changed, 18 insertions(+), 53 deletions(-) diff --git a/install.sh b/install.sh index d29949a..033b935 100755 --- a/install.sh +++ b/install.sh @@ -108,62 +108,27 @@ done read -rp " Ghost API Version [v5.0]: " GHOST_API_VERSION_INPUT GHOST_API_VERSION="${GHOST_API_VERSION_INPUT:-v5.0}" -# ─── Update ~/.claude.json ─────────────────────────────────────────────────── +# ─── Register with Claude Code ─────────────────────────────────────────────── -info "Registering ghost-mcp in ~/.claude.json..." +info "Registering ghost-mcp with Claude Code..." + +if ! command -v claude &>/dev/null; then + fatal "Claude Code CLI ('claude') not found. Install Claude Code and re-run." +fi -# JSON-encode every value before interpolating into the heredoc so that special -# characters (quotes, backslashes, unicode) in user input can't break the script. serverPath="${SCRIPT_DIR}/build/server.js" -ghostApiUrlJson=$(node -e "process.stdout.write(JSON.stringify(process.argv[1]))" "$GHOST_API_URL") -ghostAdminApiKeyJson=$(node -e "process.stdout.write(JSON.stringify(process.argv[1]))" "$GHOST_ADMIN_API_KEY") -ghostApiVersionJson=$(node -e "process.stdout.write(JSON.stringify(process.argv[1]))" "$GHOST_API_VERSION") -serverPathJson=$(node -e "process.stdout.write(JSON.stringify(process.argv[1]))" "$serverPath") - -result=$(node << NODEJS -const fs = require('fs'); -const claudeJsonPath = process.env.HOME + '/.claude.json'; - -// All values arrive as JSON literals (pre-encoded by bash above) -const actualServerPath = ${serverPathJson}; -const ghostApiUrl = ${ghostApiUrlJson}; -const ghostAdminApiKey = ${ghostAdminApiKeyJson}; -const ghostApiVersion = ${ghostApiVersionJson}; - -// Load existing config (full object) or start fresh -let config = {}; -if (fs.existsSync(claudeJsonPath)) { - const raw = fs.readFileSync(claudeJsonPath, 'utf8'); - try { - config = JSON.parse(raw); - } catch (e) { - process.stderr.write('ERROR: ~/.claude.json exists but is not valid JSON. Fix it manually and re-run.\n'); - process.exit(1); - } -} -// Ensure mcpServers key exists without disturbing any other top-level keys -if (!config.mcpServers) config.mcpServers = {}; - -// Upsert only the ghost-mcp entry — all other mcpServers entries are untouched -config.mcpServers['ghost-mcp'] = { - command: 'node', - args: [actualServerPath], - env: { - GHOST_API_URL: ghostApiUrl, - GHOST_ADMIN_API_KEY: ghostAdminApiKey, - GHOST_API_VERSION: ghostApiVersion, - } -}; - -// Write the full config back (pretty-printed, trailing newline, owner-only permissions) -fs.writeFileSync(claudeJsonPath, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 }); -process.stdout.write('OK'); -NODEJS -) - -[[ "$result" == "OK" ]] || fatal "Failed to update ~/.claude.json" -success "~/.claude.json updated (ghost-mcp entry registered)" +# Remove any existing entry first so 'add' is always idempotent +claude mcp remove ghost-mcp --scope user 2>/dev/null || true + +claude mcp add ghost-mcp \ + --scope user \ + -e "GHOST_API_URL=${GHOST_API_URL}" \ + -e "GHOST_ADMIN_API_KEY=${GHOST_ADMIN_API_KEY}" \ + -e "GHOST_API_VERSION=${GHOST_API_VERSION}" \ + -- node "$serverPath" + +success "ghost-mcp registered (user scope)" # ─── Done ──────────────────────────────────────────────────────────────────── @@ -171,7 +136,7 @@ echo "" echo -e "${GREEN}${BOLD}Setup complete!${RESET}" echo "" echo -e " ${BOLD}Server path:${RESET} ${SCRIPT_DIR}/build/server.js" -echo -e " ${BOLD}Config file:${RESET} ~/.claude.json (mcpServers.ghost-mcp)" +echo -e " ${BOLD}Registered:${RESET} claude mcp list (user scope)" echo "" echo -e "${BLUE}Next steps:${RESET}" echo " 1. Restart Claude Code (quit and reopen, or run: claude mcp restart)" From 69f6f6526f4b7be9e4126d92d49d03a448fa83b6 Mon Sep 17 00:00:00 2001 From: Devlin Liles Date: Fri, 12 Jun 2026 09:27:18 -0500 Subject: [PATCH 6/9] fix: clean up Claude Desktop config and all Claude Code scopes on install Remove ghost-mcp from ~/Library/Application Support/Claude/claude_desktop_config.json if present, so stale entries (e.g. pointing at @tryghost/mcp-server) don't conflict. Also sweep all three claude mcp scopes (user/local/project) before re-registering to ensure a clean slate on any machine. --- install.sh | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/install.sh b/install.sh index 033b935..4319be7 100755 --- a/install.sh +++ b/install.sh @@ -108,6 +108,32 @@ done read -rp " Ghost API Version [v5.0]: " GHOST_API_VERSION_INPUT GHOST_API_VERSION="${GHOST_API_VERSION_INPUT:-v5.0}" +# ─── Clean up Claude Desktop config ───────────────────────────────────────── + +CLAUDE_DESKTOP_CONFIG="${HOME}/Library/Application Support/Claude/claude_desktop_config.json" + +if [[ -f "$CLAUDE_DESKTOP_CONFIG" ]]; then + info "Removing ghost-mcp from Claude Desktop config (if present)..." + node << NODEJS +const fs = require('fs'); +const path = '${CLAUDE_DESKTOP_CONFIG}'; +const raw = fs.readFileSync(path, 'utf8'); +let config; +try { config = JSON.parse(raw); } catch (e) { + process.stderr.write('WARNING: claude_desktop_config.json is not valid JSON — skipping cleanup.\n'); + process.exit(0); +} +if (config.mcpServers && config.mcpServers['ghost-mcp']) { + delete config.mcpServers['ghost-mcp']; + fs.writeFileSync(path, JSON.stringify(config, null, 2) + '\n'); + process.stdout.write('removed\n'); +} else { + process.stdout.write('not present\n'); +} +NODEJS + success "Claude Desktop config cleaned up" +fi + # ─── Register with Claude Code ─────────────────────────────────────────────── info "Registering ghost-mcp with Claude Code..." @@ -118,8 +144,10 @@ fi serverPath="${SCRIPT_DIR}/build/server.js" -# Remove any existing entry first so 'add' is always idempotent +# Remove any existing entry from all scopes so 'add' is always idempotent claude mcp remove ghost-mcp --scope user 2>/dev/null || true +claude mcp remove ghost-mcp --scope local 2>/dev/null || true +claude mcp remove ghost-mcp --scope project 2>/dev/null || true claude mcp add ghost-mcp \ --scope user \ From 45478779d5675493e6e166e7f8142ca92b203b82 Mon Sep 17 00:00:00 2001 From: Devlin Liles Date: Fri, 12 Jun 2026 09:48:23 -0500 Subject: [PATCH 7/9] fix: write ghost-mcp into Claude Desktop config with absolute node path Installer now upserts the ghost-mcp entry into claude_desktop_config.json instead of only removing it. Uses the absolute path to the node binary so Claude Desktop (which launches with a minimal env) can resolve the executable without relying on PATH. --- install.sh | 65 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 43 insertions(+), 22 deletions(-) diff --git a/install.sh b/install.sh index 4319be7..ebda90b 100755 --- a/install.sh +++ b/install.sh @@ -108,31 +108,50 @@ done read -rp " Ghost API Version [v5.0]: " GHOST_API_VERSION_INPUT GHOST_API_VERSION="${GHOST_API_VERSION_INPUT:-v5.0}" -# ─── Clean up Claude Desktop config ───────────────────────────────────────── +# ─── Register with Claude Desktop ──────────────────────────────────────────── CLAUDE_DESKTOP_CONFIG="${HOME}/Library/Application Support/Claude/claude_desktop_config.json" +CLAUDE_DESKTOP_DIR="${HOME}/Library/Application Support/Claude" -if [[ -f "$CLAUDE_DESKTOP_CONFIG" ]]; then - info "Removing ghost-mcp from Claude Desktop config (if present)..." - node << NODEJS +info "Registering ghost-mcp with Claude Desktop..." + +mkdir -p "$CLAUDE_DESKTOP_DIR" + +# Claude Desktop launches with a minimal env — 'node' won't resolve via PATH. +# Capture the absolute binary path now so the config is self-contained. +NODE_BIN="$(command -v node)" +[[ -n "$NODE_BIN" ]] || fatal "Cannot resolve absolute path for node — ensure Node.js is on PATH and re-run." + +node << NODEJS const fs = require('fs'); -const path = '${CLAUDE_DESKTOP_CONFIG}'; -const raw = fs.readFileSync(path, 'utf8'); -let config; -try { config = JSON.parse(raw); } catch (e) { - process.stderr.write('WARNING: claude_desktop_config.json is not valid JSON — skipping cleanup.\n'); - process.exit(0); -} -if (config.mcpServers && config.mcpServers['ghost-mcp']) { - delete config.mcpServers['ghost-mcp']; - fs.writeFileSync(path, JSON.stringify(config, null, 2) + '\n'); - process.stdout.write('removed\n'); -} else { - process.stdout.write('not present\n'); +const configPath = '${CLAUDE_DESKTOP_CONFIG}'; +const serverPath = '${SCRIPT_DIR}/build/server.js'; +const nodeBin = '${NODE_BIN}'; + +let config = {}; +if (fs.existsSync(configPath)) { + const raw = fs.readFileSync(configPath, 'utf8'); + try { config = JSON.parse(raw); } catch (e) { + process.stderr.write('WARNING: claude_desktop_config.json is not valid JSON — will overwrite.\n'); + } } + +config.mcpServers = config.mcpServers || {}; +config.mcpServers['ghost-mcp'] = { + command: nodeBin, + args: [serverPath], + env: { + GHOST_API_URL: '${GHOST_API_URL}', + GHOST_ADMIN_API_KEY: '${GHOST_ADMIN_API_KEY}', + GHOST_API_VERSION: '${GHOST_API_VERSION}' + } +}; + +fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n'); +process.stdout.write('written\n'); NODEJS - success "Claude Desktop config cleaned up" -fi + +success "Claude Desktop config updated" # ─── Register with Claude Code ─────────────────────────────────────────────── @@ -164,12 +183,14 @@ echo "" echo -e "${GREEN}${BOLD}Setup complete!${RESET}" echo "" echo -e " ${BOLD}Server path:${RESET} ${SCRIPT_DIR}/build/server.js" -echo -e " ${BOLD}Registered:${RESET} claude mcp list (user scope)" +echo -e " ${BOLD}Claude Code:${RESET} claude mcp list (user scope)" +echo -e " ${BOLD}Claude Desktop:${RESET} ~/Library/Application Support/Claude/claude_desktop_config.json" echo "" echo -e "${BLUE}Next steps:${RESET}" echo " 1. Restart Claude Code (quit and reopen, or run: claude mcp restart)" -echo " 2. Verify the server is active: /mcp" -echo " 3. Try: \"List my Ghost posts\"" +echo " 2. Restart Claude Desktop (quit and reopen from the menu bar)" +echo " 3. Verify Claude Code: /mcp" +echo " 4. Try: \"List my Ghost posts\"" echo "" echo -e "${YELLOW}To update your Ghost credentials, re-run:${RESET} ./install.sh" echo "" From 676c787cff91450a87bc666032b749cce0049d0a Mon Sep 17 00:00:00 2001 From: Devlin Liles Date: Fri, 12 Jun 2026 09:52:40 -0500 Subject: [PATCH 8/9] fix: replace @tryghost/admin-api with custom client to fix Node v26 crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The buffer-equal-constant-time transitive dep (via admin-api → jsonwebtoken → jwa) references Buffer.SlowBuffer, which Node.js v26 removed, causing an immediate crash on require. Replace with a custom GhostAdminClient that signs JWTs using Node's built-in crypto.createHmac (HS256) and makes REST calls via axios. Removes @tryghost/ admin-api and the stale @types/axios shim entirely — no new dependencies added. --- package-lock.json | 132 --------------------------------------- package.json | 2 - src/ghostApi.ts | 156 ++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 136 insertions(+), 154 deletions(-) diff --git a/package-lock.json b/package-lock.json index ed2ded2..432bd2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "license": "ISC", "dependencies": { "@modelcontextprotocol/sdk": "^1.10.1", - "@tryghost/admin-api": "^1.13.13", "axios": "^1.8.4", "zod": "^3.24.3" }, @@ -18,7 +17,6 @@ "ghost-mcp": "build/server.js" }, "devDependencies": { - "@types/axios": "^0.9.36", "@types/node": "^22.14.1", "typescript": "^5.8.3" } @@ -44,24 +42,6 @@ "node": ">=18" } }, - "node_modules/@tryghost/admin-api": { - "version": "1.13.13", - "resolved": "https://registry.npmjs.org/@tryghost/admin-api/-/admin-api-1.13.13.tgz", - "integrity": "sha512-RBcL9weuLWjBHrEWVvqp838D+jrLlguiAUcda90RgBCZ+CbQkSuslCuffy0yAoTUa2QGBNj+Nlzjx1J8Q25FIA==", - "license": "MIT", - "dependencies": { - "axios": "^1.0.0", - "form-data": "^4.0.0", - "jsonwebtoken": "^9.0.0" - } - }, - "node_modules/@types/axios": { - "version": "0.9.36", - "resolved": "https://registry.npmjs.org/@types/axios/-/axios-0.9.36.tgz", - "integrity": "sha512-NLOpedx9o+rxo/X5ChbdiX6mS1atE4WHmEEIcR9NLenRVa5HoVjAvjafwU3FPTqnZEstpoqCaW7fagqSoTDNeg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/node": { "version": "22.14.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz", @@ -122,12 +102,6 @@ "node": ">=18" } }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -293,15 +267,6 @@ "node": ">= 0.4" } }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -698,91 +663,6 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, - "node_modules/jsonwebtoken": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", - "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", - "license": "MIT", - "dependencies": { - "jws": "^3.2.2", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=12", - "npm": ">=6" - } - }, - "node_modules/jwa": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", - "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", - "license": "MIT", - "dependencies": { - "jwa": "^1.4.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", - "license": "MIT" - }, - "node_modules/lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", - "license": "MIT" - }, - "node_modules/lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", - "license": "MIT" - }, - "node_modules/lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", - "license": "MIT" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "license": "MIT" - }, - "node_modules/lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", - "license": "MIT" - }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "license": "MIT" - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1027,18 +907,6 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, - "node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/send": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", diff --git a/package.json b/package.json index f1fa48b..93730e5 100644 --- a/package.json +++ b/package.json @@ -24,12 +24,10 @@ "type": "commonjs", "dependencies": { "@modelcontextprotocol/sdk": "^1.10.1", - "@tryghost/admin-api": "^1.13.13", "axios": "^1.8.4", "zod": "^3.24.3" }, "devDependencies": { - "@types/axios": "^0.9.36", "@types/node": "^22.14.1", "typescript": "^5.8.3" } diff --git a/src/ghostApi.ts b/src/ghostApi.ts index 6addbc7..2cbaef6 100644 --- a/src/ghostApi.ts +++ b/src/ghostApi.ts @@ -1,24 +1,140 @@ -import GhostAdminAPI from '@tryghost/admin-api'; +import crypto from 'crypto'; +import { readFile } from 'fs/promises'; +import path from 'path'; +import axios, { type AxiosInstance } from 'axios'; import { GHOST_API_URL, GHOST_ADMIN_API_KEY, GHOST_API_VERSION } from './config'; -// Initialize and export the Ghost Admin API client instance. -// Configuration is loaded from src/config.ts. -export const ghostApiClient = new GhostAdminAPI({ - url: GHOST_API_URL, - key: GHOST_ADMIN_API_KEY, - version: GHOST_API_VERSION -}); +type BrowseParams = Record; +type ReadParams = { id?: string; slug?: string } & Record; + +class ResourceClient { + constructor( + private readonly resource: string, + private readonly client: GhostAdminClient, + ) {} + + browse(params?: BrowseParams): Promise { + return this.client._browse(this.resource, params); + } + + read(params: ReadParams): Promise { + return this.client._read(this.resource, params); + } + + add(data: Record, options?: Record): Promise { + return this.client._add(this.resource, data, options); + } + + edit(data: Record, options?: Record): Promise { + return this.client._edit(this.resource, data, options); + } -// You can add helper functions here to wrap API calls and handle errors -// For example: -/* -export async function getPostById(postId: string): Promise { - try { - const post = await ghostApiClient.posts.read({ id: postId }); - return post; - } catch (error) { - console.error(`Error fetching post ${postId}:`, error); - throw new Error(`Failed to fetch post ${postId}`); - } + delete(params: { id: string }): Promise { + return this.client._delete(this.resource, params); + } } -*/ \ No newline at end of file + +class GhostAdminClient { + private readonly http: AxiosInstance; + private readonly keyId: string; + private readonly secret: Buffer; + private readonly version: string; + + readonly posts: ResourceClient; + readonly pages: ResourceClient; + readonly tags: ResourceClient; + readonly members: ResourceClient; + readonly tiers: ResourceClient; + readonly offers: ResourceClient; + readonly newsletters: ResourceClient; + readonly users: ResourceClient; + readonly roles: ResourceClient; + readonly invites: ResourceClient; + readonly webhooks: ResourceClient; + readonly site: ResourceClient; + readonly images: { + upload(params: { file: string; purpose?: string; ref?: string }): Promise; + }; + + constructor({ url, key, version }: { url: string; key: string; version: string }) { + const [keyId, hexSecret] = key.split(':'); + this.keyId = keyId; + this.secret = Buffer.from(hexSecret, 'hex'); + this.version = version; + + this.http = axios.create({ baseURL: `${url}/ghost/api/admin` }); + this.http.interceptors.request.use((config: any) => { + config.headers.Authorization = `Ghost ${this._generateToken()}`; + return config; + }); + + this.posts = new ResourceClient('posts', this); + this.pages = new ResourceClient('pages', this); + this.tags = new ResourceClient('tags', this); + this.members = new ResourceClient('members', this); + this.tiers = new ResourceClient('tiers', this); + this.offers = new ResourceClient('offers', this); + this.newsletters = new ResourceClient('newsletters', this); + this.users = new ResourceClient('users', this); + this.roles = new ResourceClient('roles', this); + this.invites = new ResourceClient('invites', this); + this.webhooks = new ResourceClient('webhooks', this); + this.site = new ResourceClient('site', this); + this.images = { upload: (p) => this._uploadImage(p) }; + } + + // Ghost Admin API JWT: HS256, kid in header, aud = /{version}/admin/ + _generateToken(): string { + const now = Math.floor(Date.now() / 1000); + const header = Buffer.from(JSON.stringify({ alg: 'HS256', kid: this.keyId })).toString('base64url'); + const payload = Buffer.from(JSON.stringify({ iat: now, exp: now + 300, aud: `/${this.version}/admin/` })).toString('base64url'); + const sig = crypto.createHmac('sha256', this.secret).update(`${header}.${payload}`).digest('base64url'); + return `${header}.${payload}.${sig}`; + } + + async _browse(resource: string, params?: BrowseParams): Promise { + const { data } = await this.http.get(`/${resource}/`, { params }); + const result: any[] & { meta?: any } = data[resource]; + if (data.meta) result.meta = data.meta; + return result; + } + + async _read(resource: string, params: ReadParams): Promise { + const { id, slug, ...rest } = params; + const url = id ? `/${resource}/${id}/` : slug ? `/${resource}/slug/${slug}/` : `/${resource}/`; + const { data } = await this.http.get(url, { params: rest }); + const val = data[resource]; + return Array.isArray(val) ? val[0] : val; + } + + async _add(resource: string, body: Record, options?: Record): Promise { + const { data } = await this.http.post(`/${resource}/`, { [resource]: [body] }, { params: options }); + return data[resource][0]; + } + + async _edit(resource: string, body: Record, options?: Record): Promise { + const { id } = body; + const { data } = await this.http.put(`/${resource}/${id}/`, { [resource]: [body] }, { params: options }); + return data[resource][0]; + } + + async _delete(resource: string, params: { id: string }): Promise { + await this.http.delete(`/${resource}/${params.id}/`); + } + + async _uploadImage(params: { file: string; purpose?: string; ref?: string }): Promise { + const buf = await readFile(params.file); + const form = new FormData(); + form.append('file', new Blob([buf]), path.basename(params.file)); + if (params.purpose) form.append('purpose', params.purpose); + if (params.ref) form.append('ref', params.ref); + const { data } = await this.http.post('/images/upload/', form); + return data.images[0]; + } +} + +export const ghostApiClient = new GhostAdminClient({ + url: GHOST_API_URL, + key: GHOST_ADMIN_API_KEY, + version: GHOST_API_VERSION, +}); From df8daf0c6ab3c9d7ac69af5192e7b8f02eb192e2 Mon Sep 17 00:00:00 2001 From: Devlin Liles Date: Fri, 12 Jun 2026 14:29:19 -0500 Subject: [PATCH 9/9] feat: add posts_meta tool with preview_url, url, uuid, tags, and authors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a posts_meta tool that returns full metadata for a post by id without body content — includes uuid, computed preview_url (/p/{uuid}/), live url, full tags and authors via include=, and all SEO/OG/Twitter fields. Also adds uuid to POST_READ_FIELDS and PAGE_READ_FIELDS so posts_read returns it, and introduces the respond.ts utility module used across all tool handlers. --- src/ghostApi.ts | 36 +++++++--- src/server.ts | 2 +- src/tools/invites.ts | 36 +++------- src/tools/members.ts | 81 +++++++++------------- src/tools/newsletters.ts | 56 ++++----------- src/tools/offers.ts | 56 ++++----------- src/tools/pages.ts | 67 +++++++++++------- src/tools/posts.ts | 144 ++++++++++++++++++++++++++------------- src/tools/roles.ts | 26 ++----- src/tools/tags.ts | 71 +++++++------------ src/tools/tiers.ts | 58 ++++------------ src/tools/users.ts | 59 ++++++---------- src/tools/webhooks.ts | 33 +++------ src/utils/respond.ts | 63 +++++++++++++++++ src/utils/summaries.ts | 29 ++++++++ 15 files changed, 405 insertions(+), 412 deletions(-) create mode 100644 src/utils/respond.ts diff --git a/src/ghostApi.ts b/src/ghostApi.ts index 2cbaef6..2210434 100644 --- a/src/ghostApi.ts +++ b/src/ghostApi.ts @@ -4,8 +4,17 @@ import path from 'path'; import axios, { type AxiosInstance } from 'axios'; import { GHOST_API_URL, GHOST_ADMIN_API_KEY, GHOST_API_VERSION } from './config'; -type BrowseParams = Record; -type ReadParams = { id?: string; slug?: string } & Record; +type BrowseParams = { + filter?: string; + limit?: number; + page?: number; + order?: string; + fields?: string; + include?: string; + formats?: string; +} & Record; +type ReadParams = { id?: string; slug?: string; fields?: string; include?: string; formats?: string } & Record; +type BrowseResult = { items: any[]; meta?: any }; class ResourceClient { constructor( @@ -13,7 +22,7 @@ class ResourceClient { private readonly client: GhostAdminClient, ) {} - browse(params?: BrowseParams): Promise { + browse(params?: BrowseParams): Promise { return this.client._browse(this.resource, params); } @@ -92,11 +101,12 @@ class GhostAdminClient { return `${header}.${payload}.${sig}`; } - async _browse(resource: string, params?: BrowseParams): Promise { + async _browse(resource: string, params?: BrowseParams): Promise { + if (params && typeof params.limit === 'number') { + params = { ...params, limit: Math.min(params.limit, 100) }; + } const { data } = await this.http.get(`/${resource}/`, { params }); - const result: any[] & { meta?: any } = data[resource]; - if (data.meta) result.meta = data.meta; - return result; + return { items: data[resource], meta: data.meta }; } async _read(resource: string, params: ReadParams): Promise { @@ -124,8 +134,18 @@ class GhostAdminClient { async _uploadImage(params: { file: string; purpose?: string; ref?: string }): Promise { const buf = await readFile(params.file); + const ext = path.extname(params.file).toLowerCase(); + const mimeTypes: Record = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + }; + const mimeType = mimeTypes[ext] ?? 'application/octet-stream'; const form = new FormData(); - form.append('file', new Blob([buf]), path.basename(params.file)); + form.append('file', new Blob([buf], { type: mimeType }), path.basename(params.file)); if (params.purpose) form.append('purpose', params.purpose); if (params.ref) form.append('ref', params.ref); const { data } = await this.http.post('/images/upload/', form); diff --git a/src/server.ts b/src/server.ts index 4b90f55..545222f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -16,7 +16,7 @@ import { // Create an MCP server instance const server = new McpServer({ name: "ghost-mcp-ts", - version: "1.0.0", // TODO: Get version from package.json + version: "1.1.0", // TODO: Get version from package.json }, { capabilities: { resources: {}, // Capabilities will be enabled as handlers are registered diff --git a/src/tools/invites.ts b/src/tools/invites.ts index d6846b6..51dd632 100644 --- a/src/tools/invites.ts +++ b/src/tools/invites.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ghostApiClient } from "../ghostApi"; import { toInviteSummary } from "../utils/summaries"; +import { textResult, browseEnvelope, toConfirmation } from "../utils/respond"; // Parameter schemas as ZodRawShape (object literals) const browseParams = { @@ -23,52 +24,33 @@ export function registerInviteTools(server: McpServer) { // Browse invites server.tool( "invites_browse", - "Returns a summary list of pending invites (id, email, role_id, status, created_at).", + "List pending staff invites as compact summaries (id, email, role_id, status, created_at). Check pagination.next for more pages.", browseParams, async (args, _extra) => { - const invites = await ghostApiClient.invites.browse(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(invites.map(toInviteSummary), null, 2), - }, - ], - }; + const { items, meta } = await ghostApiClient.invites.browse(args); + return textResult(browseEnvelope(items.map(toInviteSummary), meta)); } ); // Add invite server.tool( "invites_add", + "Invite a staff user by email and role_id (look up role ids with roles_browse). Returns a minimal confirmation {id,email,status}.", addParams, async (args, _extra) => { const invite = await ghostApiClient.invites.add(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(invite, null, 2), - }, - ], - }; + return textResult(toConfirmation(invite)); } ); // Delete invite server.tool( "invites_delete", + "Revoke a pending staff invite by id.", deleteParams, async (args, _extra) => { await ghostApiClient.invites.delete(args); - return { - content: [ - { - type: "text", - text: `Invite with id ${args.id} deleted.`, - }, - ], - }; + return textResult(`Invite with id ${args.id} deleted.`); } ); -} \ No newline at end of file +} diff --git a/src/tools/members.ts b/src/tools/members.ts index 6681595..597b104 100644 --- a/src/tools/members.ts +++ b/src/tools/members.ts @@ -2,14 +2,17 @@ import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ghostApiClient } from "../ghostApi"; -import { toMemberSummary } from "../utils/summaries"; +import { toMemberSummary, DEFAULT_MEMBER_FIELDS } from "../utils/summaries"; +import { textResult, browseEnvelope, toConfirmation, pickFields } from "../utils/respond"; // Parameter schemas as ZodRawShape (object literals) const browseParams = { - filter: z.string().optional(), - limit: z.number().optional(), + filter: z.string().optional().describe("Ghost NQL filter, e.g. 'status:paid'"), + limit: z.number().optional().describe("Results per page, max 100, default 15"), page: z.number().optional(), - order: z.string().optional(), + order: z.string().optional().describe("e.g. 'created_at desc'"), + fields: z.string().optional().describe("Comma-separated attributes to return, e.g. 'id,email,status'. Cannot be combined with include."), + include: z.string().optional().describe("Relations to include: 'newsletters', 'labels', or 'newsletters,labels'. When set, fields is ignored."), }; const readParams = { id: z.string().optional(), @@ -34,90 +37,74 @@ const deleteParams = { id: z.string(), }; +function toMemberSummaryWithRelations(member: any) { + return { + ...toMemberSummary(member), + labels: member.labels?.map((l: any) => ({ name: l.name, slug: l.slug })), + newsletters: member.newsletters?.map((n: any) => ({ id: n.id, name: n.name })), + }; +} + export function registerMemberTools(server: McpServer) { // Browse members server.tool( "members_browse", - "Returns a summary list of members (id, name, email, status, created_at, last_seen_at, email stats). Use members_read with an id or email to fetch full detail including subscriptions, labels, newsletters, and notes.", + "List members as compact summaries (id, name, email, status, dates, email stats). Use fields= to narrow or include='newsletters,labels' for relations. Max 100/page; check pagination.next and pass page= to continue.", browseParams, async (args, _extra) => { - const members = await ghostApiClient.members.browse(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(members.map(toMemberSummary), null, 2), - }, - ], - }; + const { fields, include, ...rest } = args; + if (include) { + const { items, meta } = await ghostApiClient.members.browse({ ...rest, include }); + return textResult(browseEnvelope(items.map(toMemberSummaryWithRelations), meta)); + } + const effectiveFields = fields ?? DEFAULT_MEMBER_FIELDS; + const { items, meta } = await ghostApiClient.members.browse({ ...rest, fields: effectiveFields }); + const fieldList = effectiveFields.split(","); + return textResult(browseEnvelope(items.map((item: any) => pickFields(item, fieldList)), meta)); } ); // Read member server.tool( "members_read", + "Fetch one member by id or email, with full detail including subscriptions, labels, newsletters, and notes.", readParams, async (args, _extra) => { const member = await ghostApiClient.members.read(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(member, null, 2), - }, - ], - }; + return textResult(member); } ); // Add member server.tool( "members_add", + "Create a new member by email. Returns a minimal confirmation {id,email,status,updated_at}.", addParams, async (args, _extra) => { const member = await ghostApiClient.members.add(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(member, null, 2), - }, - ], - }; + return textResult(toConfirmation(member)); } ); // Edit member server.tool( "members_edit", + "Update an existing member by id. Returns a minimal confirmation {id,email,status,updated_at}.", editParams, async (args, _extra) => { const member = await ghostApiClient.members.edit(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(member, null, 2), - }, - ], - }; + return textResult(toConfirmation(member)); } ); // Delete member server.tool( "members_delete", + "Permanently delete a member by id. This cannot be undone.", deleteParams, async (args, _extra) => { await ghostApiClient.members.delete(args); - return { - content: [ - { - type: "text", - text: `Member with id ${args.id} deleted.`, - }, - ], - }; + return textResult(`Member with id ${args.id} deleted.`); } ); -} \ No newline at end of file +} diff --git a/src/tools/newsletters.ts b/src/tools/newsletters.ts index 45b5eb5..0985581 100644 --- a/src/tools/newsletters.ts +++ b/src/tools/newsletters.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ghostApiClient } from "../ghostApi"; import { toNewsletterSummary } from "../utils/summaries"; +import { textResult, browseEnvelope, toConfirmation } from "../utils/respond"; // Parameter schemas as ZodRawShape (object literals) const browseParams = { @@ -61,86 +62,55 @@ export function registerNewsletterTools(server: McpServer) { // Browse newsletters server.tool( "newsletters_browse", - "Returns a summary list of newsletters (id, name, status, visibility, subscribe_on_signup, sort_order). Use newsletters_read with an id or slug to fetch full detail including sender settings, display options, and font configuration.", + "List newsletters as compact summaries (id, name, status, visibility, subscribe_on_signup, sort_order). Use newsletters_read with an id or slug for sender settings and display options. Check pagination.next for more pages.", browseParams, async (args, _extra) => { - const newsletters = await ghostApiClient.newsletters.browse(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(newsletters.map(toNewsletterSummary), null, 2), - }, - ], - }; + const { items, meta } = await ghostApiClient.newsletters.browse(args); + return textResult(browseEnvelope(items.map(toNewsletterSummary), meta)); } ); // Read newsletter server.tool( "newsletters_read", + "Fetch one newsletter by id or slug, with full detail including sender settings, display options, and font configuration.", readParams, async (args, _extra) => { const newsletter = await ghostApiClient.newsletters.read(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(newsletter, null, 2), - }, - ], - }; + return textResult(newsletter); } ); // Add newsletter server.tool( "newsletters_add", + "Create a new newsletter. Returns a minimal confirmation {id,slug,status,updated_at}.", addParams, async (args, _extra) => { const newsletter = await ghostApiClient.newsletters.add(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(newsletter, null, 2), - }, - ], - }; + return textResult(toConfirmation(newsletter)); } ); // Edit newsletter server.tool( "newsletters_edit", + "Update an existing newsletter by id. Returns a minimal confirmation {id,slug,status,updated_at}.", editParams, async (args, _extra) => { const newsletter = await ghostApiClient.newsletters.edit(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(newsletter, null, 2), - }, - ], - }; + return textResult(toConfirmation(newsletter)); } ); // Delete newsletter server.tool( "newsletters_delete", + "Permanently delete a newsletter by id. This cannot be undone.", deleteParams, async (args, _extra) => { await ghostApiClient.newsletters.delete(args); - return { - content: [ - { - type: "text", - text: `Newsletter with id ${args.id} deleted.`, - }, - ], - }; + return textResult(`Newsletter with id ${args.id} deleted.`); } ); -} \ No newline at end of file +} diff --git a/src/tools/offers.ts b/src/tools/offers.ts index 9aefc6a..deb14bb 100644 --- a/src/tools/offers.ts +++ b/src/tools/offers.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ghostApiClient } from "../ghostApi"; import { toOfferSummary } from "../utils/summaries"; +import { textResult, browseEnvelope, toConfirmation } from "../utils/respond"; // Parameter schemas as ZodRawShape (object literals) const browseParams = { @@ -45,86 +46,55 @@ export function registerOfferTools(server: McpServer) { // Browse offers server.tool( "offers_browse", - "Returns a summary list of offers (id, name, code, status, type, amount, cadence, currency, redemption_count). Use offers_read with an id or code to fetch full detail including display title, description, duration, and tier.", + "List offers as compact summaries (id, name, code, status, type, amount, cadence, currency, redemption_count). Use offers_read with an id or code for display title, description, duration, and tier. Check pagination.next for more pages.", browseParams, async (args, _extra) => { - const offers = await ghostApiClient.offers.browse(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(offers.map(toOfferSummary), null, 2), - }, - ], - }; + const { items, meta } = await ghostApiClient.offers.browse(args); + return textResult(browseEnvelope(items.map(toOfferSummary), meta)); } ); // Read offer server.tool( "offers_read", + "Fetch one offer by id or code, with full detail including display title, description, duration, and tier.", readParams, async (args, _extra) => { const offer = await ghostApiClient.offers.read(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(offer, null, 2), - }, - ], - }; + return textResult(offer); } ); // Add offer server.tool( "offers_add", + "Create a new offer. Returns a minimal confirmation {id,status,updated_at}.", addParams, async (args, _extra) => { const offer = await ghostApiClient.offers.add(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(offer, null, 2), - }, - ], - }; + return textResult(toConfirmation(offer)); } ); // Edit offer server.tool( "offers_edit", + "Update an existing offer by id (only name, code, and display fields are editable). Returns a minimal confirmation {id,status,updated_at}.", editParams, async (args, _extra) => { const offer = await ghostApiClient.offers.edit(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(offer, null, 2), - }, - ], - }; + return textResult(toConfirmation(offer)); } ); // Delete offer server.tool( "offers_delete", + "Permanently delete an offer by id. This cannot be undone.", deleteParams, async (args, _extra) => { await ghostApiClient.offers.delete(args); - return { - content: [ - { - type: "text", - text: `Offer with id ${args.id} deleted.`, - }, - ], - }; + return textResult(`Offer with id ${args.id} deleted.`); } ); -} \ No newline at end of file +} diff --git a/src/tools/pages.ts b/src/tools/pages.ts index 4053b53..37285c2 100644 --- a/src/tools/pages.ts +++ b/src/tools/pages.ts @@ -1,17 +1,24 @@ import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ghostApiClient } from "../ghostApi"; -import { toPageSummary } from "../utils/summaries"; +import { toPageSummary, DEFAULT_PAGE_FIELDS, PAGE_READ_FIELDS } from "../utils/summaries"; +import { textResult, browseEnvelope, toConfirmation, pickFields } from "../utils/respond"; const browseParams = { - filter: z.string().optional(), - limit: z.number().optional(), + filter: z.string().optional().describe("Ghost NQL filter, e.g. 'status:published'"), + limit: z.number().optional().describe("Results per page, max 100, default 15"), page: z.number().optional(), - order: z.string().optional(), + order: z.string().optional().describe("e.g. 'published_at desc'"), + fields: z.string().optional().describe("Comma-separated attributes to return, e.g. 'id,title,status,published_at'. Cannot be combined with include."), + include: z.string().optional().describe("Relations to include: 'tags', 'authors', or 'tags,authors'. When set, fields is ignored."), }; const readParams = { id: z.string().optional(), slug: z.string().optional(), + content: z + .enum(["html", "lexical"]) + .optional() + .describe("Omit for metadata only (default). Pass 'html' or 'lexical' to also return that one content format."), }; const tagRef = z.union([ z.string(), @@ -73,61 +80,73 @@ const deleteParams = { export function registerPageTools(server: McpServer) { server.tool( "pages_browse", - "Returns a summary list of pages (id, title, slug, status, dates, url, tags, authors). Use pages_read with an id or slug to fetch full content including html, SEO fields, and all metadata.", + "List pages as compact summaries. Default fields: id,title,slug,status,dates,url,excerpt. Use fields= to narrow or include='tags,authors' for relations. Max 100/page; check pagination.next and pass page= to continue.", browseParams, async (args, _extra) => { - const pages = await ghostApiClient.pages.browse(args); - return { - content: [{ type: "text", text: JSON.stringify(pages.map(toPageSummary), null, 2) }], - }; + const { fields, include, ...rest } = args; + if (include) { + // include mode: fields must not be sent alongside include, so + // project client-side via the summary function instead. + const { items, meta } = await ghostApiClient.pages.browse({ ...rest, include }); + return textResult(browseEnvelope(items.map(toPageSummary), meta)); + } + const effectiveFields = fields ?? DEFAULT_PAGE_FIELDS; + const { items, meta } = await ghostApiClient.pages.browse({ ...rest, fields: effectiveFields }); + const fieldList = effectiveFields.split(","); + return textResult(browseEnvelope(items.map((item: any) => pickFields(item, fieldList)), meta)); } ); server.tool( "pages_read", + "Fetch one page by id or slug. Returns metadata only by default; pass content='html' or 'lexical' for the body. Returns updated_at, which pages_edit requires.", readParams, async (args, _extra) => { - const page = await ghostApiClient.pages.read(args); - return { - content: [{ type: "text", text: JSON.stringify(page, null, 2) }], - }; + const { content, ...identifier } = args; + if (!content) { + const page = await ghostApiClient.pages.read({ ...identifier, fields: PAGE_READ_FIELDS }); + return textResult(page); + } + const page = await ghostApiClient.pages.read({ ...identifier, formats: content }); + // Ghost may return default content keys regardless of formats; never + // return more than the one requested format. + delete page.mobiledoc; + delete page.plaintext; + if (content === "html") delete page.lexical; + else delete page.html; + return textResult(page); } ); server.tool( "pages_add", - "Create a new Ghost page. If the page includes images (feature_image, og_image, twitter_image, or tags in html), upload them first using images_upload and use the returned URLs. Setting status to 'published' immediately makes the page live.", + "Create a new Ghost page. If the page includes images (feature_image, og_image, twitter_image, or tags in html), upload them first using images_upload and use the returned URLs. Setting status to 'published' immediately makes the page live. Returns a minimal confirmation {id,slug,status,url,updated_at}.", addParams, async (args, _extra) => { const options = args.html ? { source: "html" } : undefined; const page = await ghostApiClient.pages.add(args, options); - return { - content: [{ type: "text", text: JSON.stringify(page, null, 2) }], - }; + return textResult(toConfirmation(page)); } ); server.tool( "pages_edit", - "Update an existing Ghost page. Requires the current updated_at timestamp to prevent conflicting edits. If adding or changing images, upload them via images_upload first and use the returned URLs. Changing status to 'published' immediately makes the page live.", + "Update an existing Ghost page. Requires the current updated_at timestamp to prevent conflicting edits. If adding or changing images, upload them via images_upload first and use the returned URLs. Changing status to 'published' immediately makes the page live. Returns a minimal confirmation; its updated_at is the value required by the next edit.", editParams, async (args, _extra) => { const options = args.html ? { source: "html" } : undefined; const page = await ghostApiClient.pages.edit(args, options); - return { - content: [{ type: "text", text: JSON.stringify(page, null, 2) }], - }; + return textResult(toConfirmation(page)); } ); server.tool( "pages_delete", + "Permanently delete a page by id. This cannot be undone.", deleteParams, async (args, _extra) => { await ghostApiClient.pages.delete(args); - return { - content: [{ type: "text", text: `Page with id ${args.id} deleted.` }], - }; + return textResult(`Page with id ${args.id} deleted.`); } ); } diff --git a/src/tools/posts.ts b/src/tools/posts.ts index fa20161..e31ccfc 100644 --- a/src/tools/posts.ts +++ b/src/tools/posts.ts @@ -2,18 +2,26 @@ import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ghostApiClient } from "../ghostApi"; -import { toPostSummary } from "../utils/summaries"; +import { GHOST_API_URL } from "../config"; +import { toPostSummary, DEFAULT_POST_FIELDS, POST_READ_FIELDS } from "../utils/summaries"; +import { textResult, browseEnvelope, toConfirmation, pickFields } from "../utils/respond"; // Parameter schemas as ZodRawShape (object literals) const browseParams = { - filter: z.string().optional(), - limit: z.number().optional(), + filter: z.string().optional().describe("Ghost NQL filter, e.g. 'status:published+tag:news'"), + limit: z.number().optional().describe("Results per page, max 100, default 15"), page: z.number().optional(), - order: z.string().optional(), + order: z.string().optional().describe("e.g. 'published_at desc'"), + fields: z.string().optional().describe("Comma-separated attributes to return, e.g. 'id,title,status,published_at'. Cannot be combined with include."), + include: z.string().optional().describe("Relations to include: 'tags', 'authors', or 'tags,authors'. When set, fields is ignored."), }; const readParams = { id: z.string().optional(), slug: z.string().optional(), + content: z + .enum(["html", "lexical"]) + .optional() + .describe("Omit for metadata only (default). Pass 'html' or 'lexical' to also return that one content format."), }; // Shared mutable post fields — accepted by both posts_add and posts_edit. // Mirrors the Ghost Admin API post resource: @@ -71,6 +79,9 @@ const editParams = { title: z.string().optional(), ...postMutableFields, }; +const metaParams = { + id: z.string().describe("Post id"), +}; const deleteParams = { id: z.string(), }; @@ -79,92 +90,127 @@ export function registerPostTools(server: McpServer) { // Browse posts server.tool( "posts_browse", - "Returns a summary list of posts (id, title, slug, status, dates, url, tags, authors). Use posts_read with an id or slug to fetch full content including html, SEO fields, and all metadata.", + "List posts as compact summaries. Default fields: id,title,slug,status,dates,url,excerpt. Use fields= to narrow (e.g. 'id,title,status,published_at' for a content calendar) or include='tags,authors' for relations. Max 100/page; check pagination.next and pass page= to continue.", browseParams, async (args, _extra) => { - const posts = await ghostApiClient.posts.browse(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(posts.map(toPostSummary), null, 2), - }, - ], - }; + const { fields, include, ...rest } = args; + if (include) { + // include mode: fields must not be sent alongside include, so + // project client-side via the summary function instead. + const { items, meta } = await ghostApiClient.posts.browse({ ...rest, include }); + return textResult(browseEnvelope(items.map(toPostSummary), meta)); + } + const effectiveFields = fields ?? DEFAULT_POST_FIELDS; + const { items, meta } = await ghostApiClient.posts.browse({ ...rest, fields: effectiveFields }); + const fieldList = effectiveFields.split(","); + return textResult(browseEnvelope(items.map((item: any) => pickFields(item, fieldList)), meta)); } ); // Read post server.tool( "posts_read", + "Fetch one post by id or slug. Returns metadata only by default; pass content='html' or 'lexical' for the body. Returns updated_at, which posts_edit requires.", readParams, async (args, _extra) => { - const post = await ghostApiClient.posts.read(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(post, null, 2), - }, - ], + const { content, ...identifier } = args; + if (!content) { + const post = await ghostApiClient.posts.read({ ...identifier, fields: POST_READ_FIELDS }); + return textResult(post); + } + const post = await ghostApiClient.posts.read({ ...identifier, formats: content }); + // Ghost may return default content keys regardless of formats; never + // return more than the one requested format. + delete post.mobiledoc; + delete post.plaintext; + if (content === "html") delete post.lexical; + else delete post.html; + return textResult(post); + } + ); + + // Meta — full metadata including url, preview_url, tags, authors + server.tool( + "posts_meta", + "Fetch full metadata for a post by id: uuid, url, preview_url (for drafts: {site}/p/{uuid}/), tags, authors, feature image, SEO fields, og/twitter fields. Does not return post body content.", + metaParams, + async (args, _extra) => { + const post = await ghostApiClient.posts.read({ id: args.id, include: "tags,authors" }); + const uuid: string | undefined = post.uuid; + const preview_url = uuid ? `${GHOST_API_URL}/p/${uuid}/` : undefined; + const meta: Record = { + id: post.id, + uuid, + title: post.title, + slug: post.slug, + status: post.status, + visibility: post.visibility, + featured: post.featured, + email_only: post.email_only, + url: post.url, + preview_url, + canonical_url: post.canonical_url, + published_at: post.published_at, + created_at: post.created_at, + updated_at: post.updated_at, + feature_image: post.feature_image, + feature_image_alt: post.feature_image_alt, + feature_image_caption: post.feature_image_caption, + custom_excerpt: post.custom_excerpt, + meta_title: post.meta_title, + meta_description: post.meta_description, + og_title: post.og_title, + og_description: post.og_description, + og_image: post.og_image, + twitter_title: post.twitter_title, + twitter_description: post.twitter_description, + twitter_image: post.twitter_image, + email_subject: post.email_subject, + tags: post.tags?.map((t: any) => ({ id: t.id, name: t.name, slug: t.slug, description: t.description })), + authors: post.authors?.map((a: any) => ({ id: a.id, name: a.name, slug: a.slug, email: a.email })), }; + // Drop undefined fields to keep output clean + for (const key of Object.keys(meta)) { + if (meta[key] === undefined || meta[key] === null) delete meta[key]; + } + return textResult(meta); } ); // Add post server.tool( "posts_add", - "Create a new Ghost post. If the post includes images (feature_image, og_image, twitter_image, or tags in html), upload them first using images_upload and use the returned URLs. Setting status to 'published' immediately makes the post live.", + "Create a new Ghost post. If the post includes images (feature_image, og_image, twitter_image, or tags in html), upload them first using images_upload and use the returned URLs. Setting status to 'published' immediately makes the post live. Returns a minimal confirmation {id,slug,status,url,updated_at}.", addParams, async (args, _extra) => { // If html is present, use source: "html" to ensure Ghost uses the html content const options = args.html ? { source: "html" } : undefined; const post = await ghostApiClient.posts.add(args, options); - return { - content: [ - { - type: "text", - text: JSON.stringify(post, null, 2), - }, - ], - }; + return textResult(toConfirmation(post)); } ); // Edit post server.tool( "posts_edit", - "Update an existing Ghost post. Requires the current updated_at timestamp to prevent conflicting edits. If adding or changing images, upload them via images_upload first and use the returned URLs. Changing status to 'published' immediately makes the post live.", + "Update an existing Ghost post. Requires the current updated_at timestamp to prevent conflicting edits. If adding or changing images, upload them via images_upload first and use the returned URLs. Changing status to 'published' immediately makes the post live. Returns a minimal confirmation; its updated_at is the value required by the next edit.", editParams, async (args, _extra) => { // If html is present, use source: "html" to ensure Ghost uses the html content for updates const options = args.html ? { source: "html" } : undefined; const post = await ghostApiClient.posts.edit(args, options); - return { - content: [ - { - type: "text", - text: JSON.stringify(post, null, 2), - }, - ], - }; + return textResult(toConfirmation(post)); } ); // Delete post server.tool( "posts_delete", + "Permanently delete a post by id. This cannot be undone.", deleteParams, async (args, _extra) => { await ghostApiClient.posts.delete(args); - return { - content: [ - { - type: "text", - text: `Post with id ${args.id} deleted.`, - }, - ], - }; + return textResult(`Post with id ${args.id} deleted.`); } ); -} \ No newline at end of file +} diff --git a/src/tools/roles.ts b/src/tools/roles.ts index 2600b86..aae4a9d 100644 --- a/src/tools/roles.ts +++ b/src/tools/roles.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ghostApiClient } from "../ghostApi"; import { toRoleSummary } from "../utils/summaries"; +import { textResult, browseEnvelope } from "../utils/respond"; // Parameter schemas as ZodRawShape (object literals) const browseParams = { @@ -20,35 +21,22 @@ export function registerRoleTools(server: McpServer) { // Browse roles server.tool( "roles_browse", - "Returns a summary list of roles (id, name, description). Use roles_read with an id or name to fetch full detail.", + "List roles as compact summaries (id, name, description). Use roles_read with an id or name for full detail.", browseParams, async (args, _extra) => { - const roles = await ghostApiClient.roles.browse(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(roles.map(toRoleSummary), null, 2), - }, - ], - }; + const { items, meta } = await ghostApiClient.roles.browse(args); + return textResult(browseEnvelope(items.map(toRoleSummary), meta)); } ); // Read role server.tool( "roles_read", + "Fetch one role by id or name.", readParams, async (args, _extra) => { const role = await ghostApiClient.roles.read(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(role, null, 2), - }, - ], - }; + return textResult(role); } ); -} \ No newline at end of file +} diff --git a/src/tools/tags.ts b/src/tools/tags.ts index 6bb81a9..18d858c 100644 --- a/src/tools/tags.ts +++ b/src/tools/tags.ts @@ -2,14 +2,17 @@ import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ghostApiClient } from "../ghostApi"; -import { toTagSummary } from "../utils/summaries"; +import { toTagSummary, DEFAULT_TAG_FIELDS } from "../utils/summaries"; +import { textResult, browseEnvelope, toConfirmation, pickFields } from "../utils/respond"; // Parameter schemas as ZodRawShape (object literals) const browseParams = { filter: z.string().optional(), - limit: z.number().optional(), + limit: z.number().optional().describe("Results per page, max 100, default 15"), page: z.number().optional(), order: z.string().optional(), + fields: z.string().optional().describe("Comma-separated attributes to return, e.g. 'id,name,slug'. Cannot be combined with include."), + include: z.string().optional().describe("Use 'count.posts' to include each tag's post count. When set, fields is ignored."), }; const readParams = { id: z.string().optional(), @@ -36,86 +39,64 @@ export function registerTagTools(server: McpServer) { // Browse tags server.tool( "tags_browse", - "Returns a summary list of tags (id, name, slug, description, created_at). Use tags_read with an id or slug to fetch full detail including meta and OG fields.", + "List tags as compact summaries (id, name, slug, description, created_at). Use include='count.posts' for post counts or fields= to narrow. Max 100/page; check pagination.next and pass page= to continue.", browseParams, async (args, _extra) => { - const tags = await ghostApiClient.tags.browse(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(tags.map(toTagSummary), null, 2), - }, - ], - }; + const { fields, include, ...rest } = args; + if (include) { + const { items, meta } = await ghostApiClient.tags.browse({ ...rest, include }); + return textResult( + browseEnvelope(items.map((tag: any) => ({ ...toTagSummary(tag), count: tag.count })), meta) + ); + } + const effectiveFields = fields ?? DEFAULT_TAG_FIELDS; + const { items, meta } = await ghostApiClient.tags.browse({ ...rest, fields: effectiveFields }); + const fieldList = effectiveFields.split(","); + return textResult(browseEnvelope(items.map((item: any) => pickFields(item, fieldList)), meta)); } ); // Read tag server.tool( "tags_read", + "Fetch one tag by id or slug, with full detail including meta and OG fields.", readParams, async (args, _extra) => { const tag = await ghostApiClient.tags.read(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(tag, null, 2), - }, - ], - }; + return textResult(tag); } ); // Add tag server.tool( "tags_add", + "Create a new tag. Returns a minimal confirmation {id,slug,updated_at}.", addParams, async (args, _extra) => { const tag = await ghostApiClient.tags.add(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(tag, null, 2), - }, - ], - }; + return textResult(toConfirmation(tag)); } ); // Edit tag server.tool( "tags_edit", + "Update an existing tag by id. Returns a minimal confirmation {id,slug,updated_at}.", editParams, async (args, _extra) => { const tag = await ghostApiClient.tags.edit(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(tag, null, 2), - }, - ], - }; + return textResult(toConfirmation(tag)); } ); // Delete tag server.tool( "tags_delete", + "Permanently delete a tag by id. Posts keep their other tags. This cannot be undone.", deleteParams, async (args, _extra) => { await ghostApiClient.tags.delete(args); - return { - content: [ - { - type: "text", - text: `Tag with id ${args.id} deleted.`, - }, - ], - }; + return textResult(`Tag with id ${args.id} deleted.`); } ); -} \ No newline at end of file +} diff --git a/src/tools/tiers.ts b/src/tools/tiers.ts index 16e75e8..a6e0ece 100644 --- a/src/tools/tiers.ts +++ b/src/tools/tiers.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ghostApiClient } from "../ghostApi"; import { toTierSummary } from "../utils/summaries"; +import { textResult, browseEnvelope, toConfirmation } from "../utils/respond"; // Parameter schemas as ZodRawShape (object literals) const browseParams = { @@ -10,7 +11,7 @@ const browseParams = { limit: z.number().optional(), page: z.number().optional(), order: z.string().optional(), - include: z.string().optional(), + include: z.string().optional().describe("e.g. 'monthly_price,yearly_price,benefits' on Ghost versions where prices are include-driven"), }; const readParams = { id: z.string().optional(), @@ -48,86 +49,55 @@ export function registerTierTools(server: McpServer) { // Browse tiers server.tool( "tiers_browse", - "Returns a summary list of tiers (id, name, type, active, monthly_price, yearly_price, currency). Use tiers_read with an id or slug to fetch full detail including benefits, welcome_page_url, and description.", + "List tiers as compact summaries (id, name, type, active, prices, currency). Use tiers_read with an id or slug for benefits, welcome_page_url, and description. Check pagination.next for more pages.", browseParams, async (args, _extra) => { - const tiers = await ghostApiClient.tiers.browse(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(tiers.map(toTierSummary), null, 2), - }, - ], - }; + const { items, meta } = await ghostApiClient.tiers.browse(args); + return textResult(browseEnvelope(items.map(toTierSummary), meta)); } ); // Read tier server.tool( "tiers_read", + "Fetch one tier by id or slug, with full detail including benefits, welcome_page_url, and description.", readParams, async (args, _extra) => { const tier = await ghostApiClient.tiers.read(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(tier, null, 2), - }, - ], - }; + return textResult(tier); } ); // Add tier server.tool( "tiers_add", + "Create a new tier. Returns a minimal confirmation {id,slug,updated_at}.", addParams, async (args, _extra) => { const tier = await ghostApiClient.tiers.add(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(tier, null, 2), - }, - ], - }; + return textResult(toConfirmation(tier)); } ); // Edit tier server.tool( "tiers_edit", + "Update an existing tier by id. Returns a minimal confirmation {id,slug,updated_at}.", editParams, async (args, _extra) => { const tier = await ghostApiClient.tiers.edit(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(tier, null, 2), - }, - ], - }; + return textResult(toConfirmation(tier)); } ); // Delete tier server.tool( "tiers_delete", + "Permanently delete a tier by id. This cannot be undone.", deleteParams, async (args, _extra) => { await ghostApiClient.tiers.delete(args); - return { - content: [ - { - type: "text", - text: `Tier with id ${args.id} deleted.`, - }, - ], - }; + return textResult(`Tier with id ${args.id} deleted.`); } ); -} \ No newline at end of file +} diff --git a/src/tools/users.ts b/src/tools/users.ts index 0866f40..a220433 100644 --- a/src/tools/users.ts +++ b/src/tools/users.ts @@ -2,14 +2,17 @@ import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ghostApiClient } from "../ghostApi"; -import { toUserSummary } from "../utils/summaries"; +import { toUserSummary, DEFAULT_USER_FIELDS } from "../utils/summaries"; +import { textResult, browseEnvelope, toConfirmation, pickFields } from "../utils/respond"; // Parameter schemas as ZodRawShape (object literals) const browseParams = { filter: z.string().optional(), - limit: z.number().optional(), + limit: z.number().optional().describe("Results per page, max 100, default 15"), page: z.number().optional(), order: z.string().optional(), + fields: z.string().optional().describe("Comma-separated attributes to return, e.g. 'id,name,email'. Cannot be combined with include."), + include: z.string().optional().describe("Relations to include: 'roles'. When set, fields is ignored."), }; const readParams = { id: z.string().optional(), @@ -36,69 +39,51 @@ export function registerUserTools(server: McpServer) { // Browse users server.tool( "users_browse", - "Returns a summary list of users (id, name, email, slug, status, created_at, roles). Use users_read with an id, email, or slug to fetch full detail including bio, location, social links, and profile images.", + "List staff users as compact summaries (id, name, email, slug, status, created_at). Use include='roles' for roles or fields= to narrow. Check pagination.next and pass page= to continue.", browseParams, async (args, _extra) => { - const users = await ghostApiClient.users.browse(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(users.map(toUserSummary), null, 2), - }, - ], - }; + const { fields, include, ...rest } = args; + if (include) { + const { items, meta } = await ghostApiClient.users.browse({ ...rest, include }); + return textResult(browseEnvelope(items.map(toUserSummary), meta)); + } + const effectiveFields = fields ?? DEFAULT_USER_FIELDS; + const { items, meta } = await ghostApiClient.users.browse({ ...rest, fields: effectiveFields }); + const fieldList = effectiveFields.split(","); + return textResult(browseEnvelope(items.map((item: any) => pickFields(item, fieldList)), meta)); } ); // Read user server.tool( "users_read", + "Fetch one staff user by id, email, or slug, with full detail including bio, location, social links, and profile images.", readParams, async (args, _extra) => { const user = await ghostApiClient.users.read(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(user, null, 2), - }, - ], - }; + return textResult(user); } ); // Edit user server.tool( "users_edit", + "Update an existing staff user by id. Returns a minimal confirmation {id,slug,status,updated_at}.", editParams, async (args, _extra) => { const user = await ghostApiClient.users.edit(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(user, null, 2), - }, - ], - }; + return textResult(toConfirmation(user)); } ); // Delete user server.tool( "users_delete", + "Permanently delete a staff user by id. This cannot be undone.", deleteParams, async (args, _extra) => { await ghostApiClient.users.delete(args); - return { - content: [ - { - type: "text", - text: `User with id ${args.id} deleted.`, - }, - ], - }; + return textResult(`User with id ${args.id} deleted.`); } ); -} \ No newline at end of file +} diff --git a/src/tools/webhooks.ts b/src/tools/webhooks.ts index c432c6f..41f2522 100644 --- a/src/tools/webhooks.ts +++ b/src/tools/webhooks.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ghostApiClient } from "../ghostApi"; +import { textResult, toConfirmation } from "../utils/respond"; // Parameter schemas as ZodRawShape (object literals) const addParams = { @@ -27,51 +28,33 @@ export function registerWebhookTools(server: McpServer) { // Add webhook server.tool( "webhooks_add", + "Create a webhook for a Ghost event (e.g. 'post.published') targeting a URL. Returns a minimal confirmation {id,updated_at}.", addParams, async (args, _extra) => { const webhook = await ghostApiClient.webhooks.add(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(webhook, null, 2), - }, - ], - }; + return textResult(toConfirmation(webhook)); } ); // Edit webhook server.tool( "webhooks_edit", + "Update an existing webhook by id. Returns a minimal confirmation {id,updated_at}.", editParams, async (args, _extra) => { const webhook = await ghostApiClient.webhooks.edit(args); - return { - content: [ - { - type: "text", - text: JSON.stringify(webhook, null, 2), - }, - ], - }; + return textResult(toConfirmation(webhook)); } ); // Delete webhook server.tool( "webhooks_delete", + "Permanently delete a webhook by id.", deleteParams, async (args, _extra) => { await ghostApiClient.webhooks.delete(args); - return { - content: [ - { - type: "text", - text: `Webhook with id ${args.id} deleted.`, - }, - ], - }; + return textResult(`Webhook with id ${args.id} deleted.`); } ); -} \ No newline at end of file +} diff --git a/src/utils/respond.ts b/src/utils/respond.ts new file mode 100644 index 0000000..bdd5c50 --- /dev/null +++ b/src/utils/respond.ts @@ -0,0 +1,63 @@ +// Response helpers that keep tool output compact for agentic callers. +// All payloads are stringified without indentation; browse results carry +// pagination meta so callers can iterate page-by-page. + +export type Pagination = { + page: number; + limit: number | string; + pages: number; + total: number; + next: number | null; + prev: number | null; +}; + +export function textResult(payload: unknown) { + return { + content: [ + { + type: "text" as const, + text: typeof payload === "string" ? payload : JSON.stringify(payload), + }, + ], + }; +} + +export function browseEnvelope(items: unknown[], meta?: { pagination?: Pagination }) { + const pagination: Pagination = meta?.pagination ?? { + page: 1, + limit: items.length, + pages: 1, + total: items.length, + next: null, + prev: null, + }; + return { items, pagination }; +} + +// Minimal write confirmation shared by all resources: enough for the caller +// to reference the object (id, slug, url) and chain edits (updated_at). +export function toConfirmation(obj: any) { + const confirmation: Record = { + id: obj.id, + title: obj.title ?? obj.name ?? obj.email, + slug: obj.slug, + status: obj.status, + url: obj.url, + updated_at: obj.updated_at, + }; + for (const key of Object.keys(confirmation)) { + if (confirmation[key] === undefined) delete confirmation[key]; + } + return confirmation; +} + +// Client-side projection guard for fields mode, in case Ghost ignores or +// only partially honors the fields query param. +export function pickFields(obj: any, fields: string[]) { + const picked: Record = {}; + for (const field of fields) { + const key = field.trim(); + if (key && obj[key] !== undefined) picked[key] = obj[key]; + } + return picked; +} diff --git a/src/utils/summaries.ts b/src/utils/summaries.ts index b7a66d7..6a50b64 100644 --- a/src/utils/summaries.ts +++ b/src/utils/summaries.ts @@ -3,6 +3,35 @@ // and navigational metadata. Use the corresponding *_read tool to fetch // a full object once you have the id or slug. +// Default fields sent to Ghost via ?fields= when the caller does not request +// included relations. Must stay in sync with the to*Summary keys below +// (relations like tags/authors/roles are excluded — they require ?include=, +// which cannot be combined with ?fields=). +export const DEFAULT_POST_FIELDS = + "id,title,slug,status,featured,created_at,updated_at,published_at,url,custom_excerpt"; +export const DEFAULT_PAGE_FIELDS = DEFAULT_POST_FIELDS; +export const DEFAULT_MEMBER_FIELDS = + "id,name,email,status,created_at,last_seen_at,email_count,email_open_rate"; +export const DEFAULT_USER_FIELDS = "id,name,email,slug,status,created_at"; +export const DEFAULT_TAG_FIELDS = "id,name,slug,description,created_at"; +export const DEFAULT_NEWSLETTER_FIELDS = + "id,name,status,visibility,subscribe_on_signup,sort_order"; +export const DEFAULT_TIER_FIELDS = + "id,name,type,active,monthly_price,yearly_price,currency"; +export const DEFAULT_OFFER_FIELDS = + "id,name,code,status,type,amount,cadence,currency,redemption_count"; +export const DEFAULT_INVITE_FIELDS = "id,email,role_id,status,created_at"; +export const DEFAULT_ROLE_FIELDS = "id,name,description"; + +// Extra non-relation metadata returned by posts_read/pages_read on top of the +// browse defaults (still excludes html/lexical/mobiledoc content). +export const POST_READ_FIELDS = + DEFAULT_POST_FIELDS + + ",uuid,visibility,email_only,canonical_url,feature_image,feature_image_alt,meta_title,meta_description"; +export const PAGE_READ_FIELDS = + DEFAULT_PAGE_FIELDS + + ",uuid,visibility,canonical_url,feature_image,feature_image_alt,meta_title,meta_description,show_title_and_feature_image"; + export function toPostSummary(post: any) { return { id: post.id,