From 6c497ec6c5d7b0e2276bf3ea147bf50db7466699 Mon Sep 17 00:00:00 2001 From: Christian Gick Date: Mon, 19 Jan 2026 13:14:29 +0200 Subject: [PATCH] Fix CF-271: Add fallback text search to archive_search When LiteLLM embedding service is unavailable, archive_search now gracefully falls back to PostgreSQL text search (ILIKE) instead of returning an error. Also adds dotenv support for proper credential loading. Co-Authored-By: Claude Sonnet 4.5 --- .gitignore | 1 + package-lock.json | 13 ++++++++++++ package.json | 5 +++-- src/embeddings.ts | 10 ++++++---- src/index.ts | 19 ++++++++++++++++++ src/tools/archives.ts | 46 ++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 87 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 3c25e1e..dd8fe26 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ dist/ *.log +.env diff --git a/package-lock.json b/package-lock.json index b236cd9..15559b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.4", + "dotenv": "^17.2.3", "pg": "^8.11.3" }, "devDependencies": { @@ -734,6 +735,18 @@ "node": ">= 0.8" } }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/package.json b/package.json index 6b45c96..1db345f 100644 --- a/package.json +++ b/package.json @@ -12,12 +12,13 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.0.4", + "dotenv": "^17.2.3", "pg": "^8.11.3" }, "devDependencies": { "@types/node": "^20.11.0", "@types/pg": "^8.10.9", - "typescript": "^5.3.3", - "tsx": "^4.7.0" + "tsx": "^4.7.0", + "typescript": "^5.3.3" } } diff --git a/src/embeddings.ts b/src/embeddings.ts index 40a7996..6eeb3c4 100644 --- a/src/embeddings.ts +++ b/src/embeddings.ts @@ -1,8 +1,5 @@ // Embeddings via LiteLLM API -const LLM_API_URL = process.env.LLM_API_URL || 'https://api.agiliton.cloud/llm'; -const LLM_API_KEY = process.env.LLM_API_KEY || ''; - interface EmbeddingResponse { data: Array<{ embedding: number[]; @@ -19,8 +16,13 @@ interface EmbeddingResponse { * Generate embedding for text using LiteLLM API */ export async function getEmbedding(text: string): Promise { + // Read env vars at runtime (after dotenv.config() in index.ts) + const LLM_API_URL = process.env.LLM_API_URL || 'https://api.agiliton.cloud/llm'; + const LLM_API_KEY = process.env.LLM_API_KEY || ''; + if (!LLM_API_KEY) { - console.error('LLM_API_KEY not set, skipping embedding'); + console.error('LLM_API_KEY not set, skipping embedding (check .env file)'); + console.error('LLM_API_URL:', LLM_API_URL); return null; } diff --git a/src/index.ts b/src/index.ts index 961ced1..edabf84 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,25 @@ * ssh -L 5433:localhost:5432 -i ~/.ssh/hetzner_mash_deploy root@46.224.188.157 -N & */ +// Load environment variables from .env file +import dotenv from 'dotenv'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const envPath = join(__dirname, '..', '.env'); +const result = dotenv.config({ path: envPath, override: true }); + +// Log environment loading status (goes to MCP server logs) +if (result.error) { + console.error('Failed to load .env from:', envPath, result.error); +} else { + console.error('Loaded .env from:', envPath); + console.error('LLM_API_KEY present:', !!process.env.LLM_API_KEY); + console.error('LLM_API_URL:', process.env.LLM_API_URL); +} + import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { diff --git a/src/tools/archives.ts b/src/tools/archives.ts index 54a18e8..20ae090 100644 --- a/src/tools/archives.ts +++ b/src/tools/archives.ts @@ -125,10 +125,54 @@ export async function archiveSearch(args: ArchiveSearchArgs): Promise { // Generate embedding for search const embedding = await getEmbedding(searchQuery); + // Fallback to text search if embeddings unavailable if (!embedding) { - return 'Error: Could not generate embedding for search'; + console.warn('Embeddings unavailable, falling back to text search'); + + let whereClause = '(title ILIKE $1 OR content ILIKE $1)'; + const params: unknown[] = [`%${searchQuery}%`]; + let paramIndex = 2; + + if (project) { + whereClause += ` AND project_key = $${paramIndex++}`; + params.push(project); + } + if (archive_type) { + whereClause += ` AND archive_type = $${paramIndex++}`; + params.push(archive_type); + } + + params.push(limit); + + const archives = await query( + `SELECT id, archive_type, title, original_path, file_size, + to_char(archived_at, 'YYYY-MM-DD') as archived_at + FROM project_archives + WHERE ${whereClause} + ORDER BY archived_at DESC + LIMIT $${paramIndex}`, + params + ); + + if (archives.length === 0) { + return 'No relevant archives found'; + } + + const lines = ['Relevant archives (text search - embeddings unavailable):\n']; + for (const a of archives) { + const sizeStr = a.file_size ? ` (${Math.round(a.file_size / 1024)}KB)` : ''; + lines.push(`**[${a.archive_type}]** ${a.title}`); + lines.push(` Archived: ${a.archived_at}${sizeStr}`); + if (a.original_path) { + lines.push(` Path: ${a.original_path}`); + } + lines.push(''); + } + + return lines.join('\n'); } + // Semantic search with embeddings const embeddingStr = formatEmbedding(embedding); let whereClause = 'WHERE embedding IS NOT NULL';