import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
Tool,
} from '@modelcontextprotocol/sdk/types.js';
import axios, { AxiosRequestConfig } from 'axios';
import * as dotenv from 'dotenv';
dotenv.config();
interface ConfluenceConfig {
baseUrl: string;
email: string;
apiToken: string;
spaceKey: string;
}
interface ConfluencePage {
id: string;
title: string;
type: string;
status: string;
url: string;
lastModified: string;
excerpt?: string;
content?: string;
ancestors?: Array<{ id: string; title: string }>;
}
export class ConfluenceMCPServer {
private server: Server;
private config: ConfluenceConfig;
constructor() {
this.validateEnvironment();
this.config = {
baseUrl: process.env.CONFLUENCE_BASE_URL!,
email: process.env.CONFLUENCE_EMAIL!,
apiToken: process.env.CONFLUENCE_API_TOKEN!,
spaceKey: process.env.CONFLUENCE_SPACE_KEY!,
};
this.server = new Server(
{
name: 'confluence-mcp-server',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
}
);
this.setupToolHandlers();
}
private validateEnvironment() {
const requiredVars = [
'CONFLUENCE_BASE_URL',
'CONFLUENCE_EMAIL',
'CONFLUENCE_API_TOKEN',
'CONFLUENCE_SPACE_KEY'
];
const missing = requiredVars.filter(varName => !process.env[varName]);
if (missing.length > 0) {
throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
}
}
private getAxiosConfig(): AxiosRequestConfig {
const auth = Buffer.from(`${this.config.email}:${this.config.apiToken}`).toString('base64');
return {
headers: {
'Authorization': `Basic ${auth}`,
'Accept': 'application/json',
'Content-Type': 'application/json',
},
timeout: 30000, // 30 second timeout
};
}
// Helper methods for type-safe argument extraction
private getStringArg(args: any, key: string, defaultValue: string = ''): string {
const value = args[key];
return typeof value === 'string' ? value : defaultValue;
}
private getNumberArg(args: any, key: string, defaultValue: number = 0): number {
const value = args[key];
return typeof value === 'number' ? value : defaultValue;
}
private setupToolHandlers() {
// List available tools
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'search_confluence_pages',
description: 'Search for pages in Confluence space using CQL (Confluence Query Language)',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search query for pages (supports CQL or simple text)',
},
limit: {
type: 'number',
description: 'Maximum number of results (default: 10, max: 25)',
default: 10,
maximum: 25,
},
},
required: ['query'],
},
},
{
name: 'get_page_content',
description: 'Get full content of a specific Confluence page by ID',
inputSchema: {
type: 'object',
properties: {
pageId: {
type: 'string',
description: 'Confluence page ID',
},
format: {
type: 'string',
description: 'Content format: storage (raw HTML) or view (rendered)',
enum: ['storage', 'view'],
default: 'storage',
},
},
required: ['pageId'],
},
},
{
name: 'list_space_pages',
description: 'List all pages in the configured Confluence space',
inputSchema: {
type: 'object',
properties: {
limit: {
type: 'number',
description: 'Maximum number of pages to return (default: 25, max: 50)',
default: 25,
maximum: 50,
},
type: {
type: 'string',
description: 'Page type filter',
enum: ['page', 'blogpost'],
default: 'page',
},
},
},
},
{
name: 'get_page_hierarchy',
description: 'Get child pages hierarchy for a specific page',
inputSchema: {
type: 'object',
properties: {
pageId: {
type: 'string',
description: 'Parent page ID to get children from',
},
depth: {
type: 'number',
description: 'Hierarchy depth (1 = direct children only)',
default: 1,
minimum: 1,
maximum: 3,
},
},
required: ['pageId'],
},
},
{
name: 'get_page_by_title',
description: 'Find a page by its exact title in the space',
inputSchema: {
type: 'object',
properties: {
title: {
type: 'string',
description: 'Exact page title to search for',
},
},
required: ['title'],
},
},
] as Tool[],
};
});
// Handle tool calls
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
// Type guard to ensure args exists and has the expected structure
if (!args || typeof args !== 'object') {
return {
content: [
{
type: 'text',
text: `Error: Invalid arguments provided for tool ${name}`,
},
],
isError: true,
};
}
try {
switch (name) {
case 'search_confluence_pages': {
const query = this.getStringArg(args, 'query');
const limit = Math.min(this.getNumberArg(args, 'limit', 10), 25);
if (!query) throw new Error('Query parameter is required');
return await this.searchPages(query, limit);
}
case 'get_page_content': {
const pageId = this.getStringArg(args, 'pageId');
const format = this.getStringArg(args, 'format', 'storage');
if (!pageId) throw new Error('PageId parameter is required');
return await this.getPageContent(pageId, format);
}
case 'list_space_pages': {
const limit = Math.min(this.getNumberArg(args, 'limit', 25), 50);
const type = this.getStringArg(args, 'type', 'page');
return await this.listSpacePages(limit, type);
}
case 'get_page_hierarchy': {
const pageId = this.getStringArg(args, 'pageId');
const depth = this.getNumberArg(args, 'depth', 1);
if (!pageId) throw new Error('PageId parameter is required');
return await this.getPageHierarchy(pageId, depth);
}
case 'get_page_by_title': {
const title = this.getStringArg(args, 'title');
if (!title) throw new Error('Title parameter is required');
return await this.getPageByTitle(title);
}
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
console.error(`Error in tool ${name}:`, errorMessage);
return {
content: [
{
type: 'text',
text: `Error executing ${name}: ${errorMessage}`,
},
],
isError: true,
};
}
});
}
private async searchPages(query: string, limit: number) {
// Construct CQL query - if user provides raw CQL, use it; otherwise, create text search
const cqlQuery = query.includes('space=') || query.includes('text~')
? query
: `space="${this.config.spaceKey}" AND text~"${query.replace(/"/g, '\\"')}"`;
const url = `${this.config.baseUrl}/wiki/rest/api/content/search`;
const params = {
cql: cqlQuery,
limit: limit,
expand: 'body.storage,version,space,excerpt',
};
const response = await axios.get(url, {
...this.getAxiosConfig(),
params,
});
const pages: ConfluencePage[] = response.data.results.map((page: any) => ({
id: page.id,
title: page.title,
type: page.type,
status: page.status,
url: `${this.config.baseUrl}/wiki${page._links.webui}`,
lastModified: page.version.when,
excerpt: page.excerpt || 'No excerpt available',
}));
return {
content: [
{
type: 'text',
text: JSON.stringify({
query: cqlQuery,
pages,
totalResults: response.data.size,
message: `Found ${pages.length} pages matching "${query}"`,
}, null, 2),
},
],
};
}
private async getPageContent(pageId: string, format: string = 'storage') {
const expandParam = format === 'view' ? 'body.view' : 'body.storage';
const url = `${this.config.baseUrl}/wiki/rest/api/content/${pageId}`;
const params = {
expand: `${expandParam},version,space,ancestors`,
};
const response = await axios.get(url, {
...this.getAxiosConfig(),
params,
});
const page = response.data;
const bodyContent = format === 'view' ? page.body.view : page.body.storage;
const pageData: ConfluencePage = {
id: page.id,
title: page.title,
type: page.type,
status: page.status,
content: bodyContent?.value || 'No content available',
lastModified: page.version.when,
url: `${this.config.baseUrl}/wiki${page._links.webui}`,
ancestors: page.ancestors?.map((a: any) => ({
id: a.id,
title: a.title
})) || [],
};
return {
content: [
{
type: 'text',
text: JSON.stringify({
page: pageData,
contentFormat: format,
message: `Retrieved content for page: ${page.title}`,
}, null, 2),
},
],
};
}
private async listSpacePages(limit: number, type: string = 'page') {
const url = `${this.config.baseUrl}/wiki/rest/api/content`;
const params = {
spaceKey: this.config.spaceKey,
type: type,
limit: limit,
expand: 'version,space',
orderby: 'title',
};
const response = await axios.get(url, {
...this.getAxiosConfig(),
params,
});
const pages: ConfluencePage[] = response.data.results.map((page: any) => ({
id: page.id,
title: page.title,
type: page.type,
status: page.status,
url: `${this.config.baseUrl}/wiki${page._links.webui}`,
lastModified: page.version.when,
}));
return {
content: [
{
type: 'text',
text: JSON.stringify({
spaceKey: this.config.spaceKey,
pages,
totalResults: response.data.size,
pageType: type,
message: `Listed ${pages.length} ${type}s from space ${this.config.spaceKey}`,
}, null, 2),
},
],
};
}
private async getPageHierarchy(pageId: string, depth: number = 1) {
const url = `${this.config.baseUrl}/wiki/rest/api/content/${pageId}/child/page`;
const params = {
expand: 'version',
limit: 50,
};
const response = await axios.get(url, {
...this.getAxiosConfig(),
params,
});
const children: ConfluencePage[] = response.data.results.map((page: any) => ({
id: page.id,
title: page.title,
type: page.type,
status: page.status,
url: `${this.config.baseUrl}/wiki${page._links.webui}`,
lastModified: page.version.when,
}));
// If depth > 1, recursively get grandchildren (simplified for performance)
if (depth > 1 && children.length > 0) {
for (const child of children.slice(0, 5)) { // Limit to first 5 to avoid performance issues
try {
const grandchildrenResponse = await this.getPageHierarchy(child.id, depth - 1);
if (grandchildrenResponse.content && grandchildrenResponse.content[0]) {
const grandchildrenData = JSON.parse(grandchildrenResponse.content[0].text);
(child as any).children = grandchildrenData.children;
}
} catch (error) {
console.error(`Error getting children for page ${child.id}:`, error);
}
}
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
parentPageId: pageId,
depth: depth,
children,
totalChildren: children.length,
message: `Retrieved ${children.length} child pages`,
}, null, 2),
},
],
};
}
private async getPageByTitle(title: string) {
const url = `${this.config.baseUrl}/wiki/rest/api/content`;
const params = {
spaceKey: this.config.spaceKey,
title: title,
expand: 'body.storage,version,space',
};
const response = await axios.get(url, {
...this.getAxiosConfig(),
params,
});
if (response.data.results.length === 0) {
return {
content: [
{
type: 'text',
text: JSON.stringify({
message: `No page found with title: "${title}" in space ${this.config.spaceKey}`,
found: false,
}, null, 2),
},
],
};
}
const page = response.data.results[0];
const pageData: ConfluencePage = {
id: page.id,
title: page.title,
type: page.type,
status: page.status,
content: page.body?.storage?.value || 'No content available',
lastModified: page.version.when,
url: `${this.config.baseUrl}/wiki${page._links.webui}`,
};
return {
content: [
{
type: 'text',
text: JSON.stringify({
page: pageData,
found: true,
message: `Found page: ${title}`,
}, null, 2),
},
],
};
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error('Confluence MCP server running on stdio');
}
}
// Error handling for unhandled promises
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception:', error);
process.exit(1);
});
// Start the server
const server = new ConfluenceMCPServer();
server.run().catch((error) => {
console.error('Failed to start Confluence MCP server:', error);
process.exit(1);
});