diff --git a/src/mcp/config.test.ts b/src/mcp/config.test.ts index b8376d0..712c470 100644 --- a/src/mcp/config.test.ts +++ b/src/mcp/config.test.ts @@ -34,7 +34,8 @@ describe('MCP_SERVER_CONFIG', () => { expect(tools).toContain('search_babylon_api'); expect(tools).toContain('search_babylon_source'); expect(tools).toContain('get_babylon_source'); - expect(tools.length).toBe(5); + expect(tools).toContain('search_babylon_editor_docs'); + expect(tools.length).toBe(6); }); it('should define prompts capability', () => { @@ -61,6 +62,7 @@ describe('MCP_SERVER_CONFIG', () => { expect(instructions).toContain('search_babylon_api'); expect(instructions).toContain('search_babylon_source'); expect(instructions).toContain('get_babylon_source'); + expect(instructions).toContain('search_babylon_editor_docs'); }); }); diff --git a/src/mcp/config.ts b/src/mcp/config.ts index 9837729..62410ab 100644 --- a/src/mcp/config.ts +++ b/src/mcp/config.ts @@ -12,13 +12,14 @@ export const MCP_SERVER_CONFIG = { capabilities: { tools: { description: - 'Provides tools for searching and retrieving Babylon.js documentation, API references, and source code', + 'Provides tools for searching and retrieving Babylon.js documentation, API references, source code, and Editor documentation', available: [ 'search_babylon_docs', 'get_babylon_doc', 'search_babylon_api', 'search_babylon_source', 'get_babylon_source', + 'search_babylon_editor_docs', ], }, prompts: { @@ -32,13 +33,14 @@ export const MCP_SERVER_CONFIG = { }, instructions: - 'Babylon MCP Server provides access to Babylon.js documentation, API references, and source code. ' + + 'Babylon MCP Server provides access to Babylon.js documentation, API references, source code, and Editor documentation. ' + 'Available tools:\n' + '- search_babylon_docs: Search documentation with optional category filtering\n' + '- get_babylon_doc: Retrieve full documentation page by path\n' + '- search_babylon_api: Search API documentation (classes, methods, properties)\n' + '- search_babylon_source: Search Babylon.js source code files with optional package filtering\n' + '- get_babylon_source: Retrieve source file content with optional line range\n' + + '- search_babylon_editor_docs: Search Babylon.js Editor documentation for tool usage and workflows\n' + 'This server helps reduce token usage by providing a canonical source for Babylon.js framework information.', transport: { @@ -62,6 +64,10 @@ export const MCP_SERVER_CONFIG = { repository: 'https://github.com/BabylonJS/havok.git', description: 'Havok Physics integration', }, + editor: { + repository: 'https://github.com/BabylonJS/Editor.git', + description: 'Babylon.js Editor tool and documentation', + }, }, } as const; diff --git a/src/mcp/handlers.test.ts b/src/mcp/handlers.test.ts index 39c4220..d94a51f 100644 --- a/src/mcp/handlers.test.ts +++ b/src/mcp/handlers.test.ts @@ -17,7 +17,7 @@ describe('MCP Handlers', () => { it('should register all required tools', () => { setupHandlers(mockServer); - expect(registerToolSpy).toHaveBeenCalledTimes(5); + expect(registerToolSpy).toHaveBeenCalledTimes(6); }); it('should register search_babylon_docs tool', () => { @@ -251,6 +251,69 @@ describe('MCP Handlers', () => { }); }); + describe('search_babylon_editor_docs handler', () => { + let editorSearchHandler: (params: unknown) => Promise; + + beforeEach(() => { + setupHandlers(mockServer); + editorSearchHandler = registerToolSpy.mock.calls[5]![2]; + }); + + it('should accept required query parameter', async () => { + const params = { query: 'attaching scripts' }; + const result = (await editorSearchHandler(params)) as { content: { type: string; text: string }[] }; + + expect(result).toHaveProperty('content'); + expect(Array.isArray(result.content)).toBe(true); + }); + + it('should accept optional category parameter', async () => { + const params = { query: 'lifecycle', category: 'scripting' }; + const result = (await editorSearchHandler(params)) as { content: unknown[] }; + + expect(result).toHaveProperty('content'); + }); + + it('should accept optional limit parameter', async () => { + const params = { query: 'editor', limit: 10 }; + const result = (await editorSearchHandler(params)) as { content: unknown[] }; + + expect(result).toHaveProperty('content'); + }); + + it('should default limit to 5 when not provided', async () => { + const params = { query: 'project' }; + const result = (await editorSearchHandler(params)) as { content: { type: string; text: string }[] }; + + const responseText = result.content[0]!.text; + expect(responseText.length).toBeGreaterThan(0); + }); + + it('should return text content type', async () => { + const params = { query: 'scripts' }; + const result = (await editorSearchHandler(params)) as { content: { type: string; text: string }[] }; + + expect(result.content[0]).toHaveProperty('type', 'text'); + expect(result.content[0]).toHaveProperty('text'); + }); + + it('should return JSON-parseable response or no results message', async () => { + const params = { query: 'editor features' }; + const result = (await editorSearchHandler(params)) as { content: { type: string; text: string }[] }; + + const responseText = result.content[0]!.text; + // Response may be "No Editor documentation found" or valid JSON + if (!responseText.startsWith('No Editor documentation')) { + expect(() => JSON.parse(responseText)).not.toThrow(); + const parsed = JSON.parse(responseText); + expect(parsed).toHaveProperty('query'); + expect(parsed).toHaveProperty('source', 'editor-docs'); + expect(parsed).toHaveProperty('totalResults'); + expect(parsed).toHaveProperty('results'); + } + }); + }); + describe('Tool Schemas', () => { beforeEach(() => { setupHandlers(mockServer); @@ -292,6 +355,14 @@ describe('MCP Handlers', () => { expect(toolConfig.inputSchema).toHaveProperty('startLine'); expect(toolConfig.inputSchema).toHaveProperty('endLine'); }); + + it('search_babylon_editor_docs should have proper schema structure', () => { + const toolConfig = registerToolSpy.mock.calls[5]![1]; + + expect(toolConfig.inputSchema).toHaveProperty('query'); + expect(toolConfig.inputSchema).toHaveProperty('category'); + expect(toolConfig.inputSchema).toHaveProperty('limit'); + }); }); describe('search_babylon_source handler', () => { diff --git a/src/mcp/handlers/editor/search-editor-docs.handler.test.ts b/src/mcp/handlers/editor/search-editor-docs.handler.test.ts new file mode 100644 index 0000000..49bb8ab --- /dev/null +++ b/src/mcp/handlers/editor/search-editor-docs.handler.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import * as searchEditorDocsHandler from './search-editor-docs.handler.js'; +import * as searchInstance from '../shared/search-instance.js'; + +vi.mock('../shared/search-instance.js', () => ({ + getSearchInstance: vi.fn(), +})); + +describe('search-editor-docs.handler', () => { + let mockServer: McpServer; + let mockSearch: any; + + beforeEach(() => { + mockServer = { + registerTool: vi.fn(), + } as any; + + mockSearch = { + search: vi.fn(), + }; + + vi.mocked(searchInstance.getSearchInstance).mockResolvedValue(mockSearch); + }); + + describe('register', () => { + it('should register search_babylon_editor_docs tool with correct metadata', () => { + searchEditorDocsHandler.register(mockServer); + + expect(mockServer.registerTool).toHaveBeenCalledWith( + 'search_babylon_editor_docs', + expect.objectContaining({ + description: + 'Search Babylon.js Editor documentation for tool usage, workflows, and features', + inputSchema: expect.any(Object), + }), + expect.any(Function) + ); + }); + + it('should define input schema with query, category, and limit', () => { + searchEditorDocsHandler.register(mockServer); + + const callArgs = vi.mocked(mockServer.registerTool).mock.calls[0]; + const schema = callArgs![1]; + + expect(schema.inputSchema).toHaveProperty('query'); + expect(schema.inputSchema).toHaveProperty('category'); + expect(schema.inputSchema).toHaveProperty('limit'); + }); + }); + + describe('handler execution', () => { + let handler: Function; + + beforeEach(() => { + searchEditorDocsHandler.register(mockServer); + const callArgs = vi.mocked(mockServer.registerTool).mock.calls[0]; + handler = callArgs![2]; + }); + + it('should search with query and filter to editor-docs source', async () => { + mockSearch.search.mockResolvedValue([ + { + title: 'Adding Scripts', + description: 'Learn to attach scripts', + content: 'Scripts can be attached...', + url: 'https://editor.babylonjs.com/documentation/adding-scripts', + category: 'editor/adding-scripts', + source: 'editor-docs', + score: 0.95, + keywords: ['scripts', 'editor'], + }, + { + title: 'Vector3 Class', + description: 'Core documentation', + content: 'Vector3 is...', + url: 'https://doc.babylonjs.com/typedoc/classes/Vector3', + category: 'api', + source: 'documentation', + score: 0.85, + }, + ]); + + const result = await handler({ query: 'scripts' }); + + expect(result.content[0].text).toContain('Adding Scripts'); + expect(result.content[0].text).not.toContain('Vector3'); + expect(result.content[0].text).toContain('editor-docs'); + }); + + it('should apply category filter with editor/ prefix', async () => { + mockSearch.search.mockResolvedValue([ + { + title: 'Customizing Scripts', + description: 'Advanced scripting', + content: 'Customize your scripts...', + url: 'https://editor.babylonjs.com/documentation/scripting/customizing-scripts', + category: 'editor/scripting', + source: 'editor-docs', + score: 0.92, + keywords: ['scripting', 'editor'], + }, + ]); + + const result = await handler({ + query: 'lifecycle', + category: 'scripting', + }); + + expect(mockSearch.search).toHaveBeenCalledWith('lifecycle', { + category: 'editor/scripting', + limit: 5, + }); + expect(result.content[0].text).toContain('Customizing Scripts'); + }); + + it('should respect limit parameter', async () => { + mockSearch.search.mockResolvedValue([ + { + title: 'Doc 1', + description: 'Description', + content: 'Content', + url: 'https://editor.babylonjs.com/doc1', + category: 'editor', + source: 'editor-docs', + score: 0.9, + keywords: [], + }, + ]); + + await handler({ query: 'test', limit: 3 }); + + expect(mockSearch.search).toHaveBeenCalledWith('test', { limit: 9 }); + }); + + it('should return no results message when no editor docs found', async () => { + mockSearch.search.mockResolvedValue([ + { + title: 'Non-editor doc', + description: 'Regular doc', + content: 'Content', + url: 'https://doc.babylonjs.com/test', + category: 'api', + source: 'documentation', + score: 0.8, + keywords: [], + }, + ]); + + const result = await handler({ query: 'nonexistent' }); + + expect(result.content[0].text).toContain('No Editor documentation found'); + }); + + it('should format results with rank, relevance, and snippet', async () => { + mockSearch.search.mockResolvedValue([ + { + title: 'Creating Project', + description: 'Start a new project', + content: 'To create a project...', + url: 'https://editor.babylonjs.com/documentation/creating-project', + category: 'editor/creating-project', + source: 'editor-docs', + score: 0.95, + keywords: ['project', 'editor'], + }, + ]); + + const result = await handler({ query: 'project' }); + const resultText = result.content[0].text; + + expect(resultText).toContain('"rank": 1'); + expect(resultText).toContain('"title": "Creating Project"'); + expect(resultText).toContain('"relevance": "95.0%"'); + expect(resultText).toContain('"snippet": "To create a project..."'); + }); + + it('should handle search errors gracefully', async () => { + mockSearch.search.mockRejectedValue(new Error('Search failed')); + + const result = await handler({ query: 'test' }); + + expect(result.content[0].text).toContain('Error'); + expect(result.content[0].text).toContain('Search failed'); + }); + }); +}); diff --git a/src/mcp/handlers/editor/search-editor-docs.handler.ts b/src/mcp/handlers/editor/search-editor-docs.handler.ts new file mode 100644 index 0000000..8d72c7e --- /dev/null +++ b/src/mcp/handlers/editor/search-editor-docs.handler.ts @@ -0,0 +1,73 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { getSearchInstance } from '../shared/search-instance.js'; +import { + formatJsonResponse, + formatNoResultsResponse, +} from '../shared/response-formatters.js'; +import { withErrorHandling } from '../shared/error-handlers.js'; + +export function register(server: McpServer): void { + server.registerTool( + 'search_babylon_editor_docs', + { + description: + 'Search Babylon.js Editor documentation for tool usage, workflows, and features', + inputSchema: { + query: z + .string() + .describe('Search query for Editor documentation'), + category: z + .string() + .optional() + .describe( + 'Optional category filter (e.g., "scripting", "advanced", "tips")' + ), + limit: z + .number() + .optional() + .default(5) + .describe('Maximum number of results to return (default: 5)'), + }, + }, + withErrorHandling( + async ({ query, category, limit = 5 }) => { + const search = await getSearchInstance(); + + // Search with higher limit to ensure we get enough Editor results + const searchLimit = category ? limit : limit * 3; + const options = category ? { category: `editor/${category}`, limit: searchLimit } : { limit: searchLimit }; + const results = await search.search(query, options); + + // Filter to only Editor documentation (source = 'editor-docs') + const editorResults = results + .filter((r: any) => r.source === 'editor-docs') + .slice(0, limit); + + if (editorResults.length === 0) { + return formatNoResultsResponse(query, 'Editor documentation'); + } + + // Format results for better readability + const formattedResults = editorResults.map((result: any, index: number) => ({ + rank: index + 1, + title: result.title, + description: result.description, + url: result.url, + category: result.category, + relevance: (result.score * 100).toFixed(1) + '%', + snippet: result.content, + keywords: result.keywords, + })); + + return formatJsonResponse({ + query, + source: 'editor-docs', + totalResults: editorResults.length, + results: formattedResults, + }); + }, + 'searching Editor documentation' + ) + ); +} diff --git a/src/mcp/handlers/index.ts b/src/mcp/handlers/index.ts index 505f13e..7903a07 100644 --- a/src/mcp/handlers/index.ts +++ b/src/mcp/handlers/index.ts @@ -4,16 +4,18 @@ import * as getDocHandler from './docs/get-doc.handler.js'; import * as searchApiHandler from './api/search-api.handler.js'; import * as searchSourceHandler from './source/search-source.handler.js'; import * as getSourceHandler from './source/get-source.handler.js'; +import * as searchEditorDocsHandler from './editor/search-editor-docs.handler.js'; /** * Register all MCP tool handlers with the server. * - * This function sets up all 5 Babylon.js MCP tools: + * This function sets up all 6 Babylon.js MCP tools: * - search_babylon_docs: Search documentation * - get_babylon_doc: Get specific documentation * - search_babylon_api: Search API documentation * - search_babylon_source: Search source code * - get_babylon_source: Get source code files + * - search_babylon_editor_docs: Search Editor documentation */ export function setupHandlers(server: McpServer): void { searchDocsHandler.register(server); @@ -21,4 +23,5 @@ export function setupHandlers(server: McpServer): void { searchApiHandler.register(server); searchSourceHandler.register(server); getSourceHandler.register(server); + searchEditorDocsHandler.register(server); } diff --git a/src/search/lancedb-search.ts b/src/search/lancedb-search.ts index 06568f1..85b1536 100644 --- a/src/search/lancedb-search.ts +++ b/src/search/lancedb-search.ts @@ -55,6 +55,7 @@ export class LanceDBSearch { content: this.extractRelevantSnippet(doc.content, query), url: doc.url, category: doc.category, + source: doc.source, score: doc._distance ? 1 - doc._distance : 0, // Convert distance to similarity score keywords: doc.keywords.split(', ').filter(Boolean), })); diff --git a/src/search/tsx-parser.ts b/src/search/tsx-parser.ts index 9af5594..556a5f5 100644 --- a/src/search/tsx-parser.ts +++ b/src/search/tsx-parser.ts @@ -12,7 +12,7 @@ export class TsxParser { /** * Parse a TSX file and extract documentation content */ - async parseFile(filePath: string, urlPrefix: string): Promise { + async parseFile(filePath: string, _urlPrefix: string): Promise { const content = await fs.readFile(filePath, 'utf-8'); // Parse TSX file to AST using TypeScript Compiler API @@ -144,7 +144,7 @@ export class TsxParser { /** * Extract code blocks from CodeBlock components and template literals */ - private extractCodeBlocksFromAST(sourceFile: ts.SourceFile, content: string): CodeBlock[] { + private extractCodeBlocksFromAST(sourceFile: ts.SourceFile, _content: string): CodeBlock[] { const blocks: CodeBlock[] = []; const codeVariables = new Map(); @@ -328,29 +328,6 @@ export class TsxParser { .map(([word]) => word); } - /** - * Clean text by removing extra whitespace and decoding HTML entities - */ - private cleanText(text: string): string { - return text - .replace(/\s+/g, ' ') - .replace(/ /g, ' ') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/&/g, '&') - .trim(); - } - - /** - * Check if text looks like code or import statement - */ - private isCodeOrImport(text: string): boolean { - return /^(import|export|const|let|var|function|class|interface|type)\s/.test(text.trim()) || - /^[A-Z][a-zA-Z]+Component$/.test(text.trim()); - } - /** * Check if text looks like code */ diff --git a/src/search/types.ts b/src/search/types.ts index 648c845..1eed201 100644 --- a/src/search/types.ts +++ b/src/search/types.ts @@ -42,6 +42,7 @@ export interface SearchResult { content: string; url: string; category: string; + source: string; score: number; keywords: string[]; }