Compare commits
3 Commits
ed2d16f845
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
243844e9e7 | ||
|
|
bfd542735a | ||
|
|
3f7528a317 |
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",
|
"name": "confluence-mcp",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "MCP server for Confluence Cloud realtime collaboration",
|
"description": "MCP server for Confluence Cloud realtime collaboration",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -8,15 +8,20 @@
|
|||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"dev": "tsx src/index.ts",
|
"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": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||||
"dotenv": "^17.2.3"
|
"@sentry/node": "^10.39.0",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^4.19.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.11.0",
|
"@types/node": "^20.11.0",
|
||||||
"tsx": "^4.7.0",
|
"tsx": "^4.7.0",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3",
|
||||||
|
"@types/express": "^4.17.21"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
#!/usr/bin/env node
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
/**
|
import { dirname, join } from "path";
|
||||||
* Confluence MCP Server
|
import dotenv from "dotenv";
|
||||||
*
|
|
||||||
* 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 __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
const envPath = join(__dirname, '..', '.env');
|
dotenv.config({ path: join(__dirname, "..", ".env"), override: true });
|
||||||
dotenv.config({ path: envPath, override: true });
|
|
||||||
|
|
||||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
import { initSentry } from "./sentry.js";
|
||||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
initSentry(process.env.SENTRY_ENVIRONMENT || "production");
|
||||||
import {
|
|
||||||
ListToolsRequestSchema,
|
|
||||||
CallToolRequestSchema,
|
|
||||||
} from '@modelcontextprotocol/sdk/types.js';
|
|
||||||
|
|
||||||
import {
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
toolDefinitions,
|
import { createServer } from "./server.js";
|
||||||
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() {
|
async function main() {
|
||||||
|
const server = createServer();
|
||||||
const transport = new StdioServerTransport();
|
const transport = new StdioServerTransport();
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
console.error('confluence-mcp: Server started');
|
console.error("confluence-mcp: stdio server started");
|
||||||
}
|
}
|
||||||
|
main().catch((e) => { console.error("confluence-mcp fatal:", e); process.exit(1); });
|
||||||
main().catch((error) => {
|
process.on("SIGINT", () => process.exit(0));
|
||||||
console.error('confluence-mcp: Fatal error:', error);
|
process.on("SIGTERM", () => process.exit(0));
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('SIGINT', async () => {
|
|
||||||
await server.close();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('SIGTERM', async () => {
|
|
||||||
await server.close();
|
|
||||||
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