/**
* dnd-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: "dnd-chatbot", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "search",
description: "D&D 5e rules and World's Largest Dungeon chatbot with hybrid search. 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 || "dnd-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 || "dnd-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 || "dnd-chatbot",
description: manifest?.description || "D&D 5e rules and World's Largest Dungeon chatbot with hybrid search",
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 || "dnd-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 || "8080");
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(`dnd-chatbot MCP server running (chunks: ${chunks.length}, vectors: ${vectors.length})`);