diff --git a/package-lock.json b/package-lock.json index 676352795..aaff3686c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@clack/prompts": "^0.10.1", - "@modelcontextprotocol/sdk": "^1.11.0", + "@modelcontextprotocol/sdk": "^1.15.0", "@secretlint/core": "^9.3.1", "@secretlint/secretlint-rule-preset-recommend": "^9.3.1", "clipboardy": "^4.0.0", @@ -936,15 +936,17 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.1.tgz", - "integrity": "sha512-9LfmxKTb1v+vUS1/emSk1f5ePmTLkb9Le9AxOB5T0XM59EUumwcS45z05h7aiZx3GI0Bl7mjb3FMEglYj+acuQ==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.15.0.tgz", + "integrity": "sha512-67hnl/ROKdb03Vuu0YOr+baKTvf1/5YBHBm9KnZdjdAh8hjt4FRCPD5ucwxGB237sBpzlqQsLy1PFu7z/ekZ9Q==", "license": "MIT", "dependencies": { + "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", - "cross-spawn": "^7.0.3", + "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", @@ -956,6 +958,28 @@ "node": ">=18" } }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2599,7 +2623,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -2618,6 +2641,12 @@ "node": ">=8.6.0" } }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", @@ -3982,6 +4011,15 @@ "node": ">= 0.10" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -5667,6 +5705,15 @@ "node": ">= 0.8" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/package.json b/package.json index bdc256f5b..75715302d 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "type": "module", "dependencies": { "@clack/prompts": "^0.10.1", - "@modelcontextprotocol/sdk": "^1.11.0", + "@modelcontextprotocol/sdk": "^1.15.0", "@secretlint/core": "^9.3.1", "@secretlint/secretlint-rule-preset-recommend": "^9.3.1", "clipboardy": "^4.0.0", diff --git a/src/mcp/mcpServer.ts b/src/mcp/mcpServer.ts index c8f22e484..fe9bd09b9 100644 --- a/src/mcp/mcpServer.ts +++ b/src/mcp/mcpServer.ts @@ -10,11 +10,26 @@ import { registerPackCodebaseTool } from './tools/packCodebaseTool.js'; import { registerPackRemoteRepositoryTool } from './tools/packRemoteRepositoryTool.js'; import { registerReadRepomixOutputTool } from './tools/readRepomixOutputTool.js'; +/** + * Instructions for the Repomix MCP Server that describe its capabilities and usage + */ +const MCP_SERVER_INSTRUCTIONS = + 'Repomix MCP Server provides AI-optimized codebase analysis tools. ' + + 'Use pack_codebase or pack_remote_repository to consolidate code into a single XML file, ' + + 'then read_repomix_output and grep_repomix_output to analyze it. ' + + 'Perfect for code reviews, documentation generation, bug investigation, GitHub repository analysis, and understanding large codebases. ' + + 'Includes security scanning and supports compression for token efficiency.'; + export const createMcpServer = async () => { - const mcpServer = new McpServer({ - name: 'repomix-mcp-server', - version: await getVersion(), - }); + const mcpServer = new McpServer( + { + name: 'repomix-mcp-server', + version: await getVersion(), + }, + { + instructions: MCP_SERVER_INSTRUCTIONS, + }, + ); // Register the prompts registerPackRemoteRepositoryPrompt(mcpServer); diff --git a/src/mcp/tools/fileSystemReadDirectoryTool.ts b/src/mcp/tools/fileSystemReadDirectoryTool.ts index 242da5191..44bf4c64d 100644 --- a/src/mcp/tools/fileSystemReadDirectoryTool.ts +++ b/src/mcp/tools/fileSystemReadDirectoryTool.ts @@ -6,22 +6,36 @@ import { z } from 'zod'; import { logger } from '../../shared/logger.js'; import { buildMcpToolErrorResponse, buildMcpToolSuccessResponse } from './mcpToolRuntime.js'; +const fileSystemReadDirectoryInputSchema = z.object({ + path: z.string().describe('Absolute path to the directory to list'), +}); + +const fileSystemReadDirectoryOutputSchema = z.object({ + path: z.string().describe('The directory path that was listed'), + contents: z.array(z.string()).describe('Array of directory contents with [FILE]/[DIR] indicators'), + totalItems: z.number().describe('Total number of items in the directory'), + fileCount: z.number().describe('Number of files in the directory'), + directoryCount: z.number().describe('Number of subdirectories in the directory'), +}); + /** * Register file system directory listing tool */ export const registerFileSystemReadDirectoryTool = (mcpServer: McpServer) => { - mcpServer.tool( + mcpServer.registerTool( 'file_system_read_directory', - 'List the contents of a directory using an absolute path. Returns a formatted list showing files and subdirectories with clear [FILE]/[DIR] indicators. Useful for exploring project structure and understanding codebase organization.', - { - path: z.string().describe('Absolute path to the directory to list'), - }, { title: 'Read Directory', - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, + description: + 'List the contents of a directory using an absolute path. Returns a formatted list showing files and subdirectories with clear [FILE]/[DIR] indicators. Useful for exploring project structure and understanding codebase organization.', + inputSchema: fileSystemReadDirectoryInputSchema.shape, + outputSchema: fileSystemReadDirectoryOutputSchema.shape, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, }, async ({ path: directoryPath }): Promise => { try { @@ -29,33 +43,45 @@ export const registerFileSystemReadDirectoryTool = (mcpServer: McpServer) => { // Ensure path is absolute if (!path.isAbsolute(directoryPath)) { - return buildMcpToolErrorResponse([`Error: Path must be absolute. Received: ${directoryPath}`]); + return buildMcpToolErrorResponse({ + errorMessage: `Error: Path must be absolute. Received: ${directoryPath}`, + }); } // Check if directory exists try { const stats = await fs.stat(directoryPath); if (!stats.isDirectory()) { - return buildMcpToolErrorResponse([ - `Error: The specified path is not a directory: ${directoryPath}. Use file_system_read_file for files.`, - ]); + return buildMcpToolErrorResponse({ + errorMessage: `Error: The specified path is not a directory: ${directoryPath}. Use file_system_read_file for files.`, + }); } } catch { - return buildMcpToolErrorResponse([`Error: Directory not found at path: ${directoryPath}`]); + return buildMcpToolErrorResponse({ + errorMessage: `Error: Directory not found at path: ${directoryPath}`, + }); } // Read directory contents const entries = await fs.readdir(directoryPath, { withFileTypes: true }); - const formatted = entries - .map((entry) => `${entry.isDirectory() ? '[DIR]' : '[FILE]'} ${entry.name}`) - .join('\n'); + const contents = entries.map((entry) => `${entry.isDirectory() ? '[DIR]' : '[FILE]'} ${entry.name}`); + + const fileCount = entries.filter((entry) => entry.isFile()).length; + const directoryCount = entries.filter((entry) => entry.isDirectory()).length; + const totalItems = entries.length; - return buildMcpToolSuccessResponse([`Contents of ${directoryPath}:`, formatted || '(empty directory)']); + return buildMcpToolSuccessResponse({ + path: directoryPath, + contents: contents.length > 0 ? contents : ['(empty directory)'], + totalItems, + fileCount, + directoryCount, + } satisfies z.infer); } catch (error) { logger.error(`Error in file_system_read_directory tool: ${error}`); - return buildMcpToolErrorResponse([ - `Error listing directory: ${error instanceof Error ? error.message : String(error)}`, - ]); + return buildMcpToolErrorResponse({ + errorMessage: `Error listing directory: ${error instanceof Error ? error.message : String(error)}`, + }); } }, ); diff --git a/src/mcp/tools/fileSystemReadFileTool.ts b/src/mcp/tools/fileSystemReadFileTool.ts index e6f331086..b861ce6b3 100644 --- a/src/mcp/tools/fileSystemReadFileTool.ts +++ b/src/mcp/tools/fileSystemReadFileTool.ts @@ -8,22 +8,36 @@ import { createSecretLintConfig, runSecretLint } from '../../core/security/worke import { logger } from '../../shared/logger.js'; import { buildMcpToolErrorResponse, buildMcpToolSuccessResponse } from './mcpToolRuntime.js'; +const fileSystemReadFileInputSchema = z.object({ + path: z.string().describe('Absolute path to the file to read'), +}); + +const fileSystemReadFileOutputSchema = z.object({ + path: z.string().describe('The file path that was read'), + content: z.string().describe('The file content'), + size: z.number().describe('File size in bytes'), + encoding: z.string().describe('Text encoding used to read the file'), + lines: z.number().describe('Number of lines in the file'), +}); + /** * Register file system read file tool with security checks */ export const registerFileSystemReadFileTool = (mcpServer: McpServer) => { - mcpServer.tool( + mcpServer.registerTool( 'file_system_read_file', - 'Read a file from the local file system using an absolute path. Includes built-in security validation to detect and prevent access to files containing sensitive information (API keys, passwords, secrets).', - { - path: z.string().describe('Absolute path to the file to read'), - }, { title: 'Read File', - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, + description: + 'Read a file from the local file system using an absolute path. Includes built-in security validation to detect and prevent access to files containing sensitive information (API keys, passwords, secrets).', + inputSchema: fileSystemReadFileInputSchema.shape, + outputSchema: fileSystemReadFileOutputSchema.shape, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, }, async ({ path: filePath }): Promise => { try { @@ -31,24 +45,31 @@ export const registerFileSystemReadFileTool = (mcpServer: McpServer) => { // Ensure path is absolute if (!path.isAbsolute(filePath)) { - return buildMcpToolErrorResponse([`Error: Path must be absolute. Received: ${filePath}`]); + return buildMcpToolErrorResponse({ + errorMessage: `Error: Path must be absolute. Received: ${filePath}`, + }); } // Check if file exists try { await fs.access(filePath); } catch { - return buildMcpToolErrorResponse([`Error: File not found at path: ${filePath}`]); + return buildMcpToolErrorResponse({ + errorMessage: `Error: File not found at path: ${filePath}`, + }); } // Check if it's a directory const stats = await fs.stat(filePath); if (stats.isDirectory()) { - return buildMcpToolErrorResponse([ - `Error: The specified path is a directory, not a file: ${filePath}. Use file_system_read_directory for directories.`, - ]); + return buildMcpToolErrorResponse({ + errorMessage: `Error: The specified path is a directory, not a file: ${filePath}. Use file_system_read_directory for directories.`, + }); } + // Get file stats + const fileStats = await fs.stat(filePath); + // Read file content const fileContent = await fs.readFile(filePath, 'utf8'); @@ -58,17 +79,27 @@ export const registerFileSystemReadFileTool = (mcpServer: McpServer) => { // If security check found issues, block the file if (securityCheckResult !== null) { - return buildMcpToolErrorResponse([ - `Error: Security check failed. The file at ${filePath} may contain sensitive information.`, - ]); + return buildMcpToolErrorResponse({ + errorMessage: `Error: Security check failed. The file at ${filePath} may contain sensitive information.`, + }); } - return buildMcpToolSuccessResponse([`Content of ${filePath}:`, fileContent]); + // Calculate file metrics + const lines = fileContent.split('\n').length; + const size = fileStats.size; + + return buildMcpToolSuccessResponse({ + path: filePath, + content: fileContent, + size, + encoding: 'utf8', + lines, + } satisfies z.infer); } catch (error) { logger.error(`Error in file_system_read_file tool: ${error}`); - return buildMcpToolErrorResponse([ - `Error reading file: ${error instanceof Error ? error.message : String(error)}`, - ]); + return buildMcpToolErrorResponse({ + errorMessage: `Error reading file: ${error instanceof Error ? error.message : String(error)}`, + }); } }, ); diff --git a/src/mcp/tools/grepRepomixOutputTool.ts b/src/mcp/tools/grepRepomixOutputTool.ts index 27f37a6a8..c78efb815 100644 --- a/src/mcp/tools/grepRepomixOutputTool.ts +++ b/src/mcp/tools/grepRepomixOutputTool.ts @@ -3,7 +3,48 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; import { logger } from '../../shared/logger.js'; -import { buildMcpToolErrorResponse, buildMcpToolSuccessResponse, getOutputFilePath } from './mcpToolRuntime.js'; +import { + buildMcpToolErrorResponse, + buildMcpToolSuccessResponse, + convertErrorToJson, + getOutputFilePath, +} from './mcpToolRuntime.js'; + +const grepRepomixOutputInputSchema = z.object({ + outputId: z.string().describe('ID of the Repomix output file to search'), + pattern: z.string().describe('Search pattern (JavaScript RegExp regular expression syntax)'), + contextLines: z + .number() + .default(0) + .describe( + 'Number of context lines to show before and after each match (default: 0). Overridden by beforeLines/afterLines if specified.', + ), + beforeLines: z + .number() + .optional() + .describe('Number of context lines to show before each match (like grep -B). Takes precedence over contextLines.'), + afterLines: z + .number() + .optional() + .describe('Number of context lines to show after each match (like grep -A). Takes precedence over contextLines.'), + ignoreCase: z.boolean().default(false).describe('Perform case-insensitive matching (default: false)'), +}); + +const grepRepomixOutputOutputSchema = z.object({ + description: z.string().describe('Human-readable description of the search results'), + matches: z + .array( + z.object({ + lineNumber: z.number().describe('Line number where the match was found'), + line: z.string().describe('The full line content'), + matchedText: z.string().describe('The actual text that matched the pattern'), + }), + ) + .describe('Array of search matches found'), + formattedOutput: z.array(z.string()).describe('Formatted grep-style output with context lines'), + totalMatches: z.number().describe('Total number of matches found'), + pattern: z.string().describe('The search pattern that was used'), +}); /** * Search options for grep functionality @@ -37,38 +78,20 @@ interface SearchResult { * Register the tool to search Repomix output files with grep-like functionality */ export const registerGrepRepomixOutputTool = (mcpServer: McpServer) => { - mcpServer.tool( + mcpServer.registerTool( 'grep_repomix_output', - 'Search for patterns in a Repomix output file using grep-like functionality with JavaScript RegExp syntax. Returns matching lines with optional context lines around matches.', - { - outputId: z.string().describe('ID of the Repomix output file to search'), - pattern: z.string().describe('Search pattern (JavaScript RegExp regular expression syntax)'), - contextLines: z - .number() - .default(0) - .describe( - 'Number of context lines to show before and after each match (default: 0). Overridden by beforeLines/afterLines if specified.', - ), - beforeLines: z - .number() - .optional() - .describe( - 'Number of context lines to show before each match (like grep -B). Takes precedence over contextLines.', - ), - afterLines: z - .number() - .optional() - .describe( - 'Number of context lines to show after each match (like grep -A). Takes precedence over contextLines.', - ), - ignoreCase: z.boolean().default(false).describe('Perform case-insensitive matching (default: false)'), - }, { title: 'Grep Repomix Output', - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, + description: + 'Search for patterns in a Repomix output file using grep-like functionality with JavaScript RegExp syntax. Returns matching lines with optional context lines around matches.', + inputSchema: grepRepomixOutputInputSchema.shape, + outputSchema: grepRepomixOutputOutputSchema.shape, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, }, async ({ outputId, @@ -83,17 +106,25 @@ export const registerGrepRepomixOutputTool = (mcpServer: McpServer) => { const filePath = getOutputFilePath(outputId); if (!filePath) { - return buildMcpToolErrorResponse([ - `Error: Output file with ID ${outputId} not found. The output file may have been deleted or the ID is invalid.`, - ]); + return buildMcpToolErrorResponse({ + errorMessage: `Error: Output file with ID ${outputId} not found. The output file may have been deleted or the ID is invalid.`, + details: { + outputId, + reason: 'FILE_NOT_FOUND', + }, + }); } try { await fs.access(filePath); } catch (error) { - return buildMcpToolErrorResponse([ - `Error: Output file does not exist at path: ${filePath}. The temporary file may have been cleaned up.`, - ]); + return buildMcpToolErrorResponse({ + errorMessage: `Error: Output file does not exist at path: ${filePath}. The temporary file may have been cleaned up.`, + details: { + outputId, + reason: 'FILE_ACCESS_ERROR', + }, + }); } const content = await fs.readFile(filePath, 'utf8'); @@ -113,24 +144,36 @@ export const registerGrepRepomixOutputTool = (mcpServer: McpServer) => { ignoreCase, }); } catch (error) { - return buildMcpToolErrorResponse([`Error: ${error instanceof Error ? error.message : String(error)}`]); + return buildMcpToolErrorResponse({ + errorMessage: `Error: ${error instanceof Error ? error.message : String(error)}`, + details: { + outputId, + pattern, + reason: 'SEARCH_ERROR', + }, + }); } if (searchResult.matches.length === 0) { - return buildMcpToolSuccessResponse([ - `No matches found for pattern "${pattern}" in Repomix output file (ID: ${outputId}).`, - ]); + return buildMcpToolSuccessResponse({ + description: `No matches found for pattern "${pattern}" in Repomix output file (ID: ${outputId}).`, + matches: [], + formattedOutput: [], + totalMatches: 0, + pattern, + } satisfies z.infer); } - return buildMcpToolSuccessResponse([ - `Found ${searchResult.matches.length} match(es) for pattern "${pattern}" in Repomix output file (ID: ${outputId}):`, - searchResult.formattedOutput.join('\n'), - ]); + return buildMcpToolSuccessResponse({ + description: `Found ${searchResult.matches.length} match(es) for pattern "${pattern}" in Repomix output file (ID: ${outputId}):`, + matches: searchResult.matches, + formattedOutput: searchResult.formattedOutput, + totalMatches: searchResult.matches.length, + pattern, + } satisfies z.infer); } catch (error) { logger.error(`Error in grep_repomix_output: ${error}`); - return buildMcpToolErrorResponse([ - `Error searching Repomix output: ${error instanceof Error ? error.message : String(error)}`, - ]); + return buildMcpToolErrorResponse(convertErrorToJson(error)); } }, ); diff --git a/src/mcp/tools/mcpToolRuntime.ts b/src/mcp/tools/mcpToolRuntime.ts index 954dbff68..8109104e7 100644 --- a/src/mcp/tools/mcpToolRuntime.ts +++ b/src/mcp/tools/mcpToolRuntime.ts @@ -3,9 +3,8 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; -import { generateFileTree, generateTreeString } from '../../core/file/fileTreeGenerate.js'; +import { generateTreeString } from '../../core/file/fileTreeGenerate.js'; import type { ProcessedFile } from '../../core/file/fileTypes.js'; -import { logger } from '../../shared/logger.js'; // Map to store generated output files const outputFileRegistry = new Map(); @@ -35,6 +34,15 @@ export interface McpToolContext { repository?: string; } +// Base interface for all MCP tool responses +interface BaseMcpToolResponse { + description?: string; + errorMessage?: string; +} + +// Structured content for MCP tool responses with proper typing +type McpToolStructuredContent = (BaseMcpToolResponse & Record) | undefined; + /** * Creates a temporary directory for MCP tool operations */ @@ -60,7 +68,7 @@ export const generateOutputId = (): string => { /** * Creates a result object with metrics information for MCP tools */ -export const formatToolResponse = async ( +export const formatPackToolResponse = async ( context: McpToolContext, metrics: McpToolMetrics, outputFilePath: string, @@ -106,31 +114,14 @@ export const formatToolResponse = async ( 2, ); - return { - content: [ - { - type: 'text', - text: '🎉 Successfully packed codebase!\nPlease review the metrics below and consider adjusting compress/includePatterns/ignorePatterns if the token count is too high and you need to reduce it before reading the file content.', - }, - { - type: 'text', - text: jsonResult, - }, - { - type: 'text', - text: `Directory Structure\n\n${directoryStructure}`, - }, - { - type: 'text', - text: `For environments with direct file system access, you can read the file directly using path: ${outputFilePath}`, - }, - { - type: 'text', - text: `For environments without direct file access (e.g., web browsers or sandboxed apps), use the \`read_repomix_output\` tool with this outputId: ${outputId} to access the packed codebase contents.`, - }, - { - type: 'text', - text: `The output retrieved with \`read_repomix_output\` has the following structure: + return buildMcpToolSuccessResponse({ + description: ` +🎉 Successfully packed codebase!\nPlease review the metrics below and consider adjusting compress/includePatterns/ignorePatterns if the token count is too high and you need to reduce it before reading the file content. + +For environments with direct file system access, you can read the file directly using path: ${outputFilePath} +For environments without direct file access (e.g., web browsers or sandboxed apps), use the \`read_repomix_output\` tool with this outputId: ${outputId} to access the packed codebase contents. + +The output retrieved with \`read_repomix_output\` has the following structure: \`\`\`xml This file is a merged representation of the entire codebase, combining all repository files into a single document. @@ -161,57 +152,96 @@ index.ts \`\`\` -You can use grep with \`path=""\` to locate specific files within the output.`, +You can use grep with \`path=""\` to locate specific files within the output. +`, + result: jsonResult, + directoryStructure: directoryStructure, + outputId: outputId, + outputFilePath: outputFilePath, + totalFiles: metrics.totalFiles, + totalTokens: metrics.totalTokens, + }); +}; + +export const convertErrorToJson = ( + error: unknown, +): { + errorMessage: string; + details: { + stack?: string; + name: string; + cause?: unknown; + code?: string | number; + timestamp: string; + type: 'Error' | 'Unknown'; + }; +} => { + const timestamp = new Date().toISOString(); + + if (error instanceof Error) { + return { + errorMessage: error.message, + details: { + stack: error.stack, + name: error.name, + cause: error.cause, + code: + 'code' in error + ? (error.code as string | number) + : 'errno' in error + ? (error.errno as string | number) + : undefined, + timestamp, + type: 'Error', }, - ], + }; + } + + return { + errorMessage: String(error), + details: { + name: 'UnknownError', + timestamp, + type: 'Unknown', + }, }; }; /** - * Creates an error result for MCP tools + * Creates a successful MCP tool response with type safety + * @param structuredContent - Object containing both machine-readable data and human-readable description + * @returns CallToolResult with both text and structured content */ -export const formatToolError = (error: unknown): CallToolResult => { - logger.error(`Error in MCP tool: ${error instanceof Error ? error.message : String(error)}`); +export const buildMcpToolSuccessResponse = (structuredContent: McpToolStructuredContent): CallToolResult => { + const textContent = structuredContent !== undefined ? JSON.stringify(structuredContent, null, 2) : 'null'; return { - isError: true, content: [ { type: 'text', - text: JSON.stringify( - { - success: false, - error: error instanceof Error ? error.message : String(error), - }, - null, - 2, - ), + text: textContent, }, ], - }; -}; - -/** - * Creates a successful MCP tool response with type safety - */ -export const buildMcpToolSuccessResponse = (messages: string[]): CallToolResult => { - return { - content: messages.map((message) => ({ - type: 'text' as const, - text: message, - })), + structuredContent: structuredContent, }; }; /** * Creates an error MCP tool response with type safety + * @param structuredContent - Object containing error message and details + * @returns CallToolResult with error flag, text content, and structured content */ -export const buildMcpToolErrorResponse = (errorMessages: string[]): CallToolResult => { +export const buildMcpToolErrorResponse = (structuredContent: McpToolStructuredContent): CallToolResult => { + const textContent = structuredContent !== undefined ? JSON.stringify(structuredContent, null, 2) : 'null'; + return { isError: true, - content: errorMessages.map((message) => ({ - type: 'text' as const, - text: message, - })), + content: [ + { + type: 'text', + text: textContent, + }, + ], + structuredContent: structuredContent, }; }; diff --git a/src/mcp/tools/packCodebaseTool.ts b/src/mcp/tools/packCodebaseTool.ts index 703d55b30..c388eb3e4 100644 --- a/src/mcp/tools/packCodebaseTool.ts +++ b/src/mcp/tools/packCodebaseTool.ts @@ -6,49 +6,63 @@ import { runCli } from '../../cli/cliRun.js'; import type { CliOptions } from '../../cli/types.js'; import { buildMcpToolErrorResponse, + convertErrorToJson, createToolWorkspace, - formatToolError, - formatToolResponse, + formatPackToolResponse, } from './mcpToolRuntime.js'; +const packCodebaseInputSchema = z.object({ + directory: z.string().describe('Directory to pack (Absolute path)'), + compress: z + .boolean() + .default(false) + .describe( + 'Enable Tree-sitter compression to extract essential code signatures and structure while removing implementation details. Reduces token usage by ~70% while preserving semantic meaning. Generally not needed since grep_repomix_output allows incremental content retrieval. Use only when you specifically need the entire codebase content for large repositories (default: false).', + ), + includePatterns: z + .string() + .optional() + .describe( + 'Specify files to include using fast-glob patterns. Multiple patterns can be comma-separated (e.g., "**/*.{js,ts}", "src/**,docs/**"). Only matching files will be processed. Useful for focusing on specific parts of the codebase.', + ), + ignorePatterns: z + .string() + .optional() + .describe( + 'Specify additional files to exclude using fast-glob patterns. Multiple patterns can be comma-separated (e.g., "test/**,*.spec.js", "node_modules/**,dist/**"). These patterns supplement .gitignore and built-in exclusions.', + ), + topFilesLength: z + .number() + .optional() + .default(10) + .describe('Number of largest files by size to display in the metrics summary for codebase analysis (default: 10)'), +}); + +const packCodebaseOutputSchema = z.object({ + description: z.string().describe('Human-readable description of the packing results'), + result: z.string().describe('JSON string containing detailed metrics and file information'), + directoryStructure: z.string().describe('Tree structure of the processed directory'), + outputId: z.string().describe('Unique identifier for accessing the packed content'), + outputFilePath: z.string().describe('File path to the generated output file'), + totalFiles: z.number().describe('Total number of files processed'), + totalTokens: z.number().describe('Total token count of the content'), +}); + export const registerPackCodebaseTool = (mcpServer: McpServer) => { - mcpServer.tool( + mcpServer.registerTool( 'pack_codebase', - 'Package a local code directory into a consolidated XML file for AI analysis. This tool analyzes the codebase structure, extracts relevant code content, and generates a comprehensive report including metrics, file tree, and formatted code content. Supports Tree-sitter compression for efficient token usage.', - { - directory: z.string().describe('Directory to pack (Absolute path)'), - compress: z - .boolean() - .default(false) - .describe( - 'Enable Tree-sitter compression to extract essential code signatures and structure while removing implementation details. Reduces token usage by ~70% while preserving semantic meaning. Generally not needed since grep_repomix_output allows incremental content retrieval. Use only when you specifically need the entire codebase content for large repositories (default: false).', - ), - includePatterns: z - .string() - .optional() - .describe( - 'Specify files to include using fast-glob patterns. Multiple patterns can be comma-separated (e.g., "**/*.{js,ts}", "src/**,docs/**"). Only matching files will be processed. Useful for focusing on specific parts of the codebase.', - ), - ignorePatterns: z - .string() - .optional() - .describe( - 'Specify additional files to exclude using fast-glob patterns. Multiple patterns can be comma-separated (e.g., "test/**,*.spec.js", "node_modules/**,dist/**"). These patterns supplement .gitignore and built-in exclusions.', - ), - topFilesLength: z - .number() - .optional() - .default(10) - .describe( - 'Number of largest files by size to display in the metrics summary for codebase analysis (default: 10)', - ), - }, { title: 'Pack Local Codebase', - readOnlyHint: true, - destructiveHint: false, - idempotentHint: false, - openWorldHint: false, + description: + 'Package a local code directory into a consolidated XML file for AI analysis. This tool analyzes the codebase structure, extracts relevant code content, and generates a comprehensive report including metrics, file tree, and formatted code content. Supports Tree-sitter compression for efficient token usage.', + inputSchema: packCodebaseInputSchema.shape, + outputSchema: packCodebaseOutputSchema.shape, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, }, async ({ directory, compress, includePatterns, ignorePatterns, topFilesLength }): Promise => { let tempDir = ''; @@ -70,15 +84,17 @@ export const registerPackCodebaseTool = (mcpServer: McpServer) => { const result = await runCli(['.'], directory, cliOptions); if (!result) { - return buildMcpToolErrorResponse(['Failed to return a result']); + return buildMcpToolErrorResponse({ + errorMessage: 'Failed to return a result', + }); } // Extract metrics information from the pack result const { packResult } = result; - return await formatToolResponse({ directory }, packResult, outputFilePath, topFilesLength); + return await formatPackToolResponse({ directory }, packResult, outputFilePath, topFilesLength); } catch (error) { - return formatToolError(error); + return buildMcpToolErrorResponse(convertErrorToJson(error)); } }, ); diff --git a/src/mcp/tools/packRemoteRepositoryTool.ts b/src/mcp/tools/packRemoteRepositoryTool.ts index d01db675b..fb3edcf22 100644 --- a/src/mcp/tools/packRemoteRepositoryTool.ts +++ b/src/mcp/tools/packRemoteRepositoryTool.ts @@ -6,53 +6,67 @@ import { runCli } from '../../cli/cliRun.js'; import type { CliOptions } from '../../cli/types.js'; import { buildMcpToolErrorResponse, + convertErrorToJson, createToolWorkspace, - formatToolError, - formatToolResponse, + formatPackToolResponse, } from './mcpToolRuntime.js'; +const packRemoteRepositoryInputSchema = z.object({ + remote: z + .string() + .describe( + 'GitHub repository URL or user/repo format (e.g., "yamadashy/repomix", "https://github.com/user/repo", or "https://github.com/user/repo/tree/branch")', + ), + compress: z + .boolean() + .default(false) + .describe( + 'Enable Tree-sitter compression to extract essential code signatures and structure while removing implementation details. Reduces token usage by ~70% while preserving semantic meaning. Generally not needed since grep_repomix_output allows incremental content retrieval. Use only when you specifically need the entire codebase content for large repositories (default: false).', + ), + includePatterns: z + .string() + .optional() + .describe( + 'Specify files to include using fast-glob patterns. Multiple patterns can be comma-separated (e.g., "**/*.{js,ts}", "src/**,docs/**"). Only matching files will be processed. Useful for focusing on specific parts of the codebase.', + ), + ignorePatterns: z + .string() + .optional() + .describe( + 'Specify additional files to exclude using fast-glob patterns. Multiple patterns can be comma-separated (e.g., "test/**,*.spec.js", "node_modules/**,dist/**"). These patterns supplement .gitignore and built-in exclusions.', + ), + topFilesLength: z + .number() + .optional() + .default(10) + .describe('Number of largest files by size to display in the metrics summary for codebase analysis (default: 10)'), +}); + +const packRemoteRepositoryOutputSchema = z.object({ + description: z.string().describe('Human-readable description of the packing results'), + result: z.string().describe('JSON string containing detailed metrics and file information'), + directoryStructure: z.string().describe('Tree structure of the processed repository'), + outputId: z.string().describe('Unique identifier for accessing the packed content'), + outputFilePath: z.string().describe('File path to the generated output file'), + totalFiles: z.number().describe('Total number of files processed'), + totalTokens: z.number().describe('Total token count of the content'), +}); + export const registerPackRemoteRepositoryTool = (mcpServer: McpServer) => { - mcpServer.tool( + mcpServer.registerTool( 'pack_remote_repository', - 'Fetch, clone, and package a GitHub repository into a consolidated XML file for AI analysis. This tool automatically clones the remote repository, analyzes its structure, and generates a comprehensive report. Supports various GitHub URL formats and includes security checks to prevent exposure of sensitive information.', - { - remote: z - .string() - .describe( - 'GitHub repository URL or user/repo format (e.g., "yamadashy/repomix", "https://github.com/user/repo", or "https://github.com/user/repo/tree/branch")', - ), - compress: z - .boolean() - .default(false) - .describe( - 'Enable Tree-sitter compression to extract essential code signatures and structure while removing implementation details. Reduces token usage by ~70% while preserving semantic meaning. Generally not needed since grep_repomix_output allows incremental content retrieval. Use only when you specifically need the entire codebase content for large repositories (default: false).', - ), - includePatterns: z - .string() - .optional() - .describe( - 'Specify files to include using fast-glob patterns. Multiple patterns can be comma-separated (e.g., "**/*.{js,ts}", "src/**,docs/**"). Only matching files will be processed. Useful for focusing on specific parts of the codebase.', - ), - ignorePatterns: z - .string() - .optional() - .describe( - 'Specify additional files to exclude using fast-glob patterns. Multiple patterns can be comma-separated (e.g., "test/**,*.spec.js", "node_modules/**,dist/**"). These patterns supplement .gitignore and built-in exclusions.', - ), - topFilesLength: z - .number() - .optional() - .default(10) - .describe( - 'Number of largest files by size to display in the metrics summary for codebase analysis (default: 10)', - ), - }, { title: 'Pack Remote Repository', - readOnlyHint: true, - destructiveHint: false, - idempotentHint: false, - openWorldHint: true, + description: + 'Fetch, clone, and package a GitHub repository into a consolidated XML file for AI analysis. This tool automatically clones the remote repository, analyzes its structure, and generates a comprehensive report. Supports various GitHub URL formats and includes security checks to prevent exposure of sensitive information.', + inputSchema: packRemoteRepositoryInputSchema.shape, + outputSchema: packRemoteRepositoryOutputSchema.shape, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, }, async ({ remote, compress, includePatterns, ignorePatterns, topFilesLength }): Promise => { let tempDir = ''; @@ -75,15 +89,17 @@ export const registerPackRemoteRepositoryTool = (mcpServer: McpServer) => { const result = await runCli(['.'], process.cwd(), cliOptions); if (!result) { - return buildMcpToolErrorResponse(['Failed to return a result']); + return buildMcpToolErrorResponse({ + errorMessage: 'Failed to return a result', + }); } // Extract metrics information from the pack result const { packResult } = result; - return await formatToolResponse({ repository: remote }, packResult, outputFilePath, topFilesLength); + return await formatPackToolResponse({ repository: remote }, packResult, outputFilePath, topFilesLength); } catch (error) { - return formatToolError(error); + return buildMcpToolErrorResponse(convertErrorToJson(error)); } }, ); diff --git a/src/mcp/tools/readRepomixOutputTool.ts b/src/mcp/tools/readRepomixOutputTool.ts index bc5b0450f..c2c34e33d 100644 --- a/src/mcp/tools/readRepomixOutputTool.ts +++ b/src/mcp/tools/readRepomixOutputTool.ts @@ -3,32 +3,48 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; import { logger } from '../../shared/logger.js'; -import { buildMcpToolErrorResponse, buildMcpToolSuccessResponse, getOutputFilePath } from './mcpToolRuntime.js'; +import { + buildMcpToolErrorResponse, + buildMcpToolSuccessResponse, + convertErrorToJson, + getOutputFilePath, +} from './mcpToolRuntime.js'; + +const readRepomixOutputInputSchema = z.object({ + outputId: z.string().describe('ID of the Repomix output file to read'), + startLine: z + .number() + .optional() + .describe('Starting line number (1-based, inclusive). If not specified, reads from beginning.'), + endLine: z.number().optional().describe('Ending line number (1-based, inclusive). If not specified, reads to end.'), +}); + +const readRepomixOutputOutputSchema = z.object({ + content: z.string().describe('The file content or specified line range'), + totalLines: z.number().describe('Total number of lines in the file'), + linesRead: z.number().describe('Number of lines actually read'), + startLine: z.number().optional().describe('Starting line number used'), + endLine: z.number().optional().describe('Ending line number used'), +}); /** * Register the tool to read Repomix output files */ export const registerReadRepomixOutputTool = (mcpServer: McpServer) => { - mcpServer.tool( + mcpServer.registerTool( 'read_repomix_output', - 'Read the contents of a Repomix-generated output file. Supports partial reading with line range specification for large files. This tool is designed for environments where direct file system access is limited (e.g., web-based environments, sandboxed applications). For direct file system access, use standard file operations.', - { - outputId: z.string().describe('ID of the Repomix output file to read'), - startLine: z - .number() - .optional() - .describe('Starting line number (1-based, inclusive). If not specified, reads from beginning.'), - endLine: z - .number() - .optional() - .describe('Ending line number (1-based, inclusive). If not specified, reads to end.'), - }, { title: 'Read Repomix Output', - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, + description: + 'Read the contents of a Repomix-generated output file. Supports partial reading with line range specification for large files. This tool is designed for environments where direct file system access is limited (e.g., web-based environments, sandboxed applications). For direct file system access, use standard file operations.', + inputSchema: readRepomixOutputInputSchema.shape, + outputSchema: readRepomixOutputOutputSchema.shape, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, }, async ({ outputId, startLine, endLine }): Promise => { try { @@ -37,54 +53,76 @@ export const registerReadRepomixOutputTool = (mcpServer: McpServer) => { // Get the file path from the registry const filePath = getOutputFilePath(outputId); if (!filePath) { - return buildMcpToolErrorResponse([ - `Error: Output file with ID ${outputId} not found. The output file may have been deleted or the ID is invalid.`, - ]); + return buildMcpToolErrorResponse({ + errorMessage: `Error: Output file with ID ${outputId} not found. The output file may have been deleted or the ID is invalid.`, + }); } // Check if the file exists try { await fs.access(filePath); } catch (error) { - return buildMcpToolErrorResponse([ - `Error: Output file does not exist at path: ${filePath}. The temporary file may have been cleaned up.`, - ]); + return buildMcpToolErrorResponse({ + errorMessage: `Error: Output file does not exist at path: ${filePath}. The temporary file may have been cleaned up.`, + }); } // Read the file content const content = await fs.readFile(filePath, 'utf8'); + const lines = content.split('\n'); + const totalLines = lines.length; let processedContent = content; + let actualStartLine = 1; + let actualEndLine = totalLines; + let linesRead = totalLines; + if (startLine !== undefined || endLine !== undefined) { + // Validate that startLine and endLine are positive values + if (startLine !== undefined && startLine < 1) { + return buildMcpToolErrorResponse({ + errorMessage: `Error: Start line must be >= 1, got ${startLine}.`, + }); + } + + if (endLine !== undefined && endLine < 1) { + return buildMcpToolErrorResponse({ + errorMessage: `Error: End line must be >= 1, got ${endLine}.`, + }); + } + // Validate that startLine is less than or equal to endLine when both are provided if (startLine !== undefined && endLine !== undefined && startLine > endLine) { - return buildMcpToolErrorResponse([ - `Error: Start line (${startLine}) cannot be greater than end line (${endLine}).`, - ]); + return buildMcpToolErrorResponse({ + errorMessage: `Error: Start line (${startLine}) cannot be greater than end line (${endLine}).`, + }); } - const lines = content.split('\n'); const start = Math.max(0, (startLine || 1) - 1); const end = endLine ? Math.min(lines.length, endLine) : lines.length; if (start >= lines.length) { - return buildMcpToolErrorResponse([ - `Error: Start line ${startLine} exceeds total lines (${lines.length}) in the file.`, - ]); + return buildMcpToolErrorResponse({ + errorMessage: `Error: Start line ${startLine} exceeds total lines (${lines.length}) in the file.`, + }); } processedContent = lines.slice(start, end).join('\n'); + actualStartLine = start + 1; + actualEndLine = end; + linesRead = end - start; } - return buildMcpToolSuccessResponse([ - `Content of Repomix output file (ID: ${outputId})${startLine || endLine ? ` (lines ${startLine || 1}-${endLine || 'end'})` : ''}:`, - processedContent, - ]); + return buildMcpToolSuccessResponse({ + content: processedContent, + totalLines, + linesRead, + startLine: startLine || actualStartLine, + endLine: endLine || actualEndLine, + } satisfies z.infer); } catch (error) { logger.error(`Error reading Repomix output: ${error}`); - return buildMcpToolErrorResponse([ - `Error reading Repomix output: ${error instanceof Error ? error.message : String(error)}`, - ]); + return buildMcpToolErrorResponse(convertErrorToJson(error)); } }, ); diff --git a/tests/mcp/mcpServer.test.ts b/tests/mcp/mcpServer.test.ts index bb03154e9..98833c700 100644 --- a/tests/mcp/mcpServer.test.ts +++ b/tests/mcp/mcpServer.test.ts @@ -34,10 +34,15 @@ describe('MCP Server', () => { test('should create server with correct configuration', async () => { const server = await createMcpServer(); - expect(McpServer).toHaveBeenCalledWith({ - name: 'repomix-mcp-server', - version: mockVersion, - }); + expect(McpServer).toHaveBeenCalledWith( + { + name: 'repomix-mcp-server', + version: mockVersion, + }, + { + instructions: expect.stringContaining('Repomix MCP Server provides AI-optimized codebase analysis tools'), + }, + ); expect(server).toBeDefined(); }); }); @@ -58,6 +63,8 @@ describe('MCP Server', () => { const mockMcpServer = { tool: vi.fn().mockReturnThis(), prompt: vi.fn().mockReturnThis(), + registerTool: vi.fn().mockReturnThis(), + registerPrompt: vi.fn().mockReturnThis(), connect: vi.fn().mockRejectedValue(error), close: vi.fn().mockResolvedValue(undefined), ...createMockServerProps(), @@ -78,6 +85,8 @@ describe('MCP Server', () => { const mockServer = { tool: vi.fn().mockReturnThis(), prompt: vi.fn().mockReturnThis(), + registerTool: vi.fn().mockReturnThis(), + registerPrompt: vi.fn().mockReturnThis(), connect: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), ...createMockServerProps(), @@ -100,6 +109,8 @@ describe('MCP Server', () => { const mockServer = { tool: vi.fn().mockReturnThis(), prompt: vi.fn().mockReturnThis(), + registerTool: vi.fn().mockReturnThis(), + registerPrompt: vi.fn().mockReturnThis(), connect: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), ...createMockServerProps(), @@ -123,6 +134,8 @@ describe('MCP Server', () => { const mockServer = { tool: vi.fn().mockReturnThis(), prompt: vi.fn().mockReturnThis(), + registerTool: vi.fn().mockReturnThis(), + registerPrompt: vi.fn().mockReturnThis(), connect: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockRejectedValue(error), ...createMockServerProps(), diff --git a/tests/mcp/tools/fileSystemReadDirectoryTool.test.ts b/tests/mcp/tools/fileSystemReadDirectoryTool.test.ts index 0a5e54dbe..1ac1e5db2 100644 --- a/tests/mcp/tools/fileSystemReadDirectoryTool.test.ts +++ b/tests/mcp/tools/fileSystemReadDirectoryTool.test.ts @@ -13,7 +13,7 @@ vi.mock('../../../src/shared/logger.js'); describe('FileSystemReadDirectoryTool', () => { const mockServer = { - tool: vi.fn().mockReturnThis(), + registerTool: vi.fn().mockReturnThis(), } as unknown as McpServer; let toolHandler: (args: { path: string }) => Promise; @@ -21,18 +21,16 @@ describe('FileSystemReadDirectoryTool', () => { beforeEach(() => { vi.resetAllMocks(); registerFileSystemReadDirectoryTool(mockServer); - toolHandler = (mockServer.tool as ReturnType).mock.calls[0][4]; + toolHandler = (mockServer.registerTool as ReturnType).mock.calls[0][2]; // デフォルトのpath.isAbsoluteの動作をモック vi.mocked(path.isAbsolute).mockImplementation((p: string) => p.startsWith('/')); }); test('should register tool with correct parameters', () => { - expect(mockServer.tool).toHaveBeenCalledWith( + expect(mockServer.registerTool).toHaveBeenCalledWith( 'file_system_read_directory', - 'List the contents of a directory using an absolute path. Returns a formatted list showing files and subdirectories with clear [FILE]/[DIR] indicators. Useful for exploring project structure and understanding codebase organization.', - expect.any(Object), - expect.any(Object), // annotations + expect.any(Object), // tool spec expect.any(Function), ); }); @@ -48,9 +46,12 @@ describe('FileSystemReadDirectoryTool', () => { content: [ { type: 'text', - text: `Error: Path must be absolute. Received: ${testPath}`, + text: JSON.stringify({ errorMessage: `Error: Path must be absolute. Received: ${testPath}` }, null, 2), }, ], + structuredContent: { + errorMessage: `Error: Path must be absolute. Received: ${testPath}`, + }, }); }); @@ -66,9 +67,12 @@ describe('FileSystemReadDirectoryTool', () => { content: [ { type: 'text', - text: `Error: Directory not found at path: ${testPath}`, + text: JSON.stringify({ errorMessage: `Error: Directory not found at path: ${testPath}` }, null, 2), }, ], + structuredContent: { + errorMessage: `Error: Directory not found at path: ${testPath}`, + }, }); }); }); diff --git a/tests/mcp/tools/fileSystemReadFileTool.test.ts b/tests/mcp/tools/fileSystemReadFileTool.test.ts index a8337f58c..db16cb8a9 100644 --- a/tests/mcp/tools/fileSystemReadFileTool.test.ts +++ b/tests/mcp/tools/fileSystemReadFileTool.test.ts @@ -15,7 +15,7 @@ vi.mock('../../../src/core/security/workers/securityCheckWorker.js'); describe('FileSystemReadFileTool', () => { const mockServer = { - tool: vi.fn().mockReturnThis(), + registerTool: vi.fn().mockReturnThis(), } as unknown as McpServer; let toolHandler: (args: { path: string }) => Promise; @@ -23,18 +23,16 @@ describe('FileSystemReadFileTool', () => { beforeEach(() => { vi.resetAllMocks(); registerFileSystemReadFileTool(mockServer); - toolHandler = (mockServer.tool as ReturnType).mock.calls[0][4]; + toolHandler = (mockServer.registerTool as ReturnType).mock.calls[0][2]; // デフォルトのpath.isAbsoluteの動作をモック vi.mocked(path.isAbsolute).mockImplementation((p: string) => p.startsWith('/')); }); test('should register tool with correct parameters', () => { - expect(mockServer.tool).toHaveBeenCalledWith( + expect(mockServer.registerTool).toHaveBeenCalledWith( 'file_system_read_file', - 'Read a file from the local file system using an absolute path. Includes built-in security validation to detect and prevent access to files containing sensitive information (API keys, passwords, secrets).', - expect.any(Object), - expect.any(Object), // annotations + expect.any(Object), // tool spec expect.any(Function), ); }); @@ -50,9 +48,12 @@ describe('FileSystemReadFileTool', () => { content: [ { type: 'text', - text: `Error: Path must be absolute. Received: ${testPath}`, + text: JSON.stringify({ errorMessage: `Error: Path must be absolute. Received: ${testPath}` }, null, 2), }, ], + structuredContent: { + errorMessage: `Error: Path must be absolute. Received: ${testPath}`, + }, }); }); @@ -68,9 +69,12 @@ describe('FileSystemReadFileTool', () => { content: [ { type: 'text', - text: `Error: File not found at path: ${testPath}`, + text: JSON.stringify({ errorMessage: `Error: File not found at path: ${testPath}` }, null, 2), }, ], + structuredContent: { + errorMessage: `Error: File not found at path: ${testPath}`, + }, }); }); }); diff --git a/tests/mcp/tools/grepRepomixOutputTool.test.ts b/tests/mcp/tools/grepRepomixOutputTool.test.ts index ae1b5ab13..71c85c80a 100644 --- a/tests/mcp/tools/grepRepomixOutputTool.test.ts +++ b/tests/mcp/tools/grepRepomixOutputTool.test.ts @@ -408,7 +408,7 @@ describe('grepRepomixOutputTool', () => { describe('registerGrepRepomixOutputTool integration tests', () => { const mockMcpServer = { - tool: vi.fn(), + registerTool: vi.fn(), } as const; type ToolHandlerType = (args: { @@ -430,28 +430,13 @@ describe('grepRepomixOutputTool', () => { registerGrepRepomixOutputTool(mockMcpServer as unknown as McpServer); - toolHandler = mockMcpServer.tool.mock.calls[0][4]; + toolHandler = mockMcpServer.registerTool.mock.calls[0][2]; }); it('should register the tool with correct parameters', () => { - expect(mockMcpServer.tool).toHaveBeenCalledWith( + expect(mockMcpServer.registerTool).toHaveBeenCalledWith( 'grep_repomix_output', - expect.any(String), - expect.objectContaining({ - outputId: expect.any(Object), - pattern: expect.any(Object), - contextLines: expect.any(Object), - beforeLines: expect.any(Object), - afterLines: expect.any(Object), - ignoreCase: expect.any(Object), - }), - expect.objectContaining({ - title: 'Grep Repomix Output', - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, - }), + expect.any(Object), // tool spec expect.any(Function), ); }); @@ -466,10 +451,11 @@ describe('grepRepomixOutputTool', () => { const result = await toolHandler({ outputId: 'test-id', pattern: 'pattern' }); expect(result.isError).toBeUndefined(); - expect(result.content).toHaveLength(2); - expect(result.content[0].text).toContain('Found 2 match(es)'); - expect(result.content[1].text).toContain('2:pattern match'); - expect(result.content[1].text).toContain('4:another pattern'); + expect(result.content).toHaveLength(1); + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.description).toContain('Found 2 match(es)'); + expect(parsedResult.formattedOutput).toContain('2:pattern match'); + expect(parsedResult.formattedOutput).toContain('4:another pattern'); }); it('should handle separate before and after context lines', async () => { @@ -486,11 +472,13 @@ describe('grepRepomixOutputTool', () => { afterLines: 1, }); - expect(result.content[1].text).toContain('1-line 1'); - expect(result.content[1].text).toContain('2-line 2'); - expect(result.content[1].text).toContain('3:pattern match'); - expect(result.content[1].text).toContain('4-line 4'); - expect(result.content[1].text).not.toContain('5-line 5'); + const parsedResult = JSON.parse(result.content[0].text); + const formattedOutputString = parsedResult.formattedOutput.join('\n'); + expect(formattedOutputString).toContain('1-line 1'); + expect(formattedOutputString).toContain('2-line 2'); + expect(formattedOutputString).toContain('3:pattern match'); + expect(formattedOutputString).toContain('4-line 4'); + expect(formattedOutputString).not.toContain('5-line 5'); }); it('should prioritize beforeLines and afterLines over contextLines', async () => { @@ -506,10 +494,12 @@ describe('grepRepomixOutputTool', () => { afterLines: 0, }); - expect(result.content[1].text).toContain('2-line 2'); - expect(result.content[1].text).toContain('3:pattern match'); - expect(result.content[1].text).not.toContain('1-line 1'); - expect(result.content[1].text).not.toContain('4-line 4'); + const parsedResult = JSON.parse(result.content[0].text); + const formattedOutputString = parsedResult.formattedOutput.join('\n'); + expect(formattedOutputString).toContain('2-line 2'); + expect(formattedOutputString).toContain('3:pattern match'); + expect(formattedOutputString).not.toContain('1-line 1'); + expect(formattedOutputString).not.toContain('4-line 4'); }); it('should use contextLines when beforeLines and afterLines are not specified', async () => { @@ -519,9 +509,11 @@ describe('grepRepomixOutputTool', () => { const result = await toolHandler({ outputId: 'test-id', pattern: 'pattern', contextLines: 1 }); - expect(result.content[1].text).toContain('2-line 2'); - expect(result.content[1].text).toContain('3:pattern match'); - expect(result.content[1].text).toContain('4-line 4'); + const parsedResult = JSON.parse(result.content[0].text); + const formattedOutputString = parsedResult.formattedOutput.join('\n'); + expect(formattedOutputString).toContain('2-line 2'); + expect(formattedOutputString).toContain('3:pattern match'); + expect(formattedOutputString).toContain('4-line 4'); }); it('should handle case insensitive search', async () => { @@ -531,7 +523,9 @@ describe('grepRepomixOutputTool', () => { const result = await toolHandler({ outputId: 'test-id', pattern: 'pattern', ignoreCase: true }); - expect(result.content[1].text).toContain('2:PATTERN match'); + const parsedResult = JSON.parse(result.content[0].text); + const formattedOutputString = parsedResult.formattedOutput.join('\n'); + expect(formattedOutputString).toContain('2:PATTERN match'); }); it('should return no matches message when pattern not found', async () => { @@ -541,7 +535,8 @@ describe('grepRepomixOutputTool', () => { const result = await toolHandler({ outputId: 'test-id', pattern: 'notfound' }); - expect(result.content[0].text).toContain('No matches found'); + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.description).toContain('No matches found'); }); it('should handle invalid regex patterns', async () => { @@ -552,7 +547,8 @@ describe('grepRepomixOutputTool', () => { const result = await toolHandler({ outputId: 'test-id', pattern: '[invalid' }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Invalid regular expression pattern'); + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.errorMessage).toContain('Invalid regular expression pattern'); }); it('should handle file not found error', async () => { @@ -561,7 +557,8 @@ describe('grepRepomixOutputTool', () => { const result = await toolHandler({ outputId: 'test-id', pattern: 'test' }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Output file with ID test-id not found'); + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.errorMessage).toContain('Output file with ID test-id not found'); }); it('should handle file access error', async () => { @@ -571,7 +568,8 @@ describe('grepRepomixOutputTool', () => { const result = await toolHandler({ outputId: 'test-id', pattern: 'test' }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Output file does not exist'); + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.errorMessage).toContain('Output file does not exist'); }); // Multilingual and Unicode content integration tests @@ -585,10 +583,11 @@ describe('grepRepomixOutputTool', () => { const result = await toolHandler({ outputId: 'test-id', pattern: '日本語' }); expect(result.isError).toBeUndefined(); - expect(result.content).toHaveLength(2); - expect(result.content[0].text).toContain('Found 2 match(es)'); - expect(result.content[1].text).toContain('2:日本語のパターン'); - expect(result.content[1].text).toContain('4:別の日本語'); + expect(result.content).toHaveLength(1); + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.description).toContain('Found 2 match(es)'); + expect(parsedResult.formattedOutput).toContain('2:日本語のパターン'); + expect(parsedResult.formattedOutput).toContain('4:別の日本語'); }); it('should handle Chinese text in file content', async () => { @@ -599,10 +598,11 @@ describe('grepRepomixOutputTool', () => { const result = await toolHandler({ outputId: 'test-id', pattern: '中文' }); expect(result.isError).toBeUndefined(); - expect(result.content).toHaveLength(2); - expect(result.content[0].text).toContain('Found 2 match(es)'); - expect(result.content[1].text).toContain('2:中文搜索'); - expect(result.content[1].text).toContain('4:更多中文'); + expect(result.content).toHaveLength(1); + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.description).toContain('Found 2 match(es)'); + expect(parsedResult.formattedOutput).toContain('2:中文搜索'); + expect(parsedResult.formattedOutput).toContain('4:更多中文'); }); it('should handle Korean text in file content', async () => { @@ -615,10 +615,11 @@ describe('grepRepomixOutputTool', () => { const result = await toolHandler({ outputId: 'test-id', pattern: '한국어' }); expect(result.isError).toBeUndefined(); - expect(result.content).toHaveLength(2); - expect(result.content[0].text).toContain('Found 2 match(es)'); - expect(result.content[1].text).toContain('2:한국어 검색'); - expect(result.content[1].text).toContain('4:다른 한국어'); + expect(result.content).toHaveLength(1); + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.description).toContain('Found 2 match(es)'); + expect(parsedResult.formattedOutput).toContain('2:한국어 검색'); + expect(parsedResult.formattedOutput).toContain('4:다른 한국어'); }); it('should handle emoji content in file', async () => { @@ -631,10 +632,11 @@ describe('grepRepomixOutputTool', () => { const result = await toolHandler({ outputId: 'test-id', pattern: '🎉|🚀' }); expect(result.isError).toBeUndefined(); - expect(result.content).toHaveLength(2); - expect(result.content[0].text).toContain('Found 2 match(es)'); - expect(result.content[1].text).toContain('2:🎉 celebration'); - expect(result.content[1].text).toContain('4:🚀 rocket emoji'); + expect(result.content).toHaveLength(1); + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.description).toContain('Found 2 match(es)'); + expect(parsedResult.formattedOutput).toContain('2:🎉 celebration'); + expect(parsedResult.formattedOutput).toContain('4:🚀 rocket emoji'); }); it('should handle mixed multilingual content in file', async () => { @@ -647,12 +649,14 @@ describe('grepRepomixOutputTool', () => { const result = await toolHandler({ outputId: 'test-id', pattern: 'English', contextLines: 1 }); expect(result.isError).toBeUndefined(); - expect(result.content).toHaveLength(2); - expect(result.content[0].text).toContain('Found 4 match(es)'); - expect(result.content[1].text).toContain('1:English line'); - expect(result.content[1].text).toContain('2-日本語とEnglish混在'); - expect(result.content[1].text).toContain('3-中文和English混合'); - expect(result.content[1].text).toContain('5:नमस्ते English'); + expect(result.content).toHaveLength(1); + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.description).toContain('Found 4 match(es)'); + const formattedOutputString = parsedResult.formattedOutput.join('\n'); + expect(formattedOutputString).toContain('1:English line'); + expect(formattedOutputString).toContain('2-日本語とEnglish混在'); + expect(formattedOutputString).toContain('3-中文和English混合'); + expect(formattedOutputString).toContain('5:नमस्ते English'); }); it('should handle complex Unicode regex patterns in file content', async () => { @@ -665,12 +669,14 @@ describe('grepRepomixOutputTool', () => { const result = await toolHandler({ outputId: 'test-id', pattern: '.+@.+\\.(com|jp|org)' }); expect(result.isError).toBeUndefined(); - expect(result.content).toHaveLength(2); - expect(result.content[0].text).toContain('Found 4 match(es)'); - expect(result.content[1].text).toContain('1:user@example.com'); - expect(result.content[1].text).toContain('2:ユーザー@例.jp'); - expect(result.content[1].text).toContain('3:test@テスト.org'); - expect(result.content[1].text).toContain('4:管理者@サンプル.co.jp'); + expect(result.content).toHaveLength(1); + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.description).toContain('Found 4 match(es)'); + const formattedOutputString = parsedResult.formattedOutput.join('\n'); + expect(formattedOutputString).toContain('1:user@example.com'); + expect(formattedOutputString).toContain('2:ユーザー@例.jp'); + expect(formattedOutputString).toContain('3:test@テスト.org'); + expect(formattedOutputString).toContain('4:管理者@サンプル.co.jp'); }); it('should handle special characters with escaping in file content', async () => { @@ -683,10 +689,12 @@ describe('grepRepomixOutputTool', () => { const result = await toolHandler({ outputId: 'test-id', pattern: '\\$special', contextLines: 1 }); expect(result.isError).toBeUndefined(); - expect(result.content).toHaveLength(2); - expect(result.content[0].text).toContain('Found 2 match(es)'); - expect(result.content[1].text).toContain('2:$special chars #symbols'); - expect(result.content[1].text).toContain('4:&more $special items'); + expect(result.content).toHaveLength(1); + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.description).toContain('Found 2 match(es)'); + const formattedOutputString = parsedResult.formattedOutput.join('\n'); + expect(formattedOutputString).toContain('2:$special chars #symbols'); + expect(formattedOutputString).toContain('4:&more $special items'); }); it('should handle case-insensitive search with multibyte characters in file', async () => { @@ -699,10 +707,12 @@ describe('grepRepomixOutputTool', () => { const result = await toolHandler({ outputId: 'test-id', pattern: 'test', ignoreCase: true }); expect(result.isError).toBeUndefined(); - expect(result.content).toHaveLength(2); - expect(result.content[0].text).toContain('Found 2 match(es)'); - expect(result.content[1].text).toContain('2:NIPPON語test'); - expect(result.content[1].text).toContain('4:TEST中文'); + expect(result.content).toHaveLength(1); + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.description).toContain('Found 2 match(es)'); + const formattedOutputString = parsedResult.formattedOutput.join('\n'); + expect(formattedOutputString).toContain('2:NIPPON語test'); + expect(formattedOutputString).toContain('4:TEST中文'); }); }); }); diff --git a/tests/mcp/tools/mcpToolRuntime.test.ts b/tests/mcp/tools/mcpToolRuntime.test.ts index f619ab78e..59ee96385 100644 --- a/tests/mcp/tools/mcpToolRuntime.test.ts +++ b/tests/mcp/tools/mcpToolRuntime.test.ts @@ -3,12 +3,21 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Type guard for structured content with result property +function hasResult(obj: unknown): obj is { result: string } { + return ( + typeof obj === 'object' && + obj !== null && + 'result' in obj && + typeof (obj as Record).result === 'string' + ); +} import { buildMcpToolErrorResponse, buildMcpToolSuccessResponse, createToolWorkspace, - formatToolError, - formatToolResponse, + formatPackToolResponse, generateOutputId, getOutputFilePath, registerOutputFile, @@ -112,16 +121,28 @@ describe('mcpToolRuntime', () => { }; const outputFilePath = '/path/to/output.xml'; - const response = await formatToolResponse(context, metrics, outputFilePath); + const response = await formatPackToolResponse(context, metrics, outputFilePath); expect(response).toHaveProperty('content'); - expect(response.content).toHaveLength(6); + expect(response.content).toHaveLength(1); expect(response.content[0].type).toBe('text'); - expect(response.content[1].type).toBe('text'); - expect(response.content[1].text).toContain('"directory": "/path/to/dir"'); - expect(response.content[1].text).toContain('"outputId": "abcdef1234567890"'); - expect(response.content[1].text).toContain('"totalFiles": 10'); - expect(response.content[1].text).toContain('"totalLines": 5'); + // Check that the structured content contains the expected result JSON + const structuredContent = response.structuredContent; + expect(structuredContent).toHaveProperty('result'); + + if (!hasResult(structuredContent)) { + throw new Error('Expected structuredContent to have a result property of type string'); + } + + const resultJson = JSON.parse(structuredContent.result); + expect(resultJson.directory).toBe('/path/to/dir'); + expect(resultJson.outputId).toBe('abcdef1234567890'); + expect(resultJson.metrics.totalFiles).toBe(10); + expect(resultJson.metrics.totalLines).toBe(5); + expect(response).toHaveProperty('structuredContent'); + expect(response.structuredContent).toHaveProperty('result'); + expect(response.structuredContent).toHaveProperty('description'); + expect(response.structuredContent).toHaveProperty('directoryStructure'); }); it('should format a tool response with repository context', async () => { @@ -144,11 +165,20 @@ describe('mcpToolRuntime', () => { }; const outputFilePath = '/path/to/output.xml'; - const response = await formatToolResponse(context, metrics, outputFilePath); + const response = await formatPackToolResponse(context, metrics, outputFilePath); + + // Check that the structured content contains the expected result JSON + const structuredContent = response.structuredContent; + expect(structuredContent).toHaveProperty('result'); - expect(response.content[1].text).toContain('"repository": "user/repo"'); - expect(response.content[1].text).not.toContain('"directory":'); - expect(response.content[1].text).toContain('"totalLines": 5'); + if (!hasResult(structuredContent)) { + throw new Error('Expected structuredContent to have a result property of type string'); + } + + const resultJson = JSON.parse(structuredContent.result); + expect(resultJson.repository).toBe('user/repo'); + expect(resultJson.directory).toBeUndefined(); + expect(resultJson.metrics.totalLines).toBe(5); }); it('should limit the number of top files based on the parameter', async () => { @@ -180,113 +210,129 @@ describe('mcpToolRuntime', () => { const outputFilePath = '/path/to/output.xml'; const topFilesLen = 3; - const response = await formatToolResponse(context, metrics, outputFilePath, topFilesLen); + const response = await formatPackToolResponse(context, metrics, outputFilePath, topFilesLen); + + // Check that the structured content contains the expected result JSON + const structuredContent = response.structuredContent; + expect(structuredContent).toHaveProperty('result'); - const jsonContent = JSON.parse(response.content[1].text as string); - expect(jsonContent.metrics.topFiles).toHaveLength(3); - expect(jsonContent.metrics.topFiles[0].path).toBe('file1.js'); - expect(jsonContent.metrics.topFiles[1].path).toBe('file2.js'); - expect(jsonContent.metrics.topFiles[2].path).toBe('file3.js'); - expect(jsonContent.metrics.totalLines).toBe(5); + if (!hasResult(structuredContent)) { + throw new Error('Expected structuredContent to have a result property of type string'); + } + + const result = JSON.parse(structuredContent.result); + expect(result.metrics.topFiles).toHaveLength(3); + expect(result.metrics.topFiles[0].path).toBe('file1.js'); + expect(result.metrics.topFiles[1].path).toBe('file2.js'); + expect(result.metrics.topFiles[2].path).toBe('file3.js'); + expect(result.metrics.totalLines).toBe(5); }); }); describe('buildMcpToolSuccessResponse', () => { - it('should create a successful response with single message', () => { - const messages = ['Operation completed successfully']; - const response = buildMcpToolSuccessResponse(messages); + it('should create a successful response with structured content', () => { + const structuredContent = { description: 'Operation completed successfully' }; + const response = buildMcpToolSuccessResponse(structuredContent); expect(response).toEqual({ content: [ { type: 'text', - text: 'Operation completed successfully', + text: JSON.stringify(structuredContent, null, 2), }, ], + structuredContent: structuredContent, }); expect(response.isError).toBeUndefined(); }); - it('should create a successful response with multiple messages', () => { - const messages = ['First message', 'Second message', 'Third message']; - const response = buildMcpToolSuccessResponse(messages); + it('should create a successful response with complex structured content', () => { + const structuredContent = { + description: 'Operation completed successfully', + results: ['First result', 'Second result', 'Third result'], + count: 3, + }; + const response = buildMcpToolSuccessResponse(structuredContent); expect(response).toEqual({ content: [ { type: 'text', - text: 'First message', - }, - { - type: 'text', - text: 'Second message', - }, - { - type: 'text', - text: 'Third message', + text: JSON.stringify(structuredContent, null, 2), }, ], + structuredContent: structuredContent, }); expect(response.isError).toBeUndefined(); }); - it('should create a successful response with empty messages array', () => { - const messages: string[] = []; - const response = buildMcpToolSuccessResponse(messages); + it('should create a successful response with undefined structured content', () => { + const structuredContent = undefined; + const response = buildMcpToolSuccessResponse(structuredContent); expect(response).toEqual({ - content: [], + content: [ + { + type: 'text', + text: 'null', + }, + ], + structuredContent: structuredContent, }); expect(response.isError).toBeUndefined(); }); }); describe('buildMcpToolErrorResponse', () => { - it('should create an error response with single message', () => { - const errorMessages = ['Something went wrong']; - const response = buildMcpToolErrorResponse(errorMessages); + it('should create an error response with structured content', () => { + const errorContent = { message: 'Something went wrong' }; + const response = buildMcpToolErrorResponse(errorContent); expect(response).toEqual({ isError: true, content: [ { type: 'text', - text: 'Something went wrong', + text: JSON.stringify(errorContent, null, 2), }, ], + structuredContent: errorContent, }); }); - it('should create an error response with multiple messages', () => { - const errorMessages = ['Error 1', 'Error 2', 'Error 3']; - const response = buildMcpToolErrorResponse(errorMessages); + it('should create an error response with complex structured content', () => { + const errorContent = { + message: 'Multiple errors occurred', + errors: ['Error 1', 'Error 2', 'Error 3'], + code: 'MULTIPLE_ERRORS', + }; + const response = buildMcpToolErrorResponse(errorContent); expect(response).toEqual({ isError: true, content: [ { type: 'text', - text: 'Error 1', - }, - { - type: 'text', - text: 'Error 2', - }, - { - type: 'text', - text: 'Error 3', + text: JSON.stringify(errorContent, null, 2), }, ], + structuredContent: errorContent, }); }); - it('should create an error response with empty messages array', () => { - const errorMessages: string[] = []; - const response = buildMcpToolErrorResponse(errorMessages); + it('should create an error response with undefined structured content', () => { + const errorContent = undefined; + const response = buildMcpToolErrorResponse(errorContent); expect(response).toEqual({ isError: true, - content: [], + content: [ + { + type: 'text', + text: 'null', + }, + ], + structuredContent: errorContent, }); }); }); diff --git a/tests/mcp/tools/packCodebaseTool.test.ts b/tests/mcp/tools/packCodebaseTool.test.ts index e437ccd4a..a52a72b1e 100644 --- a/tests/mcp/tools/packCodebaseTool.test.ts +++ b/tests/mcp/tools/packCodebaseTool.test.ts @@ -3,7 +3,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { runCli } from '../../../src/cli/cliRun.js'; -import { createToolWorkspace, formatToolError, formatToolResponse } from '../../../src/mcp/tools/mcpToolRuntime.js'; +import { createToolWorkspace, formatPackToolResponse } from '../../../src/mcp/tools/mcpToolRuntime.js'; import { registerPackCodebaseTool } from '../../../src/mcp/tools/packCodebaseTool.js'; vi.mock('node:path'); @@ -13,14 +13,13 @@ vi.mock('../../../src/mcp/tools/mcpToolRuntime.js', async () => { return { ...actual, createToolWorkspace: vi.fn(), - formatToolError: vi.fn(), - formatToolResponse: vi.fn(), + formatPackToolResponse: vi.fn(), }; }); describe('PackCodebaseTool', () => { const mockServer = { - tool: vi.fn().mockReturnThis(), + registerTool: vi.fn().mockReturnThis(), } as unknown as McpServer; let toolHandler: (args: { @@ -47,19 +46,15 @@ describe('PackCodebaseTool', () => { beforeEach(() => { vi.resetAllMocks(); registerPackCodebaseTool(mockServer); - toolHandler = (mockServer.tool as ReturnType).mock.calls[0][4]; + toolHandler = (mockServer.registerTool as ReturnType).mock.calls[0][2]; // デフォルトのパスの動作をモック vi.mocked(path.join).mockImplementation((...args) => args.join('/')); // mcpToolRuntimeのデフォルトの動作をモック vi.mocked(createToolWorkspace).mockResolvedValue('/temp/dir'); - vi.mocked(formatToolResponse).mockImplementation(async () => ({ + vi.mocked(formatPackToolResponse).mockResolvedValue({ content: [{ type: 'text', text: 'Success response' }], - })); - vi.mocked(formatToolError).mockReturnValue({ - isError: true, - content: [{ type: 'text', text: 'Error response' }], }); // runCliのデフォルト動作 @@ -108,11 +103,9 @@ describe('PackCodebaseTool', () => { }); test('should register tool with correct parameters', () => { - expect(mockServer.tool).toHaveBeenCalledWith( + expect(mockServer.registerTool).toHaveBeenCalledWith( 'pack_codebase', - 'Package a local code directory into a consolidated XML file for AI analysis. This tool analyzes the codebase structure, extracts relevant code content, and generates a comprehensive report including metrics, file tree, and formatted code content. Supports Tree-sitter compression for efficient token usage.', - expect.any(Object), - expect.any(Object), // annotations + expect.any(Object), // tool spec expect.any(Function), ); }); @@ -147,15 +140,10 @@ describe('PackCodebaseTool', () => { const result = await toolHandler({ directory: testDir }); - expect(result).toEqual({ - isError: true, - content: [ - { - type: 'text', - text: 'Failed to return a result', - }, - ], - }); + expect(result.isError).toBe(true); + expect(result.content).toHaveLength(1); + const parsedResult = JSON.parse(result.content[0].text as string); + expect(parsedResult.errorMessage).toBe('Failed to return a result'); }); test('should handle general error', async () => { @@ -165,11 +153,10 @@ describe('PackCodebaseTool', () => { const result = await toolHandler({ directory: testDir }); - expect(formatToolError).toHaveBeenCalledWith(error); - expect(result).toEqual({ - isError: true, - content: [{ type: 'text', text: 'Error response' }], - }); + expect(result.isError).toBe(true); + expect(result.content).toHaveLength(1); + const parsedResult = JSON.parse(result.content[0].text as string); + expect(parsedResult.errorMessage).toBe('Pack failed'); }); test('should handle workspace creation error', async () => { @@ -179,10 +166,9 @@ describe('PackCodebaseTool', () => { const result = await toolHandler({ directory: testDir }); - expect(formatToolError).toHaveBeenCalledWith(error); - expect(result).toEqual({ - isError: true, - content: [{ type: 'text', text: 'Error response' }], - }); + expect(result.isError).toBe(true); + expect(result.content).toHaveLength(1); + const parsedResult = JSON.parse(result.content[0].text as string); + expect(parsedResult.errorMessage).toBe('Workspace creation failed'); }); }); diff --git a/tests/mcp/tools/readRepomixOutputTool.test.ts b/tests/mcp/tools/readRepomixOutputTool.test.ts index 81db22ea9..22256a511 100644 --- a/tests/mcp/tools/readRepomixOutputTool.test.ts +++ b/tests/mcp/tools/readRepomixOutputTool.test.ts @@ -21,7 +21,7 @@ vi.mock('../../../src/shared/logger.js', () => ({ describe('readRepomixOutputTool', () => { const mockMcpServer = { - tool: vi.fn(), + registerTool: vi.fn(), } as const; type ToolHandlerType = (args: { @@ -40,25 +40,13 @@ describe('readRepomixOutputTool', () => { registerReadRepomixOutputTool(mockMcpServer as unknown as McpServer); - toolHandler = mockMcpServer.tool.mock.calls[0][4]; + toolHandler = mockMcpServer.registerTool.mock.calls[0][2]; }); it('should register the tool with correct parameters', () => { - expect(mockMcpServer.tool).toHaveBeenCalledWith( + expect(mockMcpServer.registerTool).toHaveBeenCalledWith( 'read_repomix_output', - expect.any(String), - expect.objectContaining({ - outputId: expect.any(Object), - startLine: expect.any(Object), - endLine: expect.any(Object), - }), - expect.objectContaining({ - title: 'Read Repomix Output', - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, - }), + expect.any(Object), // tool spec expect.any(Function), ); }); @@ -70,7 +58,9 @@ describe('readRepomixOutputTool', () => { expect(mcpToolRuntime.getOutputFilePath).toHaveBeenCalledWith('non-existent-id'); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Error: Output file with ID non-existent-id not found'); + expect(result.content).toHaveLength(1); + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.errorMessage).toContain('Error: Output file with ID non-existent-id not found'); }); it('should return an error if the file does not exist', async () => { @@ -82,7 +72,9 @@ describe('readRepomixOutputTool', () => { expect(mcpToolRuntime.getOutputFilePath).toHaveBeenCalledWith('test-id'); expect(fs.access).toHaveBeenCalledWith('/path/to/file.xml'); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Error: Output file does not exist at path: /path/to/file.xml'); + expect(result.content).toHaveLength(1); + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.errorMessage).toContain('Error: Output file does not exist at path: /path/to/file.xml'); }); it('should successfully read the file content', async () => { @@ -96,9 +88,8 @@ describe('readRepomixOutputTool', () => { expect(fs.access).toHaveBeenCalledWith('/path/to/file.xml'); expect(fs.readFile).toHaveBeenCalledWith('/path/to/file.xml', 'utf8'); expect(result.isError).toBeUndefined(); - expect(result.content).toHaveLength(2); - expect(result.content[0].text).toContain('Content of Repomix output file (ID: test-id)'); - expect(result.content[1].text).toBe('File content here'); + expect(result.content).toHaveLength(1); + // The structured content is handled internally by the MCP framework }); it('should handle unexpected errors during execution', async () => { @@ -109,7 +100,9 @@ describe('readRepomixOutputTool', () => { const result = await toolHandler({ outputId: 'test-id' }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Error reading Repomix output: Unexpected error'); + expect(result.content).toHaveLength(1); + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.errorMessage).toContain('Unexpected error'); }); it('should read specific line range when startLine and endLine are provided', async () => { @@ -121,9 +114,8 @@ describe('readRepomixOutputTool', () => { expect(fs.readFile).toHaveBeenCalledWith('/path/to/file.xml', 'utf8'); expect(result.isError).toBeUndefined(); - expect(result.content).toHaveLength(2); - expect(result.content[0].text).toContain('Content of Repomix output file (ID: test-id) (lines 2-4)'); - expect(result.content[1].text).toBe('Line 2\nLine 3\nLine 4'); + expect(result.content).toHaveLength(1); + // The structured content is handled internally by the MCP framework }); it('should read from startLine to end when only startLine is provided', async () => { @@ -133,8 +125,8 @@ describe('readRepomixOutputTool', () => { const result = await toolHandler({ outputId: 'test-id', startLine: 3 }); - expect(result.content[0].text).toContain('Content of Repomix output file (ID: test-id) (lines 3-end)'); - expect(result.content[1].text).toBe('Line 3\nLine 4\nLine 5'); + expect(result.content).toHaveLength(1); + // The structured content is handled internally by the MCP framework }); it('should read from beginning to endLine when only endLine is provided', async () => { @@ -144,8 +136,8 @@ describe('readRepomixOutputTool', () => { const result = await toolHandler({ outputId: 'test-id', endLine: 2 }); - expect(result.content[0].text).toContain('Content of Repomix output file (ID: test-id) (lines 1-2)'); - expect(result.content[1].text).toBe('Line 1\nLine 2'); + expect(result.content).toHaveLength(1); + // The structured content is handled internally by the MCP framework }); it('should return an error if startLine exceeds total lines', async () => { @@ -156,7 +148,9 @@ describe('readRepomixOutputTool', () => { const result = await toolHandler({ outputId: 'test-id', startLine: 10 }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Error: Start line 10 exceeds total lines (3)'); + expect(result.content).toHaveLength(1); + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.errorMessage).toContain('Error: Start line 10 exceeds total lines (3)'); }); it('should return an error if startLine is greater than endLine', async () => { @@ -167,6 +161,8 @@ describe('readRepomixOutputTool', () => { const result = await toolHandler({ outputId: 'test-id', startLine: 4, endLine: 2 }); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Error: Start line (4) cannot be greater than end line (2)'); + expect(result.content).toHaveLength(1); + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.errorMessage).toContain('Error: Start line (4) cannot be greater than end line (2)'); }); });