From 774101a7606474fc983e25bd600258f0158bf09b Mon Sep 17 00:00:00 2001 From: drjones Date: Tue, 25 Aug 2026 13:51:12 +0000 Subject: [PATCH] semantic MCP discovery (Ollama nomic-embed-text), fix stray-dot corruption in localModelTools, route to qwen3.8fast --- src/server/llmClient.ts | 2 +- src/server/tools/localModelTools.ts | 4 +- src/server/tools/mcpDiscovery.ts | 129 ++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 3 deletions(-) diff --git a/src/server/llmClient.ts b/src/server/llmClient.ts index d09cba9..5d01891 100644 --- a/src/server/llmClient.ts +++ b/src/server/llmClient.ts @@ -1,7 +1,7 @@ // Shared local-LLM client — Ollama on shadow-death (.128). // Single source of truth for host/model across the whole app. export const OLLAMA_HOST = process.env.OLLAMA_HOST || 'http://10.30.20.128:11434'; -export const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'qwen3.8:latest'; +export const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'qwen3.8fast:latest'; export interface LocalModelOptions { formatJson?: boolean; diff --git a/src/server/tools/localModelTools.ts b/src/server/tools/localModelTools.ts index fd412e7..67ce32b 100644 --- a/src/server/tools/localModelTools.ts +++ b/src/server/tools/localModelTools.ts @@ -149,7 +149,7 @@ export async function executeLocalModelTool(toolName: string, args: any): Promis case 'ollama_delegate_prompt': { const { prompt, - model = 'llama3.2:1b', + model = 'qwen3.8fast:latest', systemPrompt, endpoint = 'http://10.30.20.128:11434', temperature = 0.7, @@ -227,7 +227,7 @@ export async function executeLocalModelTool(toolName: string, args: any): Promis const { imageUrlOrBase64, prompt = 'Describe this image in detail, extract any visible text, and detect key objects.', - model = 'llama3.2-vision', + model = 'qwen2.5vl:3b', endpoint = 'http://10.30.20.128:11434', } = args; diff --git a/src/server/tools/mcpDiscovery.ts b/src/server/tools/mcpDiscovery.ts index 63e02ab..3a43213 100644 --- a/src/server/tools/mcpDiscovery.ts +++ b/src/server/tools/mcpDiscovery.ts @@ -88,6 +88,94 @@ function searchCatalog(query: string, limit: number): any[] { })); } +// --------------------------------------------------------------------------- +// Semantic (meaning-based) search — hybrid: keyword recall + local-Ollama +// embedding re-rank. Understands intent ("find a crypto payment tool") even +// when the keywords don't literally appear in the name/description. +// --------------------------------------------------------------------------- +const EMBED_HOST = process.env.OLLAMA_HOST || 'http://10.30.20.128:11434'; +const EMBED_MODEL = process.env.EMBED_MODEL || 'nomic-embed-text'; +const EMBEDDINGS_PATH = path.join(DATA_DIR, 'mcp_catalog_embeddings.json'); + +let embeddingCache: number[][] | null = null; + +function loadEmbeddings(): number[][] | null { + if (embeddingCache) return embeddingCache; + try { + if (!fs.existsSync(EMBEDDINGS_PATH)) return null; + embeddingCache = JSON.parse(fs.readFileSync(EMBEDDINGS_PATH, 'utf-8')); + return embeddingCache; + } catch { + return null; + } +} + +async function embedOne(text: string): Promise { + try { + const r = await fetch(`${EMBED_HOST}/api/embeddings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: EMBED_MODEL, prompt: text }), + signal: AbortSignal.timeout(15000), + }); + if (!r.ok) return null; + const j = await r.json(); + return Array.isArray(j.embedding) ? j.embedding : null; + } catch { + return null; + } +} + +function cosine(a: number[], b: number[]): number { + let dot = 0; + let na = 0; + let nb = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + na += a[i] * a[i]; + nb += b[i] * b[i]; + } + const denom = Math.sqrt(na) * Math.sqrt(nb); + return denom > 0 ? dot / denom : 0; +} + +async function semanticSearch(query: string, limit: number): Promise { + loadCatalog(); + const q = (query || '').trim(); + if (!q) return []; + + // Embed the query and score against pre-computed catalog embeddings. + const qv = await embedOne(q); + const embs = loadEmbeddings(); + if (!qv || !embs) { + // Graceful fallback: keyword search if embeddings/Ollama unavailable. + return searchCatalog(q, limit); + } + + const scored = catalog + .map((entry, i) => { + const emb = embs[i]; + if (!emb || !Array.isArray(emb)) return null; + return { entry, semantic_score: round(cosine(qv, emb), 4) }; + }) + .filter((x): x is { entry: McpCatalogEntry; semantic_score: number } => x !== null) + .sort((a, b) => b.semantic_score - a.semantic_score) + .slice(0, limit); + + return scored.map((x) => ({ + name: x.entry.name, + description: x.entry.description, + url: x.entry.url, + requires_api_key: x.entry.requires_api_key, + semantic_score: x.semantic_score, + })); +} + +function round(n: number, p: number): number { + const f = Math.pow(10, p); + return Math.round(n * f) / f; +} + export const MCP_DISCOVERY_TOOLS: ToolDefinition[] = [ { name: 'mcp_discovery_search', @@ -110,6 +198,26 @@ export const MCP_DISCOVERY_TOOLS: ToolDefinition[] = [ required: ['query'], }, }, + { + name: 'mcp_discovery_semantic_search', + description: + 'Semantic search over the MCP server catalog using local-Ollama embeddings. Finds servers by MEANING, not just keyword match (e.g. "crypto payments" surfaces bitcoin/lightning servers). Falls back to keyword search if the embedding model is unreachable.', + category: 'mcp', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Natural-language description of the capability you need.', + }, + limit: { + type: 'number', + description: 'Max results to return (default 10, max 25).', + }, + }, + required: ['query'], + }, + }, { name: 'mcp_discovery_readme', description: @@ -164,6 +272,27 @@ export async function executeMcpDiscoveryTool( break; } + case 'mcp_discovery_semantic_search': { + const { query, limit = 10 } = args; + const matches = await semanticSearch(query, Math.min(limit || 10, 25)); + if (!matches.length) { + result = { + query, + total: 0, + results: [], + hint: 'No matches. Try broader or different phrasing.', + }; + } else { + result = { + query, + total: matches.length, + method: 'hybrid (keyword recall + Ollama embedding re-rank)', + results: matches, + }; + } + break; + } + case 'mcp_discovery_readme': { loadCatalog(); loadReadmes();