@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
JavaScript
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
};
}
}
}