Confluence MCP Server
by aaronsb
Verified
- confluence-cloud-mcp
- src
- client
import axios, { AxiosInstance } from 'axios';
import { ConfluenceError } from '../types/index.js';
import type {
ConfluenceConfig,
Space,
Page,
Label,
SearchResult,
PaginatedResponse,
SimplifiedPage
} from '../types/index.js';
export class ConfluenceClient {
private v2Client: AxiosInstance;
private v1Client: AxiosInstance;
private domain: string;
private baseURL: string;
private v2Path: string;
constructor(config: ConfluenceConfig) {
this.domain = config.domain;
this.baseURL = `https://${config.domain}/wiki`;
this.v2Path = '/api/v2';
const v1Path = '/rest/api';
// V2 API client for most operations
this.v2Client = axios.create({
baseURL: this.baseURL + this.v2Path,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': `Basic ${Buffer.from(`${config.email}:${config.apiToken}`).toString('base64')}`,
'X-Atlassian-Token': 'no-check'
}
});
// V1 API client specifically for content
this.v1Client = axios.create({
baseURL: this.baseURL + v1Path,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': `Basic ${Buffer.from(`${config.email}:${config.apiToken}`).toString('base64')}`,
'X-Atlassian-Token': 'no-check'
}
});
// Log configuration for debugging
console.error('Confluence client configured with domain:', config.domain);
}
// Verify connection to Confluence API - throws error if verification fails
async verifyApiConnection(): Promise<void> {
try {
// Make a simple API call that should work with minimal permissions
await this.v2Client.get('/spaces', { params: { limit: 1 } });
process.stderr.write('Successfully connected to Confluence API\n');
} catch (error) {
let errorMessage = 'Failed to connect to Confluence API';
if (axios.isAxiosError(error)) {
// Extract detailed error information
const errorDetails = {
status: error.response?.status,
statusText: error.response?.statusText,
message: error.message
};
// Provide specific error messages based on status code
if (error.response && error.response.status === 401) {
errorMessage = 'Authentication failed: Invalid API token or email';
} else if (error.response && error.response.status === 403) {
errorMessage = 'Authorization failed: Insufficient permissions';
} else if (error.response && error.response.status === 404) {
errorMessage = 'API endpoint not found: Check Confluence domain';
} else if (error.response && error.response.status >= 500) {
errorMessage = 'Confluence server error: API may be temporarily unavailable';
}
console.error(`${errorMessage}:`, errorDetails);
} else {
console.error(errorMessage + ':', error instanceof Error ? error.message : String(error));
}
// Throw error with detailed message to fail server initialization
throw new Error(errorMessage);
}
}
// Space operations
async getConfluenceSpaces(limit = 25, start = 0): Promise<PaginatedResponse<Space>> {
const response = await this.v2Client.get('/spaces', {
params: { limit, start }
});
return response.data;
}
async getConfluenceSpace(spaceId: string): Promise<Space> {
const response = await this.v2Client.get(`/spaces/${spaceId}`);
return response.data;
}
// Page operations
async getConfluencePages(spaceId: string, limit = 25, start = 0, title?: string): Promise<PaginatedResponse<Page>> {
const response = await this.v2Client.get('/pages', {
params: {
spaceId,
limit,
start,
status: 'current',
...(title && { title })
}
});
return response.data;
}
async searchPageByName(title: string, spaceId?: string): Promise<Page[]> {
try {
const params: any = {
title,
status: 'current',
limit: 10 // Reasonable limit for multiple matches
};
if (spaceId) {
params.spaceId = spaceId;
}
const response = await this.v2Client.get('/pages', { params });
return response.data.results;
} catch (error) {
if (axios.isAxiosError(error)) {
console.error('Error searching for page:', error.message);
throw new ConfluenceError(
`Failed to search for page: ${error.message}`,
'UNKNOWN'
);
}
throw error;
}
}
async getPageContent(pageId: string): Promise<string> {
try {
console.error(`Fetching content for page ${pageId} using v1 API`);
// Use v1 API to get content, which reliably returns body content
const response = await this.v1Client.get(`/content/${pageId}`, {
params: {
expand: 'body.storage'
}
});
const content = response.data.body?.storage?.value;
if (!content) {
throw new ConfluenceError(
'Page content is empty or not accessible',
'EMPTY_CONTENT'
);
}
return content;
} catch (error) {
if (axios.isAxiosError(error)) {
if (error.response?.status === 404) {
throw new ConfluenceError(
'Page content not found',
'PAGE_NOT_FOUND'
);
}
if (error.response?.status === 403) {
throw new ConfluenceError(
'Insufficient permissions to access page content',
'INSUFFICIENT_PERMISSIONS'
);
}
throw new ConfluenceError(
`Failed to get page content: ${error.message}`,
'UNKNOWN'
);
}
throw error;
}
}
async getConfluencePage(pageId: string): Promise<Page> {
try {
// Get page metadata
const pageResponse = await this.v2Client.get(`/pages/${pageId}`);
const page = pageResponse.data;
try {
// Get page content
const content = await this.getPageContent(pageId);
return {
...page,
body: {
storage: {
value: content,
representation: 'storage'
}
}
};
} catch (contentError) {
if (contentError instanceof ConfluenceError &&
contentError.code === 'EMPTY_CONTENT') {
return page; // Return metadata only for empty pages
}
throw contentError;
}
} catch (error) {
if (axios.isAxiosError(error)) {
console.error('Error fetching page:', error.message);
throw error;
}
console.error('Error fetching page:', error instanceof Error ? error.message : 'Unknown error');
throw new Error('Failed to fetch page content');
}
}
// Removing duplicate method since it's redundant with getConfluencePage
async createConfluencePage(spaceId: string, title: string, content: string, parentId?: string): Promise<Page> {
const body = {
spaceId,
status: 'current',
title,
body: {
representation: 'storage',
value: content
},
...(parentId && { parentId })
};
const response = await this.v2Client.post('/pages', body);
return response.data;
}
async updateConfluencePage(pageId: string, title: string, content: string, version: number): Promise<Page> {
const body = {
id: pageId,
status: 'current',
title,
body: {
representation: 'storage',
value: content
},
version: {
number: version + 1,
message: `Updated via MCP at ${new Date().toISOString()}`
}
};
const response = await this.v2Client.put(`/pages/${pageId}`, body);
return response.data;
}
// Search operations
async searchConfluenceContent(query: string, limit = 25, start = 0): Promise<SearchResult> {
try {
console.error('Searching Confluence with CQL:', query);
// Use the v1 search endpoint with CQL
const response = await this.v1Client.get('/search', {
params: {
cql: query.includes('type =') ? query : `text ~ "${query}"`,
limit,
start,
expand: 'content.space,content.version,content.body.view.value'
}
});
console.error(`Found ${response.data.results?.length || 0} results`);
return {
results: (response.data.results || []).map((result: any) => ({
content: {
id: result.content.id,
type: result.content.type,
status: result.content.status,
title: result.content.title,
spaceId: result.content.space?.id,
_links: result.content._links
},
url: `https://${this.domain}/wiki${result.content._links?.webui || ''}`,
lastModified: result.content.version?.when,
excerpt: result.excerpt || ''
})),
_links: {
next: response.data._links?.next,
base: this.baseURL + '/rest/api'
}
};
} catch (error) {
if (axios.isAxiosError(error)) {
console.error('Error searching content:', error.message, error.response?.data);
throw new ConfluenceError(
`Failed to search content: ${error.message}`,
'SEARCH_FAILED'
);
}
throw error;
}
}
// Labels operations
async getConfluenceLabels(pageId: string): Promise<PaginatedResponse<Label>> {
const response = await this.v2Client.get(`/pages/${pageId}/labels`);
return response.data;
}
async addConfluenceLabel(pageId: string, label: string): Promise<Label> {
try {
const response = await this.v2Client.post(`/pages/${pageId}/labels`, {
name: label
});
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
switch (error.response?.status) {
case 400:
throw new ConfluenceError(
'Invalid label format or label already exists',
'INVALID_LABEL'
);
case 403:
throw new ConfluenceError(
'Insufficient permissions to add labels',
'PERMISSION_DENIED'
);
case 404:
throw new ConfluenceError(
'Page not found',
'PAGE_NOT_FOUND'
);
case 409:
throw new ConfluenceError(
'Label already exists on this page',
'LABEL_EXISTS'
);
default:
console.error('Error adding label:', error.response?.data);
throw new ConfluenceError(
`Failed to add label: ${error.message}`,
'UNKNOWN'
);
}
}
throw error;
}
}
async removeConfluenceLabel(pageId: string, label: string): Promise<void> {
try {
await this.v2Client.delete(`/pages/${pageId}/labels/${label}`);
} catch (error) {
if (axios.isAxiosError(error)) {
switch (error.response?.status) {
case 403:
throw new ConfluenceError(
'Insufficient permissions to remove labels',
'PERMISSION_DENIED'
);
case 404:
throw new ConfluenceError(
'Page or label not found',
'PAGE_NOT_FOUND'
);
default:
console.error('Error removing label:', error.response?.data);
throw new ConfluenceError(
`Failed to remove label: ${error.message}`,
'UNKNOWN'
);
}
}
throw error;
}
}
}