diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d8d818..a12df8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ ## Unreleased +### Features Added + +- **Documentation Search Tool**: Added AI-powered search for Mapbox documentation (#68) + - New `search_mapbox_docs_tool` enables targeted documentation queries + - Returns ranked, relevant documentation sections instead of entire corpus + - Supports filtering by category (apis, sdks, guides, examples) + - Implements caching (1 hour TTL) for performance + - Follows Google Developer Knowledge API pattern for better AI assistance + - Includes comprehensive test suite (12 tests) + ### Documentation - **PR Guidelines**: Added CHANGELOG requirement to CLAUDE.md (#67) diff --git a/src/tools/search-mapbox-docs-tool/SearchMapboxDocsTool.input.schema.ts b/src/tools/search-mapbox-docs-tool/SearchMapboxDocsTool.input.schema.ts new file mode 100644 index 0000000..0db478d --- /dev/null +++ b/src/tools/search-mapbox-docs-tool/SearchMapboxDocsTool.input.schema.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; + +/** + * Input schema for SearchMapboxDocsTool + * + * Enables AI-powered search of Mapbox documentation for specific topics, + * providing targeted, relevant documentation instead of loading entire corpus. + */ +export const SearchMapboxDocsInputSchema = z.object({ + query: z + .string() + .min(1) + .describe( + 'Search query for finding relevant Mapbox documentation (e.g., "geocoding rate limits", "custom markers")' + ), + category: z + .enum(['apis', 'sdks', 'guides', 'examples', 'all']) + .optional() + .describe( + 'Filter results by documentation category: "apis" (REST APIs), "sdks" (Mobile/Web SDKs), "guides" (tutorials/how-tos), "examples" (code samples), or "all" (default)' + ), + limit: z + .number() + .int() + .min(1) + .max(20) + .optional() + .default(5) + .describe('Maximum number of results to return (default: 5, max: 20)') +}); + +/** + * Inferred TypeScript type for SearchMapboxDocsTool input + */ +export type SearchMapboxDocsInput = z.infer; diff --git a/src/tools/search-mapbox-docs-tool/SearchMapboxDocsTool.output.schema.ts b/src/tools/search-mapbox-docs-tool/SearchMapboxDocsTool.output.schema.ts new file mode 100644 index 0000000..5c3512c --- /dev/null +++ b/src/tools/search-mapbox-docs-tool/SearchMapboxDocsTool.output.schema.ts @@ -0,0 +1,56 @@ +import { z } from 'zod'; + +/** + * Schema for a single search result + */ +export const SearchResultSchema = z.object({ + title: z.string().describe('Title or heading of the documentation section'), + excerpt: z + .string() + .describe('Relevant excerpt or snippet from the documentation'), + category: z + .string() + .describe('Category of the result (apis, sdks, guides, examples)'), + url: z + .string() + .optional() + .describe('Link to full documentation (if available)'), + relevanceScore: z + .number() + .min(0) + .max(1) + .describe('Relevance score from 0 to 1') +}); + +/** + * Output schema for SearchMapboxDocsTool + * + * Returns an array of ranked, relevant documentation sections matching the search query. + */ +export const SearchMapboxDocsOutputSchema = z.object({ + results: z + .array(SearchResultSchema) + .describe('Array of matching documentation sections, ranked by relevance'), + query: z.string().describe('The original search query'), + totalResults: z + .number() + .int() + .min(0) + .describe('Total number of results found (before limit applied)'), + category: z + .string() + .optional() + .describe('Category filter that was applied (if any)') +}); + +/** + * Type inference for SearchMapboxDocsOutput + */ +export type SearchMapboxDocsOutput = z.infer< + typeof SearchMapboxDocsOutputSchema +>; + +/** + * Type inference for a single search result + */ +export type SearchResult = z.infer; diff --git a/src/tools/search-mapbox-docs-tool/SearchMapboxDocsTool.ts b/src/tools/search-mapbox-docs-tool/SearchMapboxDocsTool.ts new file mode 100644 index 0000000..7671542 --- /dev/null +++ b/src/tools/search-mapbox-docs-tool/SearchMapboxDocsTool.ts @@ -0,0 +1,363 @@ +import { z } from 'zod'; +import { BaseTool } from '../BaseTool.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { HttpRequest } from '../../utils/types.js'; +import { SearchMapboxDocsInputSchema } from './SearchMapboxDocsTool.input.schema.js'; +import { + SearchMapboxDocsOutputSchema, + type SearchMapboxDocsOutput, + type SearchResult +} from './SearchMapboxDocsTool.output.schema.js'; + +/** + * Represents a parsed documentation section + */ +interface DocSection { + title: string; + content: string; + category: string; + url?: string; +} + +/** + * SearchMapboxDocsTool - AI-powered documentation search + * + * Enables semantic search of Mapbox documentation for specific topics, + * providing targeted, relevant documentation sections instead of loading + * the entire documentation corpus. + * + * This tool fetches the latest Mapbox documentation from docs.mapbox.com/llms.txt, + * parses it into searchable sections, and returns ranked results matching + * the user's query. + * + * @example + * ```typescript + * const tool = new SearchMapboxDocsTool({ httpRequest }); + * const result = await tool.run({ + * query: 'geocoding rate limits', + * category: 'apis', + * limit: 5 + * }); + * ``` + */ +export class SearchMapboxDocsTool extends BaseTool< + typeof SearchMapboxDocsInputSchema, + typeof SearchMapboxDocsOutputSchema +> { + readonly name = 'search_mapbox_docs_tool'; + readonly description = + 'Search Mapbox documentation for specific topics. Returns ranked, relevant documentation sections instead of the entire corpus. Use this to find targeted information about Mapbox APIs, SDKs, guides, and examples. Supports filtering by category (apis, sdks, guides, examples) and limiting results.'; + readonly annotations = { + title: 'Search Mapbox Documentation Tool', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true + }; + + private httpRequest: HttpRequest; + private docCache: DocSection[] | null = null; + private cacheTimestamp: number | null = null; + private readonly CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour + + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: SearchMapboxDocsInputSchema, + outputSchema: SearchMapboxDocsOutputSchema + }); + this.httpRequest = params.httpRequest; + } + + /** + * Fetch and cache Mapbox documentation + */ + private async fetchDocs(): Promise { + const response = await this.httpRequest( + 'https://docs.mapbox.com/llms.txt', + { + headers: { + Accept: 'text/markdown, text/plain;q=0.9, */*;q=0.8' + } + } + ); + + if (!response.ok) { + throw new Error( + `Failed to fetch Mapbox documentation: ${response.statusText}` + ); + } + + return await response.text(); + } + + /** + * Parse documentation into searchable sections + */ + private parseDocs(content: string): DocSection[] { + const sections: DocSection[] = []; + const lines = content.split('\n'); + + let currentSection: DocSection | null = null; + let currentContent: string[] = []; + + for (const line of lines) { + // Detect section headers (lines starting with #) + if (line.startsWith('#')) { + // Save previous section if exists + if (currentSection && currentContent.length > 0) { + currentSection.content = currentContent.join('\n').trim(); + sections.push(currentSection); + } + + // Start new section + const title = line.replace(/^#+\s*/, '').trim(); + const category = this.categorizeSection(title, line); + + currentSection = { + title, + content: '', + category, + url: this.extractUrl(title) + }; + currentContent = []; + } else if (currentSection) { + // Add content to current section + currentContent.push(line); + } + } + + // Save last section + if (currentSection && currentContent.length > 0) { + currentSection.content = currentContent.join('\n').trim(); + sections.push(currentSection); + } + + return sections; + } + + /** + * Categorize a section based on its title and content + */ + private categorizeSection(title: string, _fullLine: string): string { + const lower = title.toLowerCase(); + + if ( + lower.includes('api') || + lower.includes('endpoint') || + lower.includes('rest') + ) { + return 'apis'; + } + if ( + lower.includes('sdk') || + lower.includes('ios') || + lower.includes('android') || + lower.includes('mapbox-gl') + ) { + return 'sdks'; + } + if ( + lower.includes('guide') || + lower.includes('tutorial') || + lower.includes('how to') + ) { + return 'guides'; + } + if (lower.includes('example') || lower.includes('demo')) { + return 'examples'; + } + + return 'general'; + } + + /** + * Extract URL from title if it looks like a link + */ + private extractUrl(title: string): string | undefined { + // Try to extract URL from markdown links + const linkMatch = title.match(/\[([^\]]+)\]\(([^)]+)\)/); + if (linkMatch) { + return linkMatch[2]; + } + + // Check if the title contains a URL + const urlMatch = title.match(/(https?:\/\/[^\s]+)/); + if (urlMatch) { + return urlMatch[1]; + } + + return undefined; + } + + /** + * Calculate relevance score for a section based on query + */ + private calculateRelevance(section: DocSection, query: string): number { + const queryLower = query.toLowerCase(); + const queryWords = queryLower.split(/\s+/); + + const titleLower = section.title.toLowerCase(); + const contentLower = section.content.toLowerCase(); + + let score = 0; + + // Exact phrase match in title (highest weight) + if (titleLower.includes(queryLower)) { + score += 10; + } + + // Individual word matches in title + for (const word of queryWords) { + if (word.length < 3) continue; // Skip short words + if (titleLower.includes(word)) { + score += 3; + } + } + + // Exact phrase match in content + if (contentLower.includes(queryLower)) { + score += 5; + } + + // Individual word matches in content + for (const word of queryWords) { + if (word.length < 3) continue; + const matches = (contentLower.match(new RegExp(word, 'g')) || []).length; + score += matches * 0.5; // Multiple occurrences add fractional score + } + + // Normalize score to 0-1 range (max theoretical score ~20) + return Math.min(score / 20, 1); + } + + /** + * Search documentation sections + */ + private async searchDocs( + query: string, + category?: string, + limit: number = 5 + ): Promise { + // Check cache + const now = Date.now(); + if ( + !this.docCache || + !this.cacheTimestamp || + now - this.cacheTimestamp > this.CACHE_TTL_MS + ) { + // Fetch and parse docs + const content = await this.fetchDocs(); + this.docCache = this.parseDocs(content); + this.cacheTimestamp = now; + } + + // Filter by category if specified + let sections = this.docCache; + if (category && category !== 'all') { + sections = sections.filter((s) => s.category === category); + } + + // Calculate relevance for each section + const results: SearchResult[] = sections + .map((section) => ({ + title: section.title, + excerpt: this.extractExcerpt(section.content, query), + category: section.category, + url: section.url, + relevanceScore: this.calculateRelevance(section, query) + })) + .filter((r) => r.relevanceScore > 0.05) // Filter out very low relevance + .sort((a, b) => b.relevanceScore - a.relevanceScore) + .slice(0, limit); + + return results; + } + + /** + * Extract a relevant excerpt from content + */ + private extractExcerpt(content: string, query: string): string { + const queryLower = query.toLowerCase(); + const contentLower = content.toLowerCase(); + + // Find the position of the query or first query word + const queryWords = queryLower.split(/\s+/).filter((w) => w.length >= 3); + let bestPos = -1; + + // Try exact phrase first + bestPos = contentLower.indexOf(queryLower); + + // If not found, try individual words + if (bestPos === -1) { + for (const word of queryWords) { + const pos = contentLower.indexOf(word); + if (pos !== -1) { + bestPos = pos; + break; + } + } + } + + // Extract excerpt around the match + const excerptLength = 200; + if (bestPos !== -1) { + const start = Math.max(0, bestPos - excerptLength / 2); + const end = Math.min(content.length, bestPos + excerptLength / 2); + let excerpt = content.substring(start, end).trim(); + + // Add ellipsis if truncated + if (start > 0) excerpt = '...' + excerpt; + if (end < content.length) excerpt = excerpt + '...'; + + return excerpt; + } + + // If no match, return first part of content + return content.substring(0, excerptLength).trim() + '...'; + } + + /** + * Execute the search + */ + protected async execute( + input: z.infer + ): Promise { + try { + const results = await this.searchDocs( + input.query, + input.category, + input.limit + ); + + const output: SearchMapboxDocsOutput = { + results, + query: input.query, + totalResults: results.length, + category: input.category + }; + + // Validate against output schema + const validatedOutput = SearchMapboxDocsOutputSchema.parse(output); + + return { + content: [ + { + type: 'text', + text: JSON.stringify(validatedOutput, null, 2) + } + ], + structuredContent: validatedOutput, + isError: false + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.log('error', `${this.name}: ${errorMessage}`); + + return { + content: [{ type: 'text', text: `Error: ${errorMessage}` }], + isError: true + }; + } + } +} diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index 481ea7e..621e7a2 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -15,6 +15,7 @@ import { GeojsonPreviewTool } from './geojson-preview-tool/GeojsonPreviewTool.js import { GetMapboxDocSourceTool } from './get-mapbox-doc-source-tool/GetMapboxDocSourceTool.js'; import { GetReferenceTool } from './get-reference-tool/GetReferenceTool.js'; import { ListStylesTool } from './list-styles-tool/ListStylesTool.js'; +import { SearchMapboxDocsTool } from './search-mapbox-docs-tool/SearchMapboxDocsTool.js'; import { ListTokensTool } from './list-tokens-tool/ListTokensTool.js'; import { OptimizeStyleTool } from './optimize-style-tool/OptimizeStyleTool.js'; import { PreviewStyleTool } from './preview-style-tool/PreviewStyleTool.js'; @@ -55,7 +56,8 @@ export const CORE_TOOLS = [ new TilequeryTool({ httpRequest }), new ValidateExpressionTool(), new ValidateGeojsonTool(), - new ValidateStyleTool() + new ValidateStyleTool(), + new SearchMapboxDocsTool({ httpRequest }) ] as const; /** diff --git a/test/tools/__snapshots__/tool-naming-convention.test.ts.snap b/test/tools/__snapshots__/tool-naming-convention.test.ts.snap index 5b517de..0a90529 100644 --- a/test/tools/__snapshots__/tool-naming-convention.test.ts.snap +++ b/test/tools/__snapshots__/tool-naming-convention.test.ts.snap @@ -92,6 +92,11 @@ exports[`Tool Naming Convention > should maintain consistent tool list (snapshot "description": "Retrieve a specific Mapbox style by ID", "toolName": "retrieve_style_tool", }, + { + "className": "SearchMapboxDocsTool", + "description": "Search Mapbox documentation for specific topics. Returns ranked, relevant documentation sections instead of the entire corpus. Use this to find targeted information about Mapbox APIs, SDKs, guides, and examples. Supports filtering by category (apis, sdks, guides, examples) and limiting results.", + "toolName": "search_mapbox_docs_tool", + }, { "className": "StyleBuilderTool", "description": "Generate Mapbox style JSON for creating new styles or updating existing ones. diff --git a/test/tools/search-mapbox-docs-tool/SearchMapboxDocsTool.test.ts b/test/tools/search-mapbox-docs-tool/SearchMapboxDocsTool.test.ts new file mode 100644 index 0000000..3028ce4 --- /dev/null +++ b/test/tools/search-mapbox-docs-tool/SearchMapboxDocsTool.test.ts @@ -0,0 +1,267 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect, vi } from 'vitest'; +import { SearchMapboxDocsTool } from '../../../src/tools/search-mapbox-docs-tool/SearchMapboxDocsTool.js'; +import type { HttpRequest } from '../../../src/utils/types.js'; + +// Mock documentation content for testing +const MOCK_DOCS = `# Mapbox Geocoding API + +The Mapbox Geocoding API allows you to convert location text into geographic coordinates and vice versa. + +## Forward Geocoding + +Forward geocoding converts location text into geographic coordinates. Rate limit: 600 requests per minute. + +## Reverse Geocoding + +Reverse geocoding converts coordinates into location text. + +# Mapbox GL JS SDK + +Mapbox GL JS is a JavaScript library for interactive maps. + +## Custom Markers + +Learn how to add custom markers to your map with images and popups. + +## Data-Driven Styling + +Use data properties to style your map layers dynamically. + +# Mapbox Directions API + +Get optimal routes between coordinates. + +## Route Optimization + +Optimize routes for multiple waypoints. +`; + +describe('SearchMapboxDocsTool', () => { + const mockHttpRequest: HttpRequest = vi.fn(async () => ({ + ok: true, + status: 200, + statusText: 'OK', + text: async () => MOCK_DOCS, + json: async () => ({}), + headers: new Headers() + })) as unknown as HttpRequest; + + it('should have correct tool metadata', () => { + const tool = new SearchMapboxDocsTool({ httpRequest: mockHttpRequest }); + expect(tool.name).toBe('search_mapbox_docs_tool'); + expect(tool.description).toContain('Search Mapbox documentation'); + expect(tool.annotations.readOnlyHint).toBe(true); + expect(tool.annotations.destructiveHint).toBe(false); + }); + + it('should search and return relevant results', async () => { + const tool = new SearchMapboxDocsTool({ httpRequest: mockHttpRequest }); + + const result = await tool.run({ + query: 'geocoding rate limits', + limit: 5 + }); + + expect(result.isError).toBe(false); + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe('text'); + + const response = JSON.parse(result.content[0].text); + expect(response).toHaveProperty('results'); + expect(response).toHaveProperty('query', 'geocoding rate limits'); + expect(response).toHaveProperty('totalResults'); + expect(response.results).toBeInstanceOf(Array); + + // Should find the geocoding section + expect(response.results.length).toBeGreaterThan(0); + const topResult = response.results[0]; + expect(topResult).toHaveProperty('title'); + expect(topResult).toHaveProperty('excerpt'); + expect(topResult).toHaveProperty('category'); + expect(topResult).toHaveProperty('relevanceScore'); + }); + + it('should filter results by category', async () => { + const tool = new SearchMapboxDocsTool({ httpRequest: mockHttpRequest }); + + const result = await tool.run({ + query: 'markers', + category: 'sdks', + limit: 5 + }); + + expect(result.isError).toBe(false); + const response = JSON.parse(result.content[0].text); + + expect(response.category).toBe('sdks'); + // All results should be from SDKs category + response.results.forEach((r: { category: string }) => { + expect(r.category).toBe('sdks'); + }); + }); + + it('should respect the limit parameter', async () => { + const tool = new SearchMapboxDocsTool({ httpRequest: mockHttpRequest }); + + const result = await tool.run({ + query: 'mapbox', + limit: 2 + }); + + expect(result.isError).toBe(false); + const response = JSON.parse(result.content[0].text); + + expect(response.results.length).toBeLessThanOrEqual(2); + }); + + it('should use default limit when not specified', async () => { + const tool = new SearchMapboxDocsTool({ httpRequest: mockHttpRequest }); + + const result = await tool.run({ + query: 'api' + }); + + expect(result.isError).toBe(false); + const response = JSON.parse(result.content[0].text); + + // Default limit is 5 + expect(response.results.length).toBeLessThanOrEqual(5); + }); + + it('should return structured content', async () => { + const tool = new SearchMapboxDocsTool({ httpRequest: mockHttpRequest }); + + const result = await tool.run({ + query: 'directions', + limit: 3 + }); + + expect(result.isError).toBe(false); + expect(result.structuredContent).toBeDefined(); + + const structured = result.structuredContent as { + results: unknown[]; + query: string; + totalResults: number; + }; + expect(structured).toHaveProperty('results'); + expect(structured).toHaveProperty('query', 'directions'); + expect(structured).toHaveProperty('totalResults'); + }); + + it('should rank results by relevance', async () => { + const tool = new SearchMapboxDocsTool({ httpRequest: mockHttpRequest }); + + const result = await tool.run({ + query: 'geocoding', + limit: 10 + }); + + expect(result.isError).toBe(false); + const response = JSON.parse(result.content[0].text); + + // Results should be sorted by relevance score (descending) + for (let i = 1; i < response.results.length; i++) { + expect(response.results[i - 1].relevanceScore).toBeGreaterThanOrEqual( + response.results[i].relevanceScore + ); + } + }); + + it('should include excerpts with relevant content', async () => { + const tool = new SearchMapboxDocsTool({ httpRequest: mockHttpRequest }); + + const result = await tool.run({ + query: 'rate limit', + limit: 5 + }); + + expect(result.isError).toBe(false); + const response = JSON.parse(result.content[0].text); + + if (response.results.length > 0) { + const topResult = response.results[0]; + expect(topResult.excerpt).toBeTruthy(); + expect(topResult.excerpt.toLowerCase()).toContain('rate'); + } + }); + + it('should handle HTTP errors gracefully', async () => { + const errorHttpRequest: HttpRequest = vi.fn(async () => ({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + text: async () => '', + json: async () => ({}), + headers: new Headers() + })) as unknown as HttpRequest; + + const tool = new SearchMapboxDocsTool({ httpRequest: errorHttpRequest }); + + const result = await tool.run({ + query: 'test', + limit: 5 + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error'); + }); + + it('should cache documentation for performance', async () => { + const spyHttpRequest = vi.fn(async () => ({ + ok: true, + status: 200, + statusText: 'OK', + text: async () => MOCK_DOCS, + json: async () => ({}), + headers: new Headers() + })) as unknown as HttpRequest; + + const tool = new SearchMapboxDocsTool({ httpRequest: spyHttpRequest }); + + // First search + await tool.run({ query: 'geocoding', limit: 5 }); + expect(spyHttpRequest).toHaveBeenCalledTimes(1); + + // Second search should use cache + await tool.run({ query: 'markers', limit: 5 }); + expect(spyHttpRequest).toHaveBeenCalledTimes(1); // Still 1, not 2 + }); + + it('should categorize sections correctly', async () => { + const tool = new SearchMapboxDocsTool({ httpRequest: mockHttpRequest }); + + const result = await tool.run({ + query: 'mapbox', + limit: 10 + }); + + expect(result.isError).toBe(false); + const response = JSON.parse(result.content[0].text); + + // Check that results have valid categories + response.results.forEach((r: { category: string }) => { + expect(['apis', 'sdks', 'guides', 'examples', 'general']).toContain( + r.category + ); + }); + }); + + it('should handle empty query results', async () => { + const tool = new SearchMapboxDocsTool({ httpRequest: mockHttpRequest }); + + const result = await tool.run({ + query: 'xyzzzzzzzznonexistent', + limit: 5 + }); + + expect(result.isError).toBe(false); + const response = JSON.parse(result.content[0].text); + + expect(response.results).toEqual([]); + expect(response.totalResults).toBe(0); + }); +});