index.ts•17 kB
#!/usr/bin/env node
/**
* LLM-Optimized Smart Filesystem MCP Server
*
* Provides intelligent file access with:
* - Automatic pagination for large files
* - Ripgrep integration for fast searching
* - Security sandboxing to allowed directories
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ListRootsRequestSchema,
Tool
} from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import {
setAllowedDirectories,
getAllowedDirectories,
validatePath,
getFileStats,
readFileContent,
isBinaryFile,
detectLanguage,
countLines,
listDirectory
} from './lib.js';
import {
paginateFileContent,
shouldPaginate,
generateReadingSuggestions,
LINES_PER_CHUNK,
DEFAULT_MAX_RESULTS
} from './pagination.js';
import {
ripgrepSearch,
ripgrepSearchInFile,
ripgrepFindFiles
} from './ripgrep.js';
import { getValidRootDirectories } from './roots-utils.js';
// Server instance
const server = new Server(
{
name: 'mcp-server-filesystem-smart',
version: '1.0.0',
},
{
capabilities: {
tools: {},
roots: {
listChanged: false
}
},
}
);
// Tool definitions
const tools: Tool[] = [
{
name: 'list_directory',
description: 'List contents of a directory with metadata including file sizes and line counts',
inputSchema: {
type: 'object',
properties: {
path: {
type: 'string',
description: "Directory path to list. Use '.' for workspace root"
}
},
required: ['path']
}
},
{
name: 'read_file',
description: 'Read file contents. For large files (>500 lines), use start_line to read in chunks (e.g., 0, 500, 1000). Each call returns up to 500 lines. Binary files return metadata only.',
inputSchema: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'File path to read'
},
start_line: {
type: 'number',
description: 'Line number to start reading from (0-indexed). For large files, read in chunks: start_line=0 (first 500), start_line=500 (next 500), etc.',
default: 0
}
},
required: ['path']
}
},
{
name: 'search_code',
description: `Search for code patterns using ripgrep (very fast). Supports regex patterns and advanced filtering.
PATTERN EXAMPLES:
- Exact text: "functionName"
- Multiple options: "\\\\b(class|struct|record|interface|enum)\\\\s+TypeName\\\\b"
(finds: class TypeName, record TypeName, interface TypeName, etc.)
- Regex: "async.*Promise<.*>" (finds async functions returning Promise)
- Any declaration: "\\\\b(public|private|protected)\\\\s+\\\\w+\\\\s+methodName"
COMMON USE CASES:
- Find type declaration: "\\\\b(class|struct|interface|record|enum)\\\\s+TypeName\\\\b"
- Find method: "\\\\b(public|private|protected|internal).*\\\\s+methodName\\\\s*\\\\("
- Find property: "\\\\bpublic\\\\s+\\\\w+\\\\s+propertyName\\\\s*\\\\{"
- Find async methods: "async.*Task<"
- Find implementations: ":\\\\s*IInterfaceName\\\\b"
TIPS:
- Use \\\\b for word boundaries
- Use \\\\s+ for whitespace
- Combine alternatives with (opt1|opt2|opt3)
- Escape special chars: \\\\( \\\\) \\\\{ \\\\}`,
inputSchema: {
type: 'object',
properties: {
pattern: {
type: 'string',
description: 'Regex pattern to search. For multiple alternatives use: (class|struct|interface) to match any'
},
path: {
type: 'string',
description: "Limit search to specific directory (e.g., 'src/components'). Omit to search entire workspace."
},
filePattern: {
type: 'string',
description: "File glob pattern (ripgrep -g flag). Examples: '*.js', '*.{ts,tsx}', '!*test*' (exclude). Can specify multiple patterns separated by comma."
},
caseInsensitive: {
type: 'boolean',
description: 'Ignore case in search (ripgrep -i). Default: true for LLM-friendly searching',
default: true
},
contextLines: {
type: 'number',
description: 'Lines of context before/after match (ripgrep -C)',
default: 2
},
maxResults: {
type: 'number',
description: `Maximum number of results to return (per page). Default: ${DEFAULT_MAX_RESULTS}. Configure via MCP_MAX_SEARCH_RESULTS env var.`,
default: DEFAULT_MAX_RESULTS
},
page: {
type: 'number',
description: 'Page number for paginated results (1-based). Use to get more results beyond maxResults.',
default: 1
},
literalString: {
type: 'boolean',
description: 'Treat pattern as literal string, not regex (ripgrep -F)',
default: false
},
wordBoundary: {
type: 'boolean',
description: 'Match whole words only (ripgrep -w)',
default: false
}
},
required: ['pattern']
}
},
{
name: 'search_in_file',
description: 'Search for patterns within a specific file using ripgrep. Like Ctrl+F but with regex support. Useful for finding specific sections in a known file.',
inputSchema: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'File path to search within'
},
pattern: {
type: 'string',
description: 'Regex pattern to search for'
},
caseInsensitive: {
type: 'boolean',
description: 'Ignore case in search. Default: true',
default: true
},
contextLines: {
type: 'number',
description: 'Lines of context before/after match',
default: 3
},
literalString: {
type: 'boolean',
description: 'Treat pattern as literal string, not regex',
default: false
},
wordBoundary: {
type: 'boolean',
description: 'Match whole words only',
default: false
}
},
required: ['path', 'pattern']
}
},
{
name: 'find_files',
description: `Find files by name using fast pattern matching.
PATTERN EXAMPLES:
- Exact name: "config.json"
- Wildcard: "*.config" or "*Handler*"
- Multiple extensions: "*.{ts,tsx,js}"
TIPS:
- Use * for any characters
- Use ? for single character
- Use {a,b,c} for alternatives`,
inputSchema: {
type: 'object',
properties: {
pattern: {
type: 'string',
description: "Filename pattern. Examples: 'Component.tsx', '*.json', '*Handler*', '*.{ts,tsx}'"
},
path: {
type: 'string',
description: 'Limit search to specific directory'
}
},
required: ['pattern']
}
},
{
name: 'get_file_info',
description: 'Get file metadata without reading contents. Useful to check size/line count before reading. For large files, provides reading strategy recommendations.',
inputSchema: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'File path to get info about'
}
},
required: ['path']
}
},
{
name: 'list_allowed_directories',
description: 'Show which directories this server can access (security boundaries). No parameters required.',
inputSchema: {
type: 'object',
properties: {}
}
}
];
// Tool handlers
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case 'list_directory': {
const schema = z.object({ path: z.string() });
const { path } = schema.parse(args);
const validatedPath = await validatePath(path);
const result = await listDirectory(validatedPath);
return {
content: [{
type: 'text',
text: JSON.stringify(result, null, 2)
}]
};
}
case 'read_file': {
const schema = z.object({
path: z.string(),
start_line: z.number().optional().default(0)
});
const { path, start_line } = schema.parse(args);
const validatedPath = await validatePath(path);
// Check if binary
const isBinary = await isBinaryFile(validatedPath);
if (isBinary) {
const stats = await getFileStats(validatedPath);
return {
content: [{
type: 'text',
text: JSON.stringify({
path: validatedPath,
error: 'Binary file',
message: 'This appears to be a binary file. Use get_file_info for metadata.',
size: stats.size,
type: 'binary'
}, null, 2)
}]
};
}
// Read file content
const content = await readFileContent(validatedPath);
const totalLines = countLines(content);
// Check if pagination is needed
if (shouldPaginate(totalLines) || start_line > 0) {
const result = paginateFileContent(validatedPath, content, start_line, LINES_PER_CHUNK);
return {
content: [{
type: 'text',
text: JSON.stringify(result, null, 2)
}]
};
}
// Return full file
return {
content: [{
type: 'text',
text: JSON.stringify({
path: validatedPath,
content,
startLine: 0,
endLine: totalLines - 1,
totalLines,
hasMore: false
}, null, 2)
}]
};
}
case 'search_code': {
const schema = z.object({
pattern: z.string(),
path: z.string().optional(),
filePattern: z.string().optional(),
caseInsensitive: z.boolean().optional().default(true),
contextLines: z.number().optional().default(2),
maxResults: z.number().optional().default(DEFAULT_MAX_RESULTS),
page: z.number().optional().default(1),
literalString: z.boolean().optional().default(false),
wordBoundary: z.boolean().optional().default(false)
});
const options = schema.parse(args);
const result = await ripgrepSearch(options, getAllowedDirectories());
return {
content: [{
type: 'text',
text: JSON.stringify(result, null, 2)
}]
};
}
case 'search_in_file': {
const schema = z.object({
path: z.string(),
pattern: z.string(),
caseInsensitive: z.boolean().optional().default(true),
contextLines: z.number().optional().default(3),
literalString: z.boolean().optional().default(false),
wordBoundary: z.boolean().optional().default(false)
});
const { path, pattern, caseInsensitive, contextLines, literalString, wordBoundary } = schema.parse(args);
const validatedPath = await validatePath(path);
const result = await ripgrepSearchInFile(
validatedPath,
pattern,
{ caseInsensitive, contextLines, literalString, wordBoundary }
);
return {
content: [{
type: 'text',
text: JSON.stringify(result, null, 2)
}]
};
}
case 'find_files': {
const schema = z.object({
pattern: z.string(),
path: z.string().optional()
});
const { pattern, path } = schema.parse(args);
const files = await ripgrepFindFiles(pattern, path, getAllowedDirectories());
// Get metadata for each file
const filesWithMetadata = await Promise.all(
files.map(async (file) => {
try {
const stats = await getFileStats(file);
let lines: number | undefined;
if (stats.isFile && stats.size < 10 * 1024 * 1024) {
const isBinary = await isBinaryFile(file);
if (!isBinary) {
try {
const content = await readFileContent(file);
lines = countLines(content);
} catch {
// Ignore
}
}
}
return {
path: file,
size: `${stats.size} bytes`,
lines,
matchType: 'match',
hint: `Matches pattern '${pattern}'`
};
} catch {
return {
path: file,
matchType: 'match'
};
}
})
);
return {
content: [{
type: 'text',
text: JSON.stringify({
query: {
pattern,
searchedIn: path || getAllowedDirectories().join(', ')
},
files: filesWithMetadata,
summary: {
totalFiles: filesWithMetadata.length,
searchTimeMs: 0
}
}, null, 2)
}]
};
}
case 'get_file_info': {
const schema = z.object({ path: z.string() });
const { path } = schema.parse(args);
const validatedPath = await validatePath(path);
const stats = await getFileStats(validatedPath);
const isBinary = await isBinaryFile(validatedPath);
const language = detectLanguage(validatedPath);
let lines: number | undefined;
let readingStrategy: any = undefined;
if (!isBinary && stats.isFile) {
try {
const content = await readFileContent(validatedPath);
lines = countLines(content);
if (shouldPaginate(lines)) {
readingStrategy = {
recommendation: `File is large (${lines} lines). Suggested approach:`,
options: generateReadingSuggestions(lines)
};
}
} catch {
// Ignore read errors
}
}
return {
content: [{
type: 'text',
text: JSON.stringify({
path: validatedPath,
size: {
bytes: stats.size,
readable: `${stats.size} bytes`
},
lines,
type: stats.isDirectory ? 'directory' : 'file',
language,
isBinary,
lastModified: stats.modified.toISOString(),
permissions: stats.permissions,
readingStrategy
}, null, 2)
}]
};
}
case 'list_allowed_directories': {
return {
content: [{
type: 'text',
text: JSON.stringify({
allowedDirectories: getAllowedDirectories(),
count: getAllowedDirectories().length,
note: 'This server can only access files within these directories for security'
}, null, 2)
}]
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
content: [{
type: 'text',
text: JSON.stringify({
error: errorMessage,
tool: name,
arguments: args
}, null, 2)
}],
isError: true
};
}
});
// Roots handler
server.setRequestHandler(ListRootsRequestSchema, async () => ({
roots: getAllowedDirectories().map(dir => ({
uri: `file://${dir}`,
name: dir
}))
}));
// Main startup
async function main() {
// Parse command line arguments for allowed directories
const args = process.argv.slice(2);
if (args.length > 0) {
// Use provided directories
setAllowedDirectories(args);
console.error(`MCP Smart Filesystem Server starting with allowed directories: ${args.join(', ')}`);
} else {
// Default to current working directory
setAllowedDirectories([process.cwd()]);
console.error(`MCP Smart Filesystem Server starting with default directory: ${process.cwd()}`);
}
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('MCP Smart Filesystem Server ready');
}
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});