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