237 lines
8.0 KiB
TypeScript
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 }]
|
|
};
|
|
}
|
|
}
|
|
);
|
|
}
|