Skip to main content
Glama
server.ts22.7 kB
import express from 'express'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { z } from 'zod'; import { loadConfig } from './utils/config'; import { ConfluenceClient } from './services/confluence-client'; import { MarkdownConverter } from './services/markdown-converter'; import { MarkdownPageCache } from './utils/cache'; import { ProjectConfigManager } from './utils/project-config'; import crypto from 'crypto'; // Security configuration const MCP_API_KEY = process.env.MCP_API_KEY; const RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes const RATE_LIMIT_MAX_REQUESTS = 100; // Max requests per window // Rate limiting store (in production, use Redis or similar) const rateLimitStore = new Map<string, { count: number; resetTime: number }>(); // Authentication middleware function authenticateRequest(req: express.Request, res: express.Response, next: express.NextFunction) { const apiKey = req.headers['x-mcp-api-key'] || req.query.apiKey; if (!MCP_API_KEY) { console.error('MCP_API_KEY not configured on server'); return res.status(500).json({ error: 'Server configuration error' }); } if (!apiKey || apiKey !== MCP_API_KEY) { console.warn('Unauthorized access attempt:', { ip: req.ip, userAgent: req.headers['user-agent'], timestamp: new Date().toISOString() }); return res.status(401).json({ error: 'Unauthorized: Invalid or missing API key' }); } next(); } // Rate limiting middleware function rateLimitMiddleware(req: express.Request, res: express.Response, next: express.NextFunction) { const clientId = req.ip || 'unknown'; const now = Date.now(); // Clean up expired entries for (const [key, value] of rateLimitStore.entries()) { if (now > value.resetTime) { rateLimitStore.delete(key); } } const clientData = rateLimitStore.get(clientId); if (!clientData) { rateLimitStore.set(clientId, { count: 1, resetTime: now + RATE_LIMIT_WINDOW }); return next(); } if (now > clientData.resetTime) { rateLimitStore.set(clientId, { count: 1, resetTime: now + RATE_LIMIT_WINDOW }); return next(); } if (clientData.count >= RATE_LIMIT_MAX_REQUESTS) { return res.status(429).json({ error: 'Rate limit exceeded', retryAfter: Math.ceil((clientData.resetTime - now) / 1000) }); } clientData.count++; next(); } // Security headers middleware function securityHeaders(req: express.Request, res: express.Response, next: express.NextFunction) { res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'DENY'); res.setHeader('X-XSS-Protection', '1; mode=block'); res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); res.setHeader('Content-Security-Policy', "default-src 'self'"); next(); } // Generate a secure session ID function generateSecureSessionId(): string { return crypto.randomBytes(32).toString('hex'); } // Create an MCP server instance with Confluence tools const getServer = () => { const server = new McpServer({ name: 'confluence-mcp', version: '0.1.0', }, { capabilities: { tools: {}, logging: {} } }); // Load configuration on startup loadConfig(); // Register Confluence tools server.tool('confluence_list_spaces', 'List all available Confluence spaces', {}, async () => { try { const client = new ConfluenceClient(); const spaces = await client.listSpaces(); return { content: [ { type: 'text', text: JSON.stringify({ spaces: spaces.map(space => ({ key: space.key, name: space.name })) }, null, 2) } ] }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: error.message || 'Failed to list Confluence spaces' }, null, 2) } ], isError: true }; } }); server.tool('confluence_list_pages', 'List all pages in a Confluence space', { spaceKey: z.string().describe('The key of the Confluence space to list pages from') }, async ({ spaceKey }) => { try { const client = new ConfluenceClient(); const pages = await client.listPages(spaceKey); return { content: [ { type: 'text', text: JSON.stringify({ pages: pages.map(page => ({ id: page.id, title: page.title, spaceKey: page.spaceKey, version: page.version.number })) }, null, 2) } ] }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: error.message || 'Failed to list Confluence pages' }, null, 2) } ], isError: true }; } }); server.tool('confluence_create_page', 'Create a new Confluence page from Markdown content', { title: z.string().describe('The title of the new page'), markdownContent: z.string().describe('The Markdown content to be converted and used for the page'), markdownPath: z.string().optional().describe('Optional: The path to the Markdown file in the local codebase for caching'), spaceKey: z.string().optional().describe('Optional: Override the default space key from project config'), parentPageId: z.string().optional().describe('Optional: Override the default parent page from project config') }, async ({ title, markdownContent, markdownPath, spaceKey, parentPageId }) => { try { const projectConfig = new ProjectConfigManager(); const config = projectConfig.getConfig(); // Use project config defaults if not provided const finalSpaceKey = spaceKey || config?.spaceKey; const finalParentPageId = parentPageId || config?.parentPageId; if (!finalSpaceKey) { return { content: [ { type: 'text', text: JSON.stringify({ error: 'No space key provided. Either pass spaceKey parameter or set up project config with confluence_setup_project' }, null, 2) } ], isError: true }; } const client = new ConfluenceClient(); const converter = new MarkdownConverter(); const cache = new MarkdownPageCache(); // Convert markdown to Confluence format const confluenceContent = await converter.convertToConfluence(markdownContent); // Create the page (with parent if specified) const page = await client.createPage(finalSpaceKey, title, confluenceContent, finalParentPageId); // Cache the mapping if markdownPath is provided if (markdownPath) { cache.setPageMapping(markdownPath, { markdownPath, pageId: page.id, spaceKey: page.spaceKey, title: page.title, lastUpdated: new Date().toISOString() }); } return { content: [ { type: 'text', text: JSON.stringify({ success: true, message: `✅ Page '${title}' created successfully!`, page: { id: page.id, title: page.title, spaceKey: page.spaceKey, version: page.version, parentPageId: finalParentPageId } }, null, 2) } ] }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: error.message || 'Failed to create Confluence page' }, null, 2) } ], isError: true }; } }); server.tool('confluence_update_page', 'Update an existing Confluence page from Markdown content', { pageId: z.string().describe('The ID of the Confluence page to update'), title: z.string().describe('The new title for the page'), markdownContent: z.string().describe('The Markdown content to be converted and used for the page'), markdownPath: z.string().optional().describe('Optional: The path to the Markdown file in the local codebase for caching'), version: z.number().describe('The current version number of the page (required for updates)'), parentPageId: z.string().optional().describe('Optional: Override the default parent page from project config') }, async ({ pageId, title, markdownContent, markdownPath, version, parentPageId }) => { try { const projectConfig = new ProjectConfigManager(); const config = projectConfig.getConfig(); // Use project config default parent if not provided const finalParentPageId = parentPageId || config?.parentPageId; const client = new ConfluenceClient(); const converter = new MarkdownConverter(); const cache = new MarkdownPageCache(); // Convert markdown to Confluence format const confluenceContent = await converter.convertToConfluence(markdownContent); // Update the page (with parent if specified) const page = await client.updatePage(pageId, title, confluenceContent, version, finalParentPageId); // Update cache mapping if markdownPath is provided if (markdownPath) { cache.setPageMapping(markdownPath, { markdownPath, pageId: page.id, spaceKey: page.spaceKey, title: page.title, lastUpdated: new Date().toISOString() }); } return { content: [ { type: 'text', text: JSON.stringify({ success: true, message: `✅ Page '${title}' updated successfully!`, page: { id: page.id, title: page.title, spaceKey: page.spaceKey, version: page.version, parentPageId: finalParentPageId } }, null, 2) } ] }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: error.message || 'Failed to update Confluence page' }, null, 2) } ], isError: true }; } }); server.tool('confluence_delete_page', 'Delete a Confluence page and remove it from cache', { pageId: z.string().describe('The ID of the Confluence page to delete'), markdownPath: z.string().optional().describe('Optional: The path to the Markdown file in the local codebase to remove from cache') }, async ({ pageId, markdownPath }) => { try { const client = new ConfluenceClient(); const cache = new MarkdownPageCache(); // Delete the page from Confluence await client.deletePage(pageId); // Remove from cache if markdownPath is provided if (markdownPath) { cache.removePageMapping(markdownPath); } else { // If no markdownPath provided, try to find and remove by pageId const allMappings = cache.getAllMappings(); for (const [path, mapping] of Object.entries(allMappings)) { if (mapping.pageId === pageId) { cache.removePageMapping(path); break; } } } return { content: [ { type: 'text', text: JSON.stringify({ success: true, message: `Page ${pageId} deleted successfully` }, null, 2) } ] }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: error.message || 'Failed to delete Confluence page' }, null, 2) } ], isError: true }; } }); // Add project configuration management tools server.tool('confluence_setup_project', 'Set up Confluence project configuration with your specific settings', { confluenceUrl: z.string().describe('Confluence base URL (e.g., https://realestatenexus.atlassian.net/)'), username: z.string().describe('Confluence username/email'), apiToken: z.string().describe('Confluence API token'), spaceKey: z.string().describe('Default space key (e.g., ~712020b38176381dd2400481d381324bb1fb50)'), parentPageTitle: z.string().optional().describe('Parent page title in hierarchy (e.g., REN360 Microservices Ecosystem)'), baseDir: z.string().optional().describe('Local file path mapping (optional)') }, async ({ confluenceUrl, username, apiToken, spaceKey, parentPageTitle, baseDir }) => { try { const projectConfig = new ProjectConfigManager(); // Update environment variables for immediate use process.env.CONFLUENCE_BASE_URL = confluenceUrl; process.env.CONFLUENCE_USERNAME = username; process.env.CONFLUENCE_API_TOKEN = apiToken; // Reload configuration loadConfig(); // Test the connection first const client = new ConfluenceClient(); // Validate space exists try { const spaces = await client.listSpaces(); const spaceExists = spaces.some(space => space.key === spaceKey); if (!spaceExists) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Space with key '${spaceKey}' not found`, availableSpaces: spaces.map(s => ({ key: s.key, name: s.name })) }, null, 2) } ], isError: true }; } } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Failed to connect to Confluence: ${error.message}`, troubleshooting: [ 'Check your Confluence URL format', 'Verify your username/email is correct', 'Ensure your API token is valid', 'Make sure you have access to the Confluence instance' ] }, null, 2) } ], isError: true }; } // Find parent page if specified let parentPageId: string | undefined; if (parentPageTitle) { try { const pages = await client.listPages(spaceKey); const parentPage = pages.find(page => page.title === parentPageTitle); if (parentPage) { parentPageId = parentPage.id; } else { return { content: [ { type: 'text', text: JSON.stringify({ error: `Parent page '${parentPageTitle}' not found in space '${spaceKey}'`, availablePages: pages.map(p => ({ id: p.id, title: p.title })).slice(0, 10) }, null, 2) } ], isError: true }; } } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Failed to find parent page: ${error.message}` }, null, 2) } ], isError: true }; } } // Save project configuration projectConfig.saveConfig({ confluenceUrl, username, apiToken, spaceKey, parentPageTitle, parentPageId, baseDir }); return { content: [ { type: 'text', text: JSON.stringify({ success: true, message: '✅ Confluence project configuration saved successfully!', config: { confluenceUrl, username: username.replace(/(.{3}).*(@.*)/, '$1***$2'), // Mask email spaceKey, parentPageTitle, parentPageId, baseDir } }, null, 2) } ] }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: error.message || 'Failed to set up Confluence project configuration' }, null, 2) } ], isError: true }; } }); server.tool('confluence_show_config', 'Show current project configuration', {}, async () => { try { const projectConfig = new ProjectConfigManager(); const config = projectConfig.getConfig(); if (!config) { return { content: [ { type: 'text', text: JSON.stringify({ message: 'No project configuration found. Use confluence_setup_project to configure.', configured: false }, null, 2) } ] }; } return { content: [ { type: 'text', text: JSON.stringify({ configured: true, config: { confluenceUrl: config.confluenceUrl, username: config.username.replace(/(.{3}).*(@.*)/, '$1***$2'), // Mask email spaceKey: config.spaceKey, parentPageTitle: config.parentPageTitle, parentPageId: config.parentPageId, baseDir: config.baseDir, lastUpdated: config.lastUpdated } }, null, 2) } ] }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ error: error.message || 'Failed to show project configuration' }, null, 2) } ], isError: true }; } }); server.tool('confluence_test_connection', 'Test Confluence connection', {}, async () => { try { const client = new ConfluenceClient(); const spaces = await client.listSpaces(); return { content: [ { type: 'text', text: JSON.stringify({ success: true, message: `✅ Connection successful! Found ${spaces.length} spaces.`, spacesCount: spaces.length }, null, 2) } ] }; } catch (error: any) { return { content: [ { type: 'text', text: JSON.stringify({ success: false, error: `❌ Connection failed: ${error.message}`, troubleshooting: [ 'Check your Confluence base URL', 'Verify your username/email is correct', 'Ensure your API token is valid', 'Make sure you have access to the Confluence instance' ] }, null, 2) } ], isError: true }; } }); return server; }; export function startServer(port: number): void { const app = express(); // Trust proxy for accurate IP addresses (important for Fly.io) app.set('trust proxy', true); // Apply security middleware app.use(securityHeaders); app.use(express.json({ limit: '10mb' })); // Limit payload size app.use(rateLimitMiddleware); // Store transports by session ID const transports: Record<string, SSEServerTransport> = {}; // SSE endpoint for establishing the stream (requires authentication) app.get('/mcp', authenticateRequest, async (req, res) => { console.log('Received authenticated GET request to /mcp (establishing SSE stream)'); try { // Generate a secure session ID const secureSessionId = generateSecureSessionId(); // Create a new SSE transport for the client const transport = new SSEServerTransport('/messages', res); // Override the session ID with our secure one (transport as any).sessionId = secureSessionId; // Store the transport by session ID transports[secureSessionId] = transport; // Set up onclose handler to clean up transport when closed transport.onclose = () => { console.log(`SSE transport closed for session ${secureSessionId}`); delete transports[secureSessionId]; }; // Connect the transport to the MCP server const server = getServer(); await server.connect(transport); console.log(`Established SSE stream with session ID: ${secureSessionId}`); } catch (error) { console.error('Error establishing SSE stream:', error); if (!res.headersSent) { res.status(500).send('Error establishing SSE stream'); } } }); // Messages endpoint for receiving client JSON-RPC requests (requires authentication) app.post('/messages', authenticateRequest, async (req, res) => { console.log('Received authenticated POST request to /messages'); const sessionId = req.query.sessionId as string; if (!sessionId) { console.error('No session ID provided in request URL'); res.status(400).send('Missing sessionId parameter'); return; } const transport = transports[sessionId]; if (!transport) { console.error(`No active transport found for session ID: ${sessionId}`); res.status(404).send('Session not found'); return; } try { await transport.handlePostMessage(req, res, req.body); } catch (error) { console.error('Error handling request:', error); if (!res.headersSent) { res.status(500).send('Error handling request'); } } }); // Health check endpoint app.get('/health', (_req, res) => { res.status(200).json({ status: 'ok' }); }); // Start the server app.listen(port, () => { console.log(`Confluence MCP SSE server running on port ${port}`); }); // Handle server shutdown process.on('SIGINT', async () => { console.log('Shutting down server...'); for (const sessionId in transports) { try { console.log(`Closing transport for session ${sessionId}`); await transports[sessionId].close(); delete transports[sessionId]; } catch (error) { console.error(`Error closing transport for session ${sessionId}:`, error); } } console.log('Server shutdown complete'); process.exit(0); }); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/manateeit/confluence-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server