Compare commits

..

5 Commits

Author SHA1 Message Date
Infra
243844e9e7 feat: add HTTP transport (CF-3081) 2026-04-13 09:34:37 +00:00
Christian Gick
bfd542735a feat(CF-2136): Add Sentry structured logging support
Enable enableLogs + beforeSendLog in Sentry.init, add log helpers where
applicable. Bump @sentry/node to ^10.39.0 for Sentry.logger API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:38:21 +02:00
Christian Gick
3f7528a317 feat(CF-1356): Add Sentry error tracking
Add sentry.ts with initSentry + withSentryTransaction, wrap all
tool call handlers with transaction tracing and error capture.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:20:49 +02:00
Christian Gick
ed2d16f845 chore: Remove node_modules, dist, .env from tracking
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 19:05:53 +02:00
Christian Gick
bdbb39a0f5 feat(API-11): Route API calls through AgilitonAPI gateway
Add gateway-first pattern: when AGILITON_API_KEY is set, route all
external API calls through the gateway with X-API-Key auth. Falls back
to direct API access when gateway is unavailable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 19:05:20 +02:00
9 changed files with 1235 additions and 146 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
.env
.git
*.log

16
Dockerfile Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -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
View 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));

View File

@@ -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
View 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
View 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;
}