Skip to content

Commit 6e3ee35

Browse files
scalvertclaude
andcommitted
refactor: use full URLs throughout the indexing and search system
All documents are now indexed and looked up by full URL rather than route paths. This provides a consistent interface regardless of the search provider: - FlexSearchIndexer keys docs by full URL (e.g., https://example.com/docs/intro) - FlexSearch index uses full URLs as document IDs - docs_fetch tool accepts only URL parameter (not route) - Search results return routes but lookups use URLs This simplifies integration with external search providers like Glean that work with URLs natively. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 404d0c2 commit 6e3ee35

10 files changed

Lines changed: 68 additions & 147 deletions

File tree

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: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ export type {
1515
DocsIndex,
1616
DocsSearchParams,
1717
DocsFetchParams,
18-
DocsGetPageParams,
1918
ExtractedContent,
2019
} from './types/index.js';
2120

src/mcp/server.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -123,15 +123,15 @@ export class McpDocsServer {
123123
description:
124124
'Fetch the complete content of a documentation page. Use this when you need the full content of a specific page.',
125125
inputSchema: {
126-
page: z
126+
url: z
127127
.string()
128-
.min(1)
128+
.url()
129129
.describe(
130-
'The page to fetch - either a route path (e.g., "/docs/getting-started") or a full URL (e.g., "https://docs.example.com/docs/getting-started")'
130+
'The full URL of the page to fetch (e.g., "https://docs.example.com/docs/getting-started")'
131131
),
132132
},
133133
},
134-
async ({ page }) => {
134+
async ({ url }) => {
135135
await this.initialize();
136136

137137
if (!this.searchProvider || !this.searchProvider.isReady()) {
@@ -142,9 +142,9 @@ export class McpDocsServer {
142142
}
143143

144144
try {
145-
const doc = await this.getDocument(page);
145+
const doc = await this.getDocument(url);
146146
return {
147-
content: [{ type: 'text' as const, text: formatPageContent(doc, this.config.baseUrl) }],
147+
content: [{ type: 'text' as const, text: formatPageContent(doc) }],
148148
};
149149
} catch (error) {
150150
console.error('[MCP] Get page error:', error);
@@ -158,16 +158,15 @@ export class McpDocsServer {
158158
}
159159

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

168-
// Use the provider's getDocument if available (handles both URLs and routes)
169168
if (this.searchProvider.getDocument) {
170-
return this.searchProvider.getDocument(page);
169+
return this.searchProvider.getDocument(url);
171170
}
172171

173172
return null;

src/mcp/tools/docs-fetch.ts

Lines changed: 7 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,60 +10,23 @@ export const docsFetchTool = {
1010
inputSchema: {
1111
type: 'object' as const,
1212
properties: {
13-
page: {
13+
url: {
1414
type: 'string',
15+
format: 'uri',
1516
description:
16-
'The page to fetch - either a route path (e.g., "/docs/getting-started") or a full URL (e.g., "https://docs.example.com/docs/getting-started")',
17+
'The full URL of the page to fetch (e.g., "https://docs.example.com/docs/getting-started")',
1718
},
1819
},
19-
required: ['page'],
20+
required: ['url'],
2021
},
2122
};
2223

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

6932
const lines: string[] = [];
@@ -78,12 +41,6 @@ export function formatPageContent(doc: ProcessedDoc | null, baseUrl?: string): s
7841
lines.push('');
7942
}
8043

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

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 & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -75,32 +75,13 @@ export class FlexSearchProvider implements SearchProvider {
7575
return searchIndex(this.searchIndex, this.docs, query, { limit });
7676
}
7777

78-
async getDocument(page: 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-
// If it's a URL, extract the pathname as the route
84-
let route = page;
85-
if (page.startsWith('http://') || page.startsWith('https://')) {
86-
try {
87-
const url = new URL(page);
88-
route = url.pathname;
89-
} catch {
90-
// Invalid URL, treat as route
91-
}
92-
}
93-
94-
// Try exact match first
95-
if (this.docs[route]) {
96-
return this.docs[route] ?? null;
97-
}
98-
99-
// Try with/without leading slash
100-
const normalizedRoute = route.startsWith('/') ? route : `/${route}`;
101-
const withoutSlash = route.startsWith('/') ? route.slice(1) : route;
102-
103-
return this.docs[normalizedRoute] ?? this.docs[withoutSlash] ?? null;
83+
// Direct lookup by URL
84+
return this.docs[url] ?? null;
10485
}
10586

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

src/search/flexsearch-indexer.ts

Lines changed: 18 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;

src/types/index.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -206,16 +206,8 @@ export interface DocsSearchParams {
206206
* Input parameters for docs_fetch tool
207207
*/
208208
export interface DocsFetchParams {
209-
/** Page to fetch - either a route path or full URL */
210-
page: string;
211-
}
212-
213-
/**
214-
* @deprecated Use DocsFetchParams instead
215-
*/
216-
export interface DocsGetPageParams {
217-
/** Route path of the page */
218-
route: string;
209+
/** Full URL of the page to fetch */
210+
url: string;
219211
}
220212

221213
/**

tests/playwright/mcp.spec.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -63,29 +63,26 @@ test.describe('docs_search Tool', () => {
6363
});
6464

6565
test.describe('docs_fetch Tool', () => {
66-
test('retrieves full page content by route', async ({ mcp }) => {
67-
const result = await mcp.callTool('docs_fetch', { page: '/docs/intro' });
68-
expect(result).not.toBeToolError();
69-
expect(result).toContainToolText('Introduction');
70-
expect(result).toContainToolText('Welcome to the documentation');
71-
});
72-
7366
test('retrieves full page content by URL', async ({ mcp }) => {
7467
const result = await mcp.callTool('docs_fetch', {
75-
page: 'https://docs.example.com/docs/intro',
68+
url: 'https://docs.example.com/docs/intro',
7669
});
7770
expect(result).not.toBeToolError();
7871
expect(result).toContainToolText('Introduction');
7972
expect(result).toContainToolText('Welcome to the documentation');
8073
});
8174

8275
test('returns page with markdown content', async ({ mcp }) => {
83-
const result = await mcp.callTool('docs_fetch', { page: '/docs/installation' });
76+
const result = await mcp.callTool('docs_fetch', {
77+
url: 'https://docs.example.com/docs/installation',
78+
});
8479
expect(result).toContainToolText('npm install my-platform');
8580
});
8681

8782
test('returns error for non-existent page', async ({ mcp }) => {
88-
const result = await mcp.callTool('docs_fetch', { page: '/docs/nonexistent' });
83+
const result = await mcp.callTool('docs_fetch', {
84+
url: 'https://docs.example.com/docs/nonexistent',
85+
});
8986
expect(result).toContainToolText('Page not found');
9087
});
9188
});

0 commit comments

Comments
 (0)