UNPKG

@sanderkooger/mcp-server-ragdocs

Version:

An MCP server for semantic documentation search and retrieval using vector databases to augment LLM capabilities.

124 lines (123 loc) 4.81 kB
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; import { BaseHandler } from './base-handler.js'; const COLLECTION_NAME = 'documentation'; export class ListSourcesHandler extends BaseHandler { groupSourcesByDomainAndSubdomain(sources) { const grouped = {}; for (const source of sources) { try { const url = new URL(source.url); const domain = url.hostname; const pathParts = url.pathname.split('/').filter((p) => p); const subdomain = pathParts[0] || '/'; if (!grouped[domain]) { grouped[domain] = {}; } if (!grouped[domain][subdomain]) { grouped[domain][subdomain] = []; } grouped[domain][subdomain].push(source); } catch (error) { console.error(`Invalid URL: ${source.url}`); } } return grouped; } formatGroupedSources(grouped) { const output = []; let domainCounter = 1; for (const [domain, subdomains] of Object.entries(grouped)) { output.push(`${domainCounter}. ${domain}`); // Create a Set of unique URL+title combinations const uniqueSources = new Map(); for (const sources of Object.values(subdomains)) { for (const source of sources) { uniqueSources.set(source.url, source); } } // Convert to array and sort const sortedSources = Array.from(uniqueSources.values()).sort((a, b) => a.title.localeCompare(b.title)); // Use letters for subdomain entries sortedSources.forEach((source, index) => { output.push(`${domainCounter}.${index + 1}. ${source.title} (${source.url})`); }); output.push(''); // Add blank line between domains domainCounter++; } return output.join('\n'); } async handle() { try { await this.apiClient.initCollection(COLLECTION_NAME); const pageSize = 100; let offset = null; const sources = []; while (true) { const scroll = await this.apiClient.qdrantClient.scroll(COLLECTION_NAME, { with_payload: true, with_vector: false, limit: pageSize, offset }); if (scroll.points.length === 0) break; for (const point of scroll.points) { if (point.payload && typeof point.payload === 'object' && 'url' in point.payload && 'title' in point.payload) { const payload = point.payload; sources.push({ title: payload.title, url: payload.url }); } } if (scroll.points.length < pageSize) break; offset = scroll.points?.[scroll.points.length - 1]?.id || ''; } if (sources.length === 0) { return { content: [ { type: 'text', text: 'No documentation sources found.' } ] }; } const grouped = this.groupSourcesByDomainAndSubdomain(sources); const formattedOutput = this.formatGroupedSources(grouped); return { content: [ { type: 'text', text: formattedOutput } ] }; } catch (error) { if (error instanceof Error) { if (error.message.includes('unauthorized')) { throw new McpError(ErrorCode.InvalidRequest, 'Failed to authenticate with Qdrant cloud while listing sources'); } else if (error.message.includes('ECONNREFUSED') || error.message.includes('ETIMEDOUT')) { throw new McpError(ErrorCode.InternalError, 'Connection to Qdrant cloud failed while listing sources'); } } return { content: [ { type: 'text', text: `Failed to list sources: ${error}` } ], isError: true }; } } }