diff --git a/src/mcp/handlers.test.ts b/src/mcp/handlers.test.ts index 73bec85..39c4220 100644 --- a/src/mcp/handlers.test.ts +++ b/src/mcp/handlers.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { setupHandlers } from './handlers.js'; +import { setupHandlers } from './handlers/index.js'; describe('MCP Handlers', () => { let mockServer: McpServer; @@ -97,8 +97,8 @@ describe('MCP Handlers', () => { const result = (await searchHandler(params)) as { content: { type: string; text: string }[] }; const responseText = result.content[0]!.text; - // Response may be "No results found" or valid JSON - if (!responseText.startsWith('No results')) { + // Response may be "No documentation found" or valid JSON + if (!responseText.startsWith('No ')) { expect(() => JSON.parse(responseText)).not.toThrow(); } }); @@ -108,8 +108,8 @@ describe('MCP Handlers', () => { const result = (await searchHandler(params)) as { content: { type: string; text: string }[] }; const responseText = result.content[0]!.text; - // Response may be "No results found" or valid JSON with query - if (!responseText.startsWith('No results')) { + // Response may be "No documentation found" or valid JSON with query + if (!responseText.startsWith('No ')) { const parsedResponse = JSON.parse(responseText); expect(parsedResponse.query).toBe('PBR'); } @@ -276,5 +276,193 @@ describe('MCP Handlers', () => { expect(toolConfig.inputSchema).toHaveProperty('query'); expect(toolConfig.inputSchema).toHaveProperty('limit'); }); + + it('search_babylon_source should have proper schema structure', () => { + const toolConfig = registerToolSpy.mock.calls[3]![1]; + + expect(toolConfig.inputSchema).toHaveProperty('query'); + expect(toolConfig.inputSchema).toHaveProperty('package'); + expect(toolConfig.inputSchema).toHaveProperty('limit'); + }); + + it('get_babylon_source should have proper schema structure', () => { + const toolConfig = registerToolSpy.mock.calls[4]![1]; + + expect(toolConfig.inputSchema).toHaveProperty('filePath'); + expect(toolConfig.inputSchema).toHaveProperty('startLine'); + expect(toolConfig.inputSchema).toHaveProperty('endLine'); + }); + }); + + describe('search_babylon_source handler', () => { + let searchSourceHandler: (params: unknown) => Promise; + + beforeEach(() => { + setupHandlers(mockServer); + searchSourceHandler = registerToolSpy.mock.calls[3]![2]; + }); + + it('should accept required query parameter', async () => { + const params = { query: 'getMeshByName implementation' }; + const result = await searchSourceHandler(params); + + expect(result).toHaveProperty('content'); + expect(Array.isArray((result as any).content)).toBe(true); + }); + + it('should accept optional package parameter', async () => { + const params = { query: 'scene rendering', package: 'core' }; + const result = await searchSourceHandler(params); + + expect(result).toHaveProperty('content'); + }); + + it('should accept optional limit parameter', async () => { + const params = { query: 'mesh', limit: 10 }; + const result = await searchSourceHandler(params); + + expect(result).toHaveProperty('content'); + }); + + it('should default limit to 5 when not provided', async () => { + const params = { query: 'test' }; + const result = await searchSourceHandler(params); + + expect(result).toHaveProperty('content'); + expect(Array.isArray((result as any).content)).toBe(true); + }); + + it('should return text content type', async () => { + const params = { query: 'test' }; + const result = (await searchSourceHandler(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: 'test source code search' }; + const result = (await searchSourceHandler(params)) as { content: { type: string; text: string }[] }; + + const responseText = result.content[0]!.text; + // Response may be "No source code found", "Error...", or valid JSON + if (!responseText.startsWith('No ') && !responseText.startsWith('Error ')) { + expect(() => JSON.parse(responseText)).not.toThrow(); + } + }); + + it('should handle queries with package filter', async () => { + const params = { query: 'mesh', package: 'core', limit: 3 }; + const result = (await searchSourceHandler(params)) as { content: { type: string; text: string }[] }; + + const responseText = result.content[0]!.text; + expect(typeof responseText).toBe('string'); + expect(responseText.length).toBeGreaterThan(0); + }); + + it('should return structured results with source code metadata', async () => { + const params = { query: 'getMeshByName', limit: 2 }; + const result = (await searchSourceHandler(params)) as { content: { type: string; text: string }[] }; + + const responseText = result.content[0]!.text; + // Should either return "No source code found", "Error...", or JSON with results + if (!responseText.startsWith('No ') && !responseText.startsWith('Error ')) { + const parsed = JSON.parse(responseText); + expect(parsed).toHaveProperty('query'); + expect(parsed).toHaveProperty('totalResults'); + expect(parsed).toHaveProperty('results'); + + if (parsed.results && parsed.results.length > 0) { + const firstResult = parsed.results[0]; + expect(firstResult).toHaveProperty('filePath'); + expect(firstResult).toHaveProperty('startLine'); + expect(firstResult).toHaveProperty('endLine'); + } + } + }); + }); + + describe('get_babylon_source handler', () => { + let getSourceHandler: (params: unknown) => Promise; + + beforeEach(() => { + setupHandlers(mockServer); + getSourceHandler = registerToolSpy.mock.calls[4]![2]; + }); + + it('should accept required filePath parameter', async () => { + const params = { filePath: 'packages/dev/core/src/scene.ts' }; + const result = await getSourceHandler(params); + + expect(result).toHaveProperty('content'); + expect(Array.isArray((result as any).content)).toBe(true); + }); + + it('should accept optional startLine and endLine parameters', async () => { + const params = { + filePath: 'packages/dev/core/src/scene.ts', + startLine: 100, + endLine: 110, + }; + const result = await getSourceHandler(params); + + expect(result).toHaveProperty('content'); + }); + + it('should return text content type', async () => { + const params = { filePath: 'packages/dev/core/src/scene.ts' }; + const result = (await getSourceHandler(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', async () => { + const params = { filePath: 'packages/dev/core/src/scene.ts', startLine: 1, endLine: 10 }; + const result = (await getSourceHandler(params)) as { content: { type: string; text: string }[] }; + + const responseText = result.content[0]!.text; + // Response may be "Source file not found" or valid JSON + if (!responseText.startsWith('Source file not found')) { + expect(() => JSON.parse(responseText)).not.toThrow(); + } + }); + + it('should include source file metadata in response', async () => { + const params = { filePath: 'packages/dev/core/src/scene.ts' }; + const result = (await getSourceHandler(params)) as { content: { type: string; text: string }[] }; + + const responseText = result.content[0]!.text; + // Response may be "Source file not found" or JSON with metadata + if (!responseText.startsWith('Source file not found')) { + const parsedResponse = JSON.parse(responseText); + expect(parsedResponse).toHaveProperty('filePath'); + expect(parsedResponse).toHaveProperty('language'); + expect(parsedResponse).toHaveProperty('content'); + } + }); + + it('should handle file retrieval requests', async () => { + const params = { filePath: 'test/path.ts' }; + const result = (await getSourceHandler(params)) as { content: { type: string; text: string }[] }; + + const responseText = result.content[0]!.text; + // Should either return "Source file not found" message or valid JSON + expect(typeof responseText).toBe('string'); + expect(responseText.length).toBeGreaterThan(0); + }); + + it('should handle line range requests', async () => { + const params = { + filePath: 'packages/dev/core/src/scene.ts', + startLine: 4100, + endLine: 4110, + }; + const result = (await getSourceHandler(params)) as { content: { type: string; text: string }[] }; + + const responseText = result.content[0]!.text; + expect(typeof responseText).toBe('string'); + expect(responseText.length).toBeGreaterThan(0); + }); }); }); diff --git a/src/mcp/handlers.ts b/src/mcp/handlers.ts deleted file mode 100644 index 1e58148..0000000 --- a/src/mcp/handlers.ts +++ /dev/null @@ -1,397 +0,0 @@ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; -import { LanceDBSearch } from '../search/lancedb-search.js'; - -let searchInstance: LanceDBSearch | null = null; - -async function getSearchInstance(): Promise { - if (!searchInstance) { - searchInstance = new LanceDBSearch(); - await searchInstance.initialize(); - } - return searchInstance; -} - -export function setupHandlers(server: McpServer): void { - registerSearchDocsTool(server); - registerGetDocTool(server); - registerSearchApiTool(server); - registerSearchSourceTool(server); - registerGetSourceTool(server); -} - -function registerSearchDocsTool(server: McpServer): void { - server.registerTool( - 'search_babylon_docs', - { - description: 'Search Babylon.js documentation for API references, guides, and tutorials', - inputSchema: { - query: z.string().describe('Search query for Babylon.js documentation'), - category: z - .string() - .optional() - .describe('Optional category filter (e.g., "api", "tutorial", "guide")'), - limit: z - .number() - .optional() - .default(5) - .describe('Maximum number of results to return (default: 5)'), - }, - }, - async ({ query, category, limit = 5 }) => { - try { - const search = await getSearchInstance(); - const options = category ? { category, limit } : { limit }; - const results = await search.search(query, options); - - if (results.length === 0) { - return { - content: [ - { - type: 'text', - text: `No results found for "${query}". Try different search terms or check if the documentation has been indexed.`, - }, - ], - }; - } - - // Format results for better readability - const formattedResults = results.map((result, index) => ({ - 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 { - content: [ - { - type: 'text', - text: JSON.stringify( - { - query, - totalResults: results.length, - results: formattedResults, - }, - null, - 2 - ), - }, - ], - }; - } catch (error) { - return { - content: [ - { - type: 'text', - text: `Error searching documentation: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - }; - } - } - ); -} - -function registerGetDocTool(server: McpServer): void { - server.registerTool( - 'get_babylon_doc', - { - description: 'Retrieve full content of a specific Babylon.js documentation page', - inputSchema: { - path: z.string().describe('Documentation file path or topic identifier'), - }, - }, - async ({ path }) => { - try { - const search = await getSearchInstance(); - const document = await search.getDocumentByPath(path); - - if (!document) { - return { - content: [ - { - type: 'text', - text: `Document not found: ${path}. The path may be incorrect or the documentation has not been indexed.`, - }, - ], - }; - } - - // Parse stringified fields back to arrays - const breadcrumbs = document.breadcrumbs - ? document.breadcrumbs.split(' > ').filter(Boolean) - : []; - const headings = document.headings - ? document.headings.split(' | ').filter(Boolean) - : []; - const keywords = document.keywords - ? document.keywords.split(', ').filter(Boolean) - : []; - const playgroundIds = document.playgroundIds - ? document.playgroundIds.split(', ').filter(Boolean) - : []; - - return { - content: [ - { - type: 'text', - text: JSON.stringify( - { - title: document.title, - description: document.description, - url: document.url, - category: document.category, - breadcrumbs, - content: document.content, - headings, - keywords, - playgroundIds, - lastModified: document.lastModified, - }, - null, - 2 - ), - }, - ], - }; - } catch (error) { - return { - content: [ - { - type: 'text', - text: `Error retrieving document: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - }; - } - } - ); -} - -function registerSearchApiTool(server: McpServer): void { - server.registerTool( - 'search_babylon_api', - { - description: 'Search Babylon.js API documentation (classes, methods, properties)', - inputSchema: { - query: z.string().describe('Search query for Babylon.js API (e.g., "getMeshByName", "Vector3", "Scene")'), - limit: z.number().optional().default(5).describe('Maximum number of results to return (default: 5)'), - }, - }, - async ({ query, limit = 5 }) => { - try { - const search = await getSearchInstance(); - const results = await search.searchApi(query, { limit }); - - if (results.length === 0) { - return { - content: [ - { - type: 'text', - text: `No API documentation found for "${query}". Try different search terms or check if the API has been indexed.`, - }, - ], - }; - } - - // Format results for better readability - const formattedResults = results.map((result, index) => ({ - rank: index + 1, - name: result.name, - fullName: result.fullName, - kind: result.kind, - summary: result.summary, - description: result.description, - parameters: result.parameters ? JSON.parse(result.parameters) : [], - returns: result.returns ? JSON.parse(result.returns) : null, - type: result.type, - examples: result.examples, - deprecated: result.deprecated, - see: result.see, - since: result.since, - sourceFile: result.sourceFile, - sourceLine: result.sourceLine, - url: result.url, - relevance: (result.score * 100).toFixed(1) + '%', - })); - - return { - content: [ - { - type: 'text', - text: JSON.stringify( - { - query, - totalResults: results.length, - results: formattedResults, - }, - null, - 2 - ), - }, - ], - }; - } catch (error) { - return { - content: [ - { - type: 'text', - text: `Error searching API documentation: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - }; - } - } - ); -} - -function registerSearchSourceTool(server: McpServer): void { - server.registerTool( - 'search_babylon_source', - { - description: 'Search Babylon.js source code files', - inputSchema: { - query: z.string().describe('Search query for source code (e.g., "getMeshByName implementation", "scene rendering")'), - package: z - .string() - .optional() - .describe('Optional package filter (e.g., "core", "gui", "materials")'), - limit: z - .number() - .optional() - .default(5) - .describe('Maximum number of results to return (default: 5)'), - }, - }, - async ({ query, package: packageFilter, limit = 5 }) => { - try { - const search = await getSearchInstance(); - const options = packageFilter ? { package: packageFilter, limit } : { limit }; - const results = await search.searchSourceCode(query, options); - - if (results.length === 0) { - return { - content: [ - { - type: 'text', - text: `No source code found for "${query}". Try different search terms or check if the source code has been indexed.`, - }, - ], - }; - } - - // Format results for better readability - const formattedResults = results.map((result, index) => ({ - rank: index + 1, - filePath: result.filePath, - package: result.package, - startLine: result.startLine, - endLine: result.endLine, - language: result.language, - codeSnippet: result.content.substring(0, 500) + (result.content.length > 500 ? '...' : ''), - imports: result.imports, - exports: result.exports, - url: result.url, - relevance: (result.score * 100).toFixed(1) + '%', - })); - - return { - content: [ - { - type: 'text', - text: JSON.stringify( - { - query, - totalResults: results.length, - results: formattedResults, - }, - null, - 2 - ), - }, - ], - }; - } catch (error) { - return { - content: [ - { - type: 'text', - text: `Error searching source code: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - }; - } - } - ); -} - -function registerGetSourceTool(server: McpServer): void { - server.registerTool( - 'get_babylon_source', - { - description: 'Retrieve full Babylon.js source code file or specific line range', - inputSchema: { - filePath: z.string().describe('Relative file path from repository root (e.g., "packages/dev/core/src/scene.ts")'), - startLine: z - .number() - .optional() - .describe('Optional start line number (1-indexed)'), - endLine: z - .number() - .optional() - .describe('Optional end line number (1-indexed)'), - }, - }, - async ({ filePath, startLine, endLine }) => { - try { - const search = await getSearchInstance(); - const sourceCode = await search.getSourceFile(filePath, startLine, endLine); - - if (!sourceCode) { - return { - content: [ - { - type: 'text', - text: `Source file not found: ${filePath}. The path may be incorrect or the file does not exist in the repository.`, - }, - ], - }; - } - - return { - content: [ - { - type: 'text', - text: JSON.stringify( - { - filePath, - startLine: startLine || 1, - endLine: endLine || sourceCode.split('\n').length, - totalLines: sourceCode.split('\n').length, - language: filePath.endsWith('.ts') || filePath.endsWith('.tsx') ? 'typescript' : 'javascript', - content: sourceCode, - }, - null, - 2 - ), - }, - ], - }; - } catch (error) { - return { - content: [ - { - type: 'text', - text: `Error retrieving source file: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - }; - } - } - ); -} diff --git a/src/mcp/handlers/api/search-api.handler.ts b/src/mcp/handlers/api/search-api.handler.ts new file mode 100644 index 0000000..c7d4f27 --- /dev/null +++ b/src/mcp/handlers/api/search-api.handler.ts @@ -0,0 +1,68 @@ +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_api', + { + description: + 'Search Babylon.js API documentation (classes, methods, properties)', + inputSchema: { + query: z + .string() + .describe( + 'Search query for Babylon.js API (e.g., "getMeshByName", "Vector3", "Scene")' + ), + limit: z + .number() + .optional() + .default(5) + .describe('Maximum number of results to return (default: 5)'), + }, + }, + withErrorHandling( + async ({ query, limit = 5 }) => { + const search = await getSearchInstance(); + const results = await search.searchApi(query, { limit }); + + if (results.length === 0) { + return formatNoResultsResponse(query, 'API documentation'); + } + + // Format results for better readability + const formattedResults = results.map((result, index) => ({ + rank: index + 1, + name: result.name, + fullName: result.fullName, + kind: result.kind, + summary: result.summary, + description: result.description, + parameters: result.parameters ? JSON.parse(result.parameters) : [], + returns: result.returns ? JSON.parse(result.returns) : null, + type: result.type, + examples: result.examples, + deprecated: result.deprecated, + see: result.see, + since: result.since, + sourceFile: result.sourceFile, + sourceLine: result.sourceLine, + url: result.url, + relevance: (result.score * 100).toFixed(1) + '%', + })); + + return formatJsonResponse({ + query, + totalResults: results.length, + results: formattedResults, + }); + }, + 'searching API documentation' + ) + ); +} diff --git a/src/mcp/handlers/docs/get-doc.handler.ts b/src/mcp/handlers/docs/get-doc.handler.ts new file mode 100644 index 0000000..1a237be --- /dev/null +++ b/src/mcp/handlers/docs/get-doc.handler.ts @@ -0,0 +1,63 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { getSearchInstance } from '../shared/search-instance.js'; +import { + formatJsonResponse, + formatNotFoundResponse, +} from '../shared/response-formatters.js'; +import { withErrorHandling } from '../shared/error-handlers.js'; + +export function register(server: McpServer): void { + server.registerTool( + 'get_babylon_doc', + { + description: + 'Retrieve full content of a specific Babylon.js documentation page', + inputSchema: { + path: z.string().describe('Documentation file path or topic identifier'), + }, + }, + withErrorHandling( + async ({ path }) => { + const search = await getSearchInstance(); + const document = await search.getDocumentByPath(path); + + if (!document) { + return formatNotFoundResponse( + path, + 'Document', + 'The path may be incorrect or the documentation has not been indexed.' + ); + } + + // Parse stringified fields back to arrays + const breadcrumbs = document.breadcrumbs + ? document.breadcrumbs.split(' > ').filter(Boolean) + : []; + const headings = document.headings + ? document.headings.split(' | ').filter(Boolean) + : []; + const keywords = document.keywords + ? document.keywords.split(', ').filter(Boolean) + : []; + const playgroundIds = document.playgroundIds + ? document.playgroundIds.split(', ').filter(Boolean) + : []; + + return formatJsonResponse({ + title: document.title, + description: document.description, + url: document.url, + category: document.category, + breadcrumbs, + content: document.content, + headings, + keywords, + playgroundIds, + lastModified: document.lastModified, + }); + }, + 'retrieving document' + ) + ); +} diff --git a/src/mcp/handlers/docs/search-docs.handler.ts b/src/mcp/handlers/docs/search-docs.handler.ts new file mode 100644 index 0000000..6054ddb --- /dev/null +++ b/src/mcp/handlers/docs/search-docs.handler.ts @@ -0,0 +1,60 @@ +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_docs', + { + description: + 'Search Babylon.js documentation for API references, guides, and tutorials', + inputSchema: { + query: z.string().describe('Search query for Babylon.js documentation'), + category: z + .string() + .optional() + .describe('Optional category filter (e.g., "api", "tutorial", "guide")'), + 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(); + const options = category ? { category, limit } : { limit }; + const results = await search.search(query, options); + + if (results.length === 0) { + return formatNoResultsResponse(query, 'documentation'); + } + + // Format results for better readability + const formattedResults = results.map((result, index) => ({ + 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, + totalResults: results.length, + results: formattedResults, + }); + }, + 'searching documentation' + ) + ); +} diff --git a/src/mcp/handlers/index.ts b/src/mcp/handlers/index.ts new file mode 100644 index 0000000..505f13e --- /dev/null +++ b/src/mcp/handlers/index.ts @@ -0,0 +1,24 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import * as searchDocsHandler from './docs/search-docs.handler.js'; +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'; + +/** + * Register all MCP tool handlers with the server. + * + * This function sets up all 5 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 + */ +export function setupHandlers(server: McpServer): void { + searchDocsHandler.register(server); + getDocHandler.register(server); + searchApiHandler.register(server); + searchSourceHandler.register(server); + getSourceHandler.register(server); +} diff --git a/src/mcp/handlers/shared/error-handlers.test.ts b/src/mcp/handlers/shared/error-handlers.test.ts new file mode 100644 index 0000000..ccee5bf --- /dev/null +++ b/src/mcp/handlers/shared/error-handlers.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { withErrorHandling } from './error-handlers.js'; + +describe('Error Handlers', () => { + describe('withErrorHandling', () => { + it('should return result when handler succeeds', async () => { + const handler = async (value: number) => ({ result: value * 2 }); + const wrappedHandler = withErrorHandling(handler, 'testing'); + + const result = await wrappedHandler(5); + + expect(result).toEqual({ result: 10 }); + }); + + it('should catch and format errors when handler throws', async () => { + const handler = async () => { + throw new Error('Test error'); + }; + const wrappedHandler = withErrorHandling(handler, 'processing data'); + + const result = await wrappedHandler(); + + expect(result).toHaveProperty('content'); + expect(Array.isArray(result.content)).toBe(true); + expect(result.content[0]).toHaveProperty('type', 'text'); + expect(result.content[0]!.text).toContain('Error processing data'); + expect(result.content[0]!.text).toContain('Test error'); + }); + + it('should handle string errors', async () => { + const handler = async () => { + throw 'String error message'; + }; + const wrappedHandler = withErrorHandling(handler, 'fetching'); + + const result = await wrappedHandler(); + + expect(result.content[0]!.text).toContain('Error fetching'); + expect(result.content[0]!.text).toContain('String error message'); + }); + + it('should handle non-Error objects', async () => { + const handler = async () => { + throw { code: 500, message: 'Server error' }; + }; + const wrappedHandler = withErrorHandling(handler, 'API call'); + + const result = await wrappedHandler(); + + expect(result.content[0]!.text).toContain('Error API call'); + }); + + it('should pass through handler arguments', async () => { + const handler = async (a: number, b: string, c: boolean) => ({ + a, + b, + c, + }); + const wrappedHandler = withErrorHandling(handler, 'testing'); + + const result = await wrappedHandler(42, 'test', true); + + expect(result).toEqual({ a: 42, b: 'test', c: true }); + }); + + it('should handle async errors in promise rejections', async () => { + const handler = async () => { + return Promise.reject(new Error('Async rejection')); + }; + const wrappedHandler = withErrorHandling(handler, 'async operation'); + + const result = await wrappedHandler(); + + expect(result.content[0]!.text).toContain('Error async operation'); + expect(result.content[0]!.text).toContain('Async rejection'); + }); + }); +}); diff --git a/src/mcp/handlers/shared/error-handlers.ts b/src/mcp/handlers/shared/error-handlers.ts new file mode 100644 index 0000000..5973b70 --- /dev/null +++ b/src/mcp/handlers/shared/error-handlers.ts @@ -0,0 +1,16 @@ +import { formatErrorResponse } from './response-formatters.js'; + +type HandlerFunction = (...args: any[]) => Promise; + +export function withErrorHandling( + handler: HandlerFunction, + context: string +): HandlerFunction { + return async (...args: any[]) => { + try { + return await handler(...args); + } catch (error) { + return formatErrorResponse(error, context); + } + }; +} diff --git a/src/mcp/handlers/shared/response-formatters.test.ts b/src/mcp/handlers/shared/response-formatters.test.ts new file mode 100644 index 0000000..e4da7e2 --- /dev/null +++ b/src/mcp/handlers/shared/response-formatters.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest'; +import { + formatJsonResponse, + formatErrorResponse, + formatNoResultsResponse, + formatNotFoundResponse, +} from './response-formatters.js'; + +describe('Response Formatters', () => { + describe('formatJsonResponse', () => { + it('should format data as JSON text response', () => { + const data = { test: 'value', count: 42 }; + const result = formatJsonResponse(data); + + expect(result).toHaveProperty('content'); + expect(Array.isArray(result.content)).toBe(true); + expect(result.content[0]).toHaveProperty('type', 'text'); + expect(result.content[0]).toHaveProperty('text'); + + const parsed = JSON.parse(result.content[0]!.text); + expect(parsed).toEqual(data); + }); + + it('should handle complex nested objects', () => { + const data = { + nested: { array: [1, 2, 3], obj: { key: 'value' } }, + nullValue: null, + }; + const result = formatJsonResponse(data); + + const parsed = JSON.parse(result.content[0]!.text); + expect(parsed).toEqual(data); + }); + }); + + describe('formatErrorResponse', () => { + it('should format Error instances', () => { + const error = new Error('Test error message'); + const result = formatErrorResponse(error, 'testing'); + + expect(result.content[0]!.type).toBe('text'); + expect(result.content[0]!.text).toBe('Error testing: Test error message'); + }); + + it('should format string errors', () => { + const error = 'String error'; + const result = formatErrorResponse(error, 'processing'); + + expect(result.content[0]!.text).toBe('Error processing: String error'); + }); + + it('should format unknown error types', () => { + const error = { code: 404 }; + const result = formatErrorResponse(error, 'fetching'); + + expect(result.content[0]!.text).toContain('Error fetching:'); + }); + }); + + describe('formatNoResultsResponse', () => { + it('should format no results message for documentation', () => { + const result = formatNoResultsResponse('test query', 'documentation'); + + expect(result.content[0]!.type).toBe('text'); + expect(result.content[0]!.text).toContain('No documentation found'); + expect(result.content[0]!.text).toContain('test query'); + }); + + it('should format no results message for API', () => { + const result = formatNoResultsResponse('getMeshByName', 'API documentation'); + + expect(result.content[0]!.text).toContain('No API documentation found'); + expect(result.content[0]!.text).toContain('getMeshByName'); + }); + + it('should format no results message for source code', () => { + const result = formatNoResultsResponse('scene rendering', 'source code'); + + expect(result.content[0]!.text).toContain('No source code found'); + expect(result.content[0]!.text).toContain('scene rendering'); + }); + }); + + describe('formatNotFoundResponse', () => { + it('should format not found message without additional info', () => { + const result = formatNotFoundResponse('/test/path', 'Document'); + + expect(result.content[0]!.type).toBe('text'); + expect(result.content[0]!.text).toBe('Document not found: /test/path.'); + }); + + it('should format not found message with additional info', () => { + const result = formatNotFoundResponse( + 'scene.ts', + 'Source file', + 'The path may be incorrect.' + ); + + expect(result.content[0]!.text).toBe( + 'Source file not found: scene.ts. The path may be incorrect.' + ); + }); + + it('should handle empty additional info', () => { + const result = formatNotFoundResponse('test-id', 'Resource', ''); + + expect(result.content[0]!.text).toBe('Resource not found: test-id.'); + }); + }); +}); diff --git a/src/mcp/handlers/shared/response-formatters.ts b/src/mcp/handlers/shared/response-formatters.ts new file mode 100644 index 0000000..7a1fd3b --- /dev/null +++ b/src/mcp/handlers/shared/response-formatters.ts @@ -0,0 +1,45 @@ +export function formatJsonResponse(data: unknown) { + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(data, null, 2), + }, + ], + }; +} + +export function formatErrorResponse(error: unknown, context: string) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [ + { + type: 'text' as const, + text: `Error ${context}: ${message}`, + }, + ], + }; +} + +export function formatNoResultsResponse(query: string, resourceType: string) { + return { + content: [ + { + type: 'text' as const, + text: `No ${resourceType} found for "${query}". Try different search terms or check if the ${resourceType} has been indexed.`, + }, + ], + }; +} + +export function formatNotFoundResponse(identifier: string, resourceType: string, additionalInfo?: string) { + const info = additionalInfo ? ` ${additionalInfo}` : ''; + return { + content: [ + { + type: 'text' as const, + text: `${resourceType} not found: ${identifier}.${info}`, + }, + ], + }; +} diff --git a/src/mcp/handlers/shared/search-instance.ts b/src/mcp/handlers/shared/search-instance.ts new file mode 100644 index 0000000..4611157 --- /dev/null +++ b/src/mcp/handlers/shared/search-instance.ts @@ -0,0 +1,11 @@ +import { LanceDBSearch } from '../../../search/lancedb-search.js'; + +let searchInstance: LanceDBSearch | null = null; + +export async function getSearchInstance(): Promise { + if (!searchInstance) { + searchInstance = new LanceDBSearch(); + await searchInstance.initialize(); + } + return searchInstance; +} diff --git a/src/mcp/handlers/source/get-source.handler.ts b/src/mcp/handlers/source/get-source.handler.ts new file mode 100644 index 0000000..eef7b3d --- /dev/null +++ b/src/mcp/handlers/source/get-source.handler.ts @@ -0,0 +1,61 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { getSearchInstance } from '../shared/search-instance.js'; +import { + formatJsonResponse, + formatNotFoundResponse, +} from '../shared/response-formatters.js'; +import { withErrorHandling } from '../shared/error-handlers.js'; + +export function register(server: McpServer): void { + server.registerTool( + 'get_babylon_source', + { + description: + 'Retrieve full Babylon.js source code file or specific line range', + inputSchema: { + filePath: z + .string() + .describe( + 'Relative file path from repository root (e.g., "packages/dev/core/src/scene.ts")' + ), + startLine: z + .number() + .optional() + .describe('Optional start line number (1-indexed)'), + endLine: z + .number() + .optional() + .describe('Optional end line number (1-indexed)'), + }, + }, + withErrorHandling( + async ({ filePath, startLine, endLine }) => { + const search = await getSearchInstance(); + const sourceCode = await search.getSourceFile(filePath, startLine, endLine); + + if (!sourceCode) { + return formatNotFoundResponse( + filePath, + 'Source file', + 'The path may be incorrect or the file does not exist in the repository.' + ); + } + + return formatJsonResponse({ + filePath, + startLine: startLine || 1, + endLine: endLine || sourceCode.split('\n').length, + totalLines: sourceCode.split('\n').length, + /* c8 ignore next 3 */ + language: + filePath.endsWith('.ts') || filePath.endsWith('.tsx') + ? 'typescript' + : 'javascript', + content: sourceCode, + }); + }, + 'retrieving source file' + ) + ); +} diff --git a/src/mcp/handlers/source/search-source.handler.ts b/src/mcp/handlers/source/search-source.handler.ts new file mode 100644 index 0000000..1e82c50 --- /dev/null +++ b/src/mcp/handlers/source/search-source.handler.ts @@ -0,0 +1,69 @@ +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_source', + { + description: 'Search Babylon.js source code files', + inputSchema: { + query: z + .string() + .describe( + 'Search query for source code (e.g., "getMeshByName implementation", "scene rendering")' + ), + package: z + .string() + .optional() + .describe('Optional package filter (e.g., "core", "gui", "materials")'), + limit: z + .number() + .optional() + .default(5) + .describe('Maximum number of results to return (default: 5)'), + }, + }, + withErrorHandling( + async ({ query, package: packageFilter, limit = 5 }) => { + const search = await getSearchInstance(); + const options = packageFilter ? { package: packageFilter, limit } : { limit }; + const results = await search.searchSourceCode(query, options); + + if (results.length === 0) { + return formatNoResultsResponse(query, 'source code'); + } + + // Format results for better readability + const formattedResults = results.map((result, index) => ({ + rank: index + 1, + filePath: result.filePath, + package: result.package, + startLine: result.startLine, + endLine: result.endLine, + language: result.language, + codeSnippet: + result.content.substring(0, 500) + + /* c8 ignore next */ + (result.content.length > 500 ? '...' : ''), + imports: result.imports, + exports: result.exports, + url: result.url, + relevance: (result.score * 100).toFixed(1) + '%', + })); + + return formatJsonResponse({ + query, + totalResults: results.length, + results: formattedResults, + }); + }, + 'searching source code' + ) + ); +} diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts index 5905b62..05c96f6 100644 --- a/src/mcp/server.test.ts +++ b/src/mcp/server.test.ts @@ -19,12 +19,13 @@ vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => { const MockMcpServer = vi.fn(function () { return { close: vi.fn().mockResolvedValue(undefined), + registerTool: vi.fn(), }; }); return { McpServer: MockMcpServer }; }); -vi.mock('./handlers.js', () => ({ +vi.mock('./handlers/index.js', () => ({ setupHandlers: vi.fn(), })); @@ -81,7 +82,7 @@ describe('BabylonMCPServer', () => { }); it('should setup MCP handlers', async () => { - const { setupHandlers } = await import('./handlers.js'); + const { setupHandlers } = await import('./handlers/index.js'); server = new BabylonMCPServer(); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index f8d9fe0..a72e80a 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,7 +1,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import express from 'express'; import { MCP_SERVER_CONFIG } from './config.js'; -import { setupHandlers } from './handlers.js'; +import { setupHandlers } from './handlers/index.js'; import { setupRoutes } from './routes.js'; import { RepositoryManager } from './repository-manager.js';