diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5f6e61b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +.env +.git +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ce83c67 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/package.json b/package.json index 79a24f4..0c02ae2 100644 --- a/package.json +++ b/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,16 +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", "@sentry/node": "^10.39.0", - "dotenv": "^17.2.3" + "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" } } diff --git a/src/http-server.ts b/src/http-server.ts new file mode 100644 index 0000000..4541d9b --- /dev/null +++ b/src/http-server.ts @@ -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(); +const sessionServers = new Map(); + +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)); diff --git a/src/index.ts b/src/index.ts index a22f0ea..5a47221 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,151 +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 { initSentry, withSentryTransaction } from './sentry.js'; -initSentry(process.env.SENTRY_ENVIRONMENT || 'production'); +import { initSentry } from "./sentry.js"; +initSentry(process.env.SENTRY_ENVIRONMENT || "production"); -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; - - 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 }], - }; - }); -}); +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)); diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..7478781 --- /dev/null +++ b/src/server.ts @@ -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; +}