@satiyap/confluence-reader-mcp 0.1.2 → 0.2.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
@@ -3,13 +3,22 @@
3
3
  [![npm version](https://img.shields.io/npm/v/@satiyap/confluence-reader-mcp.svg)](https://www.npmjs.com/package/@satiyap/confluence-reader-mcp)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
5
 
6
- An MCP server that lets AI assistants read Confluence pages, walk page trees, and diff Confluence content against local documentation.
6
+ An MCP server that lets AI assistants read Confluence Cloud pages as markdown, browse page trees, download image attachments, and diff content against local documentation.
7
+
8
+ ## Features
9
+
10
+ - Fetch a single Confluence page as proper GitHub-flavored markdown (headings, tables, lists, code blocks)
11
+ - List child pages for recursive traversal
12
+ - Download image attachments by filename
13
+ - Compare local content against a Confluence page with a unified diff
14
+ - Supports scoped API tokens with Basic Auth
7
15
 
8
16
  ## Setup
9
17
 
10
18
  ### 1. Get a Confluence API Token
11
19
 
12
- Create a scoped API token at: https://support.atlassian.com/confluence/kb/scoped-api-tokens-in-confluence-cloud/
20
+ Create a scoped API token from your Atlassian account:
21
+ https://support.atlassian.com/confluence/kb/scoped-api-tokens-in-confluence-cloud/
13
22
 
14
23
  ### 2. Set Environment Variables
15
24
 
@@ -17,7 +26,7 @@ Add to your shell profile (`~/.zshrc`, `~/.bashrc`, etc.):
17
26
 
18
27
  ```bash
19
28
  export CONFLUENCE_TOKEN="your_scoped_token"
20
- export CONFLUENCE_EMAIL="you@company.com"
29
+ export CONFLUENCE_EMAIL="your_email@example.com"
21
30
  export CONFLUENCE_CLOUD_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
22
31
  ```
23
32
 
@@ -28,7 +37,7 @@ Reload your shell or open a new terminal.
28
37
  | `CONFLUENCE_TOKEN` | Yes | Scoped API token |
29
38
  | `CONFLUENCE_EMAIL` | Yes | Email tied to your Atlassian account |
30
39
  | `CONFLUENCE_CLOUD_ID` | One of these | Cloud ID — routes via `api.atlassian.com` |
31
- | `CONFLUENCE_BASE_URL` | is required | Direct tenant URL, e.g. `https://yourteam.atlassian.net` |
40
+ | `CONFLUENCE_BASE_URL` | is required | Direct tenant URL, e.g. `https://your-org.atlassian.net` |
32
41
 
33
42
  ### 3. Add to MCP Config
34
43
 
@@ -49,20 +58,28 @@ Restart the MCP host to pick up the new server.
49
58
 
50
59
  ### `confluence.fetch_page`
51
60
 
52
- Fetches a single Confluence page by URL and returns its content as text. Also lists any direct child pages at the bottom of the response.
61
+ Fetches a single Confluence page by URL and returns its content as markdown. Lists any direct child pages at the bottom so the caller can decide which to fetch next.
53
62
 
54
63
  | Parameter | Type | Description |
55
64
  |-----------|------|-------------|
56
65
  | `url` | string | Confluence page URL |
57
66
 
58
- ### `confluence.fetch_page_tree`
67
+ ### `confluence.list_children`
68
+
69
+ Lists the direct child pages of a Confluence page without fetching their content. Useful for discovering page structure before fetching individual pages.
70
+
71
+ | Parameter | Type | Description |
72
+ |-----------|------|-------------|
73
+ | `url` | string | Confluence page URL |
59
74
 
60
- Fetches a page and all its descendants recursively, up to a given depth. Returns a single markdown document with nested headings.
75
+ ### `confluence.fetch_image`
61
76
 
62
- | Parameter | Type | Default | Description |
63
- |-----------|------|---------|-------------|
64
- | `url` | string | | Confluence page URL |
65
- | `depth` | number | 1 | How many levels of children to fetch |
77
+ Downloads an image attachment from a Confluence page by filename. Returns the image as base64-encoded data.
78
+
79
+ | Parameter | Type | Description |
80
+ |-----------|------|-------------|
81
+ | `url` | string | Confluence page URL |
82
+ | `filename` | string | Attachment filename (e.g. `architecture.png`) |
66
83
 
67
84
  ### `confluence.compare`
68
85
 
@@ -85,6 +102,10 @@ Returns a JSON object with `additions`, `deletions`, `totalChanges`, and the ful
85
102
  - Credentials are read from environment variables only — never passed in config files.
86
103
  - Use scoped tokens with the minimum permissions needed.
87
104
 
105
+ ## Contributing
106
+
107
+ Contributions are welcome. Please open an issue or submit a pull request.
108
+
88
109
  ## License
89
110
 
90
111
  MIT
@@ -87,3 +87,58 @@ export async function fetchChildPages(cfg, pageId) {
87
87
  }
88
88
  return all;
89
89
  }
90
+ /**
91
+ * Fetch attachments for a Confluence page.
92
+ * Returns all attachments (paginates automatically).
93
+ */
94
+ export async function fetchAttachments(cfg, pageId) {
95
+ const base = buildBase(cfg);
96
+ const all = [];
97
+ let cursor;
98
+ while (true) {
99
+ const url = new URL(`${base}/wiki/api/v2/pages/${pageId}/attachments`);
100
+ url.searchParams.set("limit", "50");
101
+ if (cursor)
102
+ url.searchParams.set("cursor", cursor);
103
+ const res = await fetch(url.toString(), {
104
+ method: "GET",
105
+ headers: {
106
+ ...buildAuthHeaders(cfg),
107
+ Accept: "application/json",
108
+ },
109
+ });
110
+ if (!res.ok) {
111
+ const text = await res.text().catch(() => "");
112
+ throw new Error(`Confluence API error ${res.status}: ${text.slice(0, 500)}`);
113
+ }
114
+ const data = (await res.json());
115
+ all.push(...data.results);
116
+ if (!data._links?.next)
117
+ break;
118
+ const nextUrl = new URL(data._links.next, base);
119
+ cursor = nextUrl.searchParams.get("cursor") ?? undefined;
120
+ if (!cursor)
121
+ break;
122
+ }
123
+ return all;
124
+ }
125
+ /**
126
+ * Download an attachment binary by its download link.
127
+ * Returns the raw Buffer and content type.
128
+ */
129
+ export async function downloadAttachment(cfg, downloadLink) {
130
+ const base = buildBase(cfg);
131
+ const url = `${base}/wiki${downloadLink}`;
132
+ const res = await fetch(url, {
133
+ method: "GET",
134
+ headers: buildAuthHeaders(cfg),
135
+ redirect: "follow",
136
+ });
137
+ if (!res.ok) {
138
+ const text = await res.text().catch(() => "");
139
+ throw new Error(`Attachment download error ${res.status}: ${text.slice(0, 500)}`);
140
+ }
141
+ const contentType = res.headers.get("content-type") ?? "application/octet-stream";
142
+ const arrayBuffer = await res.arrayBuffer();
143
+ return { buffer: Buffer.from(arrayBuffer), contentType };
144
+ }
@@ -1,35 +1,94 @@
1
+ import TurndownService from "turndown";
2
+ // @ts-expect-error — no type declarations available
3
+ import { gfm } from "turndown-plugin-gfm";
4
+ const turndown = new TurndownService({
5
+ headingStyle: "atx",
6
+ codeBlockStyle: "fenced",
7
+ bulletListMarker: "-",
8
+ });
9
+ turndown.use(gfm);
1
10
  /**
2
- * Convert Confluence storage HTML to plain text
3
- *
4
- * This is a lightweight HTML-to-text converter that:
5
- * - Strips HTML tags
6
- * - Preserves paragraph and heading breaks
7
- * - Decodes common HTML entities
8
- *
9
- * Note: Not a perfect HTML→Markdown converter; intentionally simple for MCP use.
11
+ * Pre-process Confluence storage format HTML into standard HTML
12
+ * that Turndown can handle. Confluence uses custom XML namespaces
13
+ * (ac:, ri:) that DOM parsers and Turndown don't understand.
14
+ */
15
+ function normalizeConfluenceHtml(html) {
16
+ let out = html;
17
+ // Convert ac:layout-section / ac:layout-cell to divs
18
+ out = out.replace(/<ac:layout-section>/gi, "<div>");
19
+ out = out.replace(/<\/ac:layout-section>/gi, "</div>");
20
+ out = out.replace(/<ac:layout-cell>/gi, "<div>");
21
+ out = out.replace(/<\/ac:layout-cell>/gi, "</div>");
22
+ out = out.replace(/<ac:layout>/gi, "<div>");
23
+ out = out.replace(/<\/ac:layout>/gi, "</div>");
24
+ // Convert ac:structured-macro (panels, code blocks, etc.) to divs
25
+ // Preserve the macro name as a data attribute for potential future use
26
+ out = out.replace(/<ac:structured-macro[^>]*ac:name="code"[^>]*>([\s\S]*?)<\/ac:structured-macro>/gi, (_match, inner) => {
27
+ // Extract plain-text-body for code blocks
28
+ const bodyMatch = inner.match(/<ac:plain-text-body>\s*<!\[CDATA\[([\s\S]*?)\]\]>\s*<\/ac:plain-text-body>/i);
29
+ if (bodyMatch) {
30
+ return `<pre><code>${bodyMatch[1]}</code></pre>`;
31
+ }
32
+ return `<pre><code>${inner.replace(/<[^>]+>/g, "")}</code></pre>`;
33
+ });
34
+ // Convert info/note/warning/tip panels to blockquotes
35
+ out = out.replace(/<ac:structured-macro[^>]*ac:name="(info|note|warning|tip|panel)"[^>]*>([\s\S]*?)<\/ac:structured-macro>/gi, (_match, _type, inner) => {
36
+ const bodyMatch = inner.match(/<ac:rich-text-body>([\s\S]*?)<\/ac:rich-text-body>/i);
37
+ return bodyMatch ? `<blockquote>${bodyMatch[1]}</blockquote>` : `<blockquote>${inner}</blockquote>`;
38
+ });
39
+ // Generic: any remaining ac:structured-macro — unwrap to div
40
+ out = out.replace(/<ac:structured-macro[^>]*>/gi, "<div>");
41
+ out = out.replace(/<\/ac:structured-macro>/gi, "</div>");
42
+ // ac:rich-text-body → div
43
+ out = out.replace(/<ac:rich-text-body>/gi, "<div>");
44
+ out = out.replace(/<\/ac:rich-text-body>/gi, "</div>");
45
+ // ac:plain-text-body with CDATA → pre
46
+ out = out.replace(/<ac:plain-text-body>\s*<!\[CDATA\[([\s\S]*?)\]\]>\s*<\/ac:plain-text-body>/gi, (_match, content) => `<pre>${content}</pre>`);
47
+ out = out.replace(/<ac:plain-text-body>/gi, "<pre>");
48
+ out = out.replace(/<\/ac:plain-text-body>/gi, "</pre>");
49
+ // ac:parameter tags — remove entirely
50
+ out = out.replace(/<ac:parameter[^>]*>[\s\S]*?<\/ac:parameter>/gi, "");
51
+ // ac:image → img tag
52
+ out = out.replace(/<ac:image[^>]*>([\s\S]*?)<\/ac:image>/gi, (_match, inner) => {
53
+ const filenameMatch = inner.match(/ri:filename="([^"]+)"/i);
54
+ const filename = filenameMatch ? filenameMatch[1] : "image";
55
+ return `<img alt="${filename}" src="${filename}" />`;
56
+ });
57
+ // ac:link with ri:page → anchor
58
+ out = out.replace(/<ac:link>([\s\S]*?)<\/ac:link>/gi, (_match, inner) => {
59
+ const pageMatch = inner.match(/ri:content-title="([^"]+)"/i);
60
+ const bodyMatch = inner.match(/<ac:link-body>([\s\S]*?)<\/ac:link-body>/i)
61
+ || inner.match(/<ac:plain-text-link-body>\s*<!\[CDATA\[([\s\S]*?)\]\]>\s*<\/ac:plain-text-link-body>/i);
62
+ const title = pageMatch ? pageMatch[1] : "";
63
+ const text = bodyMatch ? bodyMatch[1].replace(/<[^>]+>/g, "") : title;
64
+ return `<a href="#">${text || title}</a>`;
65
+ });
66
+ // ac:emoticon → remove
67
+ out = out.replace(/<ac:emoticon[^>]*\/>/gi, "");
68
+ // ac:task-list / ac:task / ac:task-body → ul/li
69
+ out = out.replace(/<ac:task-list>/gi, "<ul>");
70
+ out = out.replace(/<\/ac:task-list>/gi, "</ul>");
71
+ out = out.replace(/<ac:task>([\s\S]*?)<\/ac:task>/gi, (_match, inner) => {
72
+ const statusMatch = inner.match(/<ac:task-status>([\s\S]*?)<\/ac:task-status>/i);
73
+ const bodyMatch = inner.match(/<ac:task-body>([\s\S]*?)<\/ac:task-body>/i);
74
+ const checked = statusMatch && statusMatch[1].trim() === "complete";
75
+ const body = bodyMatch ? bodyMatch[1] : inner;
76
+ return `<li>${checked ? "[x] " : "[ ] "}${body}</li>`;
77
+ });
78
+ // Remove any remaining ac:* or ri:* tags but keep their text content
79
+ out = out.replace(/<\/?(?:ac|ri):[^>]*>/gi, "");
80
+ // Clean up CDATA remnants
81
+ out = out.replace(/<!\[CDATA\[/g, "");
82
+ out = out.replace(/\]\]>/g, "");
83
+ return out;
84
+ }
85
+ /**
86
+ * Convert Confluence storage format HTML to GitHub-flavored markdown.
10
87
  *
11
88
  * @param storageHtml - Confluence storage format HTML
12
- * @returns Plain text representation
89
+ * @returns Markdown with headings, tables, lists, code blocks, etc.
13
90
  */
14
- export function storageToText(storageHtml) {
15
- // Minimal, safe-ish conversion:
16
- // - strip tags
17
- // - preserve headings/paragraph-ish breaks
18
- // Not a perfect HTML->MD converter; intentionally lightweight for an MCP tool.
19
- const withBreaks = storageHtml
20
- .replace(/<\/(p|h1|h2|h3|h4|li|tr|div)>/gi, "\n")
21
- .replace(/<br\s*\/?>/gi, "\n");
22
- const stripped = withBreaks.replace(/<[^>]+>/g, "");
23
- const decoded = stripped
24
- .replace(/&nbsp;/g, " ")
25
- .replace(/&amp;/g, "&")
26
- .replace(/&lt;/g, "<")
27
- .replace(/&gt;/g, ">")
28
- .replace(/&quot;/g, "\"")
29
- .replace(/&#39;/g, "'");
30
- return decoded
31
- .split("\n")
32
- .map((l) => l.trim())
33
- .filter(Boolean)
34
- .join("\n");
91
+ export function storageToMarkdown(storageHtml) {
92
+ const normalized = normalizeConfluenceHtml(storageHtml);
93
+ return turndown.turndown(normalized);
35
94
  }
package/dist/index.js CHANGED
@@ -3,12 +3,12 @@ import { z } from "zod";
3
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
  import { extractConfluencePageId } from "./confluence/url.js";
6
- import { fetchPageById, fetchChildPages, buildAuthHeaders, buildBase } from "./confluence/client.js";
7
- import { storageToText } from "./confluence/transform.js";
6
+ import { fetchPageById, fetchChildPages, fetchAttachments, downloadAttachment, buildAuthHeaders, buildBase } from "./confluence/client.js";
7
+ import { storageToMarkdown } from "./confluence/transform.js";
8
8
  import { generateUnifiedDiff, generateDiffStats } from "./compare/diff.js";
9
9
  const server = new McpServer({
10
10
  name: "confluence-reader-mcp",
11
- version: "0.1.2"
11
+ version: "0.2.0"
12
12
  });
13
13
  function getEnv(name) {
14
14
  const v = process.env[name];
@@ -39,75 +39,101 @@ function validateEnvironment() {
39
39
  process.exit(1);
40
40
  }
41
41
  }
42
- server.tool("confluence.fetch_page", "Fetch a Confluence page and return it as markdown.", {
43
- url: z.string().describe("Confluence page URL")
42
+ /** Build config from env vars */
43
+ function getCfg() {
44
+ return {
45
+ token: getEnv("CONFLUENCE_TOKEN"),
46
+ email: getEnv("CONFLUENCE_EMAIL"),
47
+ cloudId: getEnv("CONFLUENCE_CLOUD_ID"),
48
+ baseUrl: getEnv("CONFLUENCE_BASE_URL"),
49
+ };
50
+ }
51
+ server.tool("confluence.fetch_page", "Fetch a Confluence page as markdown. Returns the page content and lists any direct child pages so the caller can decide which children to fetch next.", {
52
+ url: z.string().describe("Confluence page URL"),
44
53
  }, async ({ url }) => {
45
- const token = getEnv("CONFLUENCE_TOKEN");
46
- const email = getEnv("CONFLUENCE_EMAIL");
47
- const cloudId = getEnv("CONFLUENCE_CLOUD_ID");
48
- const baseUrl = getEnv("CONFLUENCE_BASE_URL");
49
- const cfg = { token, email, cloudId, baseUrl };
54
+ const cfg = getCfg();
50
55
  const pageId = extractConfluencePageId(url);
51
56
  const page = await fetchPageById(cfg, pageId);
52
57
  const children = await fetchChildPages(cfg, pageId);
53
58
  const storage = page.body?.storage?.value ?? "";
54
- const markdown = storage ? storageToText(storage) : "";
55
- const childLinks = children.map(c => `- [${c.title}] (id: ${c.id})`).join("\n");
56
- const body = childLinks
57
- ? `${markdown}\n\n## Child Pages\n${childLinks}`
58
- : markdown;
59
+ const markdown = storage ? storageToMarkdown(storage) : "";
60
+ const childList = children.length > 0
61
+ ? `\n\n---\n## Child Pages\n${children.map(c => `- ${c.title} (id: ${c.id})`).join("\n")}`
62
+ : "";
59
63
  return {
60
- content: [{ type: "text", text: body }]
64
+ content: [{
65
+ type: "text",
66
+ text: `# ${page.title}\n\n${markdown}${childList}`
67
+ }]
61
68
  };
62
69
  });
63
- server.tool("confluence.fetch_page_tree", "Fetch a Confluence page and all its child pages recursively up to a specified depth.", {
70
+ server.tool("confluence.list_children", "List the direct child pages of a Confluence page without fetching their content. Useful for discovering page structure before fetching individual pages.", {
71
+ url: z.string().describe("Confluence page URL")
72
+ }, async ({ url }) => {
73
+ const cfg = getCfg();
74
+ const pageId = extractConfluencePageId(url);
75
+ const children = await fetchChildPages(cfg, pageId);
76
+ const lines = children.map(c => `- ${c.title} (id: ${c.id})`);
77
+ const text = lines.length > 0
78
+ ? `Found ${lines.length} child page(s):\n\n${lines.join("\n")}`
79
+ : "No child pages found.";
80
+ return { content: [{ type: "text", text }] };
81
+ });
82
+ server.tool("confluence.fetch_image", "Download an image attachment from a Confluence page by filename. Returns the image as base64-encoded data.", {
64
83
  url: z.string().describe("Confluence page URL"),
65
- depth: z.number().optional().default(1).describe("How many levels deep to fetch child pages (default: 1)")
66
- }, async ({ url, depth }) => {
67
- const token = getEnv("CONFLUENCE_TOKEN");
68
- const email = getEnv("CONFLUENCE_EMAIL");
69
- const cloudId = getEnv("CONFLUENCE_CLOUD_ID");
70
- const baseUrl = getEnv("CONFLUENCE_BASE_URL");
71
- const cfg = { token, email, cloudId, baseUrl };
84
+ filename: z.string().describe("Attachment filename (e.g. 'architecture.png')")
85
+ }, async ({ url, filename }) => {
86
+ const cfg = getCfg();
72
87
  const pageId = extractConfluencePageId(url);
73
- async function buildTree(id, remaining) {
74
- const page = await fetchPageById(cfg, id);
75
- const storage = page.body?.storage?.value ?? "";
76
- const content = storage ? storageToText(storage) : "";
77
- const children = [];
78
- if (remaining > 0) {
79
- const childPages = await fetchChildPages(cfg, id);
80
- for (const child of childPages) {
81
- children.push(await buildTree(child.id, remaining - 1));
82
- }
83
- }
84
- return { id: page.id, title: page.title, content, children };
88
+ const attachments = await fetchAttachments(cfg, pageId);
89
+ const match = attachments.find(a => a.title.toLowerCase() === filename.toLowerCase());
90
+ if (!match) {
91
+ const available = attachments.map(a => a.title).join(", ");
92
+ return {
93
+ content: [{
94
+ type: "text",
95
+ text: `Attachment "${filename}" not found. Available: ${available || "none"}`
96
+ }]
97
+ };
85
98
  }
86
- const tree = await buildTree(pageId, depth);
87
- function renderTree(node, level) {
88
- const heading = "#".repeat(Math.min(level + 1, 6));
89
- const parts = [`${heading} ${node.title}`, node.content];
90
- for (const child of node.children) {
91
- parts.push(renderTree(child, level + 1));
92
- }
93
- return parts.join("\n\n");
99
+ const downloadLink = match.downloadLink ?? match._links?.download;
100
+ if (!downloadLink) {
101
+ return {
102
+ content: [{
103
+ type: "text",
104
+ text: `No download link available for "${filename}".`
105
+ }]
106
+ };
107
+ }
108
+ const { buffer, contentType } = await downloadAttachment(cfg, downloadLink);
109
+ const base64 = buffer.toString("base64");
110
+ // Return as base64 image content
111
+ if (contentType.startsWith("image/")) {
112
+ return {
113
+ content: [{
114
+ type: "image",
115
+ data: base64,
116
+ mimeType: contentType,
117
+ }]
118
+ };
94
119
  }
120
+ // Non-image attachment — return as base64 text
95
121
  return {
96
- content: [{ type: "text", text: renderTree(tree, 0) }]
122
+ content: [{
123
+ type: "text",
124
+ text: `Downloaded "${filename}" (${contentType}, ${buffer.length} bytes).\nBase64: ${base64.slice(0, 200)}...`
125
+ }]
97
126
  };
98
127
  });
99
128
  server.tool("confluence.compare", "Compare a local markdown file or string with a Confluence page and show the differences.", {
100
129
  url: z.string().describe("Confluence page URL"),
101
130
  localContent: z.string().describe("Local markdown content to compare against")
102
131
  }, async ({ url, localContent }) => {
103
- const token = getEnv("CONFLUENCE_TOKEN");
104
- const email = getEnv("CONFLUENCE_EMAIL");
105
- const cloudId = getEnv("CONFLUENCE_CLOUD_ID");
106
- const baseUrl = getEnv("CONFLUENCE_BASE_URL");
132
+ const cfg = getCfg();
107
133
  const pageId = extractConfluencePageId(url);
108
- const page = await fetchPageById({ token, email, cloudId, baseUrl }, pageId);
134
+ const page = await fetchPageById(cfg, pageId);
109
135
  const storage = page.body?.storage?.value ?? "";
110
- const confluenceMarkdown = storage ? storageToText(storage) : "";
136
+ const confluenceMarkdown = storage ? storageToMarkdown(storage) : "";
111
137
  const diff = generateUnifiedDiff(confluenceMarkdown.trim(), localContent.trim(), `a/confluence/${page.title}`, `b/local`);
112
138
  const stats = generateDiffStats(confluenceMarkdown.trim(), localContent.trim());
113
139
  const result = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@satiyap/confluence-reader-mcp",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "MCP server for fetching and comparing Confluence documentation with local files",
5
5
  "author": "satiyap",
6
6
  "license": "MIT",
@@ -32,10 +32,13 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@modelcontextprotocol/sdk": "^1.0.0",
35
+ "turndown": "^7.2.2",
36
+ "turndown-plugin-gfm": "^1.0.2",
35
37
  "zod": "^3.25.0"
36
38
  },
37
39
  "devDependencies": {
38
40
  "@types/node": "^22.0.0",
41
+ "@types/turndown": "^5.0.6",
39
42
  "tsx": "^4.0.0",
40
43
  "typescript": "^5.0.0"
41
44
  },