Skip to main content
Glama
index.ts30.3 kB
/** * graham-chamber-chatbot - RAG Search Server * * Auto-generated by IndexFoundry. Do not edit manually. * Regenerate with: indexfoundry_project_export * * Copyright (c) 2025 vario.automation * Proprietary and confidential. All rights reserved. */ import "dotenv/config"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { readFileSync, existsSync } from "fs"; import { fileURLToPath } from "url"; import { dirname, join } from "path"; import { randomUUID } from "crypto"; import express from "express"; const __dirname = dirname(fileURLToPath(import.meta.url)); const DATA_DIR = join(__dirname, "..", "data"); const PROJECT_DIR = join(__dirname, ".."); // ============================================================================ // Type Definitions // ============================================================================ interface Source { source_id: string; type: string; uri: string; source_name?: string; tags?: string[]; status: string; } interface Chunk { chunk_id: string; source_id: string; text: string; position: { index: number; start_char: number; end_char: number; }; metadata: Record<string, unknown>; } interface Vector { chunk_id: string; embedding: number[]; model: string; } interface ProjectManifest { project_id: string; name: string; description?: string; stats: { sources_count: number; chunks_count: number; vectors_count: number; }; } interface Message { role: 'user' | 'assistant'; content: string; } interface ChatRequest { question: string; conversation_id?: string; // Session identifier for multi-turn conversations messages?: Message[]; // Previous conversation turns system_prompt?: string; model?: string; top_k?: number; } // ============================================================================ // Data Loading // ============================================================================ let chunks: Chunk[] = []; let vectors: Vector[] = []; let sources: Source[] = []; let manifest: ProjectManifest | null = null; const chunkMap = new Map<string, Chunk>(); const sourceMap = new Map<string, Source>(); function loadJsonl<T>(filePath: string): T[] { if (!existsSync(filePath)) return []; const content = readFileSync(filePath, "utf-8").trim(); if (!content) return []; return content.split("\n").filter(Boolean).map(line => JSON.parse(line) as T); } function loadData(): void { const chunksPath = join(DATA_DIR, "chunks.jsonl"); const vectorsPath = join(DATA_DIR, "vectors.jsonl"); const sourcesPath = join(PROJECT_DIR, "sources.jsonl"); const manifestPath = join(PROJECT_DIR, "project.json"); // Load project manifest if (existsSync(manifestPath)) { manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); console.error(`Project: ${manifest?.name || "unknown"}`); } // Load sources from JSONL sources = loadJsonl<Source>(sourcesPath); sources.forEach(s => sourceMap.set(s.source_id, s)); console.error(`Loaded ${sources.length} sources`); // Load chunks chunks = loadJsonl<Chunk>(chunksPath); chunks.forEach(c => chunkMap.set(c.chunk_id, c)); // Load vectors vectors = loadJsonl<Vector>(vectorsPath); console.error(`Loaded ${chunks.length} chunks, ${vectors.length} vectors`); } // ============================================================================ // Search Utilities // ============================================================================ interface EnrichedResult { chunk_id: string; text: string; score: number; source_id: string; source_url: string | null; source_name: string | null; source_type: string | null; position: Chunk["position"]; metadata: Record<string, unknown>; } function enrichWithSource(chunk: Chunk, score: number): EnrichedResult { const source = sourceMap.get(chunk.source_id); return { chunk_id: chunk.chunk_id, text: chunk.text, score: Math.round(score * 10000) / 10000, // Round to 4 decimal places source_id: chunk.source_id, source_url: source?.uri || null, source_name: source?.source_name || null, source_type: source?.type || null, position: chunk.position, metadata: chunk.metadata }; } function cosineSimilarity(a: number[], b: number[]): number { if (a.length !== b.length) return 0; let dot = 0, normA = 0, normB = 0; for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; normA += a[i] * a[i]; normB += b[i] * b[i]; } const denom = Math.sqrt(normA) * Math.sqrt(normB); return denom === 0 ? 0 : dot / denom; } function searchKeyword(query: string, topK: number): Array<{ chunk: Chunk; score: number }> { if (!query.trim()) return []; const terms = query.toLowerCase().split(/\s+/).filter(Boolean); if (terms.length === 0) return []; const scored = chunks.map(chunk => { const text = chunk.text.toLowerCase(); let matches = 0; for (const term of terms) { if (text.includes(term)) matches++; } return { chunk, score: matches / terms.length }; }); return scored .filter(r => r.score > 0) .sort((a, b) => b.score - a.score) .slice(0, topK); } function searchSemantic(queryVector: number[], topK: number): Array<{ chunk_id: string; score: number }> { if (!queryVector || queryVector.length === 0) return []; const scored = vectors.map(v => ({ chunk_id: v.chunk_id, score: cosineSimilarity(queryVector, v.embedding), })); return scored .sort((a, b) => b.score - a.score) .slice(0, topK); } /** * Generate query embedding using OpenAI API */ async function generateQueryEmbedding(query: string): Promise<number[]> { const apiKey = process.env.OPENAI_API_KEY; if (!apiKey) { throw new Error("OPENAI_API_KEY not configured"); } const response = await fetch("https://api.openai.com/v1/embeddings", { method: "POST", headers: { "Authorization": `Bearer ${apiKey}`, "Content-Type": "application/json", }, body: JSON.stringify({ model: "text-embedding-3-small", input: query, }), }); if (!response.ok) { throw new Error(`Embedding API error: ${response.status}`); } const data = await response.json() as { data: Array<{ embedding: number[] }> }; return data.data[0].embedding; } /** * Detect anchor terms (identifiers like room numbers, codes, etc.) * These terms should be prioritized for exact keyword matching */ function detectAnchorTerms(query: string): string[] { const patterns = [ /\b([A-Z]\d{1,3})\b/g, // Room numbers: A1, D40, B108 /\b([A-Z]{2,3}\d{1,4})\b/g, // Codes: SRD52, CR10 /\b(\d{3,})\b/g, // Long numbers: 300, 5000 /"([^"]+)"/g, // Quoted terms: "myrmarch" ]; const anchors: string[] = []; for (const pattern of patterns) { let match; while ((match = pattern.exec(query)) !== null) { anchors.push(match[1] || match[0]); } } return [...new Set(anchors)]; // Dedupe } /** * Calculate query specificity (0 = very broad, 1 = very specific) * Used for adaptive weighting between keyword and semantic search */ function calculateQuerySpecificity(query: string, anchorTerms: string[]): number { const words = query.toLowerCase().split(/\s+/).filter(w => w.length > 2); let specificity = 0; // Anchors are highly specific specificity += Math.min(0.5, anchorTerms.length * 0.2); // Short queries tend to be specific searches if (words.length <= 3) specificity += 0.2; else if (words.length <= 6) specificity += 0.1; // Common broad words reduce specificity const broadWords = ['what', 'how', 'tell', 'about', 'explain', 'describe', 'overview']; const hasBroadWords = broadWords.some(w => words.includes(w)); if (hasBroadWords) specificity -= 0.2; return Math.max(0, Math.min(1, specificity)); } /** * Hybrid search combining keyword and semantic search with: * 1. Linear Score Interpolation - uses actual scores instead of RRF rank positions * 2. Query-Adaptive Weighting - dynamically adjusts weights based on query characteristics * 3. Anchor Term Boosting - boosts chunks containing identifier terms (D40, A108, etc.) */ async function searchHybrid(query: string, topK: number): Promise<Array<{ chunk: Chunk; score: number }>> { // Step 1: Detect anchor terms (identifiers like D40, A108, etc.) const anchorTerms = detectAnchorTerms(query); const hasAnchors = anchorTerms.length > 0; // Step 2: Calculate query specificity for adaptive weighting const specificity = calculateQuerySpecificity(query, anchorTerms); // Step 3: Determine weights based on specificity // High specificity (identifiers present) → keyword-heavy (60-70%) // Low specificity (conceptual query) → semantic-heavy (70-80%) // Mixed → balanced (50/50) const keywordWeight = hasAnchors ? Math.min(0.7, 0.3 + anchorTerms.length * 0.2) : // 0.3 + 0.2 per anchor, max 0.7 Math.max(0.2, 0.5 - specificity * 0.3); // 0.2 to 0.5 based on specificity const semanticWeight = 1 - keywordWeight; console.error(`[Hybrid Search] Query: "${query}" | Anchors: [${anchorTerms.join(', ')}] | Specificity: ${specificity.toFixed(2)} | Weights: kw=${keywordWeight.toFixed(2)}, sem=${semanticWeight.toFixed(2)}`); // Step 4: Get results from both search methods (get more for fusion) const keywordResults = searchKeyword(query, topK * 3); // Try semantic search if we have vectors let semanticResults: Array<{ chunk_id: string; score: number }> = []; if (vectors.length > 0) { try { const queryVector = await generateQueryEmbedding(query); semanticResults = searchSemantic(queryVector, topK * 3); } catch (err) { console.error("Embedding generation failed, using keyword-only:", err); // Fall back to keyword-only with anchor boosting return applyAnchorBoost(keywordResults, anchorTerms).slice(0, topK); } } else { // No vectors available, use keyword-only with anchor boosting return applyAnchorBoost(keywordResults, anchorTerms).slice(0, topK); } // Step 5: Build score maps for O(1) lookup // Normalize keyword scores to 0-1 range (they're already 0-1 as match ratio) const keywordMap = new Map(keywordResults.map(r => [r.chunk.chunk_id, r.score])); // Semantic scores are already cosine similarity in -1 to 1 range, normalize to 0-1 const semanticMap = new Map(semanticResults.map(r => [r.chunk_id, (r.score + 1) / 2])); // Step 6: Collect all unique chunk IDs from both result sets const allChunkIds = new Set([ ...keywordResults.map(r => r.chunk.chunk_id), ...semanticResults.map(r => r.chunk_id) ]); // Step 7: Calculate combined scores with linear interpolation const results: Array<{ chunk: Chunk; score: number }> = []; for (const chunkId of allChunkIds) { const chunk = chunkMap.get(chunkId); if (!chunk) continue; const semScore = semanticMap.get(chunkId) || 0; const kwScore = keywordMap.get(chunkId) || 0; // Step 8: Apply anchor term boosting let anchorBoost = 0; if (hasAnchors) { const chunkText = chunk.text.toLowerCase(); for (const anchor of anchorTerms) { const anchorLower = anchor.toLowerCase(); // If chunk contains the exact anchor term, boost significantly if (chunkText.includes(anchorLower)) { // Higher boost for exact pattern match (e.g., "D40." or "D40:" at start of line) const exactMatch = new RegExp(`\\b${anchor}\\s*[\\.:\\)]`, 'i'); anchorBoost += exactMatch.test(chunk.text) ? 0.4 : 0.15; } } } // Step 9: Linear interpolation with anchor boost const combinedScore = (semScore * semanticWeight) + (kwScore * keywordWeight) + anchorBoost; results.push({ chunk, score: combinedScore }); } // Step 10: Sort by combined score and return top K return results .sort((a, b) => b.score - a.score) .slice(0, topK); } /** * Helper: Apply anchor term boosting to keyword-only results * Used when semantic search is unavailable */ function applyAnchorBoost(results: Array<{ chunk: Chunk; score: number }>, anchorTerms: string[]): Array<{ chunk: Chunk; score: number }> { if (anchorTerms.length === 0) return results; return results.map(r => { let anchorBoost = 0; const chunkText = r.chunk.text.toLowerCase(); for (const anchor of anchorTerms) { const anchorLower = anchor.toLowerCase(); if (chunkText.includes(anchorLower)) { const exactMatch = new RegExp(`\\b${anchor}\\s*[\\.:\\)]`, 'i'); anchorBoost += exactMatch.test(r.chunk.text) ? 0.4 : 0.15; } } return { chunk: r.chunk, score: r.score + anchorBoost }; }).sort((a, b) => b.score - a.score); } // ============================================================================ // MCP Server // ============================================================================ const server = new Server( { name: "graham-chamber-chatbot", version: "1.0.0" }, { capabilities: { tools: {} } } ); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: "search", description: "AI chatbot for Graham County Chamber of Commerce - answers questions about membership, events, tourism, and contacts. Returns relevant text chunks with source citations.", inputSchema: { type: "object", properties: { query: { type: "string", description: "Natural language search query" }, query_vector: { type: "array", items: { type: "number" }, description: "Pre-computed embedding vector for semantic search. Required for semantic/hybrid modes." }, mode: { type: "string", enum: ["semantic", "keyword", "hybrid"], default: "keyword", description: "Search mode: keyword (fast, exact match), semantic (embedding similarity), hybrid (combined)" }, top_k: { type: "number", default: 10, minimum: 1, maximum: 100, description: "Number of results to return" } }, required: ["query"] } }, { name: "get_chunk", description: "Retrieve a specific chunk by its ID. Use this to get full context for a search result.", inputSchema: { type: "object", properties: { chunk_id: { type: "string", description: "The chunk_id from a search result" } }, required: ["chunk_id"] } }, { name: "list_sources", description: "List all indexed sources with their URIs and status", inputSchema: { type: "object", properties: {} } }, { name: "stats", description: "Get index statistics including chunk count, vector count, and source count", inputSchema: { type: "object", properties: {} } } ] })); server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; switch (name) { case "search": { const { query, query_vector, mode = "keyword", top_k = 10 } = args as { query: string; query_vector?: number[]; mode?: string; top_k?: number; }; // Validate inputs if (!query || typeof query !== "string") { return { content: [{ type: "text", text: JSON.stringify({ error: "query is required" }) }], isError: true }; } const effectiveTopK = Math.min(Math.max(1, top_k), 100); let results: Array<{ chunk: Chunk; score: number }> = []; if (mode === "keyword") { results = searchKeyword(query, effectiveTopK); } else if (mode === "semantic") { if (!query_vector || !Array.isArray(query_vector)) { return { content: [{ type: "text", text: JSON.stringify({ error: "query_vector required for semantic search" }) }], isError: true }; } const semantic = searchSemantic(query_vector, effectiveTopK); results = semantic .map(s => ({ chunk: chunkMap.get(s.chunk_id)!, score: s.score })) .filter(r => r.chunk); } else if (mode === "hybrid") { if (!query_vector || !Array.isArray(query_vector)) { // Fall back to keyword-only for hybrid without vector results = searchKeyword(query, effectiveTopK); } else { // Reciprocal Rank Fusion const keyword = searchKeyword(query, effectiveTopK * 2); const semantic = searchSemantic(query_vector, effectiveTopK * 2); const scoreMap = new Map<string, number>(); const k = 60; // RRF constant keyword.forEach((r, i) => { scoreMap.set(r.chunk.chunk_id, (scoreMap.get(r.chunk.chunk_id) || 0) + 1 / (k + i + 1)); }); semantic.forEach((r, i) => { scoreMap.set(r.chunk_id, (scoreMap.get(r.chunk_id) || 0) + 1 / (k + i + 1)); }); results = Array.from(scoreMap.entries()) .map(([id, score]) => ({ chunk: chunkMap.get(id)!, score })) .filter(r => r.chunk) .sort((a, b) => b.score - a.score) .slice(0, effectiveTopK); } } return { content: [{ type: "text", text: JSON.stringify({ results: results.map(r => enrichWithSource(r.chunk, r.score)), total: results.length, query, mode }, null, 2) }] }; } case "get_chunk": { const { chunk_id } = args as { chunk_id: string }; if (!chunk_id) { return { content: [{ type: "text", text: JSON.stringify({ error: "chunk_id is required" }) }], isError: true }; } const chunk = chunkMap.get(chunk_id); if (!chunk) { return { content: [{ type: "text", text: JSON.stringify({ error: "Chunk not found", chunk_id }) }], isError: true }; } return { content: [{ type: "text", text: JSON.stringify(enrichWithSource(chunk, 1.0), null, 2) }] }; } case "list_sources": { const sourceList = sources.map(s => ({ source_id: s.source_id, type: s.type, uri: s.uri, name: s.source_name || null, status: s.status })); return { content: [{ type: "text", text: JSON.stringify({ sources: sourceList, total: sourceList.length }, null, 2) }] }; } case "stats": { return { content: [{ type: "text", text: JSON.stringify({ project: manifest?.name || "graham-chamber-chatbot", chunks: chunks.length, vectors: vectors.length, sources: sources.length, has_embeddings: vectors.length > 0 }, null, 2) }] }; } default: return { content: [{ type: "text", text: JSON.stringify({ error: `Unknown tool: ${name}` }) }], isError: true }; } }); // ============================================================================ // HTTP Server // ============================================================================ const app = express(); app.use(express.json({ limit: "1mb" })); // Request logging app.use((req, res, next) => { const start = Date.now(); res.on("finish", () => { const duration = Date.now() - start; console.error(`${req.method} ${req.path} ${res.statusCode} ${duration}ms`); }); next(); }); // CORS middleware app.use((req, res, next) => { res.header("Access-Control-Allow-Origin", "*"); res.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); res.header("Access-Control-Allow-Headers", "Content-Type, Authorization"); if (req.method === "OPTIONS") { return res.sendStatus(200); } next(); }); // Health check endpoint app.get("/health", (_, res) => { res.json({ status: "ok", project: manifest?.name || "graham-chamber-chatbot", chunks: chunks.length, vectors: vectors.length, sources: sources.length, uptime: Math.floor(process.uptime()) }); }); // Stats endpoint app.get("/stats", (_, res) => { res.json({ project: manifest?.name || "graham-chamber-chatbot", description: manifest?.description || "AI chatbot for Graham County Chamber of Commerce - answers questions about membership, events, tourism, and contacts", chunks: chunks.length, vectors: vectors.length, sources: sources.length, has_embeddings: vectors.length > 0 }); }); // List sources endpoint app.get("/sources", (_, res) => { res.json({ sources: sources.map(s => ({ source_id: s.source_id, type: s.type, uri: s.uri, name: s.source_name || null, status: s.status })), total: sources.length }); }); // Search endpoint app.post("/search", async (req, res) => { try { const { query, query_vector, mode = "keyword", top_k = 10 } = req.body; if (!query || typeof query !== "string") { return res.status(400).json({ error: "query is required and must be a string" }); } const effectiveTopK = Math.min(Math.max(1, top_k || 10), 100); let results: Array<{ chunk: Chunk; score: number }> = []; if (mode === "keyword") { results = searchKeyword(query, effectiveTopK); } else if (mode === "semantic") { if (!query_vector || !Array.isArray(query_vector)) { return res.status(400).json({ error: "query_vector required for semantic search" }); } const semantic = searchSemantic(query_vector, effectiveTopK); results = semantic .map(s => ({ chunk: chunkMap.get(s.chunk_id)!, score: s.score })) .filter(r => r.chunk); } else if (mode === "hybrid") { if (!query_vector || !Array.isArray(query_vector)) { results = searchKeyword(query, effectiveTopK); } else { const keyword = searchKeyword(query, effectiveTopK * 2); const semantic = searchSemantic(query_vector, effectiveTopK * 2); const scoreMap = new Map<string, number>(); const k = 60; keyword.forEach((r, i) => { scoreMap.set(r.chunk.chunk_id, (scoreMap.get(r.chunk.chunk_id) || 0) + 1 / (k + i + 1)); }); semantic.forEach((r, i) => { scoreMap.set(r.chunk_id, (scoreMap.get(r.chunk_id) || 0) + 1 / (k + i + 1)); }); results = Array.from(scoreMap.entries()) .map(([id, score]) => ({ chunk: chunkMap.get(id)!, score })) .filter(r => r.chunk) .sort((a, b) => b.score - a.score) .slice(0, effectiveTopK); } } res.json({ results: results.map(r => enrichWithSource(r.chunk, r.score)), total: results.length, query, mode }); } catch (error) { console.error("Search error:", error); res.status(500).json({ error: "Search failed" }); } }); // Get chunk by ID app.get("/chunks/:chunk_id", (req, res) => { const chunk = chunkMap.get(req.params.chunk_id); if (!chunk) { return res.status(404).json({ error: "Chunk not found" }); } res.json(enrichWithSource(chunk, 1.0)); }); // Chat endpoint - RAG + LLM with streaming app.post("/chat", async (req, res) => { const { question, system_prompt, top_k = 10, model, conversation_id, messages = [] } = req.body as ChatRequest; if (!question || typeof question !== "string") { return res.status(400).json({ error: "question is required" }); } const apiKey = process.env.OPENAI_API_KEY; if (!apiKey) { return res.status(500).json({ error: "OPENAI_API_KEY not configured" }); } // Generate conversation_id if not provided const activeConversationId = conversation_id || randomUUID(); // Build conversation history context (last 10 turns) const recentMessages = (messages || []).slice(-10); const conversationHistory = recentMessages.length > 0 ? `\n\nCONVERSATION HISTORY:\n${recentMessages.map(m => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}` ).join('\n')}` : ''; // Search for relevant context using hybrid search (keyword + semantic with RRF fusion) const searchResults = await searchHybrid(question, Math.min(top_k, 10)); // Build context with source citations const contextParts = searchResults.map((r, i) => { const source = sourceMap.get(r.chunk.source_id); const sourceName = source?.source_name || source?.uri || "Unknown"; return `[Source ${i + 1}: ${sourceName}]\n${r.chunk.text}`; }); const context = contextParts.join("\n\n---\n\n"); const defaultSystemPrompt = `You are a helpful assistant with access to a knowledge base about ${manifest?.name || "graham-chamber-chatbot"}. Answer questions using ONLY the retrieved documents below. Always cite sources using [Source N] notation. If the documents don't contain relevant information to answer the question, say so clearly. RETRIEVED DOCUMENTS: ${context || "No relevant documents found."}${conversationHistory}`; const finalSystemPrompt = system_prompt ? `${system_prompt}\n\nRETRIEVED DOCUMENTS:\n${context || "No relevant documents found."}${conversationHistory}` : defaultSystemPrompt; // Set up SSE headers res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Cache-Control", "no-cache"); res.setHeader("Connection", "keep-alive"); res.flushHeaders(); // Send sources first res.write(`data: ${JSON.stringify({ type: "sources", sources: searchResults.map(r => enrichWithSource(r.chunk, r.score)) })}\n\n`); try { const response = await fetch("https://api.openai.com/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}` }, body: JSON.stringify({ model: model || process.env.OPENAI_MODEL || "gpt-5-nano-2025-08-07", messages: [ { role: "system", content: finalSystemPrompt }, { role: "user", content: question } ], stream: true, max_completion_tokens: 2048 }) }); if (!response.ok) { const error = await response.json().catch(() => ({ error: { message: "API request failed" } })); res.write(`data: ${JSON.stringify({ type: "error", error: error.error?.message || "API request failed" })}\n\n`); res.end(); return; } const reader = response.body?.getReader(); if (!reader) { res.write(`data: ${JSON.stringify({ type: "error", error: "No response body" })}\n\n`); res.end(); return; } const decoder = new TextDecoder(); let buffer = ""; let contentStreamed = false; // Debug counters for diagnosing empty responses let chunkCount = 0; let finishReason: string | null = null; let totalLinesParsed = 0; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { totalLinesParsed++; if (line.startsWith("data: ")) { if (line === "data: [DONE]") { console.error(`[DEBUG] Stream completed. Chunks: ${chunkCount}, Finish reason: ${finishReason}, Lines parsed: ${totalLinesParsed}`); continue; } try { const data = JSON.parse(line.slice(6)); const content = data.choices?.[0]?.delta?.content; finishReason = data.choices?.[0]?.finish_reason || finishReason; // Log first chunk for debugging if (chunkCount === 0 && content) { console.error(`[DEBUG] First content chunk received: "${content.substring(0, 50)}${content.length > 50 ? '...' : ''}"`); } if (content) { res.write(`data: ${JSON.stringify({ type: "delta", text: content })}\n\n`); contentStreamed = true; chunkCount++; } } catch { // Skip unparseable lines } } } } // Log warning if LLM returned no content if (!contentStreamed) { console.error("[WARN] LLM returned no content. This may indicate:"); console.error(" - Invalid or missing OPENAI_API_KEY"); console.error(" - Model rate limiting or API issues"); console.error(" - Empty response from the model"); } res.write(`data: ${JSON.stringify({ type: "done", conversation_id: activeConversationId, empty_response: !contentStreamed })}\n\n`); res.end(); } catch (error) { console.error("Chat error:", error); res.write(`data: ${JSON.stringify({ type: "error", error: "Failed to generate response" })}\n\n`); res.end(); } }); // Error handler app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => { console.error("Unhandled error:", err); res.status(500).json({ error: "Internal server error" }); }); const PORT = parseInt(process.env.PORT || "8081"); app.listen(PORT, () => { console.error(`HTTP server listening on port ${PORT}`); console.error(`Endpoints: /health, /stats, /sources, /search, /chunks/:id, /chat`); }); // ============================================================================ // Server Startup // ============================================================================ loadData(); const transport = new StdioServerTransport(); server.connect(transport).catch((err) => { console.error("Failed to connect MCP transport:", err); process.exit(1); }); console.error(`graham-chamber-chatbot MCP server running (chunks: ${chunks.length}, vectors: ${vectors.length})`);

Latest Blog Posts

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/Mnehmos/mnehmos.index-foundry.mcp'

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