feat: add HTTP transport (CF-3081)

This commit is contained in:
Infra
2026-04-13 09:34:37 +00:00
parent bfd542735a
commit 243844e9e7
6 changed files with 163 additions and 145 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"]

View File

@@ -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,16 +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",
"@sentry/node": "^10.39.0", "@sentry/node": "^10.39.0",
"dotenv": "^17.2.3" "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
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,151 +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 { initSentry, withSentryTransaction } from './sentry.js'; import { initSentry } from "./sentry.js";
initSentry(process.env.SENTRY_ENVIRONMENT || 'production'); initSentry(process.env.SENTRY_ENVIRONMENT || "production");
import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { createServer } from "./server.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 }],
};
});
});
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);
});

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