@damusix/ghost-mcp 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -158,6 +158,24 @@ Search Ghost documentation via `docs.ghost.org/llms.txt`.
158
158
  - **Admin mode** (default): Full access to all Ghost Admin API and Content API actions. Requires `GHOST_ADMIN_API_KEY`.
159
159
  - **Content mode**: Read-only access to Content API actions only. Requires `GHOST_CONTENT_API_KEY`. Admin actions are rejected with a clear error.
160
160
 
161
+ ## Bundled skill: `ghost-writing`
162
+
163
+ The package ships an [Agent Skill](https://docs.claude.com/en/docs/agents-and-tools/agent-skills/overview)
164
+ at [`skills/ghost-writing/`](skills/ghost-writing/SKILL.md) that pairs with the
165
+ MCP server: which tool to use when, the full Koenig block catalog with guidance
166
+ on choosing between similar blocks, and best practices for structuring good
167
+ posts (drafting workflow, long-post `blockFile` iteration, publishing,
168
+ newsletters, members-only content).
169
+
170
+ Install it:
171
+
172
+ ```bash
173
+ npx skills add damusix/ghost-mcp
174
+ ```
175
+
176
+ Claude picks it up automatically the next time a conversation involves writing
177
+ Ghost posts.
178
+
161
179
  ## Development
162
180
 
163
181
  Spin up a throwaway Ghost 6 + MySQL 8 stack to exercise the server against a
package/dist/index.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
2
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
5
  import { z } from "zod";
@@ -143,7 +144,11 @@ const postWriteFields = {
143
144
  "draft",
144
145
  "scheduled"
145
146
  ]).optional().describe("Post status"),
146
- tags: z.array(z.union([z.object({ id: z.string() }), z.object({ name: z.string() })])).optional().describe("Tags to assign (by id or name)"),
147
+ tags: z.array(z.union([
148
+ z.string(),
149
+ z.object({ id: z.string() }),
150
+ z.object({ name: z.string() })
151
+ ])).optional().describe("Tags to assign — a tag name string, or an object { id } or { name }"),
147
152
  authors: z.array(z.object({ id: z.string() })).optional().describe("Authors to assign (by id)"),
148
153
  featured: z.boolean().optional().describe("Whether the post is featured"),
149
154
  visibility: z.string().optional().describe("Post visibility (public, members, paid, tiers)"),
@@ -295,7 +300,11 @@ const pageWriteFields = {
295
300
  "draft",
296
301
  "scheduled"
297
302
  ]).optional().describe("Page status"),
298
- tags: z.array(z.union([z.object({ id: z.string() }), z.object({ name: z.string() })])).optional().describe("Tags to assign (by id or name)"),
303
+ tags: z.array(z.union([
304
+ z.string(),
305
+ z.object({ id: z.string() }),
306
+ z.object({ name: z.string() })
307
+ ])).optional().describe("Tags to assign — a tag name string, or an object { id } or { name }"),
299
308
  authors: z.array(z.object({ id: z.string() })).optional().describe("Authors to assign (by id)"),
300
309
  featured: z.boolean().optional().describe("Whether the page is featured"),
301
310
  visibility: z.string().optional().describe("Page visibility"),
@@ -680,7 +689,7 @@ const adminNewsletterActions = [
680
689
  ];
681
690
  //#endregion
682
691
  //#region src/actions/admin/offers.ts
683
- const browseParams$7 = z.object({}).describe("No parameters required");
692
+ const browseParams$7 = z.object({ filter: z.string().optional().describe("NQL filter expression (e.g. \"status:active\" or \"status:archived\")") });
684
693
  const readParams$8 = z.object({ id: z.string().describe("Offer ID") });
685
694
  const addSchema$2 = z.object({
686
695
  name: z.string().describe("Internal name for the offer (required)"),
@@ -712,7 +721,8 @@ const adminOfferActions = [
712
721
  method: "GET",
713
722
  path: "/offers/",
714
723
  inputSchema: browseParams$7,
715
- description: "Browse all offers"
724
+ description: "Browse all offers (Ghost returns the full list; filter by status)",
725
+ example: { filter: "status:active" }
716
726
  },
717
727
  {
718
728
  name: "offers.read",
@@ -1295,7 +1305,32 @@ function initRegistry() {
1295
1305
  }
1296
1306
  initRegistry();
1297
1307
  //#endregion
1308
+ //#region src/koenig/util.ts
1309
+ function isRecord(value) {
1310
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1311
+ }
1312
+ //#endregion
1298
1313
  //#region src/tools/use-ghost-api.ts
1314
+ /**
1315
+ * Surface the Ghost API error body instead of just the HTTP status text.
1316
+ * `@logosdx/fetch` puts the parsed response on `err.data`; Ghost returns
1317
+ * `{ errors: [{ message, context, type, property, ... }] }`. Without this the
1318
+ * caller only sees "Unprocessable Entity" and cannot tell what actually failed.
1319
+ */
1320
+ function formatApiError(err) {
1321
+ if (isRecord(err)) {
1322
+ const status = typeof err.status === "number" ? err.status : void 0;
1323
+ if (isRecord(err.data) && Array.isArray(err.data.errors) && err.data.errors.length > 0) return JSON.stringify({
1324
+ status,
1325
+ errors: err.data.errors
1326
+ });
1327
+ if (typeof err.message === "string") return JSON.stringify({
1328
+ status,
1329
+ error: err.message
1330
+ });
1331
+ }
1332
+ return JSON.stringify({ error: String(err) });
1333
+ }
1299
1334
  const extToMime = {};
1300
1335
  for (const [mime, meta] of Object.entries(mimeDb)) for (const ext of meta.extensions ?? []) extToMime[`.${ext}`] = mime;
1301
1336
  const useGhostApiSchema = z.object({
@@ -1358,30 +1393,30 @@ async function handleUseGhostApi(input, mode) {
1358
1393
  if (actionDef.method === "GET") {
1359
1394
  const queryParams = extractQueryParams(validPayload, usedPathParams);
1360
1395
  const [response, err] = await attempt(async () => engine.get(path, { params: queryParams }));
1361
- if (err) return JSON.stringify({ error: err.message });
1362
- return JSON.stringify(response);
1396
+ if (err) return formatApiError(err);
1397
+ return JSON.stringify(response.data);
1363
1398
  }
1364
1399
  if (actionDef.method === "DELETE") {
1365
1400
  const [response, err] = await attempt(async () => engine.delete(path));
1366
- if (err) return JSON.stringify({ error: err.message });
1401
+ if (err) return formatApiError(err);
1367
1402
  const resourcePrefix = `/${actionDef.name.split(".")[0]}`;
1368
1403
  await engine.invalidatePath(resourcePrefix);
1369
- return JSON.stringify(response ?? { success: true });
1404
+ return JSON.stringify(response?.data ?? { success: true });
1370
1405
  }
1371
1406
  const resourceKey = actionDef.name.split(".")[0];
1372
1407
  const body = extractBodyPayload(validPayload, usedPathParams);
1373
1408
  const wrappedBody = { [resourceKey]: [body] };
1374
1409
  if (actionDef.method === "POST") {
1375
1410
  const [response, err] = await attempt(async () => engine.post(path, wrappedBody));
1376
- if (err) return JSON.stringify({ error: err.message });
1377
- return JSON.stringify(response);
1411
+ if (err) return formatApiError(err);
1412
+ return JSON.stringify(response.data);
1378
1413
  }
1379
1414
  if (actionDef.method === "PUT") {
1380
1415
  const [response, err] = await attempt(async () => engine.put(path, wrappedBody));
1381
- if (err) return JSON.stringify({ error: err.message });
1416
+ if (err) return formatApiError(err);
1382
1417
  const resourcePrefix = `/${actionDef.name.split(".")[0]}`;
1383
1418
  await engine.invalidatePath(resourcePrefix);
1384
- return JSON.stringify(response);
1419
+ return JSON.stringify(response.data);
1385
1420
  }
1386
1421
  return JSON.stringify({ error: `Unsupported method: ${actionDef.method}` });
1387
1422
  }
@@ -1407,8 +1442,8 @@ async function handleFileUpload(actionName, payload, path) {
1407
1442
  const [response, err] = await attempt(async () => adminApi.post(path, formData, { onBeforeReq: (opts) => {
1408
1443
  delete opts.headers["Content-Type"];
1409
1444
  } }));
1410
- if (err) return JSON.stringify({ error: err.message });
1411
- return JSON.stringify(response);
1445
+ if (err) return formatApiError(err);
1446
+ return JSON.stringify(response.data);
1412
1447
  }
1413
1448
  //#endregion
1414
1449
  //#region src/tools/ghost-api-help.ts
@@ -1784,11 +1819,6 @@ const NODE_SPECS = {
1784
1819
  }
1785
1820
  };
1786
1821
  //#endregion
1787
- //#region src/koenig/util.ts
1788
- function isRecord(value) {
1789
- return typeof value === "object" && value !== null && !Array.isArray(value);
1790
- }
1791
- //#endregion
1792
1822
  //#region src/koenig/cards.ts
1793
1823
  function passthrough(def, fields) {
1794
1824
  const spec = NODE_SPECS[def.nodeType];
@@ -2477,12 +2507,13 @@ const koenigHelpSchema = z.object({ block: z.string().optional().describe("A blo
2477
2507
  function handleKoenigHelp(input) {
2478
2508
  return blockHelp(input.block);
2479
2509
  }
2510
+ const VERSION = createRequire(import.meta.url)("../package.json").version;
2480
2511
  //#endregion
2481
2512
  //#region src/index.ts
2482
2513
  const GHOST_API_MODE = process.env.GHOST_API_MODE || "admin";
2483
2514
  const server = new McpServer({
2484
2515
  name: "ghost-mcp",
2485
- version: "0.1.0"
2516
+ version: VERSION
2486
2517
  });
2487
2518
  server.tool("use_ghost_api", "Execute a Ghost API action (browse, read, add, edit, delete posts, pages, tags, members, newsletters, and more)", useGhostApiSchema.shape, async ({ api, action, payload }) => {
2488
2519
  return { content: [{