@sanderkooger/mcp-server-ragdocs

by sanderkooger
Verified
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js' import { BaseHandler } from './base-handler.js' import type { McpToolResponse } from '../types.js' import { isDocumentPayload } from '../types.js' const COLLECTION_NAME = 'documentation' interface Source { title: string url: string } interface GroupedSources { [domain: string]: { [subdomain: string]: Source[] } } export class ListSourcesHandler extends BaseHandler { private groupSourcesByDomainAndSubdomain(sources: Source[]): GroupedSources { const grouped: GroupedSources = {} 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 } private formatGroupedSources(grouped: GroupedSources): string { const output: string[] = [] 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<string, Source>() 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(): Promise<McpToolResponse> { try { await this.apiClient.initCollection(COLLECTION_NAME) const pageSize = 100 let offset = null const sources: Source[] = [] 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 as any 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 } } } }