Skip to main content
Glama

Confluence MCP Server

by Olson3R
confluence-client.ts16.6 kB
import axios, { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; import { ConfluenceConfig, ConfluencePage, ConfluenceSpace, SearchResult, CreatePageRequest, UpdatePageRequest, MovePageRequest, PaginatedResult } from './types.js'; import { validateSpaceAccess } from './config.js'; import { Logger } from './logger.js'; interface RequestWithMetadata extends InternalAxiosRequestConfig { metadata?: { startTime: number }; } export class ConfluenceClient { private client: AxiosInstance; private config: ConfluenceConfig; private logger: Logger; private spaceCache: Map<string, ConfluenceSpace> = new Map(); private spaceCacheExpiry: Map<string, number> = new Map(); private readonly CACHE_TTL = 60 * 60 * 1000; // 1 hour constructor(config: ConfluenceConfig) { this.config = config; this.logger = new Logger(); const auth = Buffer.from(`${config.username}:${config.apiToken}`).toString('base64'); this.client = axios.create({ baseURL: `${config.baseUrl}/wiki/api/v2`, headers: { 'Authorization': `Basic ${auth}`, 'Accept': 'application/json', 'Content-Type': 'application/json' }, timeout: 30000 }); // Add request/response logging this.client.interceptors.request.use(async (request: RequestWithMetadata) => { const startTime = Date.now(); request.metadata = { startTime }; await this.logger.logRequest( request.method || 'unknown', request.url || '', request.params, request.data ); if (config.debug) { console.log('Confluence API Request:', { method: request.method, url: request.url, params: request.params }); } return request; }); this.client.interceptors.response.use( async (response) => { const requestConfig = response.config as RequestWithMetadata; const duration = requestConfig.metadata?.startTime ? Date.now() - requestConfig.metadata.startTime : undefined; await this.logger.logResponse( response.config.method || 'unknown', response.config.url || '', response.status, response.data, duration ); if (config.debug) { console.log('Confluence API Response:', { status: response.status, url: response.config.url }); } return response; }, async (error) => { await this.logger.logError( error.config?.method || 'unknown', error.config?.url || '', error ); if (config.debug) { console.error('Confluence API Error:', { status: error.response?.status, message: error.message, url: error.config?.url }); } return Promise.reject(error); } ); } async searchContent( query?: string, spaceKey?: string, limit = 25, title?: string, start = 0, bodyFormat?: string ): Promise<SearchResult> { // V2 API doesn't have a direct search endpoint, so we use v1 for CQL search // This is the recommended approach as CQL search is only available in v1 // Build search conditions const searchConditions: string[] = []; if (query) { searchConditions.push(`text ~ "${query}"`); } if (title) { searchConditions.push(`title ~ "${title}"`); } // If neither query nor title provided, search for all content if (searchConditions.length === 0) { searchConditions.push('type = page'); } const searchQuery = searchConditions.join(' AND '); // Set expand based on bodyFormat parameter let expandParam = 'version,space'; if (bodyFormat) { const format = bodyFormat === 'view' ? 'body.view' : 'body.storage'; expandParam += `,${format}`; } const params: any = { cql: searchQuery, limit, start, expand: expandParam }; if (spaceKey) { if (!validateSpaceAccess(spaceKey, this.config.allowedSpaces)) { throw new Error(`Access denied to space: ${spaceKey}`); } params.cql = `space = "${spaceKey}" AND ${params.cql}`; } else { const allowedSpacesCql = this.config.allowedSpaces.map(space => `space = "${space}"`).join(' OR '); params.cql = `(${allowedSpacesCql}) AND ${params.cql}`; } // Use v1 API for search since v2 doesn't provide CQL search functionality const searchUrl = `${this.config.baseUrl}/wiki/rest/api/search`; const auth = Buffer.from(`${this.config.username}:${this.config.apiToken}`).toString('base64'); const response: AxiosResponse<{ results: ConfluencePage[], start: number, limit: number, size: number, _links: any }> = await axios.get(searchUrl, { params, headers: { 'Authorization': `Basic ${auth}`, 'Accept': 'application/json', 'Content-Type': 'application/json' }, timeout: 30000 }); return { content: response.data.results, start: response.data.start, limit: response.data.limit, size: response.data.size, _links: response.data._links }; } async getPage(pageId: string, bodyFormat?: string): Promise<ConfluencePage> { // Use v1 API for getPage since we need space information anyway let expandParam = 'space,version'; if (bodyFormat) { const format = bodyFormat === 'view' ? 'body.view' : 'body.storage'; expandParam += `,${format}`; } const v1Url = `${this.config.baseUrl}/wiki/rest/api/content/${pageId}?expand=${expandParam}`; const auth = Buffer.from(`${this.config.username}:${this.config.apiToken}`).toString('base64'); const response = await axios.get(v1Url, { headers: { 'Authorization': `Basic ${auth}`, 'Accept': 'application/json', 'Content-Type': 'application/json' }, timeout: 30000 }); // Validate space access if (!response.data.space || !response.data.space.key) { throw new Error('Unable to determine page space for access validation'); } if (!validateSpaceAccess(response.data.space.key, this.config.allowedSpaces)) { throw new Error(`Access denied to space: ${response.data.space.key}`); } return response.data; } async createPage( spaceKey: string, title: string, content: string, parentId?: string ): Promise<ConfluencePage> { if (!validateSpaceAccess(spaceKey, this.config.allowedSpaces)) { throw new Error(`Access denied to space: ${spaceKey}`); } // Get space details to obtain the space ID const space = await this.getSpaceByKey(spaceKey); if (!space.id) { throw new Error(`Unable to get space ID for space: ${spaceKey}`); } const pageData: CreatePageRequest = { spaceId: space.id, status: 'current', title, body: { representation: 'storage', value: content } }; if (parentId) { pageData.parentId = parentId; } const response: AxiosResponse<ConfluencePage> = await this.client.post('/pages', pageData); return response.data; } async updatePage( pageId: string, title: string, content: string, version: number ): Promise<ConfluencePage> { const currentPage = await this.getPage(pageId); if (!currentPage.space || !currentPage.space.key) { throw new Error('Unable to determine page space for access validation'); } if (!validateSpaceAccess(currentPage.space.key, this.config.allowedSpaces)) { throw new Error(`Access denied to space: ${currentPage.space.key}`); } const updateData: UpdatePageRequest = { id: pageId, status: 'current', title, type: 'page', version: { number: version }, body: { storage: { value: content, representation: 'storage' } } }; const response: AxiosResponse<ConfluencePage> = await this.client.put(`/pages/${pageId}`, updateData); return response.data; } async deletePage(pageId: string): Promise<void> { const currentPage = await this.getPage(pageId); if (!currentPage.space || !currentPage.space.key) { throw new Error('Unable to determine page space for access validation'); } if (!validateSpaceAccess(currentPage.space.key, this.config.allowedSpaces)) { throw new Error(`Access denied to space: ${currentPage.space.key}`); } await this.client.delete(`/pages/${pageId}`); } async listSpaces(limit = 50, cursor?: string): Promise<PaginatedResult<ConfluenceSpace>> { const params: any = { limit }; if (cursor) { params.cursor = cursor; } const response: AxiosResponse<PaginatedResult<ConfluenceSpace>> = await this.client.get('/spaces', { params }); const filteredResults = response.data.results.filter(space => validateSpaceAccess(space.key, this.config.allowedSpaces) ); // Cache the spaces filteredResults.forEach(space => this.cacheSpace(space)); return { ...response.data, results: filteredResults, size: filteredResults.length }; } async getSpaceById(spaceId: string): Promise<ConfluenceSpace> { // Check if we have this space in cache by ID for (const [key, space] of this.spaceCache.entries()) { if (space.id === spaceId && this.isSpaceCacheValid(key)) { return space; } } // Note: Since we only have access to space keys in configuration, we need to validate by key // This method is primarily for internal use after we've obtained a space ID const response: AxiosResponse<ConfluenceSpace> = await this.client.get(`/spaces/${spaceId}`); // Validate access after getting the space data if (!validateSpaceAccess(response.data.key, this.config.allowedSpaces)) { throw new Error(`Access denied to space: ${response.data.key}`); } // Cache the space this.cacheSpace(response.data); return response.data; } private isSpaceCacheValid(spaceKey: string): boolean { const expiry = this.spaceCacheExpiry.get(spaceKey); return expiry !== undefined && Date.now() < expiry; } private cacheSpace(space: ConfluenceSpace): void { const now = Date.now(); this.spaceCache.set(space.key, space); this.spaceCacheExpiry.set(space.key, now + this.CACHE_TTL); } async getSpaceByKey(spaceKey: string): Promise<ConfluenceSpace> { if (!validateSpaceAccess(spaceKey, this.config.allowedSpaces)) { throw new Error(`Access denied to space: ${spaceKey}`); } // Check cache first if (this.isSpaceCacheValid(spaceKey)) { const cachedSpace = this.spaceCache.get(spaceKey); if (cachedSpace) { return cachedSpace; } } // Search through all pages using cursor-based pagination let cursor: string | undefined; let found = false; let space: ConfluenceSpace | undefined; do { const spaces = await this.listSpaces(100, cursor); space = spaces.results.find(s => s.key === spaceKey); if (space) { found = true; break; } // Extract cursor from _links.next if available cursor = undefined; if (spaces._links?.next) { const nextUrl = new URL(spaces._links.next); cursor = nextUrl.searchParams.get('cursor') || undefined; } } while (cursor); if (!found || !space) { throw new Error(`Space not found: ${spaceKey}`); } return space; } async getSpaceContent(spaceKey: string, limit = 25, start = 0, bodyFormat?: string): Promise<PaginatedResult<ConfluencePage>> { if (!validateSpaceAccess(spaceKey, this.config.allowedSpaces)) { throw new Error(`Access denied to space: ${spaceKey}`); } // Get basic page list from v2 API const response: AxiosResponse<PaginatedResult<ConfluencePage>> = await this.client.get('/pages', { params: { 'space-key': spaceKey, limit, start } }); // If body content is requested, enhance each page with body content from v1 API if (bodyFormat && response.data.results.length > 0) { const format = bodyFormat === 'view' ? 'body.view' : 'body.storage'; const auth = Buffer.from(`${this.config.username}:${this.config.apiToken}`).toString('base64'); const enhancedResults = await Promise.all( response.data.results.map(async (page) => { try { const v1Url = `${this.config.baseUrl}/wiki/rest/api/content/${page.id}?expand=${format},version,space`; const v1Response = await axios.get(v1Url, { headers: { 'Authorization': `Basic ${auth}`, 'Accept': 'application/json', 'Content-Type': 'application/json' }, timeout: 30000 }); if (v1Response.data.body) { page.body = v1Response.data.body; } } catch (error) { if (this.config.debug) { console.warn(`Failed to retrieve body content for page ${page.id}:`, error); } } return page; }) ); response.data.results = enhancedResults; } return response.data; } async movePage( pageId: string, targetSpaceKey: string, parentId?: string ): Promise<ConfluencePage> { const currentPage = await this.getPage(pageId); if (!currentPage.space || !currentPage.space.key) { throw new Error('Unable to determine page space for access validation'); } if (!validateSpaceAccess(currentPage.space.key, this.config.allowedSpaces)) { throw new Error(`Access denied to source space: ${currentPage.space.key}`); } if (!validateSpaceAccess(targetSpaceKey, this.config.allowedSpaces)) { throw new Error(`Access denied to target space: ${targetSpaceKey}`); } const moveData: MovePageRequest = { version: { number: currentPage.version.number }, title: currentPage.title, type: 'page', space: { key: targetSpaceKey } }; if (parentId) { moveData.ancestors = [{ id: parentId }]; } const response: AxiosResponse<ConfluencePage> = await this.client.put(`/pages/${pageId}`, moveData); return response.data; } async getPageChildren(pageId: string, limit = 25, start = 0, bodyFormat?: string): Promise<PaginatedResult<ConfluencePage>> { const parentPage = await this.getPage(pageId); if (!parentPage.space || !parentPage.space.key) { throw new Error('Unable to determine page space for access validation'); } if (!validateSpaceAccess(parentPage.space.key, this.config.allowedSpaces)) { throw new Error(`Access denied to space: ${parentPage.space.key}`); } // Get basic children list from v2 API const response: AxiosResponse<PaginatedResult<ConfluencePage>> = await this.client.get(`/pages/${pageId}/children`, { params: { limit, start } }); // If body content is requested, enhance each page with body content from v1 API if (bodyFormat && response.data.results.length > 0) { const format = bodyFormat === 'view' ? 'body.view' : 'body.storage'; const auth = Buffer.from(`${this.config.username}:${this.config.apiToken}`).toString('base64'); const enhancedResults = await Promise.all( response.data.results.map(async (page) => { try { const v1Url = `${this.config.baseUrl}/wiki/rest/api/content/${page.id}?expand=${format},version,space`; const v1Response = await axios.get(v1Url, { headers: { 'Authorization': `Basic ${auth}`, 'Accept': 'application/json', 'Content-Type': 'application/json' }, timeout: 30000 }); if (v1Response.data.body) { page.body = v1Response.data.body; } } catch (error) { if (this.config.debug) { console.warn(`Failed to retrieve body content for page ${page.id}:`, error); } } return page; }) ); response.data.results = enhancedResults; } return response.data; } }

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/Olson3R/confluence-mcp'

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