claude-code-skill-power/filesystem-mcp-server/src/tools/search-files.ts

237 lines
8.0 KiB
TypeScript

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { promises as fs } from "fs";
import { lstat } from "fs/promises";
import { join } from "path";
import { DEFAULT_FILE_LIMIT, MAX_DEPTH } from "../constants.js";
import type { SearchResult } from "../types.js";
const SearchFilesInputSchema = z.object({
path: z.string()
.min(1, "Path is required")
.describe("Absolute path to the directory to search in"),
pattern: z.string()
.min(1, "Search pattern is required")
.describe("Glob pattern to match files (e.g., '*.ts', '**/*.json')"),
max_results: z.number()
.int()
.min(1)
.max(DEFAULT_FILE_LIMIT)
.default(50)
.describe("Maximum number of results to return"),
include_content: z.boolean()
.default(false)
.describe("Whether to include file content in results"),
response_format: z.enum(["markdown", "json"])
.default("markdown")
.describe("Output format: 'markdown' for human-readable or 'json' for machine-readable")
}).strict();
type SearchFilesInput = z.infer<typeof SearchFilesInputSchema>;
function matchPattern(filename: string, pattern: string): boolean {
// Simple glob matching - convert glob pattern to regex
const regexPattern = pattern
.replace(/\./g, '\\.')
.replace(/\*/g, '.*')
.replace(/\?/g, '.');
const regex = new RegExp(`^${regexPattern}$`, 'i');
return regex.test(filename);
}
async function* walkDirectory(
dir: string,
maxDepth: number,
currentDepth: number = 0
): AsyncGenerator<string> {
if (currentDepth >= maxDepth) return;
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
yield fullPath;
if (entry.isDirectory()) {
yield* walkDirectory(fullPath, maxDepth, currentDepth + 1);
}
}
}
export function registerSearchFilesTool(server: McpServer) {
server.registerTool(
"search_files",
{
title: "Search Files",
description: `Search for files in a directory using glob patterns.
This tool recursively searches a directory and returns files matching the specified pattern. It supports basic glob patterns like *.ts, **/*.json, etc.
Args:
- path (string): Absolute path to the directory to search in
- pattern (string): Glob pattern to match files (e.g., '*.ts', '**/*.json', 'src/**/*.ts')
- max_results (number): Maximum number of results to return (default: 50, max: 100)
- include_content (boolean): Whether to include file content in results (default: false)
- response_format ('markdown' | 'json'): Output format (default: 'markdown')
Returns:
For JSON format: Structured data with schema:
{
"search_path": string, // Path that was searched
"pattern": string, // Pattern used
"total_found": number, // Total files matching the pattern
"results_returned": number,// Number of results in this response
"truncated": boolean, // Whether results were truncated
"files": [
{
"name": string, // File name
"path": string, // Absolute path
"isDirectory": boolean,
"size": number, // File size in bytes
"modified": string, // ISO date string
"content": string // Present if include_content is true
}
]
}
Examples:
- Use when: "Find all TypeScript files in the project" -> params with path="/path/to/project", pattern="*.ts"
- Use when: "Find all JSON config files recursively" -> params with path="/path/to/project", pattern="**/*.json"
- Use when: "List all Markdown files with their contents" -> params with path="/path/to/docs", pattern="*.md", include_content=true
- Don't use when: You need to read a specific file (use read_file instead)
- Don't use when: You need to list all files without filtering (use list_directory instead)
Error Handling:
- Returns "Error: Directory not found" if the search path does not exist (ENOENT)
- Returns "Error: Path is not a directory" if the path is a file
- Returns "Error: Permission denied" if directories cannot be accessed (EACCES)`,
inputSchema: SearchFilesInputSchema,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false
}
},
async (params: SearchFilesInput) => {
try {
// Check if path exists and is a directory
const pathStats = await lstat(params.path);
if (!pathStats.isDirectory()) {
return {
content: [{
type: "text",
text: `Error: Path is not a directory: ${params.path}`
}]
};
}
const results: SearchResult[] = [];
let totalFound = 0;
// Search directory
for await (const filePath of walkDirectory(params.path, MAX_DEPTH)) {
const filename = filePath.split(/[/\\]/).pop() || filePath;
if (matchPattern(filename, params.pattern)) {
totalFound++;
if (results.length < params.max_results) {
const fileStats = await lstat(filePath);
const result: SearchResult = {
name: filename,
path: filePath,
isDirectory: fileStats.isDirectory(),
size: fileStats.size,
modified: fileStats.mtime.toISOString()
};
if (params.include_content && !fileStats.isDirectory()) {
try {
const contentBuffer = await fs.readFile(filePath);
result.content = contentBuffer.toString('utf-8');
} catch {
result.content = '[Unable to read file content]';
}
}
results.push(result);
}
}
}
const output = {
search_path: params.path,
pattern: params.pattern,
total_found: totalFound,
results_returned: results.length,
truncated: totalFound > params.max_results,
files: results
};
let textContent: string;
if (params.response_format === "json") {
textContent = JSON.stringify(output, null, 2);
} else {
const lines: string[] = [
`# Search Results: ${params.pattern}`,
`Search path: ${params.path}`,
`Found ${totalFound} file(s), showing ${results.length}`,
""
];
if (output.truncated) {
lines.push(`⚠️ Results truncated. Use max_results=${params.max_results} to see more.`);
lines.push("");
}
for (const file of results) {
const type = file.isDirectory ? "📁" : "📄";
lines.push(`${type} ${file.path}`);
if (params.include_content && file.content) {
lines.push("```");
const preview = file.content.slice(0, 500);
lines.push(file.content.length > 500 ? preview + "..." : preview);
lines.push("```");
}
lines.push("");
}
textContent = lines.join('\n');
}
return {
content: [{ type: "text", text: textContent }],
structuredContent: output
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
let errorText: string;
if (error instanceof Error && 'code' in error) {
const code = (error as { code: string }).code;
switch (code) {
case "ENOENT":
errorText = `Error: Directory not found: ${params.path}`;
break;
case "EACCES":
errorText = `Error: Permission denied to access directory: ${params.path}`;
break;
default:
errorText = `Error: Failed to search files: ${errorMessage}`;
}
} else {
errorText = `Error: Failed to search files: ${errorMessage}`;
}
return {
content: [{ type: "text", text: errorText }]
};
}
}
);
}