@neverprepared/mcp-markdown-to-confluence 1.1.1 → 1.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.
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { register } from 'node:module';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { dirname, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ register(pathToFileURL(resolve(__dirname, '..', 'dist', 'loader.js')));
9
+
10
+ await import(pathToFileURL(resolve(__dirname, '..', 'dist', 'index.js')));
package/dist/index.js CHANGED
@@ -4,8 +4,14 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextpro
4
4
  import { z } from 'zod';
5
5
  import { ConfluenceClient } from 'confluence.js';
6
6
  import matter from 'gray-matter';
7
- import { readFile } from 'fs/promises';
8
- import { parseMarkdownToADF, renderADFDoc, executeADFProcessingPipeline, createPublisherFunctions, MermaidRendererPlugin, } from '@markdown-confluence/lib';
7
+ import { readFile, readdir } from 'fs/promises';
8
+ import { join, extname, basename } from 'path';
9
+ // Deep imports to avoid loading adaptors/filesystem.js which has broken CJS named exports.
10
+ // Pin @markdown-confluence/lib version if these paths change.
11
+ import { parseMarkdownToADF } from '@markdown-confluence/lib/dist/MdToADF.js';
12
+ import { renderADFDoc } from '@markdown-confluence/lib/dist/ADFToMarkdown.js';
13
+ import { executeADFProcessingPipeline, createPublisherFunctions, } from '@markdown-confluence/lib/dist/ADFProcessingPlugins/types.js';
14
+ import { MermaidRendererPlugin } from '@markdown-confluence/lib/dist/ADFProcessingPlugins/MermaidRendererPlugin.js';
9
15
  import { KrokiClient, KrokiMermaidRenderer, KrokiDiagramPlugin } from './kroki/index.js';
10
16
  // ---------------------------------------------------------------------------
11
17
  // Environment
@@ -90,6 +96,41 @@ function countDiagramBlocks(adf) {
90
96
  }
91
97
  return count;
92
98
  }
99
+ function pLimit(concurrency) {
100
+ let active = 0;
101
+ const queue = [];
102
+ function next() {
103
+ if (queue.length > 0 && active < concurrency) {
104
+ active++;
105
+ queue.shift()();
106
+ }
107
+ }
108
+ return (fn) => new Promise((resolve, reject) => {
109
+ queue.push(() => {
110
+ fn().then(resolve, reject).finally(() => {
111
+ active--;
112
+ next();
113
+ });
114
+ });
115
+ next();
116
+ });
117
+ }
118
+ async function parseMarkdownFile(filePath) {
119
+ const raw = await readFile(filePath, 'utf-8');
120
+ const parsed = matter(raw);
121
+ const title = parsed.data['connie-title'] ?? parsed.data['title'] ?? '';
122
+ const spaceKey = parsed.data['connie-space-key'] ?? '';
123
+ const pageId = parsed.data['connie-page-id']
124
+ ? String(parsed.data['connie-page-id'])
125
+ : undefined;
126
+ if (!title) {
127
+ return { skipped: true, filePath, reason: 'Missing "connie-title" or "title" in frontmatter' };
128
+ }
129
+ if (!spaceKey) {
130
+ return { skipped: true, filePath, reason: 'Missing "connie-space-key" in frontmatter' };
131
+ }
132
+ return { filePath, title, spaceKey, pageId, content: parsed.content };
133
+ }
93
134
  // ---------------------------------------------------------------------------
94
135
  // Core publish logic
95
136
  // ---------------------------------------------------------------------------
@@ -248,6 +289,31 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
248
289
  required: ['filePath'],
249
290
  },
250
291
  },
292
+ {
293
+ name: 'markdown_publish_directory',
294
+ description: 'Scan a directory for markdown files with Confluence frontmatter and publish them all concurrently. ' +
295
+ 'Files without required frontmatter (connie-title, connie-space-key) are skipped.',
296
+ inputSchema: {
297
+ type: 'object',
298
+ properties: {
299
+ directoryPath: {
300
+ type: 'string',
301
+ description: 'Absolute path to the directory containing markdown files',
302
+ },
303
+ skip_preview: {
304
+ type: 'boolean',
305
+ description: 'Set to true to skip preview and publish immediately',
306
+ default: false,
307
+ },
308
+ concurrency: {
309
+ type: 'number',
310
+ description: 'Maximum number of files to publish concurrently (default: 5)',
311
+ default: 5,
312
+ },
313
+ },
314
+ required: ['directoryPath'],
315
+ },
316
+ },
251
317
  ],
252
318
  }));
253
319
  // Tool handlers
@@ -368,6 +434,114 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
368
434
  ],
369
435
  };
370
436
  }
437
+ if (name === 'markdown_publish_directory') {
438
+ const input = z
439
+ .object({
440
+ directoryPath: z.string(),
441
+ skip_preview: z.boolean().default(false),
442
+ concurrency: z.number().int().min(1).max(20).default(5),
443
+ })
444
+ .parse(args);
445
+ const entries = await readdir(input.directoryPath);
446
+ const mdFiles = entries
447
+ .filter((f) => extname(f).toLowerCase() === '.md')
448
+ .map((f) => join(input.directoryPath, f));
449
+ if (mdFiles.length === 0) {
450
+ return {
451
+ content: [{ type: 'text', text: `No .md files found in ${input.directoryPath}` }],
452
+ };
453
+ }
454
+ const parseResults = await Promise.all(mdFiles.map(parseMarkdownFile));
455
+ const valid = [];
456
+ const skipped = [];
457
+ for (const r of parseResults) {
458
+ if ('skipped' in r) {
459
+ skipped.push(r);
460
+ }
461
+ else {
462
+ valid.push(r);
463
+ }
464
+ }
465
+ if (!input.skip_preview) {
466
+ const lines = [
467
+ `=== DIRECTORY PREVIEW ===`,
468
+ `Directory: ${input.directoryPath}`,
469
+ `Total .md files: ${mdFiles.length}`,
470
+ `Files to publish: ${valid.length}`,
471
+ `Files skipped: ${skipped.length}`,
472
+ '',
473
+ '--- Files to publish ---',
474
+ ];
475
+ for (const f of valid) {
476
+ const adf = parseMarkdownToADF(f.content, CONFLUENCE_BASE_URL);
477
+ const diagrams = countDiagramBlocks(adf);
478
+ lines.push(` ${basename(f.filePath)}`);
479
+ lines.push(` Title: ${f.title} | Space: ${f.spaceKey}` +
480
+ (f.pageId ? ` | Page ID: ${f.pageId}` : ' (new page)') +
481
+ (diagrams > 0 ? ` | Diagrams: ${diagrams}` : ''));
482
+ }
483
+ if (skipped.length > 0) {
484
+ lines.push('', '--- Skipped files ---');
485
+ for (const s of skipped) {
486
+ lines.push(` ${basename(s.filePath)}: ${s.reason}`);
487
+ }
488
+ }
489
+ lines.push('', `Call again with skip_preview: true to publish all ${valid.length} file(s).`);
490
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
491
+ }
492
+ const limit = pLimit(input.concurrency);
493
+ const results = await Promise.all(valid.map((f) => limit(async () => {
494
+ try {
495
+ const result = await publishMarkdown(f.content, f.title, f.spaceKey, f.pageId, undefined, true);
496
+ return {
497
+ filePath: f.filePath,
498
+ title: f.title,
499
+ success: true,
500
+ pageId: result.pageId,
501
+ version: result.version,
502
+ diagramCount: result.diagramCount,
503
+ url: result.url,
504
+ };
505
+ }
506
+ catch (err) {
507
+ return {
508
+ filePath: f.filePath,
509
+ title: f.title,
510
+ success: false,
511
+ error: err instanceof Error ? err.message : String(err),
512
+ };
513
+ }
514
+ })));
515
+ const succeeded = results.filter((r) => r.success);
516
+ const failed = results.filter((r) => !r.success);
517
+ const lines = [
518
+ `=== DIRECTORY PUBLISH RESULTS ===`,
519
+ `Directory: ${input.directoryPath}`,
520
+ `Succeeded: ${succeeded.length} | Failed: ${failed.length} | Skipped: ${skipped.length}`,
521
+ '',
522
+ ];
523
+ if (succeeded.length > 0) {
524
+ lines.push('--- Succeeded ---');
525
+ for (const r of succeeded) {
526
+ lines.push(` "${r.title}"`);
527
+ lines.push(` Page ID: ${r.pageId} | Version: ${r.version} | Diagrams: ${r.diagramCount} | URL: ${r.url}`);
528
+ }
529
+ }
530
+ if (failed.length > 0) {
531
+ lines.push('', '--- Failed ---');
532
+ for (const r of failed) {
533
+ lines.push(` "${r.title}" (${basename(r.filePath)})`);
534
+ lines.push(` Error: ${r.error}`);
535
+ }
536
+ }
537
+ if (skipped.length > 0) {
538
+ lines.push('', '--- Skipped (invalid frontmatter) ---');
539
+ for (const s of skipped) {
540
+ lines.push(` ${basename(s.filePath)}: ${s.reason}`);
541
+ }
542
+ }
543
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
544
+ }
371
545
  return {
372
546
  isError: true,
373
547
  content: [{ type: 'text', text: `Error: Unknown tool "${name}"` }],
package/dist/loader.js ADDED
@@ -0,0 +1,57 @@
1
+ import { fileURLToPath, pathToFileURL } from 'node:url';
2
+ import { resolve as pathResolve, dirname } from 'node:path';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+
5
+ function tryResolveFile(filePath) {
6
+ // Direct .js extension
7
+ if (existsSync(filePath + '.js')) {
8
+ return pathToFileURL(filePath + '.js').href;
9
+ }
10
+ // Directory with index.js
11
+ if (existsSync(filePath + '/index.js')) {
12
+ return pathToFileURL(filePath + '/index.js').href;
13
+ }
14
+ // Directory with package.json (module or main field)
15
+ if (existsSync(filePath + '/package.json')) {
16
+ try {
17
+ const pkg = JSON.parse(readFileSync(filePath + '/package.json', 'utf-8'));
18
+ const entry = pkg.module || pkg.main;
19
+ if (entry) {
20
+ const resolved = pathResolve(filePath, entry);
21
+ if (existsSync(resolved)) {
22
+ return pathToFileURL(resolved).href;
23
+ }
24
+ }
25
+ } catch {}
26
+ }
27
+ return null;
28
+ }
29
+
30
+ export async function load(url, context, nextLoad) {
31
+ if (url.endsWith('.json')) {
32
+ return nextLoad(url, { ...context, importAttributes: { type: 'json' } });
33
+ }
34
+ return nextLoad(url, context);
35
+ }
36
+
37
+ export async function resolve(specifier, context, nextResolve) {
38
+ try {
39
+ return await nextResolve(specifier, context);
40
+ } catch (err) {
41
+ if (err?.code === 'ERR_MODULE_NOT_FOUND' || err?.code === 'ERR_UNSUPPORTED_DIR_IMPORT') {
42
+ // Try from the error's resolved URL
43
+ if (err.url) {
44
+ const resolved = tryResolveFile(fileURLToPath(err.url));
45
+ if (resolved) return { url: resolved, shortCircuit: true };
46
+ }
47
+
48
+ // Try from parent for relative specifiers
49
+ if (specifier.startsWith('.') && context.parentURL) {
50
+ const parentPath = dirname(fileURLToPath(context.parentURL));
51
+ const resolved = tryResolveFile(pathResolve(parentPath, specifier));
52
+ if (resolved) return { url: resolved, shortCircuit: true };
53
+ }
54
+ }
55
+ throw err;
56
+ }
57
+ }
package/package.json CHANGED
@@ -1,25 +1,26 @@
1
1
  {
2
2
  "name": "@neverprepared/mcp-markdown-to-confluence",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "MCP server for converting markdown to Confluence ADF and publishing pages with diagram support via Kroki",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
8
- "mcp-markdown-to-confluence": "dist/index.js"
8
+ "mcp-markdown-to-confluence": "bin/mcp-markdown-to-confluence.js"
9
9
  },
10
10
  "files": [
11
+ "bin",
11
12
  "dist",
12
13
  "docker",
13
14
  "scripts"
14
15
  ],
15
16
  "scripts": {
16
- "build": "tsc",
17
- "start": "node dist/index.js",
18
- "dev": "node --watch dist/index.js",
17
+ "build": "tsc && cp src/loader.js dist/loader.js",
18
+ "start": "node bin/mcp-markdown-to-confluence.js",
19
+ "dev": "node --watch bin/mcp-markdown-to-confluence.js",
19
20
  "postinstall": "sh scripts/postinstall.sh"
20
21
  },
21
22
  "dependencies": {
22
- "@markdown-confluence/lib": "^5.5.2",
23
+ "@markdown-confluence/lib": "5.5.2",
23
24
  "@modelcontextprotocol/sdk": "^1.10.1",
24
25
  "confluence.js": "^1.6.3",
25
26
  "gray-matter": "^4.0.3",