Skip to main content
Glama

Tonle OpenProject MCP Server

by liratanak
http-server.ts9.45 kB
#!/usr/bin/env bun /** * OpenProject MCP HTTP Server * A Model Context Protocol server using HTTP/SSE transport for OpenProject integration */ import { randomUUID } from 'node:crypto'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import { setupMcpServer } from './src/server-setup.ts'; export interface HttpServerConfig { port?: number; host?: string; } // Store transports for session management const transports: Record<string, StreamableHTTPServerTransport> = {}; export async function startHttpServer(config: HttpServerConfig = {}) { const port = config.port || parseInt(process.env.MCP_HTTP_PORT || '3100'); const host = config.host || process.env.MCP_HTTP_HOST || '0.0.0.0'; // Setup the MCP server const { server, initClient } = setupMcpServer({ name: 'openproject-mcp-http', version: '1.0.0', }); // Initialize the OpenProject client console.error('Initializing OpenProject connection...'); try { const client = await initClient(); const user = await client.getCurrentUser(); console.error(`Connected as: ${user.name} (${user.login})`); } catch (error) { console.error('Failed to connect to OpenProject:', error instanceof Error ? error.message : String(error)); process.exit(1); } // Create Bun HTTP server const bunServer = Bun.serve({ port, hostname: host, async fetch(req) { const url = new URL(req.url); const method = req.method; // CORS preflight if (method === 'OPTIONS') { return new Response(null, { status: 204, headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, mcp-session-id', }, }); } // Health check endpoint if (url.pathname === '/health' && method === 'GET') { return new Response(JSON.stringify({ status: 'ok', transport: 'http' }), { headers: { 'Content-Type': 'application/json' }, }); } // Modern Streamable HTTP endpoint if (url.pathname === '/mcp') { if (method === 'POST') { return handleMcpPost(req, server); } else if (method === 'GET') { return handleMcpGet(req); } else if (method === 'DELETE') { return handleMcpDelete(req); } } // 404 for unknown routes return new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: { 'Content-Type': 'application/json' }, }); }, }); console.error(`OpenProject MCP HTTP Server running on http://${host}:${port}`); console.error(` - Streamable HTTP endpoint: POST/GET/DELETE /mcp`); console.error(` - Health check: GET /health`); return bunServer; } async function handleMcpPost(req: Request, mcpServer: ReturnType<typeof setupMcpServer>['server']): Promise<Response> { const sessionId = req.headers.get('mcp-session-id') || undefined; let transport: StreamableHTTPServerTransport; let body: any; try { body = await req.json(); } catch { return new Response(JSON.stringify({ jsonrpc: '2.0', error: { code: -32700, message: 'Parse error' }, id: null, }), { status: 400, headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', }, }); } if (sessionId && transports[sessionId]) { // Reuse existing session transport = transports[sessionId]; } else if (!sessionId && isInitializeRequest(body)) { // New session initialization transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (id) => { transports[id] = transport; console.error('HTTP session initialized:', id); }, }); transport.onclose = () => { if (transport.sessionId) { delete transports[transport.sessionId]; console.error('HTTP session closed:', transport.sessionId); } }; await mcpServer.connect(transport); } else if (!sessionId) { return new Response(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: No valid session ID provided' }, id: null, }), { status: 400, headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', }, }); } else { return new Response(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: Session not found' }, id: null, }), { status: 400, headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', }, }); } // Create a mock request/response adapter for the transport return new Promise((resolve) => { const responseChunks: string[] = []; const headers: Record<string, string> = {}; let statusCode = 200; let resolved = false; const mockRes = { statusCode: 200, headersSent: false, setHeader(name: string, value: string) { headers[name.toLowerCase()] = value; return this; }, getHeader(name: string) { return headers[name.toLowerCase()]; }, writeHead(status: number, hdrs?: Record<string, string>) { statusCode = status; if (hdrs) { Object.entries(hdrs).forEach(([k, v]) => { headers[k.toLowerCase()] = v; }); } return this; }, write(chunk: string | Buffer) { const data = typeof chunk === 'string' ? chunk : chunk.toString(); responseChunks.push(data); return true; }, end(chunk?: string | Buffer) { if (chunk) { const data = typeof chunk === 'string' ? chunk : chunk.toString(); responseChunks.push(data); } if (!resolved) { resolved = true; headers['access-control-allow-origin'] = '*'; resolve(new Response(responseChunks.join(''), { status: statusCode, headers, })); } return this; }, on(_event: string, _cb: Function) { return this; }, once(_event: string, _cb: Function) { return this; }, emit(_event: string, ..._args: any[]) { return true; }, removeListener(_event: string, _cb: Function) { return this; }, }; const mockReq = { method: 'POST', url: '/mcp', headers: Object.fromEntries(req.headers.entries()), body, on(_event: string, _cb: Function) { return this; }, pipe(destination: any) { return destination; }, }; transport.handleRequest(mockReq as any, mockRes as any, body).catch((error) => { console.error('Error handling MCP request:', error); if (!resolved) { resolved = true; resolve(new Response(JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error' }, id: null, }), { status: 500, headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', }, })); } }); // Set a timeout to ensure we always resolve setTimeout(() => { if (!resolved) { resolved = true; resolve(new Response(responseChunks.join('') || JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: 'Request timeout' }, id: null, }), { status: statusCode || 500, headers: { ...headers, 'Access-Control-Allow-Origin': '*', }, })); } }, 30000); }); } async function handleMcpGet(req: Request): Promise<Response> { const sessionId = req.headers.get('mcp-session-id'); if (!sessionId || !transports[sessionId]) { return new Response(JSON.stringify({ error: 'Invalid or missing session' }), { status: 400, headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', }, }); } return new Response(JSON.stringify({ sessionId, status: 'active', transport: 'streamable-http' }), { headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', }, }); } async function handleMcpDelete(req: Request): Promise<Response> { const sessionId = req.headers.get('mcp-session-id'); if (!sessionId || !transports[sessionId]) { return new Response(JSON.stringify({ error: 'Invalid or missing session' }), { status: 400, headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', }, }); } const transport = transports[sessionId]; await transport.close(); delete transports[sessionId]; return new Response(JSON.stringify({ message: 'Session closed' }), { headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', }, }); } // Main entry point if (import.meta.main) { startHttpServer().catch((error) => { console.error('Failed to start HTTP server:', error); process.exit(1); }); }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/liratanak/openproject-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server