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);
});