Compare commits
5 Commits
9f7c8faf60
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
243844e9e7 | ||
|
|
bfd542735a | ||
|
|
3f7528a317 | ||
|
|
ed2d16f845 | ||
|
|
bdbb39a0f5 |
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.git
|
||||
*.log
|
||||
16
Dockerfile
Normal file
16
Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json tsconfig.json ./
|
||||
RUN npm install
|
||||
COPY src ./src
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev && npm cache clean --force
|
||||
COPY --from=build /app/dist ./dist
|
||||
USER node
|
||||
EXPOSE 9210
|
||||
CMD ["node", "dist/http-server.js"]
|
||||
915
package-lock.json
generated
915
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "confluence-mcp",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"description": "MCP server for Confluence Cloud realtime collaboration",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
@@ -8,15 +8,20 @@
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx src/index.ts",
|
||||
"clean": "rm -rf dist"
|
||||
"clean": "rm -rf dist",
|
||||
"start:http": "node dist/http-server.js",
|
||||
"dev:http": "tsx src/http-server.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||
"dotenv": "^17.2.3"
|
||||
"@sentry/node": "^10.39.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.19.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.3"
|
||||
"typescript": "^5.3.3",
|
||||
"@types/express": "^4.17.21"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
/**
|
||||
* Confluence Cloud REST API v2 client.
|
||||
* Uses basic auth with Atlassian API token.
|
||||
* Confluence Cloud REST API v2 client — routes through AgilitonAPI gateway.
|
||||
* Falls back to direct Confluence access if AGILITON_API_KEY is not set.
|
||||
*
|
||||
* Gateway: AGILITON_API_KEY + AGILITON_API_URL
|
||||
* Direct: CONFLUENCE_USER/JIRA_USER + CONFLUENCE_API_TOKEN/JIRA_API_TOKEN
|
||||
*/
|
||||
|
||||
// Gateway config
|
||||
const GATEWAY_URL = (process.env.AGILITON_API_URL || 'https://api.agiliton.cloud').replace(/\/$/, '');
|
||||
const GATEWAY_KEY = process.env.AGILITON_API_KEY || '';
|
||||
|
||||
// Direct config (fallback)
|
||||
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 useGateway(): boolean {
|
||||
return !!GATEWAY_KEY;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -17,17 +29,25 @@ function getAuth(): string {
|
||||
}
|
||||
|
||||
async function request(url: string, options: RequestInit = {}): Promise<any> {
|
||||
let finalUrl = url;
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization': `Basic ${getAuth()}`,
|
||||
'Accept': 'application/json',
|
||||
...(options.headers as Record<string, string> || {}),
|
||||
};
|
||||
|
||||
if (useGateway()) {
|
||||
// Route through gateway — translate Confluence URLs to gateway paths
|
||||
finalUrl = toGatewayUrl(url);
|
||||
headers['X-API-Key'] = GATEWAY_KEY;
|
||||
} else {
|
||||
headers['Authorization'] = `Basic ${getAuth()}`;
|
||||
}
|
||||
|
||||
if (options.body && typeof options.body === 'string') {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
const response = await fetch(finalUrl, { ...options, headers });
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
@@ -38,6 +58,37 @@ async function request(url: string, options: RequestInit = {}): Promise<any> {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a direct Confluence URL to a gateway URL.
|
||||
* /wiki/api/v2/spaces → /confluence/spaces
|
||||
* /wiki/rest/api/content/search → /confluence/search
|
||||
* /wiki/rest/api/space → /confluence/space
|
||||
*/
|
||||
function toGatewayUrl(url: string): string {
|
||||
// Extract path from full URL
|
||||
let path = url;
|
||||
if (path.startsWith('http')) {
|
||||
const u = new URL(path);
|
||||
path = u.pathname + u.search;
|
||||
}
|
||||
|
||||
// v2 API: /wiki/api/v2/... → /confluence/...
|
||||
const v2Match = path.match(/\/api\/v2\/(.*)$/);
|
||||
if (v2Match) return `${GATEWAY_URL}/confluence/${v2Match[1]}`;
|
||||
|
||||
// v1 API: /wiki/rest/api/content/search → /confluence/search
|
||||
const searchMatch = path.match(/\/rest\/api\/content\/search(.*)$/);
|
||||
if (searchMatch) return `${GATEWAY_URL}/confluence/search${searchMatch[1]}`;
|
||||
|
||||
// v1 API: /wiki/rest/api/space → /confluence/space
|
||||
const spaceMatch = path.match(/\/rest\/api\/space(.*)$/);
|
||||
if (spaceMatch) return `${GATEWAY_URL}/confluence/space${spaceMatch[1]}`;
|
||||
|
||||
// Fallback: strip /wiki prefix and use as-is
|
||||
const stripped = path.replace(/^\/wiki/, '');
|
||||
return `${GATEWAY_URL}/confluence${stripped}`;
|
||||
}
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface Space {
|
||||
|
||||
68
src/http-server.ts
Normal file
68
src/http-server.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env node
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
import dotenv from "dotenv";
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
dotenv.config({ path: join(__dirname, "..", ".env"), override: true });
|
||||
|
||||
import { initSentry } from "./sentry.js";
|
||||
initSentry(process.env.SENTRY_ENVIRONMENT || "production");
|
||||
|
||||
import express from "express";
|
||||
import { randomUUID } from "crypto";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { createServer } from "./server.js";
|
||||
|
||||
const PORT = parseInt(process.env.MCP_HTTP_PORT || "9210");
|
||||
const HOST = process.env.MCP_HTTP_HOST || "0.0.0.0";
|
||||
|
||||
const transports = new Map<string, StreamableHTTPServerTransport>();
|
||||
const sessionServers = new Map<string, Server>();
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
app.post("/mcp", async (req, res) => {
|
||||
try {
|
||||
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
||||
if (sessionId && transports.has(sessionId)) {
|
||||
await transports.get(sessionId)!.handleRequest(req, res, req.body); return;
|
||||
}
|
||||
const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: (sid) => { transports.set(sid, transport); },
|
||||
});
|
||||
transport.onclose = () => {
|
||||
const sid = transport.sessionId;
|
||||
if (sid) { transports.delete(sid); sessionServers.delete(sid); }
|
||||
};
|
||||
const sessionServer = createServer();
|
||||
await sessionServer.connect(transport);
|
||||
const sid = transport.sessionId;
|
||||
if (sid) sessionServers.set(sid, sessionServer);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch (err) {
|
||||
console.error("[confluence-mcp] POST /mcp error:", err);
|
||||
if (!res.headersSent) res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/mcp", async (req, res) => {
|
||||
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
||||
if (!sessionId || !transports.has(sessionId)) { res.status(400).json({ error: "Missing or invalid session ID" }); return; }
|
||||
await transports.get(sessionId)!.handleRequest(req, res);
|
||||
});
|
||||
|
||||
app.delete("/mcp", async (req, res) => {
|
||||
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
||||
if (!sessionId || !transports.has(sessionId)) { res.status(400).json({ error: "Missing or invalid session ID" }); return; }
|
||||
await transports.get(sessionId)!.handleRequest(req, res);
|
||||
});
|
||||
|
||||
app.get("/health", (_req, res) => res.json({ status: "ok", server: "confluence-mcp", activeSessions: transports.size }));
|
||||
|
||||
app.listen(PORT, HOST, () => console.error(`confluence-mcp: HTTP server on http://${HOST}:${PORT}/mcp`));
|
||||
process.on("SIGINT", () => process.exit(0));
|
||||
process.on("SIGTERM", () => process.exit(0));
|
||||
149
src/index.ts
149
src/index.ts
@@ -1,146 +1,23 @@
|
||||
#!/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';
|
||||
|
||||
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 });
|
||||
dotenv.config({ path: join(__dirname, "..", ".env"), 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 { initSentry } from "./sentry.js";
|
||||
initSentry(process.env.SENTRY_ENVIRONMENT || "production");
|
||||
|
||||
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 }],
|
||||
};
|
||||
});
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { createServer } from "./server.js";
|
||||
|
||||
async function main() {
|
||||
const server = createServer();
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error('confluence-mcp: Server started');
|
||||
console.error("confluence-mcp: stdio 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);
|
||||
});
|
||||
main().catch((e) => { console.error("confluence-mcp fatal:", e); process.exit(1); });
|
||||
process.on("SIGINT", () => process.exit(0));
|
||||
process.on("SIGTERM", () => process.exit(0));
|
||||
|
||||
103
src/sentry.ts
Normal file
103
src/sentry.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Sentry SDK integration for confluence-mcp.
|
||||
*
|
||||
* Provides error tracking and performance monitoring for MCP tool calls
|
||||
* while filtering out normal error responses (isError: true).
|
||||
*/
|
||||
|
||||
import * as Sentry from "@sentry/node";
|
||||
|
||||
export function initSentry(environment: string = "development"): void {
|
||||
const dsn = process.env.SENTRY_DSN || "";
|
||||
|
||||
if (!dsn) {
|
||||
console.error("[sentry] SENTRY_DSN not set, Sentry disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
Sentry.init({
|
||||
dsn,
|
||||
environment,
|
||||
tracesSampleRate: parseFloat(process.env.SENTRY_TRACE_SAMPLE_RATE || "0.1"),
|
||||
integrations: [
|
||||
Sentry.httpIntegration(),
|
||||
],
|
||||
beforeSend(event: Sentry.ErrorEvent, hint: Sentry.EventHint) {
|
||||
const originalException = hint.originalException as any;
|
||||
if (originalException?.isError === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (event.request?.headers) {
|
||||
const headers = { ...event.request.headers };
|
||||
delete headers.Authorization;
|
||||
delete headers.Cookie;
|
||||
delete headers["X-API-Key"];
|
||||
event.request.headers = headers;
|
||||
}
|
||||
|
||||
if (event.request?.data) {
|
||||
let dataStr = JSON.stringify(event.request.data);
|
||||
dataStr = dataStr.replace(
|
||||
/(api[_-]?key|token|secret)["']?\s*[:=]\s*["']?[\w-]+/gi,
|
||||
"$1=REDACTED"
|
||||
);
|
||||
try {
|
||||
event.request.data = JSON.parse(dataStr);
|
||||
} catch {
|
||||
event.request.data = dataStr;
|
||||
}
|
||||
}
|
||||
|
||||
return event;
|
||||
},
|
||||
enableLogs: true,
|
||||
beforeSendLog(log) {
|
||||
if (log.level === "debug") return null;
|
||||
return log;
|
||||
},
|
||||
maxBreadcrumbs: 30,
|
||||
attachStacktrace: true,
|
||||
release: process.env.APP_VERSION || "unknown",
|
||||
sendDefaultPii: false,
|
||||
});
|
||||
|
||||
console.error(
|
||||
`[sentry] Initialized: ${environment} - ${process.env.APP_VERSION || "unknown"}`
|
||||
);
|
||||
}
|
||||
|
||||
export function logInfo(msg: string, data?: Record<string, unknown>): void {
|
||||
Sentry.logger.info(msg, data);
|
||||
}
|
||||
|
||||
export function logWarn(msg: string, data?: Record<string, unknown>): void {
|
||||
Sentry.logger.warn(msg, data);
|
||||
}
|
||||
|
||||
export function logError(msg: string, data?: Record<string, unknown>): void {
|
||||
Sentry.logger.error(msg, data);
|
||||
}
|
||||
|
||||
export async function withSentryTransaction<T>(
|
||||
toolName: string,
|
||||
handler: () => Promise<T>
|
||||
): Promise<T> {
|
||||
return Sentry.startSpan(
|
||||
{ name: `tool_${toolName}`, op: "mcp.tool" },
|
||||
async (span: Sentry.Span) => {
|
||||
try {
|
||||
const result = await handler();
|
||||
span.setStatus({ code: 1, message: "ok" });
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
const err = error as { isError?: boolean };
|
||||
if (!err.isError) {
|
||||
Sentry.captureException(error);
|
||||
}
|
||||
span.setStatus({ code: 2, message: "error" });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
53
src/server.ts
Normal file
53
src/server.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { withSentryTransaction } from "./sentry.js";
|
||||
import {
|
||||
toolDefinitions,
|
||||
handleListSpaces, handleCreateSpace, handleSearch, handleGetPage,
|
||||
handleCreatePage, handleUpdatePage, handleGetComments, handleAddComment,
|
||||
} from "./tools.js";
|
||||
|
||||
export function createServer(): Server {
|
||||
const server = new Server(
|
||||
{ name: "confluence-mcp", version: "1.1.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;
|
||||
return withSentryTransaction(name, async () => {
|
||||
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 }] };
|
||||
});
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
Reference in New Issue
Block a user