@aashari/mcp-server-atlassian-confluence
Version:
Node.js/TypeScript MCP server for Atlassian Confluence. Provides tools enabling AI systems (LLMs) to list/get spaces & pages (content formatted as Markdown) and search via CQL. Connects AI seamlessly to Confluence knowledge bases using the standard MCP in
463 lines (462 loc) • 13.9 kB
JavaScript
;
/**
* Utility functions for converting Atlassian Document Format (ADF) to Markdown
*
* Used for standardized content handling across Atlassian products.
* This implementation is compatible with Confluence Cloud's 'atlas_doc_format' content format.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.adfToMarkdown = adfToMarkdown;
const logger_util_js_1 = require("./logger.util.js");
// Create a contextualized logger for this file
const adfLogger = logger_util_js_1.Logger.forContext('utils/adf.util.ts');
// Log ADF utility initialization
adfLogger.debug('ADF utility initialized');
/**
* Convert Atlassian Document Format (ADF) to Markdown
*
* @param adf - The ADF content to convert (can be string or object)
* @returns The converted Markdown content
*/
function adfToMarkdown(adf) {
const methodLogger = logger_util_js_1.Logger.forContext('utils/adf.util.ts', 'adfToMarkdown');
try {
// Handle empty or undefined input
if (!adf) {
return '';
}
// Parse ADF if it's a string
let adfDoc;
if (typeof adf === 'string') {
try {
adfDoc = JSON.parse(adf);
}
catch {
return adf; // Return as-is if not valid JSON
}
}
else if (typeof adf === 'object') {
adfDoc = adf;
}
else {
return String(adf);
}
// Check if it's a valid ADF document
if (!adfDoc.content || !Array.isArray(adfDoc.content)) {
return '';
}
// Process the document
const markdown = processAdfContent(adfDoc.content);
methodLogger.debug(`Converted ADF to Markdown, length: ${markdown.length}`);
return markdown;
}
catch (error) {
methodLogger.error('[src/utils/adf.util.ts@adfToMarkdown] Error converting ADF to Markdown:', error);
return '*Error converting content format*';
}
}
/**
* Process ADF content nodes
*/
function processAdfContent(content) {
if (!content || !Array.isArray(content)) {
return '';
}
return content.map((node) => processAdfNode(node)).join('\n\n');
}
/**
* Process mention node
*/
function processMention(node) {
if (!node.attrs) {
return '';
}
// Get the display text and account ID
const text = node.attrs.text || node.attrs.displayName || '';
const accountId = node.attrs.id;
if (!text) {
return '';
}
// Format as @username to preserve the mention format
// Remove any existing @ symbol to avoid double @@ in the output
const cleanText = typeof text === 'string' && text.startsWith('@')
? text.substring(1)
: text;
// If we have an account ID, create a mailto link with the user's name
// This creates a standardized format that works in markdown and doesn't need actual API calls
if (accountId) {
// Create a normalized email format from the username
// Ensure we're working with a string type
const cleanTextStr = String(cleanText);
const nameParts = cleanTextStr.split(' ');
if (nameParts.length >= 2) {
// If we have full name, create first.last@company.com format
const firstName = nameParts[0].toLowerCase();
const lastName = nameParts[nameParts.length - 1].toLowerCase();
return `[${cleanTextStr}](mailto:${firstName}.${lastName}@codapayments.com)`;
}
else {
// If just one name part, use that
return `[${cleanTextStr}](mailto:${cleanTextStr.toLowerCase().replace(/\s+/g, '.')}@codapayments.com)`;
}
}
// Fallback to the original plain text format
return `@${cleanText}`;
}
/**
* Process a single ADF node
*/
function processAdfNode(node) {
if (!node || !node.type) {
return '';
}
switch (node.type) {
case 'paragraph':
return processParagraph(node);
case 'heading':
return processHeading(node);
case 'bulletList':
return processBulletList(node);
case 'orderedList':
return processOrderedList(node);
case 'listItem':
return processListItem(node);
case 'codeBlock':
return processCodeBlock(node);
case 'blockquote':
return processBlockquote(node);
case 'rule':
return '---';
case 'mediaGroup':
return processMediaGroup(node);
case 'media':
return processMedia(node);
case 'table':
return processTable(node);
case 'text':
return processText(node);
case 'mention':
return processMention(node);
case 'inlineCard':
return processInlineCard(node);
case 'emoji':
return processEmoji(node);
case 'date':
return processDate(node);
case 'status':
return processStatus(node);
default:
// For unknown node types, try to process content if available
if (node.content) {
return processAdfContent(node.content);
}
return '';
}
}
/**
* Process paragraph node
*/
function processParagraph(node) {
if (!node.content) {
return '';
}
// Process each child node and join them with proper spacing
return node.content
.map((childNode, index) => {
// Add a space between text nodes if needed
const needsSpace = index > 0 &&
childNode.type === 'text' &&
node.content[index - 1].type === 'text' &&
!childNode.text?.startsWith(' ') &&
!node.content[index - 1].text?.endsWith(' ');
return (needsSpace ? ' ' : '') + processAdfNode(childNode);
})
.join('');
}
/**
* Process heading node
*/
function processHeading(node) {
if (!node.content || !node.attrs) {
return '';
}
const level = typeof node.attrs.level === 'number' ? node.attrs.level : 1;
const headingMarker = '#'.repeat(level);
const content = node.content
.map((childNode) => processAdfNode(childNode))
.join('');
return `${headingMarker} ${content}`;
}
/**
* Process bullet list node
*/
function processBulletList(node) {
if (!node.content) {
return '';
}
return node.content.map((item) => processAdfNode(item)).join('\n');
}
/**
* Process ordered list node
*/
function processOrderedList(node) {
if (!node.content) {
return '';
}
return node.content
.map((item, index) => {
const processedItem = processAdfNode(item);
// Replace the first "- " with "1. ", "2. ", etc.
return processedItem.replace(/^- /, `${index + 1}. `);
})
.join('\n');
}
/**
* Process list item node
*/
function processListItem(node) {
if (!node.content) {
return '';
}
const content = node.content
.map((childNode) => {
const processed = processAdfNode(childNode);
// For nested lists, add indentation
if (childNode.type === 'bulletList' ||
childNode.type === 'orderedList') {
return processed
.split('\n')
.map((line) => ` ${line}`)
.join('\n');
}
return processed;
})
.join('\n');
return `- ${content}`;
}
/**
* Process code block node
*/
function processCodeBlock(node) {
if (!node.content) {
return '```\n```';
}
const language = node.attrs?.language || '';
const code = node.content
.map((childNode) => processAdfNode(childNode))
.join('');
return `\`\`\`${language}\n${code}\n\`\`\``;
}
/**
* Process blockquote node
*/
function processBlockquote(node) {
if (!node.content) {
return '';
}
const content = node.content
.map((childNode) => processAdfNode(childNode))
.join('\n\n');
// Add > to each line
return content
.split('\n')
.map((line) => `> ${line}`)
.join('\n');
}
/**
* Process media group node
*/
function processMediaGroup(node) {
if (!node.content) {
return '';
}
return node.content
.map((mediaNode) => {
if (mediaNode.type === 'media' && mediaNode.attrs) {
const { id, type } = mediaNode.attrs;
if (type === 'file') {
return `[Attachment: ${id}]`;
}
else if (type === 'link') {
return `[External Link]`;
}
}
return '';
})
.filter(Boolean)
.join('\n');
}
/**
* Process media node
*/
function processMedia(node) {
if (!node.attrs) {
return '';
}
// Handle file attachments
if (node.attrs.type === 'file') {
const id = node.attrs.id || '';
const altText = node.attrs.alt ? node.attrs.alt : `Attachment: ${id}`;
return ``;
}
// Handle external media (e.g., YouTube embeds)
if (node.attrs.type === 'external' && node.attrs.url) {
return `[External Media](${node.attrs.url})`;
}
return '';
}
/**
* Process table node
*/
function processTable(node) {
if (!node.content) {
return '';
}
const rows = [];
// Process table rows
node.content.forEach((row) => {
if (row.type === 'tableRow' && row.content) {
const cells = [];
row.content.forEach((cell) => {
if ((cell.type === 'tableCell' ||
cell.type === 'tableHeader') &&
cell.content) {
const cellContent = cell.content
.map((cellNode) => processAdfNode(cellNode))
.join('');
cells.push(cellContent.trim());
}
});
if (cells.length > 0) {
rows.push(cells);
}
}
});
if (rows.length === 0) {
return '';
}
// Create markdown table
const columnCount = Math.max(...rows.map((row) => row.length));
// Ensure all rows have the same number of columns
const normalizedRows = rows.map((row) => {
while (row.length < columnCount) {
row.push('');
}
return row;
});
// Create header row
const headerRow = normalizedRows[0].map((cell) => cell || '');
// Create separator row
const separatorRow = headerRow.map(() => '---');
// Create content rows
const contentRows = normalizedRows.slice(1);
// Build the table
const tableRows = [
headerRow.join(' | '),
separatorRow.join(' | '),
...contentRows.map((row) => row.join(' | ')),
];
return tableRows.join('\n');
}
/**
* Process text node
*/
function processText(node) {
if (!node.text) {
return '';
}
let text = node.text;
// Apply marks if available
if (node.marks && node.marks.length > 0) {
// Sort marks to ensure consistent application (process links last)
const sortedMarks = [...node.marks].sort((a, b) => {
if (a.type === 'link')
return 1;
if (b.type === 'link')
return -1;
return 0;
});
// Apply non-link marks first
sortedMarks.forEach((mark) => {
switch (mark.type) {
case 'strong':
text = `**${text}**`;
break;
case 'em':
text = `*${text}*`;
break;
case 'code':
text = `\`${text}\``;
break;
case 'strike':
text = `~~${text}~~`;
break;
case 'underline':
// Markdown doesn't support underline, use emphasis instead
text = `_${text}_`;
break;
case 'textColor':
// Ignore in Markdown (no equivalent)
break;
case 'superscript':
// Some flavors of Markdown support ^superscript^
text = `^${text}^`;
break;
case 'subscript':
// Some flavors of Markdown support ~subscript~
// but this conflicts with strikethrough
text = `~${text}~`;
break;
case 'link':
if (mark.attrs && mark.attrs.href) {
text = `[${text}](${mark.attrs.href})`;
}
break;
}
});
}
return text;
}
/**
* Process inline card node (references to Jira issues, Confluence pages, etc.)
*/
function processInlineCard(node) {
if (!node.attrs) {
return '[Link]';
}
const url = node.attrs.url || '';
// Extract the name/ID from the URL if possible
const match = url.match(/\/([^/]+)$/);
const name = match ? match[1] : 'Link';
return `[${name}](${url})`;
}
/**
* Process emoji node
*/
function processEmoji(node) {
if (!node.attrs) {
return '';
}
// Return shortName if available, otherwise fallback
return (node.attrs.shortName || node.attrs.id || '📝');
}
/**
* Process date node
*/
function processDate(node) {
if (!node.attrs) {
return '';
}
return node.attrs.timestamp || '';
}
/**
* Process status node (status lozenges)
*/
function processStatus(node) {
if (!node.attrs) {
return '[Status]';
}
const text = node.attrs.text || 'Status';
// Markdown doesn't support colored lozenges, so we use brackets
return `[${text}]`;
}