@neverprepared/mcp-markdown-to-confluence 1.0.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.
@@ -0,0 +1,28 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ ci:
11
+ name: Build
12
+ runs-on: ubuntu-latest
13
+
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - uses: actions/setup-node@v4
18
+ with:
19
+ node-version: 20
20
+ cache: npm
21
+
22
+ - name: Install dependencies
23
+ run: npm ci
24
+ env:
25
+ PUPPETEER_SKIP_DOWNLOAD: "true"
26
+
27
+ - name: Build
28
+ run: npm run build
@@ -0,0 +1,48 @@
1
+ name: Release Please
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+
7
+ permissions:
8
+ contents: write
9
+ pull-requests: write
10
+ packages: write
11
+
12
+ jobs:
13
+ release-please:
14
+ runs-on: ubuntu-latest
15
+ outputs:
16
+ release_created: ${{ steps.release.outputs.release_created }}
17
+ steps:
18
+ - uses: googleapis/release-please-action@v4
19
+ id: release
20
+ with:
21
+ release-type: node
22
+
23
+ publish:
24
+ runs-on: ubuntu-latest
25
+ needs: release-please
26
+ if: needs.release-please.outputs.release_created == 'true'
27
+
28
+ steps:
29
+ - uses: actions/checkout@v4
30
+
31
+ - uses: actions/setup-node@v4
32
+ with:
33
+ node-version: 20
34
+ cache: npm
35
+ registry-url: https://registry.npmjs.org/
36
+
37
+ - name: Install dependencies
38
+ run: npm ci
39
+ env:
40
+ PUPPETEER_SKIP_DOWNLOAD: "true"
41
+
42
+ - name: Build
43
+ run: npm run build
44
+
45
+ - name: Publish to npm
46
+ run: npm publish --access public
47
+ env:
48
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
package/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ ## [1.0.1](https://github.com/neverprepared/mcp-markdown-to-confluence/compare/v1.0.0...v1.0.1) (2026-04-01)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * resolve packages from npm registry and fix API compatibility ([7b36bd7](https://github.com/neverprepared/mcp-markdown-to-confluence/commit/7b36bd78f9f0ad07c2e0e60658584b443215668e))
9
+ * skip Puppeteer Chromium download in CI ([31da53d](https://github.com/neverprepared/mcp-markdown-to-confluence/commit/31da53d48ea88b5444df5715bf414a4d4d70cca5))
10
+
11
+ ## 1.0.0 (2026-04-01)
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * resolve packages from npm registry and fix API compatibility ([7b36bd7](https://github.com/neverprepared/mcp-markdown-to-confluence/commit/7b36bd78f9f0ad07c2e0e60658584b443215668e))
package/README.md ADDED
@@ -0,0 +1,134 @@
1
+ # mcp-markdown-to-confluence
2
+
3
+ An MCP (Model Context Protocol) server that converts Markdown files to Atlassian Document Format (ADF) and publishes them to Confluence. Mermaid diagrams are rendered to PNG images and uploaded as page attachments.
4
+
5
+ ## Features
6
+
7
+ - Convert Markdown → Confluence ADF with full formatting support (tables, code blocks, callouts, TOC)
8
+ - Render Mermaid diagrams to PNG via headless Chromium and attach them to pages
9
+ - Preview content before publishing
10
+ - Create new pages or update existing ones
11
+ - Publish from inline Markdown or directly from a `.md` file using frontmatter
12
+
13
+ ## Installation
14
+
15
+ ### npm
16
+
17
+ ```bash
18
+ npm install @neverprepared/mcp-markdown-to-confluence
19
+ ```
20
+
21
+ ### From source
22
+
23
+ ```bash
24
+ git clone https://github.com/neverprepared/mcp-markdown-to-confluence.git
25
+ cd mcp-markdown-to-confluence
26
+ npm install
27
+ npm run build
28
+ ```
29
+
30
+ ## Configuration
31
+
32
+ Set the following environment variables:
33
+
34
+ | Variable | Description |
35
+ |---|---|
36
+ | `CONFLUENCE_BASE_URL` | Your Confluence base URL, e.g. `https://your-org.atlassian.net` |
37
+ | `CONFLUENCE_USERNAME` | Your Atlassian account email |
38
+ | `CONFLUENCE_API_TOKEN` | Your Atlassian API token ([create one here](https://id.atlassian.com/manage-profile/security/api-tokens)) |
39
+
40
+ ## Claude Code Setup
41
+
42
+ Add to your Claude Code MCP config (`.claude/.claude.json`):
43
+
44
+ ```json
45
+ {
46
+ "mcpServers": {
47
+ "markdown-to-confluence": {
48
+ "command": "node",
49
+ "args": ["/path/to/mcp-markdown-to-confluence/dist/index.js"],
50
+ "env": {
51
+ "CONFLUENCE_BASE_URL": "https://your-org.atlassian.net",
52
+ "CONFLUENCE_USERNAME": "you@example.com",
53
+ "CONFLUENCE_API_TOKEN": "your-api-token"
54
+ }
55
+ }
56
+ }
57
+ }
58
+ ```
59
+
60
+ ## Tools
61
+
62
+ ### `markdown_preview`
63
+
64
+ Convert Markdown to ADF and return a text preview — no Confluence calls made.
65
+
66
+ | Parameter | Type | Required | Description |
67
+ |---|---|---|---|
68
+ | `markdown` | string | yes | Markdown content to preview |
69
+ | `title` | string | yes | Page title |
70
+
71
+ ### `markdown_publish`
72
+
73
+ Publish Markdown to a Confluence page. By default shows a preview first.
74
+
75
+ | Parameter | Type | Required | Description |
76
+ |---|---|---|---|
77
+ | `markdown` | string | yes | Markdown content |
78
+ | `title` | string | yes | Confluence page title |
79
+ | `spaceKey` | string | yes | Confluence space key (e.g. `ENG`) |
80
+ | `pageId` | string | no | Existing page ID to update; omit to create a new page |
81
+ | `parentId` | string | no | Parent page ID for new pages |
82
+ | `skip_preview` | boolean | no | Set `true` to publish without preview (default: `false`) |
83
+
84
+ ### `markdown_publish_file`
85
+
86
+ Read a `.md` file from disk and publish it. Page metadata is read from frontmatter.
87
+
88
+ | Parameter | Type | Required | Description |
89
+ |---|---|---|---|
90
+ | `filePath` | string | yes | Absolute path to the Markdown file |
91
+ | `skip_preview` | boolean | no | Set `true` to publish without preview (default: `false`) |
92
+
93
+ **Supported frontmatter keys:**
94
+
95
+ ```yaml
96
+ ---
97
+ connie-title: My Page Title
98
+ connie-space-key: ENG
99
+ connie-page-id: "123456" # omit to create a new page
100
+ title: Fallback title # used if connie-title is absent
101
+ ---
102
+ ```
103
+
104
+ ## Preview Flow
105
+
106
+ By default, `markdown_publish` and `markdown_publish_file` return a rendered preview and prompt you to confirm before publishing. To publish in one step, pass `skip_preview: true`.
107
+
108
+ ```
109
+ # Step 1 — review
110
+ markdown_publish(markdown: "...", title: "My Page", spaceKey: "ENG")
111
+ → returns preview text
112
+
113
+ # Step 2 — publish
114
+ markdown_publish(markdown: "...", title: "My Page", spaceKey: "ENG", skip_preview: true)
115
+ → returns page URL
116
+ ```
117
+
118
+ ## Mermaid Diagrams
119
+
120
+ Mermaid code blocks are automatically detected, rendered to PNG via headless Chromium (bundled with Puppeteer), and uploaded as Confluence page attachments. The first run will download Chromium (~170 MB).
121
+
122
+ ````markdown
123
+ ```mermaid
124
+ flowchart TD
125
+ A[Write Markdown] --> B[Preview]
126
+ B --> C{Looks good?}
127
+ C -- Yes --> D[Publish]
128
+ C -- No --> A
129
+ ```
130
+ ````
131
+
132
+ ## License
133
+
134
+ MIT
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,357 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
4
+ import { z } from 'zod';
5
+ import { ConfluenceClient } from 'confluence.js';
6
+ import matter from 'gray-matter';
7
+ import { readFile } from 'fs/promises';
8
+ import { parseMarkdownToADF, renderADFDoc, executeADFProcessingPipeline, createPublisherFunctions, } from '@markdown-confluence/lib';
9
+ import { MermaidRendererPlugin } from '@markdown-confluence/lib';
10
+ import { PuppeteerMermaidRenderer } from '@markdown-confluence/mermaid-puppeteer-renderer';
11
+ // ---------------------------------------------------------------------------
12
+ // Environment
13
+ // ---------------------------------------------------------------------------
14
+ const CONFLUENCE_BASE_URL = process.env.CONFLUENCE_BASE_URL ?? '';
15
+ const CONFLUENCE_USERNAME = process.env.CONFLUENCE_USERNAME ?? '';
16
+ const CONFLUENCE_API_TOKEN = process.env.CONFLUENCE_API_TOKEN ?? '';
17
+ // ---------------------------------------------------------------------------
18
+ // Confluence client
19
+ // ---------------------------------------------------------------------------
20
+ const confluenceClient = new ConfluenceClient({
21
+ host: CONFLUENCE_BASE_URL,
22
+ authentication: {
23
+ basic: {
24
+ email: CONFLUENCE_USERNAME,
25
+ apiToken: CONFLUENCE_API_TOKEN,
26
+ },
27
+ },
28
+ });
29
+ // ---------------------------------------------------------------------------
30
+ // Stub LoaderAdaptor — only uploadBuffer is called by the mermaid plugin
31
+ // ---------------------------------------------------------------------------
32
+ const stubAdaptor = {
33
+ readFile: async (_filePath) => undefined,
34
+ readBinary: async (_filePath) => false,
35
+ fileExists: async (_filePath) => false,
36
+ listFiles: async () => [],
37
+ uploadBuffer: async (_buffer, _fileName, _mimeType) => undefined,
38
+ };
39
+ // ---------------------------------------------------------------------------
40
+ // Helpers
41
+ // ---------------------------------------------------------------------------
42
+ function countMermaidBlocks(adf) {
43
+ if (typeof adf !== 'object' || adf === null)
44
+ return 0;
45
+ const node = adf;
46
+ let count = 0;
47
+ if (node['type'] === 'codeBlock' &&
48
+ typeof node['attrs'] === 'object' &&
49
+ node['attrs'] !== null &&
50
+ node['attrs']['language'] === 'mermaid') {
51
+ count += 1;
52
+ }
53
+ for (const value of Object.values(node)) {
54
+ if (Array.isArray(value)) {
55
+ for (const item of value) {
56
+ count += countMermaidBlocks(item);
57
+ }
58
+ }
59
+ else if (typeof value === 'object' && value !== null) {
60
+ count += countMermaidBlocks(value);
61
+ }
62
+ }
63
+ return count;
64
+ }
65
+ // ---------------------------------------------------------------------------
66
+ // Core publish logic
67
+ // ---------------------------------------------------------------------------
68
+ async function publishMarkdown(markdown, title, spaceKey, pageId, parentId, skipPreview = false) {
69
+ // Parse markdown → ADF
70
+ const adf = parseMarkdownToADF(markdown, CONFLUENCE_BASE_URL);
71
+ const mermaidCount = countMermaidBlocks(adf);
72
+ if (!skipPreview) {
73
+ const previewText = renderADFDoc(adf);
74
+ return { isPreview: true, previewText, mermaidCount };
75
+ }
76
+ // ----- Full publish -----
77
+ let currentVersion = 1;
78
+ let resolvedPageId = pageId;
79
+ if (resolvedPageId) {
80
+ // Fetch existing page to get current version
81
+ const existingPage = await confluenceClient.content.getContentById({
82
+ id: resolvedPageId,
83
+ expand: ['version'],
84
+ });
85
+ currentVersion = existingPage.version.number;
86
+ }
87
+ else {
88
+ // Create a placeholder page to obtain a pageId
89
+ const blankAdf = {
90
+ version: 1,
91
+ type: 'doc',
92
+ content: [],
93
+ };
94
+ const createParams = {
95
+ space: { key: spaceKey },
96
+ title,
97
+ type: 'page',
98
+ body: {
99
+ atlas_doc_format: {
100
+ value: JSON.stringify(blankAdf),
101
+ representation: 'atlas_doc_format',
102
+ },
103
+ },
104
+ };
105
+ if (parentId) {
106
+ createParams.ancestors = [{ id: parentId }];
107
+ }
108
+ const created = await confluenceClient.content.createContent(createParams);
109
+ resolvedPageId = created.id;
110
+ currentVersion = created.version.number;
111
+ }
112
+ // Fetch current attachments to build the map
113
+ const attachmentsResult = await confluenceClient.contentAttachments.getAttachments({
114
+ id: resolvedPageId,
115
+ });
116
+ const currentAttachments = {};
117
+ for (const att of attachmentsResult.results ?? []) {
118
+ const attTitle = att.title ?? '';
119
+ const fileId = att.extensions?.fileId ?? '';
120
+ const collectionName = att.extensions?.collectionName ?? '';
121
+ if (attTitle) {
122
+ currentAttachments[attTitle] = {
123
+ filehash: att.metadata?.comment ?? '',
124
+ attachmentId: fileId,
125
+ collectionName,
126
+ };
127
+ }
128
+ }
129
+ // Build publisher functions
130
+ const publisherFunctions = createPublisherFunctions(confluenceClient, stubAdaptor, resolvedPageId, title, currentAttachments);
131
+ // Run ADF processing pipeline (renders mermaid diagrams)
132
+ const finalAdf = await executeADFProcessingPipeline([new MermaidRendererPlugin(new PuppeteerMermaidRenderer())], adf, publisherFunctions);
133
+ // Update the page with the final ADF
134
+ const updateParams = {
135
+ id: resolvedPageId,
136
+ title,
137
+ type: 'page',
138
+ version: { number: currentVersion + 1 },
139
+ body: {
140
+ atlas_doc_format: {
141
+ value: JSON.stringify(finalAdf),
142
+ representation: 'atlas_doc_format',
143
+ },
144
+ },
145
+ };
146
+ if (parentId) {
147
+ updateParams.ancestors = [{ id: parentId }];
148
+ }
149
+ await confluenceClient.content.updateContent(updateParams);
150
+ const url = `${CONFLUENCE_BASE_URL}/wiki/spaces/${spaceKey}/pages/${resolvedPageId}`;
151
+ return {
152
+ isPreview: false,
153
+ pageId: resolvedPageId,
154
+ version: currentVersion + 1,
155
+ mermaidCount,
156
+ url,
157
+ };
158
+ }
159
+ // ---------------------------------------------------------------------------
160
+ // MCP Server
161
+ // ---------------------------------------------------------------------------
162
+ const server = new Server({ name: 'mcp-markdown-to-confluence', version: '1.0.0' }, { capabilities: { tools: {} } });
163
+ // Tool definitions
164
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
165
+ tools: [
166
+ {
167
+ name: 'markdown_preview',
168
+ description: 'Convert markdown to Confluence ADF and return a text preview. Does not publish to Confluence.',
169
+ inputSchema: {
170
+ type: 'object',
171
+ properties: {
172
+ markdown: { type: 'string', description: 'Markdown content to preview' },
173
+ title: { type: 'string', description: 'Page title (used during ADF conversion)' },
174
+ },
175
+ required: ['markdown', 'title'],
176
+ },
177
+ },
178
+ {
179
+ name: 'markdown_publish',
180
+ description: 'Publish markdown to a Confluence page. By default runs a preview first; set skip_preview: true to publish immediately.',
181
+ inputSchema: {
182
+ type: 'object',
183
+ properties: {
184
+ markdown: { type: 'string', description: 'Markdown content to publish' },
185
+ title: { type: 'string', description: 'Confluence page title' },
186
+ spaceKey: { type: 'string', description: 'Confluence space key (e.g. "ENG")' },
187
+ pageId: {
188
+ type: 'string',
189
+ description: 'Existing page ID to update (omit to create a new page)',
190
+ },
191
+ parentId: {
192
+ type: 'string',
193
+ description: 'Parent page ID for new page creation',
194
+ },
195
+ skip_preview: {
196
+ type: 'boolean',
197
+ description: 'Set to true to skip preview and publish immediately',
198
+ default: false,
199
+ },
200
+ },
201
+ required: ['markdown', 'title', 'spaceKey'],
202
+ },
203
+ },
204
+ {
205
+ name: 'markdown_publish_file',
206
+ description: 'Read a markdown file from disk and publish it to Confluence. Frontmatter keys: connie-title / title, connie-space-key, connie-page-id.',
207
+ inputSchema: {
208
+ type: 'object',
209
+ properties: {
210
+ filePath: { type: 'string', description: 'Absolute path to the markdown file' },
211
+ skip_preview: {
212
+ type: 'boolean',
213
+ description: 'Set to true to skip preview and publish immediately',
214
+ default: false,
215
+ },
216
+ },
217
+ required: ['filePath'],
218
+ },
219
+ },
220
+ ],
221
+ }));
222
+ // Tool handlers
223
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
224
+ const { name, arguments: args } = request.params;
225
+ try {
226
+ if (name === 'markdown_preview') {
227
+ const input = z
228
+ .object({ markdown: z.string(), title: z.string() })
229
+ .parse(args);
230
+ const adf = parseMarkdownToADF(input.markdown, CONFLUENCE_BASE_URL);
231
+ const mermaidCount = countMermaidBlocks(adf);
232
+ const previewText = renderADFDoc(adf);
233
+ const lines = [previewText];
234
+ if (mermaidCount > 0) {
235
+ lines.push(`\n[Note: ${mermaidCount} mermaid diagram(s) detected — they will be rendered as images when published.]`);
236
+ }
237
+ return { content: [{ type: 'text', text: lines.join('') }] };
238
+ }
239
+ if (name === 'markdown_publish') {
240
+ const input = z
241
+ .object({
242
+ markdown: z.string(),
243
+ title: z.string(),
244
+ spaceKey: z.string(),
245
+ pageId: z.string().optional(),
246
+ parentId: z.string().optional(),
247
+ skip_preview: z.boolean().default(false),
248
+ })
249
+ .parse(args);
250
+ const result = await publishMarkdown(input.markdown, input.title, input.spaceKey, input.pageId, input.parentId, input.skip_preview);
251
+ if (result.isPreview) {
252
+ const lines = ['=== PREVIEW ===\n', result.previewText ?? ''];
253
+ if ((result.mermaidCount ?? 0) > 0) {
254
+ lines.push(`\n[Note: ${result.mermaidCount} mermaid diagram(s) detected — they will be rendered when published.]`);
255
+ }
256
+ lines.push('\n\nCall again with skip_preview: true to publish to Confluence.');
257
+ return { content: [{ type: 'text', text: lines.join('') }] };
258
+ }
259
+ return {
260
+ content: [
261
+ {
262
+ type: 'text',
263
+ text: [
264
+ `Successfully published to Confluence.`,
265
+ `Title: ${input.title}`,
266
+ `Page ID: ${result.pageId}`,
267
+ `Version: ${result.version}`,
268
+ `Mermaid diagrams rendered: ${result.mermaidCount}`,
269
+ `URL: ${result.url}`,
270
+ ].join('\n'),
271
+ },
272
+ ],
273
+ };
274
+ }
275
+ if (name === 'markdown_publish_file') {
276
+ const input = z
277
+ .object({
278
+ filePath: z.string(),
279
+ skip_preview: z.boolean().default(false),
280
+ })
281
+ .parse(args);
282
+ const raw = await readFile(input.filePath, 'utf-8');
283
+ const parsed = matter(raw);
284
+ const title = parsed.data['connie-title'] ?? parsed.data['title'] ?? '';
285
+ const spaceKey = parsed.data['connie-space-key'] ?? '';
286
+ const pageId = parsed.data['connie-page-id']
287
+ ? String(parsed.data['connie-page-id'])
288
+ : undefined;
289
+ if (!title) {
290
+ return {
291
+ isError: true,
292
+ content: [
293
+ {
294
+ type: 'text',
295
+ text: 'Error: Missing page title. Set "connie-title" or "title" in frontmatter.',
296
+ },
297
+ ],
298
+ };
299
+ }
300
+ if (!spaceKey) {
301
+ return {
302
+ isError: true,
303
+ content: [
304
+ {
305
+ type: 'text',
306
+ text: 'Error: Missing space key. Set "connie-space-key" in frontmatter.',
307
+ },
308
+ ],
309
+ };
310
+ }
311
+ const result = await publishMarkdown(parsed.content, title, spaceKey, pageId, undefined, input.skip_preview);
312
+ if (result.isPreview) {
313
+ const lines = [
314
+ `File: ${input.filePath}\n`,
315
+ '=== PREVIEW ===\n',
316
+ result.previewText ?? '',
317
+ ];
318
+ if ((result.mermaidCount ?? 0) > 0) {
319
+ lines.push(`\n[Note: ${result.mermaidCount} mermaid diagram(s) detected — they will be rendered when published.]`);
320
+ }
321
+ lines.push('\n\nCall again with skip_preview: true to publish to Confluence.');
322
+ return { content: [{ type: 'text', text: lines.join('') }] };
323
+ }
324
+ return {
325
+ content: [
326
+ {
327
+ type: 'text',
328
+ text: [
329
+ `Successfully published "${title}" to Confluence.`,
330
+ `File: ${input.filePath}`,
331
+ `Page ID: ${result.pageId}`,
332
+ `Version: ${result.version}`,
333
+ `Mermaid diagrams rendered: ${result.mermaidCount}`,
334
+ `URL: ${result.url}`,
335
+ ].join('\n'),
336
+ },
337
+ ],
338
+ };
339
+ }
340
+ return {
341
+ isError: true,
342
+ content: [{ type: 'text', text: `Error: Unknown tool "${name}"` }],
343
+ };
344
+ }
345
+ catch (err) {
346
+ const message = err instanceof Error ? err.message : String(err);
347
+ return {
348
+ isError: true,
349
+ content: [{ type: 'text', text: `Error: ${message}` }],
350
+ };
351
+ }
352
+ });
353
+ // ---------------------------------------------------------------------------
354
+ // Start
355
+ // ---------------------------------------------------------------------------
356
+ const transport = new StdioServerTransport();
357
+ await server.connect(transport);
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@neverprepared/mcp-markdown-to-confluence",
3
+ "version": "1.0.1",
4
+ "description": "MCP server for converting markdown to Confluence ADF and publishing pages with mermaid diagram support",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "mcp-markdown-to-confluence": "dist/index.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "start": "node dist/index.js",
13
+ "dev": "node --watch dist/index.js"
14
+ },
15
+ "dependencies": {
16
+ "@markdown-confluence/lib": "^5.5.2",
17
+ "@markdown-confluence/mermaid-puppeteer-renderer": "^5.5.2",
18
+ "@modelcontextprotocol/sdk": "^1.10.1",
19
+ "confluence.js": "^1.6.3",
20
+ "gray-matter": "^4.0.3",
21
+ "zod": "^3.24.3"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^22.0.0",
25
+ "typescript": "^5.8.3"
26
+ },
27
+ "engines": {
28
+ "node": ">=20.0.0"
29
+ },
30
+ "publishConfig": {
31
+ "registry": "https://registry.npmjs.org/",
32
+ "access": "public"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "https://github.com/neverprepared/mcp-markdown-to-confluence.git"
37
+ },
38
+ "keywords": [
39
+ "mcp",
40
+ "confluence",
41
+ "markdown",
42
+ "atlassian",
43
+ "mermaid",
44
+ "model-context-protocol"
45
+ ],
46
+ "license": "MIT"
47
+ }
package/src/index.ts ADDED
@@ -0,0 +1,482 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import {
4
+ CallToolRequestSchema,
5
+ ListToolsRequestSchema,
6
+ } from '@modelcontextprotocol/sdk/types.js';
7
+ import { z } from 'zod';
8
+ import { ConfluenceClient } from 'confluence.js';
9
+ import matter from 'gray-matter';
10
+ import { readFile } from 'fs/promises';
11
+
12
+ import {
13
+ parseMarkdownToADF,
14
+ renderADFDoc,
15
+ executeADFProcessingPipeline,
16
+ createPublisherFunctions,
17
+ } from '@markdown-confluence/lib';
18
+ import { MermaidRendererPlugin } from '@markdown-confluence/lib';
19
+ import { PuppeteerMermaidRenderer } from '@markdown-confluence/mermaid-puppeteer-renderer';
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Environment
23
+ // ---------------------------------------------------------------------------
24
+
25
+ const CONFLUENCE_BASE_URL = process.env.CONFLUENCE_BASE_URL ?? '';
26
+ const CONFLUENCE_USERNAME = process.env.CONFLUENCE_USERNAME ?? '';
27
+ const CONFLUENCE_API_TOKEN = process.env.CONFLUENCE_API_TOKEN ?? '';
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Confluence client
31
+ // ---------------------------------------------------------------------------
32
+
33
+ const confluenceClient = new ConfluenceClient({
34
+ host: CONFLUENCE_BASE_URL,
35
+ authentication: {
36
+ basic: {
37
+ email: CONFLUENCE_USERNAME,
38
+ apiToken: CONFLUENCE_API_TOKEN,
39
+ },
40
+ },
41
+ });
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Stub LoaderAdaptor — only uploadBuffer is called by the mermaid plugin
45
+ // ---------------------------------------------------------------------------
46
+
47
+ const stubAdaptor = {
48
+ readFile: async (_filePath: string) => undefined,
49
+ readBinary: async (_filePath: string) => false as const,
50
+ fileExists: async (_filePath: string) => false,
51
+ listFiles: async () => [],
52
+ uploadBuffer: async (
53
+ _buffer: Buffer,
54
+ _fileName: string,
55
+ _mimeType: string
56
+ ) => undefined,
57
+ } as unknown as import('@markdown-confluence/lib').LoaderAdaptor;
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Helpers
61
+ // ---------------------------------------------------------------------------
62
+
63
+ function countMermaidBlocks(adf: unknown): number {
64
+ if (typeof adf !== 'object' || adf === null) return 0;
65
+
66
+ const node = adf as Record<string, unknown>;
67
+ let count = 0;
68
+
69
+ if (
70
+ node['type'] === 'codeBlock' &&
71
+ typeof node['attrs'] === 'object' &&
72
+ node['attrs'] !== null &&
73
+ (node['attrs'] as Record<string, unknown>)['language'] === 'mermaid'
74
+ ) {
75
+ count += 1;
76
+ }
77
+
78
+ for (const value of Object.values(node)) {
79
+ if (Array.isArray(value)) {
80
+ for (const item of value) {
81
+ count += countMermaidBlocks(item);
82
+ }
83
+ } else if (typeof value === 'object' && value !== null) {
84
+ count += countMermaidBlocks(value);
85
+ }
86
+ }
87
+
88
+ return count;
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Core publish logic
93
+ // ---------------------------------------------------------------------------
94
+
95
+ async function publishMarkdown(
96
+ markdown: string,
97
+ title: string,
98
+ spaceKey: string,
99
+ pageId?: string,
100
+ parentId?: string,
101
+ skipPreview = false
102
+ ): Promise<{ isPreview: boolean; previewText?: string; mermaidCount?: number; pageId?: string; version?: number; url?: string }> {
103
+ // Parse markdown → ADF
104
+ const adf = parseMarkdownToADF(
105
+ markdown,
106
+ CONFLUENCE_BASE_URL
107
+ ) as unknown as any;
108
+
109
+ const mermaidCount = countMermaidBlocks(adf);
110
+
111
+ if (!skipPreview) {
112
+ const previewText = renderADFDoc(adf as unknown as any);
113
+ return { isPreview: true, previewText, mermaidCount };
114
+ }
115
+
116
+ // ----- Full publish -----
117
+
118
+ let currentVersion = 1;
119
+ let resolvedPageId = pageId;
120
+
121
+ if (resolvedPageId) {
122
+ // Fetch existing page to get current version
123
+ const existingPage = await confluenceClient.content.getContentById({
124
+ id: resolvedPageId,
125
+ expand: ['version'],
126
+ });
127
+ currentVersion = existingPage.version!.number!;
128
+ } else {
129
+ // Create a placeholder page to obtain a pageId
130
+ const blankAdf = {
131
+ version: 1,
132
+ type: 'doc',
133
+ content: [],
134
+ };
135
+
136
+ const createParams: Parameters<typeof confluenceClient.content.createContent>[0] = {
137
+ space: { key: spaceKey },
138
+ title,
139
+ type: 'page',
140
+ body: {
141
+ atlas_doc_format: {
142
+ value: JSON.stringify(blankAdf),
143
+ representation: 'atlas_doc_format',
144
+ },
145
+ },
146
+ };
147
+
148
+ if (parentId) {
149
+ createParams.ancestors = [{ id: parentId }];
150
+ }
151
+
152
+ const created = await confluenceClient.content.createContent(createParams);
153
+ resolvedPageId = created.id!;
154
+ currentVersion = created.version!.number!;
155
+ }
156
+
157
+ // Fetch current attachments to build the map
158
+ const attachmentsResult = await confluenceClient.contentAttachments.getAttachments({
159
+ id: resolvedPageId,
160
+ });
161
+
162
+ type CurrentAttachments = Record<
163
+ string,
164
+ { filehash: string; attachmentId: string; collectionName: string }
165
+ >;
166
+
167
+ const currentAttachments: CurrentAttachments = {};
168
+ for (const att of attachmentsResult.results ?? []) {
169
+ const attTitle = att.title ?? '';
170
+ const fileId = (att.extensions as any)?.fileId ?? '';
171
+ const collectionName = (att.extensions as any)?.collectionName ?? '';
172
+ if (attTitle) {
173
+ currentAttachments[attTitle] = {
174
+ filehash: (att.metadata as any)?.comment ?? '',
175
+ attachmentId: fileId,
176
+ collectionName,
177
+ };
178
+ }
179
+ }
180
+
181
+ // Build publisher functions
182
+ const publisherFunctions = createPublisherFunctions(
183
+ confluenceClient as unknown as any,
184
+ stubAdaptor,
185
+ resolvedPageId,
186
+ title,
187
+ currentAttachments
188
+ );
189
+
190
+ // Run ADF processing pipeline (renders mermaid diagrams)
191
+ const finalAdf = await executeADFProcessingPipeline(
192
+ [new MermaidRendererPlugin(new PuppeteerMermaidRenderer())],
193
+ adf as unknown as any,
194
+ publisherFunctions
195
+ );
196
+
197
+ // Update the page with the final ADF
198
+ const updateParams: Parameters<typeof confluenceClient.content.updateContent>[0] = {
199
+ id: resolvedPageId,
200
+ title,
201
+ type: 'page',
202
+ version: { number: currentVersion + 1 },
203
+ body: {
204
+ atlas_doc_format: {
205
+ value: JSON.stringify(finalAdf),
206
+ representation: 'atlas_doc_format',
207
+ },
208
+ },
209
+ };
210
+
211
+ if (parentId) {
212
+ updateParams.ancestors = [{ id: parentId }];
213
+ }
214
+
215
+ await confluenceClient.content.updateContent(updateParams);
216
+
217
+ const url = `${CONFLUENCE_BASE_URL}/wiki/spaces/${spaceKey}/pages/${resolvedPageId}`;
218
+
219
+ return {
220
+ isPreview: false,
221
+ pageId: resolvedPageId,
222
+ version: currentVersion + 1,
223
+ mermaidCount,
224
+ url,
225
+ };
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // MCP Server
230
+ // ---------------------------------------------------------------------------
231
+
232
+ const server = new Server(
233
+ { name: 'mcp-markdown-to-confluence', version: '1.0.0' },
234
+ { capabilities: { tools: {} } }
235
+ );
236
+
237
+ // Tool definitions
238
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
239
+ tools: [
240
+ {
241
+ name: 'markdown_preview',
242
+ description:
243
+ 'Convert markdown to Confluence ADF and return a text preview. Does not publish to Confluence.',
244
+ inputSchema: {
245
+ type: 'object',
246
+ properties: {
247
+ markdown: { type: 'string', description: 'Markdown content to preview' },
248
+ title: { type: 'string', description: 'Page title (used during ADF conversion)' },
249
+ },
250
+ required: ['markdown', 'title'],
251
+ },
252
+ },
253
+ {
254
+ name: 'markdown_publish',
255
+ description:
256
+ 'Publish markdown to a Confluence page. By default runs a preview first; set skip_preview: true to publish immediately.',
257
+ inputSchema: {
258
+ type: 'object',
259
+ properties: {
260
+ markdown: { type: 'string', description: 'Markdown content to publish' },
261
+ title: { type: 'string', description: 'Confluence page title' },
262
+ spaceKey: { type: 'string', description: 'Confluence space key (e.g. "ENG")' },
263
+ pageId: {
264
+ type: 'string',
265
+ description: 'Existing page ID to update (omit to create a new page)',
266
+ },
267
+ parentId: {
268
+ type: 'string',
269
+ description: 'Parent page ID for new page creation',
270
+ },
271
+ skip_preview: {
272
+ type: 'boolean',
273
+ description: 'Set to true to skip preview and publish immediately',
274
+ default: false,
275
+ },
276
+ },
277
+ required: ['markdown', 'title', 'spaceKey'],
278
+ },
279
+ },
280
+ {
281
+ name: 'markdown_publish_file',
282
+ description:
283
+ 'Read a markdown file from disk and publish it to Confluence. Frontmatter keys: connie-title / title, connie-space-key, connie-page-id.',
284
+ inputSchema: {
285
+ type: 'object',
286
+ properties: {
287
+ filePath: { type: 'string', description: 'Absolute path to the markdown file' },
288
+ skip_preview: {
289
+ type: 'boolean',
290
+ description: 'Set to true to skip preview and publish immediately',
291
+ default: false,
292
+ },
293
+ },
294
+ required: ['filePath'],
295
+ },
296
+ },
297
+ ],
298
+ }));
299
+
300
+ // Tool handlers
301
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
302
+ const { name, arguments: args } = request.params;
303
+
304
+ try {
305
+ if (name === 'markdown_preview') {
306
+ const input = z
307
+ .object({ markdown: z.string(), title: z.string() })
308
+ .parse(args);
309
+
310
+ const adf = parseMarkdownToADF(
311
+ input.markdown,
312
+ CONFLUENCE_BASE_URL
313
+ ) as unknown as any;
314
+
315
+ const mermaidCount = countMermaidBlocks(adf);
316
+ const previewText = renderADFDoc(adf as unknown as any);
317
+
318
+ const lines: string[] = [previewText];
319
+ if (mermaidCount > 0) {
320
+ lines.push(
321
+ `\n[Note: ${mermaidCount} mermaid diagram(s) detected — they will be rendered as images when published.]`
322
+ );
323
+ }
324
+
325
+ return { content: [{ type: 'text', text: lines.join('') }] };
326
+ }
327
+
328
+ if (name === 'markdown_publish') {
329
+ const input = z
330
+ .object({
331
+ markdown: z.string(),
332
+ title: z.string(),
333
+ spaceKey: z.string(),
334
+ pageId: z.string().optional(),
335
+ parentId: z.string().optional(),
336
+ skip_preview: z.boolean().default(false),
337
+ })
338
+ .parse(args);
339
+
340
+ const result = await publishMarkdown(
341
+ input.markdown,
342
+ input.title,
343
+ input.spaceKey,
344
+ input.pageId,
345
+ input.parentId,
346
+ input.skip_preview
347
+ );
348
+
349
+ if (result.isPreview) {
350
+ const lines: string[] = ['=== PREVIEW ===\n', result.previewText ?? ''];
351
+ if ((result.mermaidCount ?? 0) > 0) {
352
+ lines.push(
353
+ `\n[Note: ${result.mermaidCount} mermaid diagram(s) detected — they will be rendered when published.]`
354
+ );
355
+ }
356
+ lines.push(
357
+ '\n\nCall again with skip_preview: true to publish to Confluence.'
358
+ );
359
+ return { content: [{ type: 'text', text: lines.join('') }] };
360
+ }
361
+
362
+ return {
363
+ content: [
364
+ {
365
+ type: 'text',
366
+ text: [
367
+ `Successfully published to Confluence.`,
368
+ `Title: ${input.title}`,
369
+ `Page ID: ${result.pageId}`,
370
+ `Version: ${result.version}`,
371
+ `Mermaid diagrams rendered: ${result.mermaidCount}`,
372
+ `URL: ${result.url}`,
373
+ ].join('\n'),
374
+ },
375
+ ],
376
+ };
377
+ }
378
+
379
+ if (name === 'markdown_publish_file') {
380
+ const input = z
381
+ .object({
382
+ filePath: z.string(),
383
+ skip_preview: z.boolean().default(false),
384
+ })
385
+ .parse(args);
386
+
387
+ const raw = await readFile(input.filePath, 'utf-8');
388
+ const parsed = matter(raw);
389
+
390
+ const title: string =
391
+ parsed.data['connie-title'] ?? parsed.data['title'] ?? '';
392
+ const spaceKey: string = parsed.data['connie-space-key'] ?? '';
393
+ const pageId: string | undefined = parsed.data['connie-page-id']
394
+ ? String(parsed.data['connie-page-id'])
395
+ : undefined;
396
+
397
+ if (!title) {
398
+ return {
399
+ isError: true,
400
+ content: [
401
+ {
402
+ type: 'text',
403
+ text: 'Error: Missing page title. Set "connie-title" or "title" in frontmatter.',
404
+ },
405
+ ],
406
+ };
407
+ }
408
+
409
+ if (!spaceKey) {
410
+ return {
411
+ isError: true,
412
+ content: [
413
+ {
414
+ type: 'text',
415
+ text: 'Error: Missing space key. Set "connie-space-key" in frontmatter.',
416
+ },
417
+ ],
418
+ };
419
+ }
420
+
421
+ const result = await publishMarkdown(
422
+ parsed.content,
423
+ title,
424
+ spaceKey,
425
+ pageId,
426
+ undefined,
427
+ input.skip_preview
428
+ );
429
+
430
+ if (result.isPreview) {
431
+ const lines: string[] = [
432
+ `File: ${input.filePath}\n`,
433
+ '=== PREVIEW ===\n',
434
+ result.previewText ?? '',
435
+ ];
436
+ if ((result.mermaidCount ?? 0) > 0) {
437
+ lines.push(
438
+ `\n[Note: ${result.mermaidCount} mermaid diagram(s) detected — they will be rendered when published.]`
439
+ );
440
+ }
441
+ lines.push(
442
+ '\n\nCall again with skip_preview: true to publish to Confluence.'
443
+ );
444
+ return { content: [{ type: 'text', text: lines.join('') }] };
445
+ }
446
+
447
+ return {
448
+ content: [
449
+ {
450
+ type: 'text',
451
+ text: [
452
+ `Successfully published "${title}" to Confluence.`,
453
+ `File: ${input.filePath}`,
454
+ `Page ID: ${result.pageId}`,
455
+ `Version: ${result.version}`,
456
+ `Mermaid diagrams rendered: ${result.mermaidCount}`,
457
+ `URL: ${result.url}`,
458
+ ].join('\n'),
459
+ },
460
+ ],
461
+ };
462
+ }
463
+
464
+ return {
465
+ isError: true,
466
+ content: [{ type: 'text', text: `Error: Unknown tool "${name}"` }],
467
+ };
468
+ } catch (err: unknown) {
469
+ const message = err instanceof Error ? err.message : String(err);
470
+ return {
471
+ isError: true,
472
+ content: [{ type: 'text', text: `Error: ${message}` }],
473
+ };
474
+ }
475
+ });
476
+
477
+ // ---------------------------------------------------------------------------
478
+ // Start
479
+ // ---------------------------------------------------------------------------
480
+
481
+ const transport = new StdioServerTransport();
482
+ await server.connect(transport);
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "node",
6
+ "allowSyntheticDefaultImports": true,
7
+ "esModuleInterop": true,
8
+ "strict": true,
9
+ "skipLibCheck": true,
10
+ "declaration": true,
11
+ "outDir": "./dist",
12
+ "rootDir": "./src"
13
+ },
14
+ "include": ["src/**/*"],
15
+ "exclude": ["node_modules", "dist"]
16
+ }