File Rank MCP Server

by admica
Verified
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import * as chokidar from "chokidar"; import * as fs from "fs/promises"; import * as path from "path"; import * as readline from "readline"; import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; import { SimpleFileNode, ToolResponse } from "./types.js"; function debounce<T extends (...args: any[]) => void>(fn: T, wait: number): (...args: Parameters<T>) => void { let timeout: NodeJS.Timeout | null = null; return (...args: Parameters<T>) => { if (timeout) clearTimeout(timeout); timeout = setTimeout(() => { fn(...args); timeout = null; }, wait); }; } function normalizePath(filepath: string): string { return filepath.split(path.sep).join('/'); } function toPlatformPath(normalizedPath: string): string { return normalizedPath.split('/').join(path.sep); } const server = new McpServer({ name: "file-rank-mcp", version: "1.0.0", }); const PROJECT_ROOT = process.cwd(); const TREE_FILE = path.join(PROJECT_ROOT, "file-tree.json"); let fileTree: SimpleFileNode | null = null; interface ToolMetadata { name: string; description: string; schema: z.ZodRawShape; handler: (args: any, extra: any) => Promise<ToolResponse>; } const toolMetadata: { [key: string]: ToolMetadata } = {}; async function buildFileTree(root: string): Promise<SimpleFileNode> { const stat = await fs.stat(root); const normalizedRoot = normalizePath(root); const node: SimpleFileNode = { path: normalizedRoot, isDirectory: stat.isDirectory(), }; if (stat.isDirectory()) { const entries = await fs.readdir(root); node.children = []; for (const entry of entries) { if (entry === "node_modules" || entry === ".git" || entry === "file-tree.json") continue; const childPath = path.join(root, entry); const childNode = await buildFileTree(childPath); node.children.push(childNode); } } return node; } async function loadFileTree(): Promise<SimpleFileNode> { try { const data = await fs.readFile(TREE_FILE, "utf-8"); const loadedTree = JSON.parse(data) as SimpleFileNode; console.error("Loaded file tree from", TREE_FILE); return loadedTree; } catch (e: unknown) { const errorMessage = e instanceof Error ? e.message : String(e); console.error("No existing file tree found or failed to load, building new tree:", errorMessage); const newTree = await buildFileTree(PROJECT_ROOT); await saveFileTree(newTree); return newTree; } } async function saveFileTree(tree: SimpleFileNode): Promise<void> { try { await fs.writeFile(TREE_FILE, JSON.stringify(tree, null, 2), "utf-8"); console.error("File tree saved to", TREE_FILE); } catch (e: unknown) { const errorMessage = e instanceof Error ? e.message : String(e); console.error("Failed to save file tree:", errorMessage); } } function findNode(node: SimpleFileNode, targetPath: string): SimpleFileNode | null { const normalizedTarget = normalizePath(targetPath); console.error(`Searching for: ${normalizedTarget} in node: ${node.path}`); if (node.path === normalizedTarget) { console.error(`Found match: ${node.path}`); return node; } if (node.children) { for (const child of node.children) { const found = findNode(child, targetPath); if (found) return found; } } return null; } async function addFileToTree(targetPath: string): Promise<void> { if (!fileTree) return; const relativePath = path.relative(PROJECT_ROOT, targetPath); const parts = relativePath.split(path.sep); let currentNode = fileTree; for (let i = 0; i < parts.length - 1; i++) { const part = parts[i]; if (!currentNode.children) return; const normalizedBasename = normalizePath(path.basename(toPlatformPath(part))); const child = currentNode.children.find((node) => path.basename(toPlatformPath(node.path)) === normalizedBasename); if (!child || !child.isDirectory) return; currentNode = child; } const newNode = await buildFileTree(targetPath); if (!currentNode.children) currentNode.children = []; const normalizedTargetPath = normalizePath(targetPath); const existingIndex = currentNode.children.findIndex((node) => node.path === normalizedTargetPath); if (existingIndex >= 0) { currentNode.children[existingIndex] = newNode; } else { currentNode.children.push(newNode); } await saveFileTree(fileTree); } async function updateFileInTree(targetPath: string): Promise<void> { await removeFileFromTree(targetPath); await addFileToTree(targetPath); } async function removeFileFromTree(targetPath: string): Promise<void> { if (!fileTree) return; const relativePath = path.relative(PROJECT_ROOT, targetPath); const parts = relativePath.split(path.sep); let currentNode = fileTree; for (let i = 0; i < parts.length - 1; i++) { const part = parts[i]; if (!currentNode.children) return; const normalizedBasename = normalizePath(path.basename(toPlatformPath(part))); const child = currentNode.children.find((node) => path.basename(toPlatformPath(node.path)) === normalizedBasename); if (!child || !child.isDirectory) return; currentNode = child; } if (currentNode.children) { const normalizedTargetPath = normalizePath(targetPath); currentNode.children = currentNode.children.filter((node) => node.path !== normalizedTargetPath); await saveFileTree(fileTree); } } async function initializeFileTree(): Promise<void> { fileTree = await loadFileTree(); const watcher = chokidar.watch(PROJECT_ROOT, { ignored: /(node_modules|\.git|file-tree\.json)/, persistent: true, }); const debouncedAdd = debounce(addFileToTree, 100); const debouncedUpdate = debounce(updateFileInTree, 100); const debouncedRemove = debounce(removeFileFromTree, 100); watcher .on("add", (filePath) => { console.error(`File added: ${filePath}`); debouncedAdd(filePath); }) .on("change", (filePath) => { console.error(`File changed: ${filePath}`); debouncedUpdate(filePath); }) .on("unlink", (filePath) => { console.error(`File deleted: ${filePath}`); debouncedRemove(filePath); }) .on("error", (error) => { console.error("Watcher error:", error.message); }); } server.tool( "get_file_tree", "Retrieve the entire project file tree", {}, async (_args: {}, _extra: any): Promise<ToolResponse> => { console.error("Received tool request: get_file_tree"); if (!fileTree) throw new Error("File tree not initialized"); return { content: [{ type: "text", text: JSON.stringify(fileTree, null, 2) }] }; } ); toolMetadata["get_file_tree"] = { name: "get_file_tree", description: "Retrieve the entire project file tree", schema: {}, handler: async (_args: {}, _extra: any) => { if (!fileTree) throw new Error("File tree not initialized"); return { content: [{ type: "text", text: JSON.stringify(fileTree, null, 2) }] }; }, }; server.tool( "get_file_info", "Retrieve information about a specific file or directory", { path: z.string().describe("Absolute path to the file or directory") }, async (args: { path: string }, _extra: any): Promise<ToolResponse> => { console.error("Received tool request: get_file_info with path:", args.path); if (!fileTree) throw new Error("File tree not initialized"); const resolvedPath = path.resolve(PROJECT_ROOT, args.path); const normalizedPath = normalizePath(resolvedPath); console.error(`Resolved path: ${resolvedPath}, Normalized path: ${normalizedPath}`); const targetNode = findNode(fileTree, normalizedPath); if (!targetNode) { console.error(`No node found for: ${normalizedPath}`); return { content: [{ type: "text", text: `File or directory not found: ${resolvedPath}` }] }; } return { content: [{ type: "text", text: JSON.stringify(targetNode, null, 2) }] }; } ); toolMetadata["get_file_info"] = { name: "get_file_info", description: "Retrieve information about a specific file or directory", schema: { path: z.string().describe("Absolute path to the file or directory") }, handler: async (args: { path: string }, _extra: any) => { if (!fileTree) throw new Error("File tree not initialized"); const resolvedPath = path.resolve(PROJECT_ROOT, args.path); const targetNode = findNode(fileTree, resolvedPath); if (!targetNode) { return { content: [{ type: "text", text: `File or directory not found: ${resolvedPath}` }] }; } return { content: [{ type: "text", text: JSON.stringify(targetNode, null, 2) }] }; }, }; console.log("Registered tools:", Object.keys(toolMetadata)); interface JsonSchema7Object { type: "object"; properties?: Record<string, any>; required?: string[]; additionalProperties?: boolean; } function isObjectSchema(schema: any): schema is JsonSchema7Object { return schema && typeof schema === "object" && "type" in schema && schema.type === "object"; } async function startServer() { try { await initializeFileTree(); console.error("MCP File Rank Server started. Watching:", PROJECT_ROOT); const rl = readline.createInterface({ input: process.stdin, output: undefined, terminal: false, }); rl.on("line", async (line) => { let request: any; try { request = JSON.parse(line.trim()); console.error("Received request:", request); const response: { jsonrpc: string; id: number | string | null; result?: any; error?: any } = { jsonrpc: "2.0", id: request.id, }; const method = request.method; const params = request.params || {}; let toolName: string | undefined; let args: any = {}; if (method === "tool" || method === "tools/call") { toolName = params.name; args = params.args || {}; } else if (method === "initialize") { response.result = { capabilities: { tools: Object.keys(toolMetadata).map((name) => { const schema = toolMetadata[name].schema; if (Object.keys(schema).length === 0) { return { name, description: toolMetadata[name].description, schema: {} }; } const jsonSchema = zodToJsonSchema(z.object(schema), { target: "jsonSchema7" }); if (isObjectSchema(jsonSchema)) { return { name, description: toolMetadata[name].description, schema: { type: "object", properties: jsonSchema.properties || {}, required: jsonSchema.required || Object.keys(jsonSchema.properties || {}), additionalProperties: false, }, }; } return { name, description: toolMetadata[name].description, schema: {} }; }), }, }; console.log("Initialize response:", JSON.stringify(response, null, 2)); console.log(JSON.stringify(response) + "\n"); return; } else { response.error = { code: -32601, message: `Method not found: ${method}` }; console.error("Sending error response:", response); console.log(JSON.stringify(response) + "\n"); return; } if (!toolName || !toolMetadata[toolName]) { response.error = { code: -32601, message: `Tool not found: ${toolName}` }; console.error("Sending error response:", response); console.log(JSON.stringify(response) + "\n"); return; } const schema = toolMetadata[toolName].schema; const validator = z.object(schema); const validatedArgs = validator.parse(args); const handler = toolMetadata[toolName].handler; const result = await handler(validatedArgs, {}); response.result = result; console.error("Sending response:", response); console.log(JSON.stringify(response) + "\n"); } catch (e: unknown) { const errorMessage = e instanceof Error ? e.message : String(e); console.error("Error processing request:", errorMessage); const response = { jsonrpc: "2.0", id: request?.id ?? null, error: { code: -32000, message: errorMessage }, }; console.log(JSON.stringify(response) + "\n"); } }); process.stdin.on("end", () => { console.error("Stdin closed, server exiting."); process.exit(0); }); } catch (e: unknown) { const errorMessage = e instanceof Error ? e.message : String(e); console.error("Failed to start server:", errorMessage); process.exit(1); } } startServer().catch((e: unknown) => { const errorMessage = e instanceof Error ? e.message : String(e); console.error("Server failed to start:", errorMessage); });