@damusix/ghost-mcp 0.3.0 → 0.4.1

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/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";
@@ -6,6 +7,8 @@ import { attempt } from "@logosdx/utils";
6
7
  import { FetchEngine, config, get } from "@logosdx/fetch";
7
8
  import mimeDb from "mime-db";
8
9
  import jwt from "jsonwebtoken";
10
+ import { readFileSync } from "node:fs";
11
+ import { isAbsolute } from "node:path";
9
12
  //#region src/ghost-client.ts
10
13
  const GHOST_URL = process.env.GHOST_URL || "";
11
14
  const GHOST_ADMIN_API_KEY = process.env.GHOST_ADMIN_API_KEY || "";
@@ -141,7 +144,11 @@ const postWriteFields = {
141
144
  "draft",
142
145
  "scheduled"
143
146
  ]).optional().describe("Post status"),
144
- 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 }"),
145
152
  authors: z.array(z.object({ id: z.string() })).optional().describe("Authors to assign (by id)"),
146
153
  featured: z.boolean().optional().describe("Whether the post is featured"),
147
154
  visibility: z.string().optional().describe("Post visibility (public, members, paid, tiers)"),
@@ -293,7 +300,11 @@ const pageWriteFields = {
293
300
  "draft",
294
301
  "scheduled"
295
302
  ]).optional().describe("Page status"),
296
- 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 }"),
297
308
  authors: z.array(z.object({ id: z.string() })).optional().describe("Authors to assign (by id)"),
298
309
  featured: z.boolean().optional().describe("Whether the page is featured"),
299
310
  visibility: z.string().optional().describe("Page visibility"),
@@ -678,7 +689,7 @@ const adminNewsletterActions = [
678
689
  ];
679
690
  //#endregion
680
691
  //#region src/actions/admin/offers.ts
681
- 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\")") });
682
693
  const readParams$8 = z.object({ id: z.string().describe("Offer ID") });
683
694
  const addSchema$2 = z.object({
684
695
  name: z.string().describe("Internal name for the offer (required)"),
@@ -710,7 +721,8 @@ const adminOfferActions = [
710
721
  method: "GET",
711
722
  path: "/offers/",
712
723
  inputSchema: browseParams$7,
713
- description: "Browse all offers"
724
+ description: "Browse all offers (Ghost returns the full list; filter by status)",
725
+ example: { filter: "status:active" }
714
726
  },
715
727
  {
716
728
  name: "offers.read",
@@ -1293,7 +1305,32 @@ function initRegistry() {
1293
1305
  }
1294
1306
  initRegistry();
1295
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
1296
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
+ }
1297
1334
  const extToMime = {};
1298
1335
  for (const [mime, meta] of Object.entries(mimeDb)) for (const ext of meta.extensions ?? []) extToMime[`.${ext}`] = mime;
1299
1336
  const useGhostApiSchema = z.object({
@@ -1356,30 +1393,30 @@ async function handleUseGhostApi(input, mode) {
1356
1393
  if (actionDef.method === "GET") {
1357
1394
  const queryParams = extractQueryParams(validPayload, usedPathParams);
1358
1395
  const [response, err] = await attempt(async () => engine.get(path, { params: queryParams }));
1359
- if (err) return JSON.stringify({ error: err.message });
1360
- return JSON.stringify(response);
1396
+ if (err) return formatApiError(err);
1397
+ return JSON.stringify(response.data);
1361
1398
  }
1362
1399
  if (actionDef.method === "DELETE") {
1363
1400
  const [response, err] = await attempt(async () => engine.delete(path));
1364
- if (err) return JSON.stringify({ error: err.message });
1401
+ if (err) return formatApiError(err);
1365
1402
  const resourcePrefix = `/${actionDef.name.split(".")[0]}`;
1366
1403
  await engine.invalidatePath(resourcePrefix);
1367
- return JSON.stringify(response ?? { success: true });
1404
+ return JSON.stringify(response?.data ?? { success: true });
1368
1405
  }
1369
1406
  const resourceKey = actionDef.name.split(".")[0];
1370
1407
  const body = extractBodyPayload(validPayload, usedPathParams);
1371
1408
  const wrappedBody = { [resourceKey]: [body] };
1372
1409
  if (actionDef.method === "POST") {
1373
1410
  const [response, err] = await attempt(async () => engine.post(path, wrappedBody));
1374
- if (err) return JSON.stringify({ error: err.message });
1375
- return JSON.stringify(response);
1411
+ if (err) return formatApiError(err);
1412
+ return JSON.stringify(response.data);
1376
1413
  }
1377
1414
  if (actionDef.method === "PUT") {
1378
1415
  const [response, err] = await attempt(async () => engine.put(path, wrappedBody));
1379
- if (err) return JSON.stringify({ error: err.message });
1416
+ if (err) return formatApiError(err);
1380
1417
  const resourcePrefix = `/${actionDef.name.split(".")[0]}`;
1381
1418
  await engine.invalidatePath(resourcePrefix);
1382
- return JSON.stringify(response);
1419
+ return JSON.stringify(response.data);
1383
1420
  }
1384
1421
  return JSON.stringify({ error: `Unsupported method: ${actionDef.method}` });
1385
1422
  }
@@ -1405,8 +1442,8 @@ async function handleFileUpload(actionName, payload, path) {
1405
1442
  const [response, err] = await attempt(async () => adminApi.post(path, formData, { onBeforeReq: (opts) => {
1406
1443
  delete opts.headers["Content-Type"];
1407
1444
  } }));
1408
- if (err) return JSON.stringify({ error: err.message });
1409
- return JSON.stringify(response);
1445
+ if (err) return formatApiError(err);
1446
+ return JSON.stringify(response.data);
1410
1447
  }
1411
1448
  //#endregion
1412
1449
  //#region src/tools/ghost-api-help.ts
@@ -1448,20 +1485,11 @@ const ghostDocsSchema = z.object({
1448
1485
  search: z.string().optional().describe("Case-insensitive substring search across the documentation"),
1449
1486
  regex: z.string().optional().describe("Regex pattern string to match (e.g. \"/pattern/i\")")
1450
1487
  });
1451
- async function fetchDocs() {
1452
- const [response, err] = await attempt(async () => docsApi.get("/llms.txt"));
1453
- if (err) throw new Error(`Failed to fetch Ghost docs: ${err.message}`);
1454
- return response.data;
1455
- }
1456
1488
  async function handleGhostDocs(input) {
1457
1489
  const { all, search, regex } = input;
1458
1490
  if (!all && !search && !regex) return "Provide one of: `all: true` to get full docs, `search` for text search, or `regex` for pattern matching.";
1459
- let content;
1460
- try {
1461
- content = await fetchDocs();
1462
- } catch (error) {
1463
- return `Error: ${error.message}`;
1464
- }
1491
+ const response = await docsApi.get("/llms.txt");
1492
+ const content = String(response.data);
1465
1493
  if (all) return content;
1466
1494
  const lines = content.split("\n");
1467
1495
  const matchedLines = [];
@@ -1494,11 +1522,998 @@ async function handleGhostDocs(input) {
1494
1522
  return matchedLines.join("\n");
1495
1523
  }
1496
1524
  //#endregion
1525
+ //#region src/koenig/inline.ts
1526
+ const FORMAT = {
1527
+ bold: 1,
1528
+ italic: 2,
1529
+ strikethrough: 4,
1530
+ underline: 8,
1531
+ code: 16
1532
+ };
1533
+ function textNode(text, format = 0) {
1534
+ return {
1535
+ type: "extended-text",
1536
+ version: 1,
1537
+ text,
1538
+ format,
1539
+ mode: "normal",
1540
+ style: "",
1541
+ detail: 0
1542
+ };
1543
+ }
1544
+ function linkNode(url, label) {
1545
+ return {
1546
+ type: "link",
1547
+ version: 1,
1548
+ direction: "ltr",
1549
+ format: "",
1550
+ indent: 0,
1551
+ rel: null,
1552
+ target: null,
1553
+ title: null,
1554
+ url,
1555
+ children: [textNode(label)]
1556
+ };
1557
+ }
1558
+ const TOKEN = /\[([^\]]+)\]\(([^)\s]+)\)|`([^`]+)`|\*\*([^*]+)\*\*|\*([^*]+)\*|(?<![\w])_([^_]+)_(?![\w])/g;
1559
+ function parseInline(input) {
1560
+ const nodes = [];
1561
+ let last = 0;
1562
+ let m;
1563
+ TOKEN.lastIndex = 0;
1564
+ while ((m = TOKEN.exec(input)) !== null) {
1565
+ if (m.index > last) nodes.push(textNode(input.slice(last, m.index)));
1566
+ if (m[1] !== void 0) nodes.push(linkNode(m[2], m[1]));
1567
+ else if (m[3] !== void 0) nodes.push(textNode(m[3], FORMAT.code));
1568
+ else if (m[4] !== void 0) nodes.push(textNode(m[4], FORMAT.bold));
1569
+ else if (m[5] !== void 0) nodes.push(textNode(m[5], FORMAT.italic));
1570
+ else if (m[6] !== void 0) nodes.push(textNode(m[6], FORMAT.italic));
1571
+ last = m.index + m[0].length;
1572
+ }
1573
+ if (last < input.length) nodes.push(textNode(input.slice(last)));
1574
+ if (nodes.length === 0) nodes.push(textNode(input));
1575
+ return nodes;
1576
+ }
1577
+ //#endregion
1578
+ //#region src/koenig/node-specs.ts
1579
+ const NODE_SPECS = {
1580
+ audio: {
1581
+ nodeType: "audio",
1582
+ hasVisibility: false,
1583
+ fields: {
1584
+ duration: 0,
1585
+ mimeType: "",
1586
+ src: "",
1587
+ title: "",
1588
+ thumbnailSrc: ""
1589
+ }
1590
+ },
1591
+ bookmark: {
1592
+ nodeType: "bookmark",
1593
+ hasVisibility: false,
1594
+ fields: {
1595
+ title: "",
1596
+ description: "",
1597
+ url: "",
1598
+ caption: "",
1599
+ author: "",
1600
+ publisher: ""
1601
+ }
1602
+ },
1603
+ button: {
1604
+ nodeType: "button",
1605
+ hasVisibility: false,
1606
+ fields: {
1607
+ buttonText: "",
1608
+ alignment: "center",
1609
+ buttonUrl: ""
1610
+ }
1611
+ },
1612
+ "call-to-action": {
1613
+ nodeType: "call-to-action",
1614
+ hasVisibility: true,
1615
+ fields: {
1616
+ layout: "minimal",
1617
+ alignment: "left",
1618
+ textValue: "",
1619
+ showButton: true,
1620
+ showDividers: true,
1621
+ buttonText: "Learn more",
1622
+ buttonUrl: "",
1623
+ buttonColor: "#000000",
1624
+ buttonTextColor: "#ffffff",
1625
+ hasSponsorLabel: true,
1626
+ sponsorLabel: "<p><span style=\"white-space: pre-wrap;\">SPONSORED</span></p>",
1627
+ backgroundColor: "grey",
1628
+ linkColor: "text",
1629
+ imageUrl: "",
1630
+ imageWidth: null,
1631
+ imageHeight: null
1632
+ }
1633
+ },
1634
+ callout: {
1635
+ nodeType: "callout",
1636
+ hasVisibility: false,
1637
+ fields: {
1638
+ calloutText: "",
1639
+ calloutEmoji: "💡",
1640
+ backgroundColor: "blue"
1641
+ }
1642
+ },
1643
+ codeblock: {
1644
+ nodeType: "codeblock",
1645
+ hasVisibility: false,
1646
+ fields: {
1647
+ code: "",
1648
+ language: "",
1649
+ caption: ""
1650
+ }
1651
+ },
1652
+ email: {
1653
+ nodeType: "email",
1654
+ hasVisibility: false,
1655
+ fields: { html: "" }
1656
+ },
1657
+ "email-cta": {
1658
+ nodeType: "email-cta",
1659
+ hasVisibility: false,
1660
+ fields: {
1661
+ alignment: "left",
1662
+ buttonText: "",
1663
+ buttonUrl: "",
1664
+ html: "",
1665
+ segment: "status:free",
1666
+ showButton: false,
1667
+ showDividers: true
1668
+ }
1669
+ },
1670
+ embed: {
1671
+ nodeType: "embed",
1672
+ hasVisibility: false,
1673
+ fields: {
1674
+ url: "",
1675
+ embedType: "",
1676
+ html: "",
1677
+ caption: ""
1678
+ }
1679
+ },
1680
+ file: {
1681
+ nodeType: "file",
1682
+ hasVisibility: false,
1683
+ fields: {
1684
+ src: "",
1685
+ fileTitle: "",
1686
+ fileCaption: "",
1687
+ fileName: "",
1688
+ fileSize: 0
1689
+ }
1690
+ },
1691
+ gallery: {
1692
+ nodeType: "gallery",
1693
+ hasVisibility: false,
1694
+ fields: {
1695
+ images: "[]",
1696
+ caption: ""
1697
+ }
1698
+ },
1699
+ header: {
1700
+ nodeType: "header",
1701
+ hasVisibility: false,
1702
+ fields: {
1703
+ size: "small",
1704
+ style: "dark",
1705
+ buttonEnabled: false,
1706
+ buttonUrl: "",
1707
+ buttonText: "",
1708
+ header: "",
1709
+ subheader: "",
1710
+ backgroundImageSrc: "",
1711
+ version: 1,
1712
+ accentColor: "#FF1A75",
1713
+ alignment: "center",
1714
+ backgroundColor: "#000000",
1715
+ backgroundImageWidth: null,
1716
+ backgroundImageHeight: null,
1717
+ backgroundSize: "cover",
1718
+ textColor: "#FFFFFF",
1719
+ buttonColor: "#ffffff",
1720
+ buttonTextColor: "#000000",
1721
+ layout: "full",
1722
+ swapped: false
1723
+ }
1724
+ },
1725
+ html: {
1726
+ nodeType: "html",
1727
+ hasVisibility: true,
1728
+ fields: { html: "" }
1729
+ },
1730
+ image: {
1731
+ nodeType: "image",
1732
+ hasVisibility: false,
1733
+ fields: {
1734
+ src: "",
1735
+ caption: "",
1736
+ title: "",
1737
+ alt: "",
1738
+ cardWidth: "regular",
1739
+ width: null,
1740
+ height: null,
1741
+ href: ""
1742
+ }
1743
+ },
1744
+ markdown: {
1745
+ nodeType: "markdown",
1746
+ hasVisibility: false,
1747
+ fields: { markdown: "" }
1748
+ },
1749
+ product: {
1750
+ nodeType: "product",
1751
+ hasVisibility: false,
1752
+ fields: {
1753
+ productImageSrc: "",
1754
+ productImageWidth: null,
1755
+ productImageHeight: null,
1756
+ productTitle: "",
1757
+ productDescription: "",
1758
+ productRatingEnabled: false,
1759
+ productStarRating: 5,
1760
+ productButtonEnabled: false,
1761
+ productButton: "",
1762
+ productUrl: ""
1763
+ }
1764
+ },
1765
+ signup: {
1766
+ nodeType: "signup",
1767
+ hasVisibility: false,
1768
+ fields: {
1769
+ alignment: "left",
1770
+ backgroundColor: "#F0F0F0",
1771
+ backgroundImageSrc: "",
1772
+ backgroundSize: "cover",
1773
+ textColor: "",
1774
+ buttonColor: "accent",
1775
+ buttonTextColor: "#FFFFFF",
1776
+ buttonText: "Subscribe",
1777
+ disclaimer: "",
1778
+ header: "",
1779
+ layout: "wide",
1780
+ subheader: "",
1781
+ successMessage: "Email sent! Check your inbox to complete your signup.",
1782
+ swapped: false
1783
+ }
1784
+ },
1785
+ toggle: {
1786
+ nodeType: "toggle",
1787
+ hasVisibility: false,
1788
+ fields: {
1789
+ heading: "",
1790
+ content: ""
1791
+ }
1792
+ },
1793
+ transistor: {
1794
+ nodeType: "transistor",
1795
+ hasVisibility: true,
1796
+ fields: {
1797
+ accentColor: "",
1798
+ backgroundColor: ""
1799
+ }
1800
+ },
1801
+ video: {
1802
+ nodeType: "video",
1803
+ hasVisibility: false,
1804
+ fields: {
1805
+ src: "",
1806
+ caption: "",
1807
+ fileName: "",
1808
+ mimeType: "",
1809
+ width: null,
1810
+ height: null,
1811
+ duration: 0,
1812
+ thumbnailSrc: "",
1813
+ customThumbnailSrc: "",
1814
+ thumbnailWidth: null,
1815
+ thumbnailHeight: null,
1816
+ cardWidth: "regular",
1817
+ loop: false
1818
+ }
1819
+ }
1820
+ };
1821
+ //#endregion
1822
+ //#region src/koenig/cards.ts
1823
+ function passthrough(def, fields) {
1824
+ const spec = NODE_SPECS[def.nodeType];
1825
+ const validFields = spec ? new Set(Object.keys(spec.fields)) : null;
1826
+ const node = {};
1827
+ for (const [key, value] of Object.entries(fields)) {
1828
+ const target = def.aliases[key] ?? key;
1829
+ if (validFields && !validFields.has(target)) {
1830
+ const allowed = [...Object.keys(def.aliases), ...validFields ?? []].join(", ");
1831
+ throw new Error(`unknown field "${key}" for card "${def.nodeType}". allowed: ${allowed}`);
1832
+ }
1833
+ node[target] = value;
1834
+ }
1835
+ return node;
1836
+ }
1837
+ /** keyed by the friendly block `type` the LLM writes */
1838
+ const CARDS = {
1839
+ image: {
1840
+ nodeType: "image",
1841
+ version: 1,
1842
+ group: "media",
1843
+ description: "An image with optional caption, alt text, and link.",
1844
+ required: ["src"],
1845
+ aliases: {},
1846
+ example: {
1847
+ src: "https://example.com/photo.jpg",
1848
+ alt: "A photo",
1849
+ caption: "My caption"
1850
+ }
1851
+ },
1852
+ gallery: {
1853
+ nodeType: "gallery",
1854
+ version: 1,
1855
+ group: "media",
1856
+ description: "A grid of images. Provide `images` as an array of { src, alt?, width?, height? }.",
1857
+ required: ["images"],
1858
+ aliases: {},
1859
+ example: {
1860
+ images: [{ src: "https://example.com/1.jpg" }, { src: "https://example.com/2.jpg" }],
1861
+ caption: "Trip photos"
1862
+ },
1863
+ build(fields) {
1864
+ const node = { images: (Array.isArray(fields.images) ? fields.images : []).map((img, i) => {
1865
+ const o = isRecord(img) ? img : {};
1866
+ return {
1867
+ fileName: o.fileName ?? `image-${i}.jpg`,
1868
+ row: o.row ?? 0,
1869
+ src: o.src ?? "",
1870
+ width: o.width ?? 0,
1871
+ height: o.height ?? 0,
1872
+ title: o.title ?? "",
1873
+ alt: o.alt ?? ""
1874
+ };
1875
+ }) };
1876
+ if (typeof fields.caption === "string") node.caption = fields.caption;
1877
+ return node;
1878
+ }
1879
+ },
1880
+ video: {
1881
+ nodeType: "video",
1882
+ version: 1,
1883
+ group: "media",
1884
+ description: "A video file. Needs `src`; `thumbnailSrc` sets the poster.",
1885
+ required: ["src"],
1886
+ aliases: { thumbnail: "thumbnailSrc" },
1887
+ example: {
1888
+ src: "https://example.com/clip.mp4",
1889
+ caption: "A clip",
1890
+ thumbnail: "https://example.com/poster.jpg"
1891
+ }
1892
+ },
1893
+ audio: {
1894
+ nodeType: "audio",
1895
+ version: 1,
1896
+ group: "media",
1897
+ description: "An audio file with a title.",
1898
+ required: ["src"],
1899
+ aliases: { thumbnail: "thumbnailSrc" },
1900
+ example: {
1901
+ src: "https://example.com/track.mp3",
1902
+ title: "Episode 1",
1903
+ duration: 320
1904
+ }
1905
+ },
1906
+ file: {
1907
+ nodeType: "file",
1908
+ version: 1,
1909
+ group: "media",
1910
+ description: "A downloadable file card.",
1911
+ required: ["src"],
1912
+ aliases: {
1913
+ title: "fileTitle",
1914
+ name: "fileName",
1915
+ caption: "fileCaption",
1916
+ size: "fileSize"
1917
+ },
1918
+ example: {
1919
+ src: "https://example.com/guide.pdf",
1920
+ title: "Whitepaper",
1921
+ caption: "Download our guide"
1922
+ }
1923
+ },
1924
+ bookmark: {
1925
+ nodeType: "bookmark",
1926
+ version: 1,
1927
+ group: "embed",
1928
+ description: "A rich link preview. Provide `url`; optionally title/description/author/publisher/icon/thumbnail.",
1929
+ required: ["url"],
1930
+ aliases: {},
1931
+ example: {
1932
+ url: "https://ghost.org",
1933
+ title: "Ghost",
1934
+ description: "Publishing platform"
1935
+ },
1936
+ build(fields) {
1937
+ const metaKeys = [
1938
+ "title",
1939
+ "description",
1940
+ "author",
1941
+ "publisher",
1942
+ "icon",
1943
+ "thumbnail"
1944
+ ];
1945
+ const metadata = { url: fields.url };
1946
+ for (const k of metaKeys) metadata[k] = fields[k] ?? (k === "author" ? null : "");
1947
+ return {
1948
+ url: fields.url,
1949
+ caption: fields.caption ?? "",
1950
+ metadata
1951
+ };
1952
+ }
1953
+ },
1954
+ embed: {
1955
+ nodeType: "embed",
1956
+ version: 1,
1957
+ group: "embed",
1958
+ description: "An external embed (YouTube, Twitter, etc.). Provide `url` and the embed `html`.",
1959
+ required: ["url"],
1960
+ aliases: {},
1961
+ example: {
1962
+ url: "https://youtube.com/watch?v=abc",
1963
+ embedType: "video",
1964
+ html: "<iframe src=\"...\"></iframe>"
1965
+ }
1966
+ },
1967
+ html: {
1968
+ nodeType: "html",
1969
+ version: 1,
1970
+ group: "embed",
1971
+ description: "Raw HTML passthrough. Use only when no native card fits.",
1972
+ required: ["html"],
1973
+ aliases: {},
1974
+ example: { html: "<div class=\"custom\">Raw HTML</div>" }
1975
+ },
1976
+ markdown: {
1977
+ nodeType: "markdown",
1978
+ version: 1,
1979
+ group: "embed",
1980
+ description: "A markdown block (rendered as one unit). Prefer paragraph/heading/list blocks for editable prose.",
1981
+ required: ["markdown"],
1982
+ aliases: { text: "markdown" },
1983
+ example: { markdown: "## Heading\n\nSome **markdown**." }
1984
+ },
1985
+ codeblock: {
1986
+ nodeType: "codeblock",
1987
+ version: 1,
1988
+ group: "embed",
1989
+ description: "A syntax-highlighted code block.",
1990
+ required: ["code"],
1991
+ aliases: { lang: "language" },
1992
+ example: {
1993
+ code: "const x = 1;",
1994
+ language: "javascript",
1995
+ caption: "snippet"
1996
+ }
1997
+ },
1998
+ callout: {
1999
+ nodeType: "callout",
2000
+ version: 1,
2001
+ group: "layout",
2002
+ description: "A highlighted callout box with an emoji and background color.",
2003
+ required: ["text"],
2004
+ aliases: {
2005
+ text: "calloutText",
2006
+ emoji: "calloutEmoji",
2007
+ color: "backgroundColor"
2008
+ },
2009
+ example: {
2010
+ text: "Heads up!",
2011
+ emoji: "💡",
2012
+ color: "blue"
2013
+ }
2014
+ },
2015
+ toggle: {
2016
+ nodeType: "toggle",
2017
+ version: 1,
2018
+ group: "layout",
2019
+ description: "A collapsible accordion. `content` is HTML. (No-op in email.)",
2020
+ required: ["heading"],
2021
+ aliases: {},
2022
+ example: {
2023
+ heading: "Click to expand",
2024
+ content: "<p>Hidden content.</p>"
2025
+ }
2026
+ },
2027
+ button: {
2028
+ nodeType: "button",
2029
+ version: 1,
2030
+ group: "layout",
2031
+ description: "A call-to-action button.",
2032
+ required: ["text", "url"],
2033
+ aliases: {
2034
+ text: "buttonText",
2035
+ url: "buttonUrl"
2036
+ },
2037
+ example: {
2038
+ text: "Subscribe",
2039
+ url: "https://example.com",
2040
+ alignment: "center"
2041
+ }
2042
+ },
2043
+ header: {
2044
+ nodeType: "header",
2045
+ version: 2,
2046
+ group: "layout",
2047
+ description: "A large hero header with optional background image and button.",
2048
+ required: [],
2049
+ aliases: { title: "header" },
2050
+ example: {
2051
+ header: "Big Header",
2052
+ subheader: "A subheader",
2053
+ buttonEnabled: true,
2054
+ buttonText: "Start",
2055
+ buttonUrl: "https://example.com"
2056
+ }
2057
+ },
2058
+ cta: {
2059
+ nodeType: "call-to-action",
2060
+ version: 1,
2061
+ group: "layout",
2062
+ description: "A call-to-action card with text, optional image and button. `text` accepts HTML or plain text.",
2063
+ required: [],
2064
+ aliases: { buttonColor: "buttonColor" },
2065
+ example: {
2066
+ text: "Subscribe for more.",
2067
+ buttonText: "Join",
2068
+ buttonUrl: "https://example.com",
2069
+ showButton: true
2070
+ },
2071
+ build(fields) {
2072
+ const node = {};
2073
+ for (const k of [
2074
+ "layout",
2075
+ "alignment",
2076
+ "showButton",
2077
+ "showDividers",
2078
+ "buttonText",
2079
+ "buttonUrl",
2080
+ "buttonColor",
2081
+ "buttonTextColor",
2082
+ "hasSponsorLabel",
2083
+ "sponsorLabel",
2084
+ "backgroundColor",
2085
+ "linkColor",
2086
+ "imageUrl",
2087
+ "imageWidth",
2088
+ "imageHeight",
2089
+ "visibility"
2090
+ ]) if (k in fields) node[k] = fields[k];
2091
+ if (typeof fields.text === "string") node.textValue = /^\s*</.test(fields.text) ? fields.text : `<p>${fields.text}</p>`;
2092
+ return node;
2093
+ }
2094
+ },
2095
+ signup: {
2096
+ nodeType: "signup",
2097
+ version: 1,
2098
+ group: "membership",
2099
+ description: "A member signup form. (No-op in email.)",
2100
+ required: [],
2101
+ aliases: {},
2102
+ example: {
2103
+ header: "Subscribe",
2104
+ subheader: "Join the newsletter",
2105
+ disclaimer: "No spam."
2106
+ }
2107
+ },
2108
+ product: {
2109
+ nodeType: "product",
2110
+ version: 1,
2111
+ group: "layout",
2112
+ description: "A product card with image, rating, and button.",
2113
+ required: ["productTitle"],
2114
+ aliases: {
2115
+ title: "productTitle",
2116
+ description: "productDescription",
2117
+ image: "productImageSrc",
2118
+ button: "productButton",
2119
+ url: "productUrl",
2120
+ rating: "productStarRating"
2121
+ },
2122
+ example: {
2123
+ title: "The Product",
2124
+ description: "A great product.",
2125
+ rating: 5,
2126
+ button: "Buy",
2127
+ url: "https://example.com",
2128
+ productButtonEnabled: true,
2129
+ productRatingEnabled: true
2130
+ }
2131
+ },
2132
+ divider: {
2133
+ nodeType: "horizontalrule",
2134
+ version: 1,
2135
+ group: "divider",
2136
+ description: "A horizontal rule / divider.",
2137
+ required: [],
2138
+ aliases: {},
2139
+ example: {},
2140
+ build() {
2141
+ return {};
2142
+ }
2143
+ },
2144
+ paywall: {
2145
+ nodeType: "paywall",
2146
+ version: 1,
2147
+ group: "membership",
2148
+ description: "Splits free vs members-only content. Everything after it is members-only.",
2149
+ required: [],
2150
+ aliases: {},
2151
+ example: {},
2152
+ build() {
2153
+ return {};
2154
+ }
2155
+ },
2156
+ email: {
2157
+ nodeType: "email",
2158
+ version: 1,
2159
+ group: "email-only",
2160
+ description: "Content shown ONLY in the email newsletter (empty on web). `html` supports {first_name, \"fallback\"}.",
2161
+ required: ["html"],
2162
+ aliases: {},
2163
+ example: { html: "<p>Hello {first_name, \"there\"}!</p>" }
2164
+ },
2165
+ "email-cta": {
2166
+ nodeType: "email-cta",
2167
+ version: 1,
2168
+ group: "email-only",
2169
+ description: "A newsletter-only call to action targeting a member segment.",
2170
+ required: [],
2171
+ aliases: {},
2172
+ example: {
2173
+ html: "<p>Read more.</p>",
2174
+ buttonText: "Read",
2175
+ buttonUrl: "https://example.com",
2176
+ segment: "status:free"
2177
+ }
2178
+ }
2179
+ };
2180
+ function buildCardNode(blockType, fields) {
2181
+ const def = CARDS[blockType];
2182
+ if (!def) throw new Error(`unknown card type "${blockType}"`);
2183
+ for (const r of def.required) if (fields[r] === void 0 || fields[r] === null || fields[r] === "") throw new Error(`card "${blockType}" requires field "${r}"`);
2184
+ const data = def.build ? def.build(fields) : passthrough(def, fields);
2185
+ return {
2186
+ type: def.nodeType,
2187
+ version: def.version,
2188
+ ...data
2189
+ };
2190
+ }
2191
+ function isCardType(blockType) {
2192
+ return blockType in CARDS;
2193
+ }
2194
+ //#endregion
2195
+ //#region src/koenig/blocks.ts
2196
+ const ELEMENT = {
2197
+ direction: "ltr",
2198
+ format: "",
2199
+ indent: 0
2200
+ };
2201
+ function element(type, extra, children) {
2202
+ return {
2203
+ type,
2204
+ version: 1,
2205
+ ...ELEMENT,
2206
+ ...extra,
2207
+ children
2208
+ };
2209
+ }
2210
+ function asString(value, field, blockType) {
2211
+ if (typeof value !== "string") throw new Error(`block "${blockType}" field "${field}" must be a string`);
2212
+ return value;
2213
+ }
2214
+ function buildProse(block) {
2215
+ switch (block.type) {
2216
+ case "paragraph": return element("paragraph", {}, parseInline(asString(block.text, "text", "paragraph")));
2217
+ case "heading": return element("extended-heading", { tag: `h${typeof block.level === "number" ? Math.min(6, Math.max(1, block.level)) : 2}` }, parseInline(asString(block.text, "text", "heading")));
2218
+ case "quote": return element("extended-quote", {}, parseInline(asString(block.text, "text", "quote")));
2219
+ case "aside": return element("aside", {}, parseInline(asString(block.text, "text", "aside")));
2220
+ case "list": {
2221
+ const items = Array.isArray(block.items) ? block.items : [];
2222
+ if (items.length === 0) throw new Error("block \"list\" requires a non-empty \"items\" array");
2223
+ const ordered = block.style === "number" || block.style === "ordered";
2224
+ const children = items.map((item, i) => element("listitem", { value: i + 1 }, parseInline(String(item))));
2225
+ return element("list", {
2226
+ listType: ordered ? "number" : "bullet",
2227
+ tag: ordered ? "ol" : "ul",
2228
+ start: 1
2229
+ }, children);
2230
+ }
2231
+ default: return null;
2232
+ }
2233
+ }
2234
+ function isBlock(value) {
2235
+ return isRecord(value) && typeof value.type === "string";
2236
+ }
2237
+ function buildBlock(block) {
2238
+ if (!isBlock(block)) throw new Error("each block must be an object with a string \"type\"");
2239
+ const prose = buildProse(block);
2240
+ if (prose) return prose;
2241
+ if (isCardType(block.type)) {
2242
+ const { type, ...fields } = block;
2243
+ return buildCardNode(type, fields);
2244
+ }
2245
+ throw new Error(`unknown block type "${block.type}". valid types: ${[...PROSE_TYPES, ...Object.keys(CARDS)].join(", ")}`);
2246
+ }
2247
+ const PROSE_TYPES = [
2248
+ "paragraph",
2249
+ "heading",
2250
+ "list",
2251
+ "quote",
2252
+ "aside"
2253
+ ];
2254
+ //#endregion
2255
+ //#region src/koenig/compose.ts
2256
+ var ComposeError = class extends Error {
2257
+ constructor(issues) {
2258
+ const detail = issues.map((i) => `[#${i.index} ${i.type}] ${i.message}`).join("; ");
2259
+ super(`composition failed (${issues.length} issue${issues.length === 1 ? "" : "s"}): ${detail}`);
2260
+ this.issues = issues;
2261
+ this.name = "ComposeError";
2262
+ }
2263
+ };
2264
+ function composeRoot(blocks) {
2265
+ if (!Array.isArray(blocks) || blocks.length === 0) throw new ComposeError([{
2266
+ index: -1,
2267
+ type: "(none)",
2268
+ message: "blocks must be a non-empty array"
2269
+ }]);
2270
+ const children = [];
2271
+ const issues = [];
2272
+ blocks.forEach((block, index) => {
2273
+ try {
2274
+ children.push(buildBlock(block));
2275
+ } catch (error) {
2276
+ issues.push({
2277
+ index,
2278
+ type: isRecord(block) && typeof block.type === "string" ? block.type : "(invalid)",
2279
+ message: error instanceof Error ? error.message : String(error)
2280
+ });
2281
+ }
2282
+ });
2283
+ if (issues.length > 0) throw new ComposeError(issues);
2284
+ return { root: {
2285
+ type: "root",
2286
+ version: 1,
2287
+ direction: "ltr",
2288
+ format: "",
2289
+ indent: 0,
2290
+ children
2291
+ } };
2292
+ }
2293
+ function compose(blocks) {
2294
+ return JSON.stringify(composeRoot(blocks));
2295
+ }
2296
+ //#endregion
2297
+ //#region src/koenig/help.ts
2298
+ const PROSE = [
2299
+ {
2300
+ type: "paragraph",
2301
+ description: "A text paragraph. `text` supports inline **bold**, _italic_, `code`, [links](url).",
2302
+ example: {
2303
+ type: "paragraph",
2304
+ text: "Some **bold** and a [link](https://x.com)."
2305
+ }
2306
+ },
2307
+ {
2308
+ type: "heading",
2309
+ description: "A heading. `level` 1–6 (default 2). `text` supports inline markdown.",
2310
+ example: {
2311
+ type: "heading",
2312
+ level: 2,
2313
+ text: "Section title"
2314
+ }
2315
+ },
2316
+ {
2317
+ type: "list",
2318
+ description: "A bullet or numbered list. `style`: \"bullet\" (default) or \"number\". `items` is a string array (inline markdown supported).",
2319
+ example: {
2320
+ type: "list",
2321
+ style: "bullet",
2322
+ items: ["First", "Second"]
2323
+ }
2324
+ },
2325
+ {
2326
+ type: "quote",
2327
+ description: "A blockquote. `text` supports inline markdown.",
2328
+ example: {
2329
+ type: "quote",
2330
+ text: "A memorable quote."
2331
+ }
2332
+ },
2333
+ {
2334
+ type: "aside",
2335
+ description: "A pull-quote / aside.",
2336
+ example: {
2337
+ type: "aside",
2338
+ text: "An aside."
2339
+ }
2340
+ }
2341
+ ];
2342
+ function blockHelp(blockType) {
2343
+ if (blockType) {
2344
+ const prose = PROSE.find((p) => p.type === blockType);
2345
+ if (prose) return [
2346
+ `# block: ${prose.type}`,
2347
+ "",
2348
+ prose.description,
2349
+ "",
2350
+ "```json",
2351
+ JSON.stringify(prose.example, null, 2),
2352
+ "```"
2353
+ ].join("\n");
2354
+ const card = CARDS[blockType];
2355
+ if (!card) return `Unknown block type "${blockType}". Run koenig_help with no argument to list all block types.`;
2356
+ const example = {
2357
+ type: blockType,
2358
+ ...card.example
2359
+ };
2360
+ const lines = [
2361
+ `# block: ${blockType}`,
2362
+ "",
2363
+ `${card.description}`,
2364
+ "",
2365
+ `- Lexical node: \`${card.nodeType}\` (version ${card.version})`,
2366
+ card.required.length ? `- Required fields: ${card.required.map((r) => `\`${r}\``).join(", ")}` : "- Required fields: none"
2367
+ ];
2368
+ if (Object.keys(card.aliases).length) lines.push(`- Aliases: ${Object.entries(card.aliases).map(([f, t]) => `\`${f}\`→\`${t}\``).join(", ")}`);
2369
+ lines.push("", "```json", JSON.stringify(example, null, 2), "```");
2370
+ return lines.join("\n");
2371
+ }
2372
+ const lines = [
2373
+ "# Koenig block types",
2374
+ "",
2375
+ "Compose posts from these blocks instead of raw HTML — they produce clean, natively-editable Ghost content.",
2376
+ "",
2377
+ "## Prose (native, inline markdown in `text`)"
2378
+ ];
2379
+ for (const p of PROSE) lines.push(`- **${p.type}** — ${p.description}`);
2380
+ const byGroup = {};
2381
+ for (const [type, def] of Object.entries(CARDS)) (byGroup[def.group] ??= []).push(`- **${type}** — ${def.description}`);
2382
+ for (const [group, entries] of Object.entries(byGroup)) {
2383
+ lines.push("", `## ${group}`);
2384
+ lines.push(...entries);
2385
+ }
2386
+ lines.push("", "Use `koenig_help` with a `block` name for fields + a JSON example. Then call `compose_post` (creates the post) or `compose_lexical` (returns the lexical string).");
2387
+ return lines.join("\n");
2388
+ }
2389
+ //#endregion
2390
+ //#region src/tools/blocks-source.ts
2391
+ function extractBlocks(parsed) {
2392
+ if (Array.isArray(parsed)) return parsed;
2393
+ if (isRecord(parsed) && Array.isArray(parsed.blocks)) return parsed.blocks;
2394
+ return null;
2395
+ }
2396
+ function resolveBlocks(src) {
2397
+ const hasFile = typeof src.blockFile === "string" && src.blockFile.trim() !== "";
2398
+ if (Array.isArray(src.blocks)) {
2399
+ if (hasFile) throw new Error("provide either \"blocks\" or \"blockFile\", not both");
2400
+ return src.blocks;
2401
+ }
2402
+ if (!hasFile) throw new Error("provide \"blocks\" (inline array) or \"blockFile\" (absolute path to a JSON file of blocks)");
2403
+ const file = src.blockFile;
2404
+ if (typeof file !== "string") throw new Error("blockFile must be a string path");
2405
+ if (!isAbsolute(file)) throw new Error(`blockFile must be an absolute path (got "${file}"). Write the JSON to an absolute path, e.g. under your tmp/ directory.`);
2406
+ let raw;
2407
+ try {
2408
+ raw = readFileSync(file, "utf8");
2409
+ } catch {
2410
+ throw new Error(`could not read blockFile: ${file}`);
2411
+ }
2412
+ let parsed;
2413
+ try {
2414
+ parsed = JSON.parse(raw);
2415
+ } catch {
2416
+ throw new Error(`blockFile is not valid JSON: ${file}`);
2417
+ }
2418
+ const blocks = extractBlocks(parsed);
2419
+ if (!blocks) throw new Error(`blockFile must contain a JSON array of blocks, or { "blocks": [...] }: ${file}`);
2420
+ return blocks;
2421
+ }
2422
+ //#endregion
2423
+ //#region src/tools/compose-post.ts
2424
+ const blockSchema = z.object({ type: z.string().describe("Block type: paragraph, heading, list, quote, aside, image, gallery, video, audio, file, bookmark, embed, html, markdown, codeblock, callout, toggle, button, header, cta, signup, product, divider, paywall, email, email-cta. Run koenig_help for fields.") }).passthrough();
2425
+ const composeFields = {
2426
+ blocks: z.array(blockSchema).optional().describe("Ordered content blocks (inline). Prefer native blocks (paragraph/heading/list/quote) and cards over raw html. Prose `text` supports inline **bold**, _italic_, `code`, [links](url). Use koenig_help to discover block fields. For long posts, write the blocks to a JSON file and pass `blockFile` instead."),
2427
+ blockFile: z.string().optional().describe("Absolute path to a local JSON file containing the blocks — either a bare array `[...]` or `{ \"blocks\": [...] }`. Use this for long posts: compose/edit the file (validating with compose_lexical), then pass the path instead of re-sending the whole array. Provide exactly one of `blocks` or `blockFile`. Writing to an absolute path under tmp/ is recommended."),
2428
+ title: z.string().optional().describe("Post title (required when creating a new post)"),
2429
+ id: z.string().optional().describe("Post ID to update. Omit to create a new post."),
2430
+ updated_at: z.string().optional().describe("Required when updating (id set): the post's current updated_at, for collision detection. Get it via posts.read."),
2431
+ status: z.enum([
2432
+ "published",
2433
+ "draft",
2434
+ "scheduled"
2435
+ ]).optional().describe("Post status (default draft)"),
2436
+ tags: z.array(z.union([z.object({ id: z.string() }), z.object({ name: z.string() })])).optional().describe("Tags to assign (by id or name)"),
2437
+ feature_image: z.string().optional().describe("Feature image URL"),
2438
+ excerpt: z.string().optional().describe("Custom excerpt (maps to custom_excerpt)"),
2439
+ slug: z.string().optional().describe("Custom URL slug"),
2440
+ visibility: z.string().optional().describe("public, members, paid, or tiers")
2441
+ };
2442
+ const composePostSchema = z.object(composeFields);
2443
+ async function handleComposePost(input, mode) {
2444
+ let lexical;
2445
+ try {
2446
+ lexical = compose(resolveBlocks({
2447
+ blocks: input.blocks,
2448
+ blockFile: input.blockFile
2449
+ }));
2450
+ } catch (error) {
2451
+ if (error instanceof ComposeError) return JSON.stringify({
2452
+ error: "composition failed",
2453
+ issues: error.issues
2454
+ });
2455
+ return JSON.stringify({
2456
+ error: "invalid blocks input",
2457
+ message: error instanceof Error ? error.message : String(error)
2458
+ });
2459
+ }
2460
+ const { blocks: _blocks, blockFile: _blockFile, id, excerpt, ...rest } = input;
2461
+ const payload = {
2462
+ ...rest,
2463
+ lexical
2464
+ };
2465
+ if (excerpt !== void 0) payload.custom_excerpt = excerpt;
2466
+ if (id) return handleUseGhostApi({
2467
+ api: "admin",
2468
+ action: "posts.edit",
2469
+ payload: {
2470
+ id,
2471
+ ...payload
2472
+ }
2473
+ }, mode);
2474
+ return handleUseGhostApi({
2475
+ api: "admin",
2476
+ action: "posts.add",
2477
+ payload
2478
+ }, mode);
2479
+ }
2480
+ //#endregion
2481
+ //#region src/tools/compose-lexical.ts
2482
+ const composeLexicalSchema = z.object({
2483
+ blocks: z.array(z.object({ type: z.string() }).passthrough()).optional().describe("Ordered content blocks (same shape as compose_post), inline."),
2484
+ blockFile: z.string().optional().describe("Absolute path to a JSON file of blocks. Use to validate a file you are building before calling compose_post. Provide exactly one of `blocks` or `blockFile`.")
2485
+ });
2486
+ function handleComposeLexical(input) {
2487
+ try {
2488
+ const lexical = compose(resolveBlocks({
2489
+ blocks: input.blocks,
2490
+ blockFile: input.blockFile
2491
+ }));
2492
+ return JSON.stringify({ lexical });
2493
+ } catch (error) {
2494
+ if (error instanceof ComposeError) return JSON.stringify({
2495
+ error: "composition failed",
2496
+ issues: error.issues
2497
+ });
2498
+ return JSON.stringify({
2499
+ error: "invalid blocks input",
2500
+ message: error instanceof Error ? error.message : String(error)
2501
+ });
2502
+ }
2503
+ }
2504
+ //#endregion
2505
+ //#region src/tools/koenig-help.ts
2506
+ const koenigHelpSchema = z.object({ block: z.string().optional().describe("A block type (e.g. \"callout\", \"image\") for its fields + a JSON example. Omit to list all block types.") });
2507
+ function handleKoenigHelp(input) {
2508
+ return blockHelp(input.block);
2509
+ }
2510
+ const VERSION = createRequire(import.meta.url)("../package.json").version;
2511
+ //#endregion
1497
2512
  //#region src/index.ts
1498
2513
  const GHOST_API_MODE = process.env.GHOST_API_MODE || "admin";
1499
2514
  const server = new McpServer({
1500
2515
  name: "ghost-mcp",
1501
- version: "0.1.0"
2516
+ version: VERSION
1502
2517
  });
1503
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 }) => {
1504
2519
  return { content: [{
@@ -1529,6 +2544,36 @@ server.tool("ghost_docs", "Search Ghost CMS documentation — fetch full docs, s
1529
2544
  })
1530
2545
  }] };
1531
2546
  });
2547
+ server.tool("compose_post", "Create or update a Ghost post from structured Koenig content blocks (paragraphs, headings, lists, callouts, images, buttons, etc.). PREFER THIS over use_ghost_api with raw html/lexical — it produces clean, natively-editable posts. Pass blocks inline for short posts, or write them to a JSON file and pass `blockFile` (absolute path) for long posts. Omit `id` to create, set `id`+`updated_at` to update. Call koenig_help to discover block types and fields.", composePostSchema.shape, async (input) => {
2548
+ return { content: [{
2549
+ type: "text",
2550
+ text: await handleComposePost(input, GHOST_API_MODE)
2551
+ }] };
2552
+ });
2553
+ server.tool("compose_lexical", "Compile Koenig content blocks into a Lexical JSON string without creating a post (for preview/inspection). Same block shape as compose_post.", composeLexicalSchema.shape, async (input) => {
2554
+ return { content: [{
2555
+ type: "text",
2556
+ text: handleComposeLexical(input)
2557
+ }] };
2558
+ });
2559
+ server.tool("koenig_help", "List Koenig content block types, or get the fields and a JSON example for one block. Use before compose_post to build clean, editable posts instead of raw HTML.", koenigHelpSchema.shape, async ({ block }) => {
2560
+ return { content: [{
2561
+ type: "text",
2562
+ text: handleKoenigHelp({ block })
2563
+ }] };
2564
+ });
2565
+ server.registerPrompt("compose_ghost_post", { description: "Guidance for composing clean, editable Ghost posts from Koenig blocks instead of raw HTML." }, () => ({ messages: [{
2566
+ role: "assistant",
2567
+ content: {
2568
+ type: "text",
2569
+ text: [
2570
+ "When writing Ghost post content, do NOT push raw HTML into the API. Compose the post from structured Koenig blocks via the `compose_post` tool — this yields clean, natively-editable posts.",
2571
+ "Workflow: (1) call `koenig_help` to see block types; (2) build a `blocks` array — prose as paragraph/heading/list/quote blocks (their `text` supports inline **bold**, _italic_, `code`, [links](url)), and rich features as cards (callout, image, button, bookmark, embed, codeblock, toggle, gallery, etc.); (3) call `compose_post`. Use the `html` block ONLY when no native block fits.",
2572
+ "For a short post, pass `blocks` inline. For a long post, write the blocks JSON to an absolute path (e.g. under tmp/), optionally validate it with `compose_lexical` (`blockFile`), then call `compose_post` with `blockFile` — this avoids re-sending the whole array on each edit.",
2573
+ blockHelp()
2574
+ ].join("\n\n")
2575
+ }
2576
+ }] }));
1532
2577
  async function main() {
1533
2578
  const transport = new StdioServerTransport();
1534
2579
  await server.connect(transport);