FileScopeMCP
by admica
Verified
- FileScopeMCP
- src
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { ReadBuffer, deserializeMessage, serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js";
import { z } from "zod";
import * as fs from "fs/promises";
import * as path from "path";
import {
FileNode,
ToolResponse,
FileTreeConfig,
FileTreeStorage,
MermaidDiagramConfig,
FileWatchingConfig
} from "./types.js";
import { scanDirectory, calculateImportance, setFileImportance, buildDependentMap, normalizePath, addFileNode, removeFileNode, excludeAndRemoveFile } from "./file-utils.js";
import {
createFileTreeConfig,
saveFileTree,
loadFileTree,
listSavedFileTrees,
updateFileNode,
getFileNode,
normalizeAndResolvePath
} from "./storage-utils.js";
import * as fsSync from "fs";
import { MermaidGenerator } from "./mermaid-generator.js";
import { setProjectRoot, getProjectRoot, setConfig, getConfig } from './global-state.js';
import { loadConfig, saveConfig } from './config-utils.js';
import { FileWatcher, FileEventType } from './file-watcher.js';
import { log, enableFileLogging } from './logger.js';
// Enable file logging for debugging
enableFileLogging(true, 'mcp-debug.log');
// Initialize server state
let fileTree: FileNode | null = null;
let currentConfig: FileTreeConfig | null = null;
let DEFAULT_CONFIG: FileTreeConfig;
let fileWatcher: FileWatcher | null = null;
// Map to hold debounce timers for file events
const fileEventDebounceTimers: Map<string, NodeJS.Timeout> = new Map();
const DEBOUNCE_DURATION_MS = 2000; // 2 seconds
// Check if we're running as an MCP server for Cursor
function isRunningAsMcpServer(): boolean {
// Check if we were launched by Cursor's MCP client
const isMcpServerMode = process.argv.some(arg =>
arg.includes('mcp-server.js') ||
arg.includes('mcp.json')
);
// Check if we're communicating over stdio
const isStdioMode = process.stdin.isTTY === false;
return isMcpServerMode || isStdioMode;
}
// Find the actual FileScopeMCP project directory
async function findFileScopeMcpDirectory(): Promise<string | null> {
// Try to extract it from command line arguments
const scriptPath = process.argv[1] || '';
log('Script path: ' + scriptPath);
if (scriptPath.includes('FileScopeMCP')) {
// Extract the project directory from script path
const match = scriptPath.match(/(.+?FileScopeMCP)/i);
if (match && match[1]) {
const mcpDir = match[1];
try {
// Verify this directory by checking for package.json
const packageJsonPath = path.join(mcpDir, 'package.json');
await fs.access(packageJsonPath);
log(`Verified FileScopeMCP directory: ${mcpDir}`);
return mcpDir;
} catch (error) {
log(`Could not verify directory ${mcpDir}`);
}
}
}
// Check environment variables (could be set by Cursor)
const envProjectDir = process.env.MCP_PROJECT_DIR;
if (envProjectDir) {
try {
await fs.access(envProjectDir);
log(`Found project directory from env: ${envProjectDir}`);
return envProjectDir;
} catch (error) {
log(`Invalid environment directory: ${envProjectDir}`);
}
}
// Look for common development directories with FileScopeMCP in the path
const commonDevPaths = [
'C:/Users/Adrian/code/mcp/FileScopeMCP',
'/Users/Adrian/code/mcp/FileScopeMCP',
path.join(process.env.HOME || '', 'code/mcp/FileScopeMCP'),
path.join(process.env.USERPROFILE || '', 'code/mcp/FileScopeMCP')
];
for (const testPath of commonDevPaths) {
try {
await fs.access(testPath);
log(`Found project directory in common paths: ${testPath}`);
return testPath;
} catch (error) {
// Path doesn't exist, try next one
}
}
return null;
}
// Server initialization
async function initializeServer(): Promise<void> {
log('Starting FileScopeMCP server initialization');
log('Initial working directory: ' + process.cwd());
log('Command line args: ' + process.argv);
// First try to get base directory from command line
const baseDirArg = process.argv.find(arg => arg.startsWith('--base-dir='));
let projectRoot: string;
let config = await loadConfig();
log('========== CONFIG DEBUGGING ==========');
log('Loaded config: ' + JSON.stringify(config, null, 2));
log('Exclude patterns count: ' + (config.excludePatterns?.length || 0));
log('=====================================');
if (baseDirArg) {
// Use command line argument if provided
projectRoot = normalizeAndResolvePath(baseDirArg.split('=')[1]);
log(`Using base directory from command line: ${projectRoot}`);
// Update config with command line base directory
config.baseDirectory = projectRoot;
} else {
// Check if baseDirectory is set in config
if (!config.baseDirectory) {
log('Error: baseDirectory must be set in either config.json or via --base-dir parameter');
process.exit(1);
}
projectRoot = normalizeAndResolvePath(config.baseDirectory);
log(`Using base directory from config: ${projectRoot}`);
}
// Set the global project root and config
setProjectRoot(projectRoot);
setConfig(config);
log('Global state after initialization:');
log('- Project root: ' + getProjectRoot());
log('- Config loaded: ' + (getConfig() !== null));
if (getConfig()) {
log('- Exclude patterns count: ' + (getConfig()?.excludePatterns?.length || 0));
if (getConfig()?.excludePatterns?.length) {
log('- First few exclude patterns: ' + getConfig()?.excludePatterns?.slice(0, 5));
}
}
// Verify the directory exists
try {
await fs.access(projectRoot);
} catch (error) {
log(`Error: Base directory ${projectRoot} does not exist`);
process.exit(1);
}
process.chdir(projectRoot);
log('Changed working directory to: ' + process.cwd());
// Now we can safely set the default config
DEFAULT_CONFIG = {
filename: "FileScopeMCP-tree.json",
baseDirectory: projectRoot,
projectRoot: projectRoot,
lastUpdated: new Date()
};
// Try to load the default file tree
try {
await buildFileTree(DEFAULT_CONFIG);
} catch (error) {
log("Failed to build default file tree: " + error);
}
// Initialize file watcher if enabled in config
const fileWatchingConfig = getConfig()?.fileWatching;
if (fileWatchingConfig?.enabled) {
log('File watching is enabled in config, initializing watcher...');
await initializeFileWatcher();
} else {
log('File watching is disabled in config');
}
}
/**
* Initialize the file watcher
*/
async function initializeFileWatcher(): Promise<void> {
try {
const config = getConfig();
if (!config || !config.fileWatching) {
log('Cannot initialize file watcher: config or fileWatching not available');
return;
}
// Stop any existing watcher
if (fileWatcher) {
fileWatcher.stop();
fileWatcher = null;
}
// Create and start a new watcher
fileWatcher = new FileWatcher(config.fileWatching, getProjectRoot());
fileWatcher.addEventCallback((filePath, eventType) => handleFileEvent(filePath, eventType));
fileWatcher.start();
log('File watcher initialized and started successfully');
} catch (error) {
log('Error initializing file watcher: ' + error);
}
}
/**
* Handle a file event
* @param filePath The path of the file that changed (already normalized by watcher)
* @param eventType The type of event
*/
async function handleFileEvent(filePath: string, eventType: FileEventType): Promise<void> {
log(`[MCP Server] Handling file event: ${eventType} for ${filePath}`);
// Use the module-level active config and tree
const activeConfig = currentConfig;
const activeTree = fileTree;
const projectRoot = getProjectRoot();
const fileWatchingConfig = getConfig()?.fileWatching;
if (!activeConfig || !activeTree || !projectRoot || !fileWatchingConfig) {
log('[MCP Server] Ignoring file event: Active config, tree, project root, or watching config not available.');
return;
}
if (!fileWatchingConfig.autoRebuildTree) {
log('[MCP Server] Ignoring file event: Auto-rebuild is disabled.');
return;
}
// --- Debounce Logic ---
const debounceKey = `${filePath}:${eventType}`;
const existingTimer = fileEventDebounceTimers.get(debounceKey);
if (existingTimer) {
clearTimeout(existingTimer);
}
const newTimer = setTimeout(async () => {
fileEventDebounceTimers.delete(debounceKey); // Remove timer reference once it executes
log(`[MCP Server] Debounced processing for: ${eventType} - ${filePath}`);
try {
let updated = false;
switch (eventType) {
case 'add':
if (fileWatchingConfig.watchForNewFiles) {
log(`[MCP Server] Calling addFileNode for ${filePath}`);
await addFileNode(filePath, activeTree, projectRoot);
updated = true;
}
break;
case 'change':
// TODO: Implement incremental update for changed files if needed
if (fileWatchingConfig.watchForChanged) {
log(`[MCP Server] CHANGE detected for ${filePath}, incremental handling not implemented. Triggering full rebuild as fallback.`);
// Fallback to full rebuild for now
await buildFileTree(activeConfig);
updated = true; // Assume tree changed
}
break;
case 'unlink':
if (fileWatchingConfig.watchForDeleted) {
log(`[MCP Server] Calling removeFileNode for ${filePath}`);
await removeFileNode(filePath, activeTree, projectRoot);
updated = true;
}
break;
}
// Save the potentially modified tree if an add/remove/rebuild happened
if (updated) {
// We need the latest references in case buildFileTree was called
const latestActiveConfig = currentConfig;
const latestActiveTree = fileTree;
if(latestActiveConfig && latestActiveTree){
log(`[MCP Server] Saving updated file tree after ${eventType} event.`);
await saveFileTree(latestActiveConfig, latestActiveTree);
} else {
log(`[MCP Server] Error saving tree after ${eventType} event: active config or tree became null.`);
}
}
} catch (error) {
log(`[MCP Server] Error processing debounced file event ${eventType} for ${filePath}: ${error}`);
}
}, DEBOUNCE_DURATION_MS);
fileEventDebounceTimers.set(debounceKey, newTimer);
}
/**
* A simple implementation of the Transport interface for stdio
*/
class StdioTransport implements Transport {
private readonly MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10MB limit
private buffer = new ReadBuffer();
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
sessionId?: string;
constructor() {}
async start(): Promise<void> {
process.stdin.on('data', (chunk) => {
try {
// Check buffer size before appending
const currentSize = this.buffer.toString().length;
if (currentSize + chunk.length > this.MAX_BUFFER_SIZE) {
log(`Buffer overflow: size would exceed ${this.MAX_BUFFER_SIZE} bytes`);
this.onerror?.(new Error('Buffer overflow: maximum size exceeded'));
this.buffer = new ReadBuffer(); // Reset buffer to prevent memory issues
return;
}
this.buffer.append(chunk);
let message: JSONRPCMessage | null;
while ((message = this.buffer.readMessage())) {
if (this.onmessage) {
this.onmessage(message);
}
}
} catch (error) {
log('Error processing message: ' + error);
if (this.onerror) {
this.onerror(error instanceof Error ? error : new Error(String(error)));
}
this.buffer = new ReadBuffer(); // Reset buffer on error
}
});
process.stdin.on('end', () => {
if (this.onclose) {
this.onclose();
}
});
process.stdin.resume();
}
async send(message: JSONRPCMessage): Promise<void> {
// Ensure we only write valid JSON messages to stdout
const serialized = serializeMessage(message);
// Check message size
if (serialized.length > this.MAX_BUFFER_SIZE) {
log(`Message too large: ${serialized.length} bytes`);
throw new Error('Message exceeds maximum size limit');
}
// Only log a summary of the message to stderr, not the full content
const isResponse = 'result' in message;
const msgType = isResponse ? 'response' : 'request';
const msgId = (message as any).id || 'none';
process.stderr.write(`Sending ${msgType} message (id: ${msgId})\n`);
// Write to stdout without adding an extra newline
process.stdout.write(serialized);
}
async close(): Promise<void> {
this.buffer = new ReadBuffer(); // Reset buffer
process.stdin.pause();
}
}
// Helper function to create MCP responses
function createMcpResponse(content: any, isError = false): ToolResponse {
let formattedContent;
if (Array.isArray(content) && content.every(item =>
typeof item === 'object' &&
('type' in item) &&
(item.type === 'text' || item.type === 'image' || item.type === 'resource'))) {
// Content is already in correct format
formattedContent = content;
} else if (Array.isArray(content)) {
// For arrays of non-formatted items, convert each item to a proper object
formattedContent = content.map(item => ({
type: "text",
text: typeof item === 'string' ? item : JSON.stringify(item, null, 2)
}));
} else if (typeof content === 'string') {
formattedContent = [{
type: "text",
text: content
}];
} else {
// Convert objects or other types to string
formattedContent = [{
type: "text",
text: typeof content === 'object' ? JSON.stringify(content, null, 2) : String(content)
}];
}
return {
content: formattedContent,
isError
};
}
// Utility functions
function findNode(node: FileNode, targetPath: string): FileNode | null {
// Normalize both paths for comparison
const normalizedTargetPath = normalizePath(targetPath);
const normalizedNodePath = normalizePath(node.path);
log('Finding node: ' + JSON.stringify({
targetPath: normalizedTargetPath,
currentNodePath: normalizedNodePath,
isDirectory: node.isDirectory,
childCount: node.children?.length
}));
// Try exact match first
if (normalizedNodePath === normalizedTargetPath) {
log('Found exact matching node');
return node;
}
// Try case-insensitive match for Windows compatibility
if (normalizedNodePath.toLowerCase() === normalizedTargetPath.toLowerCase()) {
log('Found case-insensitive matching node');
return node;
}
// Check if the path ends with our target (to handle relative vs absolute paths)
if (normalizedTargetPath.endsWith(normalizedNodePath) || normalizedNodePath.endsWith(normalizedTargetPath)) {
log('Found path suffix matching node');
return node;
}
// Check children if this is a directory
if (node.children) {
for (const child of node.children) {
const found = findNode(child, targetPath);
if (found) {
return found;
}
}
}
return null;
}
// Get all file nodes as a flat array
function getAllFileNodes(node: FileNode): FileNode[] {
const results: FileNode[] = [];
function traverse(currentNode: FileNode) {
if (!currentNode.isDirectory) {
results.push(currentNode);
}
if (currentNode.children && currentNode.children.length > 0) {
for (const child of currentNode.children) {
traverse(child);
}
}
}
// Start traversal with the root node
traverse(node);
log(`Found ${results.length} file nodes`);
return results;
}
// Build or load the file tree
async function buildFileTree(config: FileTreeConfig): Promise<FileNode> {
log('\n🌲 BUILD FILE TREE STARTED');
log('==========================================');
log('Building file tree with config: ' + JSON.stringify(config, null, 2));
log('Current working directory: ' + process.cwd());
log('Config in global state: ' + (getConfig() !== null ? '✅ YES' : '❌ NO'));
if (getConfig()) {
log('Global config exclude patterns count: ' + (getConfig()?.excludePatterns?.length || 0));
}
// First try to load from file
try {
const savedTree = await loadFileTree(config.filename);
if (savedTree) {
// Use the saved tree
if (!savedTree.fileTree) {
log('❌ Invalid file tree structure in saved file');
throw new Error('Invalid file tree structure');
}
log('✅ Using existing file tree from: ' + config.filename);
log('Tree root path: ' + savedTree.fileTree.path);
log('Tree has children: ' + (savedTree.fileTree.children?.length || 0));
fileTree = savedTree.fileTree;
currentConfig = savedTree.config;
log('🌲 BUILD FILE TREE COMPLETED (loaded from file)');
log('==========================================\n');
return fileTree;
}
} catch (error) {
log('❌ Failed to load existing file tree: ' + error);
// Continue to build new tree
}
// If not found or failed to load, build from scratch
log('🔍 Building new file tree for directory: ' + config.baseDirectory);
// Verify config is in global state before scanning
if (!getConfig()) {
log('⚠️ WARNING: No config in global state, setting it now');
// Get the current config
const currentConfig = await loadConfig();
// Set the global config
setConfig(currentConfig);
log('Config set in global state: ' + (getConfig() !== null ? '✅ YES' : '❌ NO'));
if (getConfig()) {
log('Global config exclude patterns count: ' + (getConfig()?.excludePatterns?.length || 0));
}
}
fileTree = await scanDirectory(config.baseDirectory);
if (!fileTree.children || fileTree.children.length === 0) {
log('❌ Failed to scan directory - no children found');
throw new Error('Failed to scan directory');
} else {
log(`✅ Successfully scanned directory, found ${fileTree.children.length} top-level entries`);
}
log('📊 Building dependency map...');
buildDependentMap(fileTree);
log('📈 Calculating importance values...');
calculateImportance(fileTree);
// Save to disk
log('💾 Saving file tree to: ' + config.filename);
try {
await saveFileTree(config, fileTree);
log('✅ Successfully saved file tree');
currentConfig = config;
} catch (error) {
log('❌ Failed to save file tree: ' + error);
throw error;
}
log('🌲 BUILD FILE TREE COMPLETED (built from scratch)');
log('==========================================\n');
return fileTree;
}
// Read the content of a file
async function readFileContent(filePath: string): Promise<string> {
try {
return await fs.readFile(filePath, 'utf-8');
} catch (error) {
log(`Failed to read file ${filePath}: ` + error);
throw error;
}
}
// Server implementation
const serverInfo = {
name: "FileScopeMCP",
version: "1.0.0",
description: "A tool for ranking files in your codebase by importance and providing summaries with dependency tracking"
};
// Create the MCP server
const server = new McpServer(serverInfo, {
capabilities: {
tools: { listChanged: true }
}
});
// Register tools
server.tool("list_saved_trees", "List all saved file trees", async () => {
const trees = await listSavedFileTrees();
return createMcpResponse(trees);
});
server.tool("delete_file_tree", "Delete a file tree configuration", {
filename: z.string().describe("Name of the JSON file to delete")
}, async (params: { filename: string }) => {
try {
const normalizedPath = normalizeAndResolvePath(params.filename);
await fs.unlink(normalizedPath);
// Clear from memory if it's the current tree
if (currentConfig?.filename === normalizedPath) {
currentConfig = null;
fileTree = null;
}
return createMcpResponse(`Successfully deleted ${normalizedPath}`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return createMcpResponse(`File tree ${params.filename} does not exist`);
}
return createMcpResponse(`Failed to delete ${params.filename}: ` + error, true);
}
});
server.tool("create_file_tree", "Create or load a file tree configuration", {
filename: z.string().describe("Name of the JSON file to store the file tree"),
baseDirectory: z.string().describe("Base directory to scan for files")
}, async (params: { filename: string, baseDirectory: string }) => {
log('Create file tree called with params: ' + JSON.stringify(params));
log('Current working directory: ' + process.cwd());
try {
// Ensure we're using paths relative to the current directory
const relativeFilename = path.isAbsolute(params.filename)
? path.relative(process.cwd(), params.filename)
: params.filename;
log('Relative filename: ' + relativeFilename);
// Handle special case for current directory
let baseDir = params.baseDirectory;
if (baseDir === '.' || baseDir === './') {
baseDir = getProjectRoot(); // Use the project root instead of cwd
log('Resolved "." to project root: ' + baseDir);
}
// Normalize the base directory relative to project root if not absolute
if (!path.isAbsolute(baseDir)) {
baseDir = path.join(getProjectRoot(), baseDir);
log('Resolved relative base directory: ' + baseDir);
}
const config = await createFileTreeConfig(relativeFilename, baseDir);
log('Created config: ' + JSON.stringify(config));
// Build the tree with the new config, not the default
const tree = await buildFileTree(config);
log('Built file tree with root path: ' + tree.path);
// Update global state
fileTree = tree;
currentConfig = config;
return createMcpResponse({
message: `File tree created and stored in ${config.filename}`,
config
});
} catch (error) {
log('Error in create_file_tree: ' + error);
return createMcpResponse(`Failed to create file tree: ` + error, true);
}
});
server.tool("select_file_tree", "Select an existing file tree to work with", {
filename: z.string().describe("Name of the JSON file containing the file tree")
}, async (params: { filename: string }) => {
const storage = await loadFileTree(params.filename);
if (!storage) {
return createMcpResponse(`File tree not found: ${params.filename}`, true);
}
fileTree = storage.fileTree;
currentConfig = storage.config;
return createMcpResponse({
message: `File tree loaded from ${params.filename}`,
config: currentConfig
});
});
server.tool("list_files", "List all files in the project with their importance rankings", async () => {
if (!fileTree) {
await buildFileTree(DEFAULT_CONFIG);
}
return createMcpResponse(fileTree);
});
server.tool("get_file_importance", "Get the importance ranking of a specific file", {
filepath: z.string().describe("The path to the file to check")
}, async (params: { filepath: string }) => {
log('Get file importance called with params: ' + JSON.stringify(params));
log('Current config: ' + JSON.stringify(currentConfig));
log('File tree root path: ' + fileTree?.path);
try {
if (!fileTree || !currentConfig) {
log('No file tree loaded, building default tree');
await buildFileTree(DEFAULT_CONFIG);
}
const normalizedPath = normalizePath(params.filepath);
log('Normalized path: ' + normalizedPath);
const node = findNode(fileTree!, normalizedPath);
log('Found node: ' + JSON.stringify(node ? {
path: node.path,
importance: node.importance,
dependencies: node.dependencies?.length,
dependents: node.dependents?.length
} : null));
if (!node) {
return createMcpResponse(`File not found: ${params.filepath}`, true);
}
return createMcpResponse({
path: node.path,
importance: node.importance || 0,
dependencies: node.dependencies || [],
dependents: node.dependents || [],
summary: node.summary || null
});
} catch (error) {
log('Error in get_file_importance: ' + error);
return createMcpResponse(`Failed to get file importance: ` + error, true);
}
});
server.tool("find_important_files", "Find the most important files in the project", {
limit: z.number().optional().describe("Number of files to return (default: 10)"),
minImportance: z.number().optional().describe("Minimum importance score (0-10)")
}, async (params: { limit?: number, minImportance?: number }) => {
if (!fileTree) {
await buildFileTree(DEFAULT_CONFIG);
}
const limit = params.limit || 10;
const minImportance = params.minImportance || 0;
// Get all files as a flat array
const allFiles = getAllFileNodes(fileTree!);
// Filter by minimum importance and sort by importance (descending)
const importantFiles = allFiles
.filter(file => (file.importance || 0) >= minImportance)
.sort((a, b) => (b.importance || 0) - (a.importance || 0))
.slice(0, limit)
.map(file => ({
path: file.path,
importance: file.importance || 0,
dependentCount: file.dependents?.length || 0,
dependencyCount: file.dependencies?.length || 0,
hasSummary: !!file.summary
}));
return createMcpResponse(importantFiles);
});
// New tool to get the summary of a file
server.tool("get_file_summary", "Get the summary of a specific file", {
filepath: z.string().describe("The path to the file to check")
}, async (params: { filepath: string }) => {
if (!fileTree) {
await buildFileTree(DEFAULT_CONFIG);
}
const normalizedPath = normalizePath(params.filepath);
const node = getFileNode(fileTree!, normalizedPath);
if (!node) {
return createMcpResponse(`File not found: ${params.filepath}`, true);
}
if (!node.summary) {
return createMcpResponse(`No summary available for ${params.filepath}`);
}
return createMcpResponse({
path: node.path,
summary: node.summary
});
});
// New tool to set the summary of a file
server.tool("set_file_summary", "Set the summary of a specific file", {
filepath: z.string().describe("The path to the file to update"),
summary: z.string().describe("The summary text to set")
}, async (params: { filepath: string, summary: string }) => {
if (!fileTree || !currentConfig) {
await buildFileTree(DEFAULT_CONFIG);
}
const normalizedPath = normalizePath(params.filepath);
const updated = updateFileNode(fileTree!, normalizedPath, {
summary: params.summary
});
if (!updated) {
return createMcpResponse(`File not found: ${params.filepath}`, true);
}
// Save the updated tree
await saveFileTree(currentConfig!, fileTree!);
return createMcpResponse({
message: `Summary updated for ${params.filepath}`,
path: normalizedPath,
summary: params.summary
});
});
// New tool to read a file's content
server.tool("read_file_content", "Read the content of a specific file", {
filepath: z.string().describe("The path to the file to read")
}, async (params: { filepath: string }) => {
try {
const content = await readFileContent(params.filepath);
return createMcpResponse(content);
} catch (error) {
return createMcpResponse(`Failed to read file: ${params.filepath} - ` + error, true);
}
});
// New tool to set the importance of a file manually
server.tool("set_file_importance", "Manually set the importance ranking of a specific file", {
filepath: z.string().describe("The path to the file to update"),
importance: z.number().min(0).max(10).describe("The importance value to set (0-10)")
}, async (params: { filepath: string, importance: number }) => {
try {
if (!fileTree || !currentConfig) {
await buildFileTree(DEFAULT_CONFIG);
}
log('set_file_importance called with params: ' + JSON.stringify(params));
log('Current file tree root: ' + fileTree?.path);
// Get a list of all files
const allFiles = getAllFileNodes(fileTree!);
log(`Total files in tree: ${allFiles.length}`);
// First try the findAndSetImportance method
const wasUpdated = setFileImportance(fileTree!, params.filepath, params.importance);
// If that didn't work, try matching by basename
if (!wasUpdated) {
const basename = path.basename(params.filepath);
log(`Looking for file with basename: ${basename}`);
let foundFile = false;
for (const file of allFiles) {
const fileBasename = path.basename(file.path);
log(`Checking file: ${file.path} with basename: ${fileBasename}`);
if (fileBasename === basename) {
log(`Found match: ${file.path}`);
file.importance = Math.min(10, Math.max(0, params.importance));
foundFile = true;
break;
}
}
if (!foundFile) {
log('File not found by any method');
return createMcpResponse(`File not found: ${params.filepath}`, true);
}
}
// Save the updated tree
await saveFileTree(currentConfig!, fileTree!);
return createMcpResponse({
message: `Importance updated for ${params.filepath}`,
path: params.filepath,
importance: params.importance
});
} catch (error) {
log('Error in set_file_importance: ' + error);
return createMcpResponse(`Failed to set file importance: ` + error, true);
}
});
// Add a tool to recalculate importance for all files
server.tool("recalculate_importance", "Recalculate importance values for all files based on dependencies", async () => {
if (!fileTree || !currentConfig) {
await buildFileTree(DEFAULT_CONFIG);
}
log('Recalculating importance values...');
buildDependentMap(fileTree!);
calculateImportance(fileTree!);
// Save the updated tree
if (currentConfig) {
await saveFileTree(currentConfig, fileTree!);
}
// Count files with non-zero importance
const allFiles = getAllFileNodes(fileTree!);
const filesWithImportance = allFiles.filter(file => (file.importance || 0) > 0);
return createMcpResponse({
message: "Importance values recalculated",
totalFiles: allFiles.length,
filesWithImportance: filesWithImportance.length
});
});
// File watching tools
server.tool("toggle_file_watching", "Toggle file watching on/off", async () => {
const config = getConfig();
if (!config) {
return createMcpResponse('No configuration loaded', true);
}
// Create default file watching config if it doesn't exist
if (!config.fileWatching) {
config.fileWatching = {
enabled: true,
debounceMs: 300,
ignoreDotFiles: true,
autoRebuildTree: true,
maxWatchedDirectories: 1000,
watchForNewFiles: true,
watchForDeleted: true,
watchForChanged: true
};
} else {
// Toggle the enabled status
config.fileWatching.enabled = !config.fileWatching.enabled;
}
// Save the updated config
setConfig(config);
await saveConfig(config);
if (config.fileWatching.enabled) {
// Start watching
await initializeFileWatcher();
return createMcpResponse('File watching enabled');
} else {
// Stop watching
if (fileWatcher) {
fileWatcher.stop();
fileWatcher = null;
}
return createMcpResponse('File watching disabled');
}
});
server.tool("get_file_watching_status", "Get the current status of file watching", async () => {
const config = getConfig();
const status = {
enabled: config?.fileWatching?.enabled || false,
isActive: fileWatcher !== null && fileWatcher !== undefined,
config: config?.fileWatching || null
};
return createMcpResponse(status);
});
server.tool("update_file_watching_config", "Update file watching configuration", {
config: z.object({
enabled: z.boolean().optional(),
debounceMs: z.number().int().positive().optional(),
ignoreDotFiles: z.boolean().optional(),
autoRebuildTree: z.boolean().optional(),
maxWatchedDirectories: z.number().int().positive().optional(),
watchForNewFiles: z.boolean().optional(),
watchForDeleted: z.boolean().optional(),
watchForChanged: z.boolean().optional()
}).describe("File watching configuration options")
}, async (params: { config: Partial<FileWatchingConfig> }) => {
const config = getConfig();
if (!config) {
return createMcpResponse('No configuration loaded', true);
}
// Create or update file watching config
if (!config.fileWatching) {
config.fileWatching = {
enabled: false,
debounceMs: 300,
ignoreDotFiles: true,
autoRebuildTree: true,
maxWatchedDirectories: 1000,
watchForNewFiles: true,
watchForDeleted: true,
watchForChanged: true,
...params.config
};
} else {
config.fileWatching = {
...config.fileWatching,
...params.config
};
}
// Save the updated config
setConfig(config);
await saveConfig(config);
// Restart watcher if it's enabled
if (config.fileWatching.enabled) {
await initializeFileWatcher();
} else if (fileWatcher) {
fileWatcher.stop();
fileWatcher = null;
}
return createMcpResponse({
message: 'File watching configuration updated',
config: config.fileWatching
});
});
server.tool("debug_list_all_files", "List all file paths in the current file tree", async () => {
if (!fileTree) {
await buildFileTree(DEFAULT_CONFIG);
}
// Get a flat list of all files
const allFiles = getAllFileNodes(fileTree!);
// Extract just the paths and basenames
const fileDetails = allFiles.map(file => ({
path: file.path,
basename: path.basename(file.path),
importance: file.importance || 0
}));
return createMcpResponse({
totalFiles: fileDetails.length,
files: fileDetails
});
});
// Add a function to create the HTML wrapper for a Mermaid diagram
function createMermaidHtml(mermaidCode: string, title: string): string {
const now = new Date();
const timestamp = `${now.toDateString()} ${now.toLocaleTimeString()}`;
// Re-add escaping for backticks and dollar signs
const escapedMermaidCode = mermaidCode.replace(/`/g, '\\`').replace(/\$/g, '\\$');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>${title}</title>
<!-- Load Mermaid from CDN -->
<script src="https://cdn.jsdelivr.net/npm/mermaid@10.9.1/dist/mermaid.min.js"></script>
<style>
body {
font-family: 'Inter', sans-serif;
margin: 0;
padding: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
transition: background 0.5s ease;
}
.dark-mode {
background: linear-gradient(135deg, #1e1e2f 0%, #1d2426 100%);
}
.light-mode {
background: linear-gradient(135deg, #f5f6fa 0%, #dcdde1 100%);
}
header {
position: absolute;
top: 20px;
left: 20px;
text-align: left;
}
#theme-toggle {
position: absolute;
top: 20px;
right: 20px;
padding: 10px 20px;
border: none;
border-radius: 50px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
}
#diagram-container {
width: 90%;
max-width: 1200px;
margin: 75px 0;
padding: 25px;
border-radius: 15px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
transition: all 0.5s ease;
position: relative;
}
#mermaid-graph {
overflow: auto;
max-height: 70vh;
}
#error-message {
position: absolute;
bottom: 10px;
left: 10px;
padding: 8px 16px;
border-radius: 8px;
font-size: 14px;
display: none;
}
/* Styles for collapsed nodes */
.collapsed-node text {
font-weight: bold;
}
.collapsed-node rect, .collapsed-node circle, .collapsed-node polygon {
stroke-width: 3px !important;
}
.collapsed-indicator {
fill: #4cd137;
font-weight: bold;
}
/* Add + symbol to collapsed nodes */
.collapsed-node .collapsed-icon {
fill: #4cd137;
font-size: 16px;
font-weight: bold;
}
</style>
</head>
<body class="light-mode">
<!-- Header -->
<header style="color: #2d3436;">
<h1 style="margin: 0; font-size: 28px;">${title}</h1>
<div style="font-size: 14px; margin-top: 5px;">Generated on ${timestamp}</div>
</header>
<!-- Theme Toggle Button - Initial state for light mode -->
<button id="theme-toggle" style="background: #dcdde1; color: #2d3436;">Switch to Dark Mode</button>
<!-- Diagram Container - Initial state for light mode -->
<div id="diagram-container" style="background: rgba(255, 255, 255, 0.8); border: 1px solid rgba(0, 0, 0, 0.1);">
<div id="mermaid-graph"></div>
<div id="error-message" style="background: rgba(45, 52, 54, 0.9); color: #ff7675;"></div>
<!-- Mermaid Code -->
<pre id="raw-code" style="display: none;">
${escapedMermaidCode}
</pre>
</div>
<script>
// Unique render ID counter
let renderCount = 0;
// Track collapsible groups
const collapsibleGroups = {};
let expandedGroups = new Set();
let collapsedGroups = new Set();
// Initialize Mermaid with light theme by default
mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'loose',
flowchart: {
htmlLabels: true,
curve: 'basis',
nodeSpacing: 42,
rankSpacing: 60,
useMaxWidth: true
},
themeVariables: {
// Default light theme variables (adjust if needed)
nodeBorder: "#2d3436",
mainBkg: "#f8f9fa", // Light background
nodeTextColor: "#333333", // Dark text
fontSize: "16px"
}
});
// Render on DOM load
document.addEventListener('DOMContentLoaded', () => {
if (typeof mermaid === 'undefined') {
log('Mermaid library failed to load. Check network or CDN URL.');
document.getElementById('error-message').style.display = 'block';
document.getElementById('error-message').textContent = 'Error: Mermaid library not loaded';
return;
}
renderMermaid();
});
// Handle node click events
window.toggleGroup = function(nodeId) {
if (expandedGroups.has(nodeId)) {
// Collapse the group
expandedGroups.delete(nodeId);
collapsedGroups.add(nodeId);
} else {
// Expand the group
collapsedGroups.delete(nodeId);
expandedGroups.add(nodeId);
}
renderMermaid();
};
// Re-add processMermaidSvg function
function processMermaidSvg(svgElement) {
// Process click events on nodes
const clickables = svgElement.querySelectorAll('[id^="flowchart-"]');
clickables.forEach(node => {
const nodeId = node.id.replace('flowchart-', '');
// Is this a collapsible group?
if (Object.keys(collapsibleGroups).includes(nodeId)) {
// Add visual indicator for collapsed/expanded state
const textElement = node.querySelector('text');
if (textElement && collapsedGroups.has(nodeId)) {
// Add a + sign for collapsed groups
const currentText = textElement.textContent || '';
if (!currentText.includes('[+]')) {
textElement.textContent = currentText + ' [+]';
}
// Add a class for styling
node.classList.add('collapsed-node');
}
// Make nodes clickable visually
node.style.cursor = 'pointer';
// Add the children count to the label
const childCount = collapsibleGroups[nodeId].length;
const childLabel = '(' + childCount + ' items)';
const label = node.querySelector('text');
if (label && !label.textContent.includes(childLabel)) {
label.textContent += ' ' + childLabel;
}
}
});
// Hide children of collapsed groups
collapsedGroups.forEach(groupId => {
const children = collapsibleGroups[groupId] || [];
children.forEach(childId => {
const childElement = svgElement.querySelector('#flowchart-' + childId);
if (childElement) {
childElement.style.display = 'none';
// Also hide edges to/from this element
const edges = svgElement.querySelectorAll('path.flowchart-link');
edges.forEach(edge => {
const edgeId = edge.id;
if (edgeId.includes(childId)) {
edge.style.display = 'none';
}
});
}
});
});
}
// Re-add detectCollapsibleGroups function
function detectCollapsibleGroups(mermaidCode) {
// Reset the collapsible groups
Object.keys(collapsibleGroups).forEach(key => delete collapsibleGroups[key]);
// Look for click handler definitions like 'click node1 toggleGroup "node1"'
// Ensure backslashes for regex characters and quotes are properly escaped for the final HTML
const clickHandlerRegex = /click\\s+(\\w+)\\s+toggleGroup\\s+\"([^\"]+)\"/g;
let match;
while ((match = clickHandlerRegex.exec(mermaidCode)) !== null) {
const nodeId = match[1];
// Now find children of this group in the subgraph definition
// Ensure backslashes for regex characters are properly escaped for the final HTML
const subgraphRegex = new RegExp('subgraph\\s+' + nodeId + '.*?\\n([\\s\\S]*?)\\nend', 'g');
const subgraphMatch = subgraphRegex.exec(mermaidCode);
if (subgraphMatch) {
const subgraphContent = subgraphMatch[1];
// Extract node IDs from the subgraph
// Ensure backslashes for regex characters are properly escaped for the final HTML
const nodeRegex = /\\s+(\\w+)/g;
const children = [];
let nodeMatch;
while ((nodeMatch = nodeRegex.exec(subgraphContent)) !== null) {
const childId = nodeMatch[1].trim();
if (childId !== nodeId) {
children.push(childId);
}
}
if (children.length > 0) {
collapsibleGroups[nodeId] = children;
// By default, all groups start expanded
expandedGroups.add(nodeId);
}
}
}
log('Detected collapsible groups: ' + JSON.stringify(collapsibleGroups));
}
// Render Mermaid diagram
function renderMermaid() {
const mermaidDiv = document.getElementById('mermaid-graph');
const errorDiv = document.getElementById('error-message');
const rawCode = document.getElementById('raw-code').textContent.trim();
const uniqueId = 'mermaid-svg-' + Date.now() + '-' + renderCount++;
// Detect collapsible groups in the diagram
detectCollapsibleGroups(rawCode);
// Clear previous content
mermaidDiv.innerHTML = '';
errorDiv.style.display = 'none';
// Render using promise
mermaid.render(uniqueId, rawCode)
.then(({ svg }) => {
mermaidDiv.innerHTML = svg;
// Process the SVG after it's been inserted into the DOM
const svgElement = mermaidDiv.querySelector('svg');
if (svgElement) {
processMermaidSvg(svgElement);
}
})
.catch(error => {
log('Mermaid rendering failed: ' + error);
errorDiv.style.display = 'block';
errorDiv.textContent = error.message;
// Create a <pre> element and set its text content safely
const preElement = document.createElement('pre');
preElement.style.color = '#ff7675'; // Apply style directly
preElement.textContent = rawCode; // Use textContent for safety
// Clear mermaidDiv and append the new <pre> element
mermaidDiv.innerHTML = ''; // Clear previous attempts
mermaidDiv.appendChild(preElement);
});
}
// Theme toggle function
function toggleTheme() {
const body = document.body;
const toggleBtn = document.getElementById('theme-toggle');
const diagramContainer = document.getElementById('diagram-container');
const header = document.querySelector('header');
const isDarkMode = body.classList.contains('dark-mode');
if (isDarkMode) {
// Switch to Light Mode
body.classList.remove('dark-mode');
body.classList.add('light-mode');
toggleBtn.textContent = 'Switch to Dark Mode';
toggleBtn.style.background = '#dcdde1';
toggleBtn.style.color = '#2d3436';
diagramContainer.style.background = 'rgba(255, 255, 255, 0.8)';
diagramContainer.style.border = '1px solid rgba(0, 0, 0, 0.1)';
header.style.color = '#2d3436';
// Update Mermaid theme to light with dark text
mermaid.initialize({
theme: 'default',
themeVariables: {
nodeBorder: "#2d3436",
mainBkg: "#f8f9fa",
nodeTextColor: "#333333",
fontSize: "16px"
}
});
} else {
// Switch to Dark Mode
body.classList.remove('light-mode');
body.classList.add('dark-mode');
toggleBtn.textContent = 'Switch to Light Mode';
toggleBtn.style.background = '#2d3436';
toggleBtn.style.color = '#ffffff';
diagramContainer.style.background = 'rgba(255, 255, 255, 0.05)';
diagramContainer.style.border = '1px solid rgba(255, 255, 255, 0.1)';
header.style.color = '#ffffff';
// Update Mermaid theme to dark with bright white text
mermaid.initialize({
theme: 'dark',
themeVariables: {
nodeBorder: "#2d3436",
mainBkg: "#1e272e",
nodeTextColor: "#ffffff",
fontSize: "16px"
}
});
}
// Re-render diagram after theme change
renderMermaid();
}
// Attach theme toggle event
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
</script>
</body>
</html>`;
}
// Update the generate_diagram tool
server.tool("generate_diagram", "Generate a Mermaid diagram for the current file tree", {
style: z.enum(['default', 'dependency', 'directory', 'hybrid', 'package-deps']).describe('Diagram style'),
maxDepth: z.number().optional().describe('Maximum depth for directory trees (1-10)'),
minImportance: z.number().optional().describe('Only show files above this importance (0-10)'),
showDependencies: z.boolean().optional().describe('Whether to show dependency relationships'),
showPackageDeps: z.boolean().optional().describe('Whether to show package dependencies'),
packageGrouping: z.boolean().optional().describe('Whether to group packages by scope'),
autoGroupThreshold: z.number().optional().describe("Auto-group nodes when parent has more than this many direct children (default: 8)"),
excludePackages: z.array(z.string()).optional().describe('Packages to exclude from diagram'),
includeOnlyPackages: z.array(z.string()).optional().describe('Only include these packages (if specified)'),
outputPath: z.string().optional().describe('Full path or relative path where to save the diagram file (.mmd or .html)'),
outputFormat: z.enum(['mmd', 'html']).optional().describe('Output format (mmd or html)'),
layout: z.object({
direction: z.enum(['TB', 'BT', 'LR', 'RL']).optional().describe("Graph direction"),
rankSpacing: z.number().min(10).max(100).optional().describe("Space between ranks"),
nodeSpacing: z.number().min(10).max(100).optional().describe("Space between nodes")
}).optional()
}, async (params) => {
try {
if (!fileTree) {
return createMcpResponse("No file tree loaded. Please create or select a file tree first.", true);
}
// Use specialized config for package-deps style
if (params.style === 'package-deps') {
// Package-deps style should show package dependencies by default
params.showPackageDeps = params.showPackageDeps ?? true;
// Default to left-to-right layout for better readability of packages
if (!params.layout) {
params.layout = { direction: 'LR' };
} else if (!params.layout.direction) {
params.layout.direction = 'LR';
}
}
// Generate the diagram with added autoGroupThreshold parameter
const generator = new MermaidGenerator(fileTree, {
style: params.style,
maxDepth: params.maxDepth,
minImportance: params.minImportance,
showDependencies: params.showDependencies,
showPackageDeps: params.showPackageDeps,
packageGrouping: params.packageGrouping,
autoGroupThreshold: params.autoGroupThreshold,
excludePackages: params.excludePackages,
includeOnlyPackages: params.includeOnlyPackages,
layout: params.layout
});
const diagram = generator.generate();
const mermaidContent = diagram.code;
// Enhanced title based on diagram type
let titlePrefix = "File Scope Diagram";
switch (params.style) {
case 'package-deps':
titlePrefix = "Package Dependencies";
break;
case 'dependency':
titlePrefix = "Code Dependencies";
break;
case 'directory':
titlePrefix = "Directory Structure";
break;
case 'hybrid':
titlePrefix = "Hybrid View";
break;
}
// Save diagram to file if requested
if (params.outputPath) {
const outputFormat = params.outputFormat || 'mmd';
const baseOutputPath = path.resolve(process.cwd(), params.outputPath);
const outputDir = path.dirname(baseOutputPath);
log(`[${new Date().toISOString()}] Attempting to save diagram file(s):`);
log(`[${new Date().toISOString()}] - Base output path: ${baseOutputPath}`);
log(`[${new Date().toISOString()}] - Output directory: ${outputDir}`);
log(`[${new Date().toISOString()}] - Output format: ${outputFormat}`);
// Ensure output directory exists
try {
await fs.mkdir(outputDir, { recursive: true });
log(`[${new Date().toISOString()}] Created output directory: ${outputDir}`);
} catch (err: any) {
if (err.code !== 'EEXIST') {
log(`[${new Date().toISOString()}] Error creating output directory: ` + err);
return createMcpResponse(`Failed to create output directory: ${err.message}`, true);
}
}
// Save the appropriate file based on format
if (outputFormat === 'mmd') {
// Save Mermaid file
const mmdPath = baseOutputPath.endsWith('.mmd') ? baseOutputPath : baseOutputPath + '.mmd';
try {
await fs.writeFile(mmdPath, mermaidContent, 'utf8');
log(`[${new Date().toISOString()}] Successfully saved Mermaid file to: ${mmdPath}`);
return createMcpResponse({
message: `Successfully generated diagram in mmd format`,
filePath: mmdPath,
stats: diagram.stats
});
} catch (err: any) {
log(`[${new Date().toISOString()}] Error saving Mermaid file: ` + err);
return createMcpResponse(`Failed to save Mermaid file: ${err.message}`, true);
}
} else if (outputFormat === 'html') {
// Generate HTML with embedded Mermaid
const title = `${titlePrefix} - ${path.basename(baseOutputPath)}`;
const htmlContent = createMermaidHtml(mermaidContent, title);
// Save HTML file
const htmlPath = baseOutputPath.endsWith('.html') ? baseOutputPath : baseOutputPath + '.html';
try {
await fs.writeFile(htmlPath, htmlContent, 'utf8');
log(`[${new Date().toISOString()}] Successfully saved HTML file to: ${htmlPath}`);
return createMcpResponse({
message: `Successfully generated diagram in html format`,
filePath: htmlPath,
stats: diagram.stats
});
} catch (err: any) {
log(`[${new Date().toISOString()}] Error saving HTML file: ` + err);
return createMcpResponse(`Failed to save HTML file: ${err.message}`, true);
}
}
}
// Return both the diagram content and file information
return createMcpResponse([
{
type: "text",
text: JSON.stringify({
stats: diagram.stats,
style: diagram.style,
generated: diagram.timestamp
}, null, 2)
},
{
type: "resource" as const,
resource: {
uri: 'data:text/x-mermaid;base64,' + Buffer.from(mermaidContent).toString('base64'),
text: mermaidContent,
mimeType: "text/x-mermaid"
}
}
]);
} catch (error) {
log('Error generating diagram: ' + error);
return createMcpResponse(`Failed to generate diagram: ` + error, true);
}
});
// Register a new tool to exclude and remove a file or pattern
server.tool("exclude_and_remove", "Exclude and remove a file or pattern from the file tree", {
filepath: z.string().describe("The path or pattern of the file to exclude and remove")
}, async (params: { filepath: string }) => {
try {
if (!fileTree || !currentConfig) {
await buildFileTree(DEFAULT_CONFIG);
}
log('exclude_and_remove called with params: ' + JSON.stringify(params));
log('Current file tree root: ' + fileTree?.path);
// Use the excludeAndRemoveFile function
await excludeAndRemoveFile(params.filepath, fileTree!, getProjectRoot());
// Save the updated tree
if (currentConfig) {
await saveFileTree(currentConfig, fileTree!);
}
return createMcpResponse({
message: `File or pattern excluded and removed: ${params.filepath}`
});
} catch (error) {
log('Error in exclude_and_remove: ' + error);
return createMcpResponse(`Failed to exclude and remove file or pattern: ` + error, true);
}
});
// Start the server
(async () => {
try {
// Initialize server first
await initializeServer();
// Connect to transport
const transport = new StdioTransport();
await server.connect(transport);
} catch (error) {
log('Server error: ' + error);
process.exit(1);
}
})();
ID: mcrren8xsa