@sanderkooger/mcp-server-ragdocs
by sanderkooger
Verified
- src
- handlers
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
}
}
}
}