feat: Initial confluence-mcp server for realtime collaboration

Provides 8 MCP tools for Confluence Cloud:
- confluence_list_spaces, confluence_create_space
- confluence_search, confluence_get_page
- confluence_create_page, confluence_update_page
- confluence_get_comments, confluence_add_comment

Uses Confluence REST API v2 with basic auth.
Registered in Claude Code and mcp-proxy.

Refs: CF-935

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-02-10 18:09:30 +02:00
commit 2768650b42
3824 changed files with 859428 additions and 0 deletions

240
src/client.ts Normal file
View File

@@ -0,0 +1,240 @@
/**
* Confluence Cloud REST API v2 client.
* Uses basic auth with Atlassian API token.
*/
const BASE_URL = process.env.CONFLUENCE_URL || 'https://agiliton.atlassian.net/wiki';
const API_V2 = `${BASE_URL}/api/v2`;
const API_V1 = `${BASE_URL}/rest/api`;
function getAuth(): string {
const user = process.env.CONFLUENCE_USER || process.env.JIRA_USER;
const token = process.env.CONFLUENCE_API_TOKEN || process.env.JIRA_API_TOKEN;
if (!user || !token) {
throw new Error('CONFLUENCE_USER and CONFLUENCE_API_TOKEN (or JIRA_USER/JIRA_API_TOKEN) must be set');
}
return Buffer.from(`${user}:${token}`).toString('base64');
}
async function request(url: string, options: RequestInit = {}): Promise<any> {
const headers: Record<string, string> = {
'Authorization': `Basic ${getAuth()}`,
'Accept': 'application/json',
...(options.headers as Record<string, string> || {}),
};
if (options.body && typeof options.body === 'string') {
headers['Content-Type'] = 'application/json';
}
const response = await fetch(url, { ...options, headers });
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`Confluence API ${response.status}: ${text.slice(0, 500)}`);
}
if (response.status === 204) return null;
return response.json();
}
// --- Types ---
export interface Space {
id: string;
key: string;
name: string;
type: string;
status: string;
description?: string;
}
export interface Page {
id: string;
status: string;
title: string;
spaceId: string;
parentId?: string;
authorId: string;
createdAt: string;
version: {
number: number;
message?: string;
createdAt: string;
authorId?: string;
};
body?: {
storage?: { representation: string; value: string };
atlas_doc_format?: { representation: string; value: string };
};
_links?: {
webui?: string;
editui?: string;
};
}
export interface Comment {
id: string;
status: string;
title: string;
body?: {
storage?: { representation: string; value: string };
};
version: { number: number; createdAt: string };
}
// --- Spaces ---
export async function listSpaces(limit = 25): Promise<Space[]> {
const data = await request(`${API_V2}/spaces?limit=${limit}&sort=name`);
return data.results;
}
export async function getSpace(spaceIdOrKey: string): Promise<Space> {
return request(`${API_V2}/spaces/${spaceIdOrKey}`);
}
export async function createSpace(key: string, name: string, description?: string): Promise<Space> {
// v2 doesn't have space creation — use v1
const body: any = {
key,
name,
type: 'global',
};
if (description) {
body.description = {
plain: { value: description, representation: 'plain' },
};
}
return request(`${API_V1}/space`, {
method: 'POST',
body: JSON.stringify(body),
});
}
// --- Pages ---
export async function searchPages(query: string, spaceId?: string, limit = 10): Promise<Page[]> {
let cql = `type=page AND text ~ "${query.replace(/"/g, '\\"')}"`;
if (spaceId) cql += ` AND space.id=${spaceId}`;
cql += ' ORDER BY lastmodified DESC';
const params = new URLSearchParams({
cql,
limit: String(limit),
});
// CQL search is v1 only
const data = await request(`${API_V1}/content/search?${params}`);
return data.results.map((r: any) => ({
id: r.id,
status: r.status,
title: r.title,
spaceId: r.space?.id || '',
authorId: r.history?.createdBy?.accountId || '',
createdAt: r.history?.createdDate || '',
version: {
number: r.version?.number || 1,
createdAt: r.version?.when || '',
},
_links: {
webui: r._links?.webui ? `${BASE_URL}${r._links.webui}` : undefined,
},
}));
}
export async function getPage(pageId: string, includeBody = true): Promise<Page> {
const params = includeBody ? '?body-format=storage' : '';
return request(`${API_V2}/pages/${pageId}${params}`);
}
export async function getPageByTitle(spaceId: string, title: string): Promise<Page | null> {
const params = new URLSearchParams({
title,
'space-id': spaceId,
'body-format': 'storage',
limit: '1',
});
const data = await request(`${API_V2}/pages?${params}`);
return data.results?.[0] || null;
}
export async function createPage(
spaceId: string,
title: string,
body: string,
parentId?: string,
): Promise<Page> {
const payload: any = {
spaceId,
status: 'current',
title,
body: {
representation: 'storage',
value: body,
},
};
if (parentId) payload.parentId = parentId;
return request(`${API_V2}/pages`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function updatePage(
pageId: string,
title: string,
body: string,
versionNumber: number,
versionMessage?: string,
): Promise<Page> {
const payload: any = {
id: pageId,
status: 'current',
title,
body: {
representation: 'storage',
value: body,
},
version: {
number: versionNumber,
message: versionMessage || 'Updated via Claude',
},
};
return request(`${API_V2}/pages/${pageId}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
// --- Comments ---
export async function getPageComments(pageId: string, limit = 25): Promise<Comment[]> {
const data = await request(`${API_V2}/pages/${pageId}/footer-comments?body-format=storage&limit=${limit}`);
return data.results;
}
export async function addPageComment(pageId: string, body: string): Promise<Comment> {
return request(`${API_V2}/footer-comments`, {
method: 'POST',
body: JSON.stringify({
pageId,
body: {
representation: 'storage',
value: body,
},
}),
});
}
// --- Utility ---
export function getWebUrl(page: Page): string {
if (page._links?.webui) {
return page._links.webui.startsWith('http')
? page._links.webui
: `${BASE_URL}${page._links.webui}`;
}
return `${BASE_URL}/pages/${page.id}`;
}

146
src/index.ts Normal file
View File

@@ -0,0 +1,146 @@
#!/usr/bin/env node
/**
* Confluence MCP Server
*
* Provides Confluence Cloud page CRUD and collaboration via Model Context Protocol.
* Tools: confluence_list_spaces, confluence_create_space, confluence_search,
* confluence_get_page, confluence_create_page, confluence_update_page,
* confluence_get_comments, confluence_add_comment
*/
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import dotenv from 'dotenv';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const envPath = join(__dirname, '..', '.env');
dotenv.config({ path: envPath, override: true });
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
ListToolsRequestSchema,
CallToolRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import {
toolDefinitions,
handleListSpaces,
handleCreateSpace,
handleSearch,
handleGetPage,
handleCreatePage,
handleUpdatePage,
handleGetComments,
handleAddComment,
} from './tools.js';
const server = new Server(
{ name: 'confluence-mcp', version: '1.0.0' },
{ capabilities: { tools: {} } },
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: toolDefinitions,
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
const a = args as any;
let result: string;
try {
switch (name) {
case 'confluence_list_spaces':
result = await handleListSpaces({ limit: a.limit });
break;
case 'confluence_create_space':
result = await handleCreateSpace({
key: a.key,
name: a.name,
description: a.description,
});
break;
case 'confluence_search':
result = await handleSearch({
query: a.query,
space_id: a.space_id,
limit: a.limit,
});
break;
case 'confluence_get_page':
result = await handleGetPage({ page_id: String(a.page_id) });
break;
case 'confluence_create_page':
result = await handleCreatePage({
space_id: a.space_id,
title: a.title,
body: a.body,
parent_id: a.parent_id,
});
break;
case 'confluence_update_page':
result = await handleUpdatePage({
page_id: String(a.page_id),
title: a.title,
body: a.body,
version_number: a.version_number,
version_message: a.version_message,
});
break;
case 'confluence_get_comments':
result = await handleGetComments({
page_id: String(a.page_id),
limit: a.limit,
});
break;
case 'confluence_add_comment':
result = await handleAddComment({
page_id: String(a.page_id),
body: a.body,
});
break;
default:
result = `Unknown tool: ${name}`;
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
result = `Error: ${message}`;
}
return {
content: [{ type: 'text', text: result }],
};
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('confluence-mcp: Server started');
}
main().catch((error) => {
console.error('confluence-mcp: Fatal error:', error);
process.exit(1);
});
process.on('SIGINT', async () => {
await server.close();
process.exit(0);
});
process.on('SIGTERM', async () => {
await server.close();
process.exit(0);
});

287
src/tools.ts Normal file
View File

@@ -0,0 +1,287 @@
/**
* MCP tool definitions and handlers for Confluence Cloud.
*/
import {
listSpaces,
searchPages,
getPage,
createPage,
updatePage,
getPageComments,
addPageComment,
createSpace,
getWebUrl,
} from './client.js';
// --- Tool Definitions ---
export const toolDefinitions = [
{
name: 'confluence_list_spaces',
description: 'List available Confluence spaces.',
inputSchema: {
type: 'object' as const,
properties: {
limit: {
type: 'number',
description: 'Max spaces to return (default: 25)',
},
},
},
},
{
name: 'confluence_create_space',
description: 'Create a new Confluence space.',
inputSchema: {
type: 'object' as const,
properties: {
key: {
type: 'string',
description: 'Space key (uppercase, e.g. "AI", "CLAUDE")',
},
name: {
type: 'string',
description: 'Space display name',
},
description: {
type: 'string',
description: 'Space description (optional)',
},
},
required: ['key', 'name'],
},
},
{
name: 'confluence_search',
description: 'Search Confluence pages by text content. Returns matching pages with titles and links.',
inputSchema: {
type: 'object' as const,
properties: {
query: {
type: 'string',
description: 'Search text to find in pages',
},
space_id: {
type: 'string',
description: 'Filter by space ID (optional)',
},
limit: {
type: 'number',
description: 'Max results (default: 10)',
},
},
required: ['query'],
},
},
{
name: 'confluence_get_page',
description: 'Get a Confluence page by ID, including its full body content and version number. Use the version number for updates.',
inputSchema: {
type: 'object' as const,
properties: {
page_id: {
type: 'string',
description: 'Confluence page ID',
},
},
required: ['page_id'],
},
},
{
name: 'confluence_create_page',
description: 'Create a new Confluence page in a space. Body is XHTML storage format (supports <p>, <h1>-<h6>, <table>, <ac:structured-macro>, etc).',
inputSchema: {
type: 'object' as const,
properties: {
space_id: {
type: 'string',
description: 'Space ID to create the page in',
},
title: {
type: 'string',
description: 'Page title',
},
body: {
type: 'string',
description: 'Page body in Confluence storage format (XHTML). Example: "<p>Hello world</p>"',
},
parent_id: {
type: 'string',
description: 'Parent page ID (optional, creates at root if omitted)',
},
},
required: ['space_id', 'title', 'body'],
},
},
{
name: 'confluence_update_page',
description: 'Update an existing Confluence page. MUST provide the current version number (from confluence_get_page) incremented by 1. Replaces the full page body.',
inputSchema: {
type: 'object' as const,
properties: {
page_id: {
type: 'string',
description: 'Page ID to update',
},
title: {
type: 'string',
description: 'Page title (can be changed)',
},
body: {
type: 'string',
description: 'New page body in Confluence storage format (replaces entire body)',
},
version_number: {
type: 'number',
description: 'New version number (must be current version + 1)',
},
version_message: {
type: 'string',
description: 'Version change message (optional)',
},
},
required: ['page_id', 'title', 'body', 'version_number'],
},
},
{
name: 'confluence_get_comments',
description: 'Get footer comments on a Confluence page.',
inputSchema: {
type: 'object' as const,
properties: {
page_id: {
type: 'string',
description: 'Page ID',
},
limit: {
type: 'number',
description: 'Max comments (default: 25)',
},
},
required: ['page_id'],
},
},
{
name: 'confluence_add_comment',
description: 'Add a footer comment to a Confluence page.',
inputSchema: {
type: 'object' as const,
properties: {
page_id: {
type: 'string',
description: 'Page ID to comment on',
},
body: {
type: 'string',
description: 'Comment body in storage format (XHTML)',
},
},
required: ['page_id', 'body'],
},
},
];
// --- Handlers ---
export async function handleListSpaces(args: { limit?: number }): Promise<string> {
const spaces = await listSpaces(args.limit);
if (spaces.length === 0) return 'No spaces found.';
const lines = spaces.map((s) =>
`${s.key} | ${s.name} | ID: ${s.id} | ${s.type}`
);
return `Found ${spaces.length} spaces:\n\n${lines.join('\n')}`;
}
export async function handleCreateSpace(args: {
key: string;
name: string;
description?: string;
}): Promise<string> {
const space = await createSpace(args.key, args.name, args.description);
return `Space created: ${space.key} (${space.name}) — ID: ${space.id}`;
}
export async function handleSearch(args: {
query: string;
space_id?: string;
limit?: number;
}): Promise<string> {
const pages = await searchPages(args.query, args.space_id, args.limit);
if (pages.length === 0) return `No pages found for: ${args.query}`;
const lines = pages.map((p) => {
const url = getWebUrl(p);
return `${p.title} | ID: ${p.id} | v${p.version.number} | ${url}`;
});
return `Found ${pages.length} pages:\n\n${lines.join('\n')}`;
}
export async function handleGetPage(args: { page_id: string }): Promise<string> {
const page = await getPage(args.page_id, true);
const url = getWebUrl(page);
const body = page.body?.storage?.value || '(empty)';
return [
`Title: ${page.title}`,
`ID: ${page.id} | Space: ${page.spaceId} | Version: ${page.version.number}`,
`URL: ${url}`,
`Last updated: ${page.version.createdAt}`,
'',
'--- Content (storage format) ---',
body,
].join('\n');
}
export async function handleCreatePage(args: {
space_id: string;
title: string;
body: string;
parent_id?: string;
}): Promise<string> {
const page = await createPage(args.space_id, args.title, args.body, args.parent_id);
const url = getWebUrl(page);
return `Page created: "${page.title}" | ID: ${page.id} | Version: ${page.version.number}\nURL: ${url}`;
}
export async function handleUpdatePage(args: {
page_id: string;
title: string;
body: string;
version_number: number;
version_message?: string;
}): Promise<string> {
const page = await updatePage(
args.page_id,
args.title,
args.body,
args.version_number,
args.version_message,
);
const url = getWebUrl(page);
return `Page updated: "${page.title}" | Version: ${page.version.number}\nURL: ${url}`;
}
export async function handleGetComments(args: {
page_id: string;
limit?: number;
}): Promise<string> {
const comments = await getPageComments(args.page_id, args.limit);
if (comments.length === 0) return 'No comments on this page.';
const lines = comments.map((c) => {
const body = c.body?.storage?.value || '(empty)';
const date = c.version?.createdAt || 'unknown';
return `[${date}] ${body}`;
});
return `${comments.length} comments:\n\n${lines.join('\n\n')}`;
}
export async function handleAddComment(args: {
page_id: string;
body: string;
}): Promise<string> {
const comment = await addPageComment(args.page_id, args.body);
return `Comment added (ID: ${comment.id})`;
}