import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import express, { Request, Response } from 'express'; import crypto from 'crypto'; import { CallToolRequestSchema, ListToolsRequestSchema, Tool, } from "@modelcontextprotocol/sdk/types.js"; import axios from "axios"; import https from "https"; const GLITCHTIP_BASE_URL = process.env.GLITCHTIP_BASE_URL || "https://glitchtip.infra.mintel.me"; const GLITCHTIP_API_KEY = process.env.GLITCHTIP_API_KEY; if (!GLITCHTIP_API_KEY) { console.error("Warning: GLITCHTIP_API_KEY is not set. API calls will fail."); } const httpsAgent = new https.Agent({ rejectUnauthorized: false, // For internal infra }); const glitchtipClient = axios.create({ baseURL: `${GLITCHTIP_BASE_URL}/api/0`, headers: { Authorization: `Bearer ${GLITCHTIP_API_KEY}` }, httpsAgent, timeout: 10000 // Add timeout to prevent hangs }); // Logging middleware for GlitchTip client glitchtipClient.interceptors.request.use((config) => { console.error(`GlitchTip Request: ${config.method?.toUpperCase()} ${config.url}`); return config; }, (error) => { console.error(`GlitchTip Request Error: ${error.message}`); return Promise.reject(error); }); glitchtipClient.interceptors.response.use((response) => { return response; }, (error) => { const msg = error.response?.data?.detail || error.response?.data?.message || error.message; console.error(`GlitchTip API Error (${error.config?.url}): ${msg}`); return Promise.reject(error); }); const LIST_PROJECTS_TOOL: Tool = { name: "glitchtip_list_projects", description: "List all projects and organizations in GlitchTip", inputSchema: { type: "object", properties: {} }, }; const LIST_ISSUES_TOOL: Tool = { name: "glitchtip_list_issues", description: "List issues (errors) for a specific project", inputSchema: { type: "object", properties: { organization_slug: { type: "string", description: "The organization slug" }, project_slug: { type: "string", description: "The project slug" }, query: { type: "string", description: "Optional query filter (e.g., 'is:unresolved')" }, limit: { type: "number", description: "Maximum number of issues to return (default: 20)" }, }, required: ["organization_slug", "project_slug"], }, }; const GET_ISSUE_DETAILS_TOOL: Tool = { name: "glitchtip_get_issue_details", description: "Get detailed information about a specific issue, including stack trace", inputSchema: { type: "object", properties: { issue_id: { type: "string", description: "The ID of the issue" }, }, required: ["issue_id"], }, }; const UPDATE_ISSUE_TOOL: Tool = { name: "glitchtip_update_issue", description: "Update the status of an issue (e.g., resolve it)", inputSchema: { type: "object", properties: { issue_id: { type: "string", description: "The ID of the issue" }, status: { type: "string", enum: ["resolved", "unresolved", "ignored"], description: "The new status" }, }, required: ["issue_id", "status"], }, }; const server = new Server( { name: "glitchtip-mcp", version: "1.0.0" }, { capabilities: { tools: {} } } ); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ LIST_PROJECTS_TOOL, LIST_ISSUES_TOOL, GET_ISSUE_DETAILS_TOOL, UPDATE_ISSUE_TOOL, ], })); server.setRequestHandler(CallToolRequestSchema, async (request) => { try { if (request.params.name === "glitchtip_list_projects") { const res = await glitchtipClient.get('/projects/'); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } if (request.params.name === "glitchtip_list_issues") { const { organization_slug, project_slug, query, limit = 20 } = request.params.arguments as any; const res = await glitchtipClient.get(`/projects/${organization_slug}/${project_slug}/issues/`, { params: { query, limit } }); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } if (request.params.name === "glitchtip_get_issue_details") { const { issue_id } = request.params.arguments as any; const res = await glitchtipClient.get(`/issues/${issue_id}/`); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } if (request.params.name === "glitchtip_update_issue") { const { issue_id, status } = request.params.arguments as any; const res = await glitchtipClient.put(`/issues/${issue_id}/`, { status }); return { content: [{ type: "text", text: `Issue ${issue_id} status updated to ${status}.` }] }; } throw new Error(`Unknown tool: ${request.params.name}`); } catch (e: any) { const msg = e.response?.data?.detail || e.response?.data?.message || e.message; return { isError: true, content: [{ type: "text", text: `GlitchTip Service Error: ${msg}` }] }; } }); async function run() { const isStdio = process.argv.includes('--stdio'); if (isStdio) { const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js'); const transport = new StdioServerTransport(); await server.connect(transport); console.error('GlitchTip MCP server is running on stdio'); } else { const app = express(); const transports = new Map(); // Manual CORS middleware app.use((req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-mcp-protocol-version'); if (req.method === 'OPTIONS') { res.status(204).end(); return; } next(); }); app.use((req, _res, next) => { console.error(`${req.method} ${req.url}`); next(); }); app.get('/sse', async (req, res) => { const transport = new SSEServerTransport('/message', res as any); const sessionId = transport.sessionId; console.error(`New SSE connection: ${sessionId}`); transports.set(sessionId, transport); const heartbeatInterval = setInterval(() => { res.write(": heartbeat\n\n"); }, 15000); req.on('close', () => { console.error(`SSE connection closed: ${sessionId}`); clearInterval(heartbeatInterval); transports.delete(sessionId); }); await server.connect(transport); await new Promise((resolve) => { req.on('close', resolve); }); }); app.post('/message', express.json(), async (req, res) => { const sessionId = req.query.sessionId as string; const transport = transports.get(sessionId); console.error(`POST /message/${sessionId} - Active transports: ${transports.size}`); if (!transport) { console.error(`No transport found for session: ${sessionId}`); res.status(400).send('No active SSE connection for this session'); return; } await transport.handlePostMessage(req, res); }); const PORT = Number(process.env.GLITCHTIP_MCP_PORT) || 3005; app.listen(PORT, '0.0.0.0', () => { console.error(`GlitchTip MCP server running on http://0.0.0.0:${PORT}/sse`); }); } } run().catch((err) => { console.error("Fatal error:", err); process.exit(1); });