UNPKG

git-contextor

Version:

A code context tool with vector search and real-time monitoring, with optional Git integration.

308 lines (261 loc) 11.6 kB
const express = require('express'); const cors = require('cors'); const helmet = require('helmet'); const path = require('path'); const logger =require('../cli/utils/logger'); const { apiKeyAuth } = require('../utils/security'); // Import routes const searchRoutes = require('./routes/search'); const statusRoutes = require('./routes/status'); const metricsRoutes = require('./routes/metrics'); const healthRoutes = require('./routes/health'); const reindexRoutes = require('./routes/reindex'); const uiconfigRoutes = require('./routes/uiconfig'); const docsRoutes = require('./routes/docs'); const configRoutes = require('./routes/config'); const filesRoutes = require('./routes/files'); const collectionRoutes = require('./routes/collection'); // Import routes for chat and sharing const chatRoutes = require('./routes/chat'); const shareRoutes = require('./routes/share'); const sharedRoutes = require('./routes/shared'); const tunnelRoutes = require('./routes/tunnel'); // Nach den Imports hinzufügen function mcpAuth(sharingService) { return async (req, res, next) => { const ip = req.ip; const socketAddr = req.socket?.remoteAddress; const hostname = req.hostname; const hostHeader = req.headers['host']; const isIpLocal = ['::1', '127.0.0.1', '::ffff:127.0.0.1'].includes(ip); const isSocketLocal = ['::1', '127.0.0.1'].includes(socketAddr); const isHostnameLocal = ['localhost', '127.0.0.1'].includes(hostname); const isHostHeaderLocal = hostHeader && (hostHeader.startsWith('localhost') || hostHeader.startsWith('127.0.0.1')); const isLocalhost = isIpLocal || isSocketLocal || isHostnameLocal || isHostHeaderLocal; logger.debug(`MCP Auth check: IP=${ip}, SocketAddr=${socketAddr}, Host=${hostname}, HostHeader=${hostHeader}, isLocalhost=${isLocalhost}`); // If the request is from localhost, bypass token validation. if (isLocalhost) { logger.debug(`Bypassing MCP auth for local request to ${req.originalUrl}`); return next(); } const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ error: 'Unauthorized: Missing Bearer token.' }); } const token = authHeader.substring(7); // Token ist der API-Schlüssel des Shares const share = await sharingService.getAndValidateShareByApiKey(token); if (!share) { return res.status(403).json({ error: 'Forbidden: Invalid or expired token.' }); } req.share = share; // Share-Konfiguration an die Anfrage anhängen next(); }; } let server; function start(config, services, serviceManager) { const app = express(); // Middleware app.use(cors()); app.use(helmet({ contentSecurityPolicy: { directives: { ...helmet.contentSecurityPolicy.getDefaultDirectives(), "script-src": ["'self'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com"], }, }, })); app.use(express.json()); // Middleware to distinguish local and public access app.use((req, res, next) => { req.isLocal = req.hostname === 'localhost' || req.hostname === '127.0.0.1' || req.hostname === '::1'; next(); }); // Middleware to handle tunnel routing - strip tunnel ID prefix from path app.use((req, res, next) => { // Check if the request comes through a tunnel with format /tunnel/{tunnelId}/... const tunnelMatch = req.url.match(/^\/tunnel\/[^\/]+(.*)$/); if (tunnelMatch) { // Strip the tunnel prefix and update the URL req.url = tunnelMatch[1] || '/'; req.originalUrl = req.url; logger.debug(`Tunnel request detected, stripped path to: ${req.url}`); } next(); }); const localOnly = (req, res, next) => { if (req.isLocal) { return next(); } logger.warn(`Forbidden public access attempt to admin resource: ${req.method} ${req.originalUrl} from ${req.ip}`); res.status(403).json({ error: 'Forbidden: This resource is only available on localhost.' }); }; // Public health check endpoint app.use('/health', healthRoutes); // Admin-only API endpoints app.use('/api/uiconfig', localOnly, uiconfigRoutes(config)); app.use('/api/docs', localOnly, docsRoutes(config)); // API routes are local-only const apiRouter = express.Router(); apiRouter.use(localOnly); apiRouter.use(apiKeyAuth(config)); // Protect all /api routes apiRouter.use('/files', filesRoutes(config)); apiRouter.use('/search', searchRoutes(services)); apiRouter.use('/status', statusRoutes(serviceManager)); apiRouter.use('/metrics', metricsRoutes(services)); apiRouter.use('/reindex', reindexRoutes(services)); apiRouter.use('/chat', chatRoutes(services)); apiRouter.use('/collection', collectionRoutes(services)); apiRouter.use('/share', shareRoutes(services)); apiRouter.use('/config', configRoutes(config, serviceManager)); apiRouter.use('/tunnel', tunnelRoutes(services)); apiRouter.use('/tunnels', tunnelRoutes(services)); app.use('/api', apiRouter); // Shared access routes (public, with their own validation) app.use('/shared', sharedRoutes(services)); // --- MCP (Meta-Context Protocol) Routes for Custom Integrations --- const mcpRouter = express.Router(); // Spec-Endpunkt mcpRouter.route('/spec') .head((req, res) => { // Handle HEAD requests first, as some clients use this to probe the endpoint. res.status(200).end(); }) .get((req, res) => { const repoName = config.repository.name; res.json({ name: `Git Contextor: ${repoName}`, description: `Provides context-aware search for the ${repoName} repository.`, tools: [{ name: 'code_search', description: 'Searches the repository for code snippets, file contents, and documentation relevant to the user query.', parameters: { type: 'object', properties: { query: { type: 'string', description: 'The natural language query to search for.' } }, required: ['query'] } }] }); }); // MCP Chat Streaming Endpunkt mcpRouter.post('/chat', async (req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', }); const { query, options = {} } = req.body; if (!query) { res.write(`data: ${JSON.stringify({ type: 'error', error: 'Missing query' })}\n\n`); return res.end(); } try { // Context is fetched first by performing a search const searchResult = await services.contextOptimizer.search(query, options); // Determine LLM configuration const chatConfig = config.chat || {}; let llmConfig = chatConfig.llm || config.llm; if ((!llmConfig || !llmConfig.provider) && chatConfig.provider) { llmConfig = chatConfig; } if (!llmConfig || !llmConfig.provider) { throw new Error('LLM configuration is missing for streaming chat.'); } // Get the streaming response generator const stream = services.contextOptimizer.generateConversationalResponseStream( query, searchResult.optimizedContext, 'general', llmConfig ); for await (const chunk of stream) { res.write(`data: ${JSON.stringify({ type: 'chunk', content: chunk })}\n\n`); } res.write(`data: ${JSON.stringify({ type: 'end' })}\n\n`); res.end(); } catch (error) { logger.error('Error in MCP chat stream:', error); res.write(`data: ${JSON.stringify({ type: 'error', error: error.message })}\n\n`); res.end(); } }); // Tool Invocation Endpunkt mcpRouter.post('/tools/code_search/invoke', async (req, res) => { const { query } = req.body; if (!query) { return res.status(400).json({ error: 'Missing query in request body.' }); } try { // Correctly access the results array from the search response const searchResponse = await services.contextOptimizer.search(query); const formattedResults = searchResponse.results.map(r => `File: ${r.filePath}\nLines: ${r.startLine}-${r.endLine}\n\`\`\`\n${r.content}\n\`\`\`` ).join('\n\n---\n\n'); // Only increment usage if the request came through a share token if (req.share && req.share.id) { await services.sharingService.incrementUsage(req.share.id); } res.status(200).json({ content: formattedResults || "No relevant context found for the query." }); } catch (error) { logger.error('Error during MCP code search:', error); res.status(500).json({ error: 'An internal error occurred during search.' }); } }); app.use('/mcp/v1', mcpAuth(services.sharingService), mcpRouter); const publicPath = path.join(__dirname, '../ui/public'); // Explicitly serve public assets needed by shared.html and tunnel.html app.use('/css', express.static(path.join(publicPath, 'css'))); app.use('/js', express.static(path.join(publicPath, 'js'))); // Serve the rest of the UI assets only for local requests app.use((req, res, next) => { if (req.isLocal) { express.static(publicPath)(req, res, next); } else { next(); } }); // Fallback to index.html for SPA (local) or tunnel.html (public) app.get('*', (req, res) => { if (req.isLocal) { res.sendFile(path.join(publicPath, 'index.html')); } else { res.sendFile(path.join(publicPath, 'tunnel.html')); } }); // Global error handler app.use((err, req, res, next) => { logger.error('API Error:', err.message); logger.debug(err.stack); res.status(500).json({ error: 'Internal Server Error' }); }); return new Promise((resolve) => { const port = config.services.port; server = app.listen(port, () => { logger.info(`Application server with UI running at http://localhost:${port}`); resolve(server); }); }); } function stop() { return new Promise((resolve, reject) => { if (server) { server.close((err) => { if (err) { logger.error('Error stopping API server:', err); return reject(err); } logger.info('API server stopped.'); resolve(); }); } else { resolve(); } }); } module.exports = { start, stop };