Skip to content

Commit 644c4f6

Browse files
scalvertclaude
andauthored
refactor: use full URLs throughout indexing and search system (#4)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 84a687f commit 644c4f6

13 files changed

Lines changed: 107 additions & 182 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ jobs:
2727
- name: Setup mise
2828
uses: jdx/mise-action@v2
2929
with:
30-
install_args: "node@${{ matrix.node }}"
30+
install_args: 'node@${{ matrix.node }}'
3131

3232
- name: Install dependencies
3333
run: npm ci

scripts/test-mcp-server.mjs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,23 +91,26 @@ Access tokens are used to authenticate API requests.`,
9191
},
9292
];
9393

94-
// Convert to Record for lookups
94+
const BASE_URL = 'https://docs.example.com';
95+
96+
// Convert to Record for lookups - keyed by full URL
9597
const sampleDocs = {};
9698
for (const doc of sampleDocsArray) {
97-
sampleDocs[doc.route] = doc;
99+
const fullUrl = `${BASE_URL}${doc.route}`;
100+
sampleDocs[fullUrl] = doc;
98101
}
99102

100103
async function main() {
101-
// Build search index
102-
const searchIndex = buildSearchIndex(sampleDocsArray);
104+
// Build search index with baseUrl so IDs are full URLs
105+
const searchIndex = buildSearchIndex(sampleDocsArray, BASE_URL);
103106
const searchIndexData = await exportSearchIndex(searchIndex);
104107

105108
const mcpServer = new McpDocsServer({
106109
name: 'test-docs',
107110
version: '1.0.0',
108111
docs: sampleDocs,
109112
searchIndexData,
110-
baseUrl: 'https://docs.example.com',
113+
baseUrl: BASE_URL,
111114
});
112115

113116
await mcpServer.initialize();

src/index.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@ export type {
1414
McpServerDataConfig,
1515
DocsIndex,
1616
DocsSearchParams,
17-
DocsGetPageParams,
18-
DocsGetSectionParams,
17+
DocsFetchParams,
1918
ExtractedContent,
2019
} from './types/index.js';
2120

src/mcp/server.ts

Lines changed: 12 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,7 @@ export class McpDocsServer {
103103
try {
104104
const results = await this.searchProvider.search(query, { limit });
105105
return {
106-
content: [
107-
{ type: 'text' as const, text: formatSearchResults(results, this.config.baseUrl) },
108-
],
106+
content: [{ type: 'text' as const, text: formatSearchResults(results) }],
109107
};
110108
} catch (error) {
111109
console.error('[MCP] Search error:', error);
@@ -123,13 +121,15 @@ export class McpDocsServer {
123121
description:
124122
'Fetch the complete content of a documentation page. Use this when you need the full content of a specific page.',
125123
inputSchema: {
126-
route: z
124+
url: z
127125
.string()
128-
.min(1)
129-
.describe('The page route path (e.g., "/docs/getting-started" or "/api/reference")'),
126+
.url()
127+
.describe(
128+
'The full URL of the page to fetch (e.g., "https://docs.example.com/docs/getting-started")'
129+
),
130130
},
131131
},
132-
async ({ route }) => {
132+
async ({ url }) => {
133133
await this.initialize();
134134

135135
if (!this.searchProvider || !this.searchProvider.isReady()) {
@@ -140,9 +140,9 @@ export class McpDocsServer {
140140
}
141141

142142
try {
143-
const doc = await this.getDocument(route);
143+
const doc = await this.getDocument(url);
144144
return {
145-
content: [{ type: 'text' as const, text: formatPageContent(doc, this.config.baseUrl) }],
145+
content: [{ type: 'text' as const, text: formatPageContent(doc) }],
146146
};
147147
} catch (error) {
148148
console.error('[MCP] Get page error:', error);
@@ -156,33 +156,15 @@ export class McpDocsServer {
156156
}
157157

158158
/**
159-
* Get a document by route using the search provider
159+
* Get a document by URL using the search provider
160160
*/
161-
private async getDocument(route: string): Promise<ProcessedDoc | null> {
161+
private async getDocument(url: string): Promise<ProcessedDoc | null> {
162162
if (!this.searchProvider) {
163163
return null;
164164
}
165165

166-
// Use the provider's getDocument if available
167166
if (this.searchProvider.getDocument) {
168-
return this.searchProvider.getDocument(route);
169-
}
170-
171-
// For FlexSearchProvider, we can access docs directly
172-
if (this.searchProvider instanceof FlexSearchProvider) {
173-
const docs = this.searchProvider.getDocs();
174-
if (!docs) return null;
175-
176-
// Try exact match first
177-
if (docs[route]) {
178-
return docs[route];
179-
}
180-
181-
// Try with/without leading slash
182-
const normalizedRoute = route.startsWith('/') ? route : `/${route}`;
183-
const withoutSlash = route.startsWith('/') ? route.slice(1) : route;
184-
185-
return docs[normalizedRoute] ?? docs[withoutSlash] ?? null;
167+
return this.searchProvider.getDocument(url);
186168
}
187169

188170
return null;

src/mcp/tools/docs-fetch.ts

Lines changed: 8 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ProcessedDoc, DocsGetPageParams } from '../../types/index.js';
1+
import type { ProcessedDoc } from '../../types/index.js';
22

33
/**
44
* Tool definition for docs_fetch
@@ -10,59 +10,23 @@ export const docsFetchTool = {
1010
inputSchema: {
1111
type: 'object' as const,
1212
properties: {
13-
route: {
13+
url: {
1414
type: 'string',
15-
description: 'The route path of the page (e.g., /docs/getting-started)',
15+
format: 'uri',
16+
description:
17+
'The full URL of the page to fetch (e.g., "https://docs.example.com/docs/getting-started")',
1618
},
1719
},
18-
required: ['route'],
20+
required: ['url'],
1921
},
2022
};
2123

22-
/**
23-
* Execute the docs_get_page tool
24-
*/
25-
export function executeDocsGetPage(
26-
params: DocsGetPageParams,
27-
docs: Record<string, ProcessedDoc>
28-
): ProcessedDoc | null {
29-
const { route } = params;
30-
31-
// Validate parameters
32-
if (!route || typeof route !== 'string') {
33-
throw new Error('Route parameter is required and must be a string');
34-
}
35-
36-
// Normalize route (ensure leading slash, remove trailing slash)
37-
let normalizedRoute = route.trim();
38-
if (!normalizedRoute.startsWith('/')) {
39-
normalizedRoute = '/' + normalizedRoute;
40-
}
41-
if (normalizedRoute.length > 1 && normalizedRoute.endsWith('/')) {
42-
normalizedRoute = normalizedRoute.slice(0, -1);
43-
}
44-
45-
// Look up the document
46-
const doc = docs[normalizedRoute];
47-
48-
if (!doc) {
49-
// Try without leading slash
50-
const altRoute = normalizedRoute.slice(1);
51-
if (docs[altRoute]) {
52-
return docs[altRoute] ?? null;
53-
}
54-
return null;
55-
}
56-
57-
return doc;
58-
}
59-
6024
/**
6125
* Format page content for MCP response
6226
*/
63-
export function formatPageContent(doc: ProcessedDoc | null, baseUrl?: string): string {
27+
export function formatPageContent(doc: ProcessedDoc | null): string {
6428
if (!doc) {
65-
return 'Page not found. Please check the route path and try again.';
29+
return 'Page not found. Please check the URL and try again.';
6630
}
6731

6832
const lines: string[] = [];
@@ -77,12 +41,6 @@ export function formatPageContent(doc: ProcessedDoc | null, baseUrl?: string): s
7741
lines.push('');
7842
}
7943

80-
// Include full URL if baseUrl is provided
81-
if (baseUrl) {
82-
const fullUrl = `${baseUrl.replace(/\/$/, '')}${doc.route}`;
83-
lines.push(`**URL:** ${fullUrl}`);
84-
}
85-
8644
lines.push(`**Route:** ${doc.route}`);
8745
lines.push('');
8846

src/mcp/tools/docs-search.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export function executeDocsSearch(
5353
/**
5454
* Format search results for MCP response
5555
*/
56-
export function formatSearchResults(results: SearchResult[], baseUrl?: string): string {
56+
export function formatSearchResults(results: SearchResult[]): string {
5757
if (results.length === 0) {
5858
return 'No matching documents found.';
5959
}
@@ -65,14 +65,7 @@ export function formatSearchResults(results: SearchResult[], baseUrl?: string):
6565
if (!result) continue;
6666

6767
lines.push(`${i + 1}. **${result.title}**`);
68-
69-
// Include full URL if baseUrl is provided
70-
if (baseUrl) {
71-
const fullUrl = `${baseUrl.replace(/\/$/, '')}${result.route}`;
72-
lines.push(` URL: ${fullUrl}`);
73-
}
74-
75-
lines.push(` Route: ${result.route}`);
68+
lines.push(` URL: ${result.url}`);
7669

7770
if (result.matchingHeadings && result.matchingHeadings.length > 0) {
7871
lines.push(` Matching sections: ${result.matchingHeadings.join(', ')}`);
@@ -82,5 +75,7 @@ export function formatSearchResults(results: SearchResult[], baseUrl?: string):
8275
lines.push('');
8376
}
8477

78+
lines.push('Use docs_fetch with the URL to retrieve the full page content.');
79+
8580
return lines.join('\n');
8681
}

src/providers/indexers/flexsearch-indexer.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ import { buildSearchIndex, exportSearchIndex } from '../../search/flexsearch-ind
66
* Built-in FlexSearch content indexer.
77
*
88
* This indexer builds a local FlexSearch index and produces:
9-
* - docs.json: All processed documents keyed by route
9+
* - docs.json: All processed documents keyed by full URL
1010
* - search-index.json: Exported FlexSearch index for runtime search
1111
*/
1212
export class FlexSearchIndexer implements ContentIndexer {
1313
readonly name = 'flexsearch';
1414

15+
private baseUrl = '';
1516
private docsIndex: Record<string, ProcessedDoc> = {};
1617
private exportedIndex: unknown = null;
1718
private docCount = 0;
@@ -24,8 +25,9 @@ export class FlexSearchIndexer implements ContentIndexer {
2425
return true;
2526
}
2627

27-
async initialize(_context: ProviderContext): Promise<void> {
28+
async initialize(context: ProviderContext): Promise<void> {
2829
// Reset state for fresh indexing
30+
this.baseUrl = context.baseUrl.replace(/\/$/, '');
2931
this.docsIndex = {};
3032
this.exportedIndex = null;
3133
this.docCount = 0;
@@ -34,14 +36,15 @@ export class FlexSearchIndexer implements ContentIndexer {
3436
async indexDocuments(docs: ProcessedDoc[]): Promise<void> {
3537
this.docCount = docs.length;
3638

37-
// Build docs index (keyed by route)
39+
// Build docs index (keyed by full URL)
3840
for (const doc of docs) {
39-
this.docsIndex[doc.route] = doc;
41+
const fullUrl = `${this.baseUrl}${doc.route}`;
42+
this.docsIndex[fullUrl] = doc;
4043
}
4144

42-
// Build and export FlexSearch index
45+
// Build and export FlexSearch index (use full URLs as document IDs)
4346
console.log('[FlexSearch] Building search index...');
44-
const searchIndex = buildSearchIndex(docs);
47+
const searchIndex = buildSearchIndex(docs, this.baseUrl);
4548
this.exportedIndex = await exportSearchIndex(searchIndex);
4649
console.log(`[FlexSearch] Indexed ${this.docCount} documents`);
4750
}

src/providers/search/flexsearch-provider.ts

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -75,21 +75,13 @@ export class FlexSearchProvider implements SearchProvider {
7575
return searchIndex(this.searchIndex, this.docs, query, { limit });
7676
}
7777

78-
async getDocument(route: string): Promise<ProcessedDoc | null> {
78+
async getDocument(url: string): Promise<ProcessedDoc | null> {
7979
if (!this.docs) {
8080
throw new Error('[FlexSearch] Provider not initialized');
8181
}
8282

83-
// Try exact match first
84-
if (this.docs[route]) {
85-
return this.docs[route];
86-
}
87-
88-
// Try with/without leading slash
89-
const normalizedRoute = route.startsWith('/') ? route : `/${route}`;
90-
const withoutSlash = route.startsWith('/') ? route.slice(1) : route;
91-
92-
return this.docs[normalizedRoute] ?? this.docs[withoutSlash] ?? null;
83+
// Direct lookup by URL
84+
return this.docs[url] ?? null;
9385
}
9486

9587
async healthCheck(): Promise<{ healthy: boolean; message?: string }> {

src/search/flexsearch-indexer.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,21 @@ export function createSearchIndex(): FlexSearchDocument {
9696

9797
/**
9898
* Add a document to the search index
99+
*
100+
* @param index - The FlexSearch index
101+
* @param doc - The document to add
102+
* @param baseUrl - Optional base URL to construct full URL as document ID
99103
*/
100-
export function addDocumentToIndex(index: FlexSearchDocument, doc: ProcessedDoc): void {
104+
export function addDocumentToIndex(
105+
index: FlexSearchDocument,
106+
doc: ProcessedDoc,
107+
baseUrl?: string
108+
): void {
109+
// Use full URL as ID if baseUrl is provided, otherwise use route
110+
const id = baseUrl ? `${baseUrl.replace(/\/$/, '')}${doc.route}` : doc.route;
111+
101112
const indexable: IndexableDocument = {
102-
id: doc.route,
113+
id,
103114
title: doc.title,
104115
content: doc.markdown,
105116
headings: doc.headings.map((h) => h.text).join(' '),
@@ -111,12 +122,15 @@ export function addDocumentToIndex(index: FlexSearchDocument, doc: ProcessedDoc)
111122

112123
/**
113124
* Build the search index from processed documents
125+
*
126+
* @param docs - Documents to index
127+
* @param baseUrl - Optional base URL to construct full URLs as document IDs
114128
*/
115-
export function buildSearchIndex(docs: ProcessedDoc[]): FlexSearchDocument {
129+
export function buildSearchIndex(docs: ProcessedDoc[], baseUrl?: string): FlexSearchDocument {
116130
const index = createSearchIndex();
117131

118132
for (const doc of docs) {
119-
addDocumentToIndex(index, doc);
133+
addDocumentToIndex(index, doc, baseUrl);
120134
}
121135

122136
return index;
@@ -180,6 +194,7 @@ export function searchIndex(
180194
if (!doc) continue;
181195

182196
results.push({
197+
url: docId, // docId is the full URL when indexed with baseUrl
183198
route: doc.route,
184199
title: doc.title,
185200
score,

0 commit comments

Comments
 (0)