feat: add HTTP transport (CF-3081)
This commit is contained in:
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"]
|
||||
12
package.json
12
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"
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
154
src/index.ts
154
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));
|
||||
|
||||
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