Skip to main content
Glama
fs-read.tool.ts48.5 kB
/** * fs_read Tool * * Unified exploration tool for directories, files, and search. */ import fs from 'node:fs/promises'; import path from 'node:path'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; import { addLineNumbers, createIgnoreMatcherForDir, extractLines, findMatches, findPresetMatches, generateChecksum, getMounts, isPresetPattern, isTextFile, type MatchResult, matchesType, type PatternMode, parseLineRange, resolvePath as resolveVirtualPath, shouldExclude, } from '../lib/index.js'; import type { HandlerExtra } from '../types/index.js'; // ───────────────────────────────────────────────────────────── // Schema // ───────────────────────────────────────────────────────────── export const fsReadInputSchema = z .object({ path: z .string() .min(1) .describe( 'Relative path to file or directory. Examples: "." (current dir), "docs/", "src/index.ts". ' + 'For directories: returns tree. For files: returns content with line numbers.', ), pattern: z .string() .optional() .describe( 'Search pattern to find within file(s). Matches both file contents AND filenames.\n' + '• With a file path: returns matching lines with context.\n' + '• With a directory path: searches recursively.\n' + 'By default, pattern is treated as LITERAL TEXT (not regex). Examples:\n' + '• pattern="TODO" → finds exact text "TODO"\n' + '• pattern="foo|bar" with patternMode="literal" → finds literal "foo|bar" (NOT "foo" or "bar"!)\n' + '• pattern="foo|bar" with patternMode="regex" → finds "foo" OR "bar"\n' + 'For OR searches, ALWAYS use patternMode="regex". For common Markdown patterns, use "preset" instead.', ), preset: z .enum([ 'wikilinks', 'tags', 'tasks', 'tasks_open', 'tasks_done', 'headings', 'codeblocks', 'frontmatter', ]) .optional() .describe( 'Use a preset pattern for common Obsidian/Markdown searches. Easier than writing regex. ' + 'Options: "wikilinks" → [[links]], "tags" → #tags, "tasks" → all tasks, ' + '"tasks_open" → incomplete tasks, "tasks_done" → completed tasks, ' + '"headings" → # headings, "codeblocks" → ``` blocks, "frontmatter" → YAML ---. ' + 'Cannot be used with "pattern".', ), patternMode: z .enum(['literal', 'regex', 'fuzzy']) .optional() .default('literal') .describe( 'How to interpret the pattern string:\n' + '• "literal" (default): Exact text match. Pattern "foo|bar" searches for literal "foo|bar".\n' + '• "regex": Full regular expression. Pattern "foo|bar" matches "foo" OR "bar". Use for OR searches, wildcards, etc.\n' + '• "fuzzy": Literal match with flexible whitespace. "hello world" matches "hello world".\n' + 'IMPORTANT: To search for multiple terms (OR), you MUST use patternMode="regex" with pattern="term1|term2|term3".', ), caseInsensitive: z .boolean() .optional() .default(false) .describe( 'Ignore case when matching. Works with all pattern modes.\n' + 'Examples: caseInsensitive=true with pattern="todo" matches "TODO", "Todo", "todo".', ), multiline: z .boolean() .optional() .default(false) .describe( 'Enable patterns to span multiple lines. The dot (.) will match newlines. ' + 'Note: ^ and $ still match string boundaries, not line boundaries. ' + 'For line-start matching, use preset="headings" or patternMode="regex" with (?m) flag.', ), wholeWord: z .boolean() .optional() .default(false) .describe('Match whole words only. Prevents "cat" from matching "category".'), lines: z .string() .optional() .describe( 'Limit file reading to specific lines. Format: "10" (single line), "10-50" (range). ' + 'Useful for large files or when you know the target area.', ), depth: z .number() .int() .min(1) .max(20) .optional() .default(7) .describe( 'How many directory levels deep to traverse. Default 7. ' + 'Applies to directory listing, search, and find operations.', ), context: z .number() .int() .min(0) .max(20) .optional() .default(3) .describe( 'For search results: number of lines to show before and after each match. Default 3.', ), types: z .array(z.string()) .optional() .describe('Filter by file type. Examples: ["ts", "js"], ["md"], ["config"].'), glob: z.string().optional().describe('Glob pattern to filter files. Example: "**/*.ts".'), find: z .string() .optional() .describe( 'Find files by name (not content). Searches recursively from path. ' + 'Partial matching by default: "music" finds "music.md", "my-music-file.txt", etc. ' + 'Use wildcards for more control: "*.md" (all markdown), "config.json" (exact match with extension). ' + 'Returns list of matching file paths. Use this when you know the filename but not the location.', ), exclude: z .array(z.string()) .optional() .describe('Patterns to exclude. Example: ["**/test/**", "**/*.spec.ts"].'), respectIgnore: z .boolean() .optional() .default(true) .describe('Respect .gitignore and .ignore files. Default true.'), output: z .enum(['full', 'list', 'count', 'summary']) .optional() .default('full') .describe( '"full" (default): complete content. "list": just file paths. ' + '"count": match counts per file. "summary": overview statistics.', ), maxMatches: z .number() .int() .min(1) .max(1000) .optional() .default(100) .describe('Maximum matches to return. Default 100.'), maxFiles: z .number() .int() .min(1) .optional() .default(999999) .describe('Maximum files with matches to return. Searches all files but caps results. No limit by default.'), details: z .boolean() .optional() .default(false) .describe( 'Include file details (size, modified time) in directory listings. ' + 'Default false for compact output. Set true when you need file metadata.', ), }) .passthrough() // Allow extra keys from SDK context .refine((data) => !(data.pattern && data.preset), { message: 'Cannot use both "pattern" and "preset". Use one or the other.', path: ['preset'], }); export type FsReadInput = z.infer<typeof fsReadInputSchema>; // ───────────────────────────────────────────────────────────── // Types // ───────────────────────────────────────────────────────────── interface TreeEntry { path: string; kind: 'file' | 'directory'; size?: string; modified?: string; children?: number; } interface SearchMatch { file: string; /** First line of this match/cluster */ line: number; /** Last line of this match/cluster (same as line for single match) */ endLine: number; /** Number of individual matches in this cluster */ matchCount: number; /** Line numbers where matches occur within this cluster */ matchLines: number[]; /** The matched text(s) */ text: string; /** Context lines around the cluster, formatted as "LINE_NUM|content" */ context: { before: string[]; match: string[]; after: string[]; }; } interface FsReadResult { success: boolean; path: string; type: 'directory' | 'file' | 'search'; tree?: { entries: TreeEntry[]; summary: string; }; content?: { text: string; checksum: string; totalLines: number; range?: { start: number; end: number }; truncated: boolean; }; matches?: SearchMatch[]; matchCount?: number; filesSearched?: number; truncated?: boolean; stats?: { filesSearched: number; filesMatched: number; totalMatches: number; }; error?: { code: string; message: string; }; hint: string; } // ───────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────── function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes}B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; } function formatRelativeTime(date: Date): string { const now = Date.now(); const diff = now - date.getTime(); const minutes = Math.floor(diff / 60000); const hours = Math.floor(diff / 3600000); const days = Math.floor(diff / 86400000); if (minutes < 1) return 'just now'; if (minutes < 60) return `${minutes}m ago`; if (hours < 24) return `${hours}h ago`; if (days < 7) return `${days}d ago`; return date.toISOString().split('T')[0] ?? date.toISOString(); } /** * Check if the path is requesting mount listing (root path). */ function isRootPath(pathStr: string): boolean { const trimmed = pathStr.trim(); return trimmed === '.' || trimmed === '' || trimmed === '/'; } /** * List all available mount points, or directly show single mount contents. */ async function listMountsOrSingleMount( depth: number, options: { types?: string[]; exclude?: string[]; respectIgnore: boolean; maxFiles: number; details?: boolean; }, ): Promise<FsReadResult> { const mounts = getMounts(); // SINGLE MOUNT: Skip the extra navigation step — show contents directly if (mounts.length === 1) { const mount = mounts[0]; if (!mount) { throw new Error('Unexpected: mounts array is empty after length check'); } const { entries, truncated } = await listDirectory(mount.absolutePath, '', depth, options); const fileCount = entries.filter((e) => e.kind === 'file').length; const dirCount = entries.filter((e) => e.kind === 'directory').length; return { success: true, path: '.', type: 'directory', tree: { entries, summary: `${entries.length} items (${fileCount} files, ${dirCount} directories)`, }, truncated, hint: entries.length === 0 ? 'Directory is empty or all files are ignored.' : `Showing contents of "${mount.name}". Use fs_read on any path to explore deeper.`, }; } // MULTIPLE MOUNTS: Show mount list const entries: TreeEntry[] = []; for (const mount of mounts) { try { const stat = await fs.stat(mount.absolutePath); let childCount = 0; try { const children = await fs.readdir(mount.absolutePath); childCount = children.length; } catch { // Can't read } const entry: TreeEntry = { path: mount.name, kind: 'directory', children: childCount, }; if (options.details) { entry.modified = formatRelativeTime(stat.mtime); } entries.push(entry); } catch { // Mount not accessible, still show it entries.push({ path: mount.name, kind: 'directory', children: 0, }); } } const mountNames = mounts.map((m) => m.name).join(', '); return { success: true, path: '.', type: 'directory', tree: { entries, summary: `${mounts.length} mount point(s): ${mountNames}`, }, truncated: false, hint: `${mounts.length} mounts available. Use fs_read("mountname/") to explore a specific mount, or find="filename" to search all mounts.`, }; } // ───────────────────────────────────────────────────────────── // Core Functions // ───────────────────────────────────────────────────────────── async function listDirectory( absPath: string, relativePath: string, depth: number, options: { types?: string[]; exclude?: string[]; respectIgnore: boolean; maxFiles: number; details?: boolean; }, ): Promise<{ entries: TreeEntry[]; truncated: boolean }> { const entries: TreeEntry[] = []; let truncated = false; const ignoreMatcher = options.respectIgnore ? await createIgnoreMatcherForDir(absPath) : null; async function walk(dir: string, relDir: string, currentDepth: number): Promise<void> { if (currentDepth > depth || entries.length >= options.maxFiles) { truncated = entries.length >= options.maxFiles; return; } let items: string[]; try { items = await fs.readdir(dir); } catch { return; } for (const item of items) { if (entries.length >= options.maxFiles) { truncated = true; break; } const itemPath = path.join(dir, item); const itemRelPath = path.join(relDir, item); // Check ignore if (ignoreMatcher?.isIgnored(itemRelPath)) continue; if (options.exclude && shouldExclude(itemRelPath, options.exclude)) continue; try { const stat = await fs.stat(itemPath); if (stat.isDirectory()) { // Count children let childCount = 0; try { const children = await fs.readdir(itemPath); childCount = children.length; } catch { // Can't read } const entry: TreeEntry = { path: itemRelPath, kind: 'directory', children: childCount, }; if (options.details) { entry.modified = formatRelativeTime(stat.mtime); } entries.push(entry); if (currentDepth < depth) { await walk(itemPath, itemRelPath, currentDepth + 1); } } else if (stat.isFile()) { // Type filter if (options.types && options.types.length > 0) { if (!matchesType(item, options.types)) continue; } const entry: TreeEntry = { path: itemRelPath, kind: 'file', }; if (options.details) { entry.size = formatSize(stat.size); entry.modified = formatRelativeTime(stat.mtime); } entries.push(entry); } } catch { // Skip inaccessible items } } } await walk(absPath, relativePath === '.' ? '' : relativePath, 1); return { entries, truncated }; } async function readFile( absPath: string, relativePath: string, options: { lines?: string }, ): Promise<FsReadResult> { // Check if text file if (!isTextFile(absPath)) { return { success: false, path: relativePath, type: 'file', error: { code: 'NOT_TEXT', message: 'Cannot read binary files' }, hint: 'This appears to be a binary file. Only text files can be read.', }; } let content: string; try { content = await fs.readFile(absPath, 'utf8'); } catch (e) { const err = e as NodeJS.ErrnoException; if (err.code === 'ENOENT') { return { success: false, path: relativePath, type: 'file', error: { code: 'NOT_FOUND', message: `File does not exist: ${relativePath}` }, hint: 'File not found. Use fs_read on the parent directory to see available files, or fs_write with operation="create" to create it.', }; } return { success: false, path: relativePath, type: 'file', error: { code: 'IO_ERROR', message: err.message }, hint: 'Could not read file. Check if the path is correct.', }; } const checksum = generateChecksum(content); const totalLines = content.split('\n').length; let text = content; let range: { start: number; end: number } | undefined; let truncated = false; // Handle line range if (options.lines) { const parsedRange = parseLineRange(options.lines); if (!parsedRange) { return { success: false, path: relativePath, type: 'file', error: { code: 'INVALID_RANGE', message: `Invalid line range: ${options.lines}` }, hint: 'Line range format: "10" for single line, "10-50" for range.', }; } const extracted = extractLines(content, parsedRange.start, parsedRange.end); text = addLineNumbers(extracted.text, extracted.actualStart); range = { start: extracted.actualStart, end: extracted.actualEnd }; } else { // Truncate large files to first 100 lines const PREVIEW_LINES = 100; if (totalLines > PREVIEW_LINES) { const extracted = extractLines(content, 1, PREVIEW_LINES); text = addLineNumbers(extracted.text); truncated = true; range = { start: 1, end: PREVIEW_LINES }; } else { text = addLineNumbers(content); } } return { success: true, path: relativePath, type: 'file', content: { text, checksum, totalLines, range, truncated, }, hint: truncated ? `📄 LARGE FILE: ${totalLines.toLocaleString()} lines total, showing lines 1-${range?.end ?? 100}. ` + `To read more: use lines="101-200", lines="500-600", etc. ` + `To find specific content: use pattern="search term". ` + `Checksum: ${checksum}` : `File read complete. Checksum: ${checksum}. To edit this file, use fs_write with this checksum. Reference lines by number for precise edits.`, }; } /** * Cluster nearby matches to avoid redundant overlapping results. * Matches within CLUSTER_DISTANCE lines of each other are merged. */ const CLUSTER_DISTANCE = 5; function clusterMatches( matches: MatchResult[], content: string, contextLines: number, ): SearchMatch[] { if (matches.length === 0) return []; // Sort by line number const sorted = [...matches].sort((a, b) => a.line - b.line); const first = sorted.at(0); if (!first) return []; const clusters: SearchMatch[] = []; let currentCluster: MatchResult[] = [first]; for (let i = 1; i < sorted.length; i++) { const match = sorted.at(i); const lastInCluster = currentCluster.at(-1); if (!match || !lastInCluster) continue; // If this match is close to the last one, add to cluster if (match.line - lastInCluster.line <= CLUSTER_DISTANCE) { currentCluster.push(match); } else { // Finalize current cluster and start new one clusters.push(buildClusterResult(currentCluster, content, contextLines)); currentCluster = [match]; } } // Don't forget the last cluster clusters.push(buildClusterResult(currentCluster, content, contextLines)); return clusters; } /** * Format a line with its line number for context output. * Example: " 42|const x = 1;" */ function formatLineWithNumber(lineNum: number, text: string, maxLineNum: number): string { const padding = String(maxLineNum).length; return `${String(lineNum).padStart(padding)}|${text}`; } function buildClusterResult( matches: MatchResult[], content: string, contextLines: number, ): SearchMatch { const lines = content.split('\n'); const firstMatch = matches.at(0); const lastMatch = matches.at(-1); // Should never happen if called correctly, but satisfy linter if (!firstMatch || !lastMatch) { return { file: '', line: 0, endLine: 0, matchCount: 0, matchLines: [], text: '', context: { before: [], match: [], after: [] }, }; } // Calculate line ranges const beforeStart = Math.max(1, firstMatch.line - contextLines); const afterEnd = Math.min(lines.length, lastMatch.line + contextLines); const maxLineNum = afterEnd; // For padding calculation // Build context.before: lines before the first match const beforeLines: string[] = []; for (let i = beforeStart; i < firstMatch.line; i++) { beforeLines.push(formatLineWithNumber(i, lines[i - 1] ?? '', maxLineNum)); } // Build context.match: all lines from first to last match (inclusive) const matchLineTexts: string[] = []; for (let i = firstMatch.line; i <= lastMatch.line; i++) { matchLineTexts.push(formatLineWithNumber(i, lines[i - 1] ?? '', maxLineNum)); } // Build context.after: lines after the last match const afterLines: string[] = []; for (let i = lastMatch.line + 1; i <= afterEnd; i++) { afterLines.push(formatLineWithNumber(i, lines[i - 1] ?? '', maxLineNum)); } // Build text showing matched content const matchTexts = matches.map((m) => m.text); const uniqueTexts = [...new Set(matchTexts)]; const firstText = matchTexts.at(0) ?? ''; const firstUnique = uniqueTexts.at(0) ?? ''; let text: string; if (matches.length === 1) { text = firstText; } else if (uniqueTexts.length === 1) { // All matches are the same text text = `"${firstUnique}" (×${matches.length} in lines ${firstMatch.line}-${lastMatch.line})`; } else { // Different matched texts text = `${matches.length} matches: ${uniqueTexts .slice(0, 3) .map((t) => `"${t}"`) .join(', ')}${uniqueTexts.length > 3 ? '...' : ''}`; } return { file: '', // Will be set by caller line: firstMatch.line, endLine: lastMatch.line, matchCount: matches.length, matchLines: matches.map((m) => m.line), text, context: { before: beforeLines, match: matchLineTexts, after: afterLines, }, }; } async function searchInFile( _absPath: string, relativePath: string, content: string, patternOrPreset: string, options: { patternMode: PatternMode; multiline: boolean; wholeWord: boolean; caseInsensitive: boolean; context: number; maxMatches: number; isPreset?: boolean; }, ): Promise<SearchMatch[]> { let matches: MatchResult[]; if (options.isPreset && isPresetPattern(patternOrPreset)) { matches = findPresetMatches(content, patternOrPreset, { maxMatches: options.maxMatches, }); } else { matches = findMatches(content, patternOrPreset, options.patternMode, { multiline: options.multiline, wholeWord: options.wholeWord, caseInsensitive: options.caseInsensitive, maxMatches: options.maxMatches, }); } // Cluster nearby matches to reduce redundancy const clustered = clusterMatches(matches, content, options.context); // Set file path on all results return clustered.map((cluster) => ({ ...cluster, file: relativePath, })); } async function searchDirectory( absPath: string, relativePath: string, patternOrPreset: string, options: { patternMode: PatternMode; multiline: boolean; wholeWord: boolean; caseInsensitive: boolean; context: number; depth: number; types?: string[]; exclude?: string[]; respectIgnore: boolean; output: 'full' | 'list' | 'count' | 'summary'; maxMatches: number; maxFiles: number; isPreset?: boolean; }, ): Promise<FsReadResult> { const allMatches: SearchMatch[] = []; const fileCounts: Record<string, number> = {}; let filesSearched = 0; let truncated = false; const ignoreMatcher = options.respectIgnore ? await createIgnoreMatcherForDir(absPath) : null; async function walk(dir: string, relDir: string, currentDepth: number): Promise<void> { if (currentDepth > options.depth) { return; } const filesMatched = Object.keys(fileCounts).length; if (filesMatched >= options.maxFiles || allMatches.length >= options.maxMatches) { truncated = true; return; } let items: string[]; try { items = await fs.readdir(dir); } catch { return; } for (const item of items) { const filesMatchedInLoop = Object.keys(fileCounts).length; if (filesMatchedInLoop >= options.maxFiles || allMatches.length >= options.maxMatches) { truncated = true; break; } const itemPath = path.join(dir, item); const itemRelPath = relDir ? path.join(relDir, item) : item; if (ignoreMatcher?.isIgnored(itemRelPath)) continue; if (options.exclude && shouldExclude(itemRelPath, options.exclude)) continue; try { const stat = await fs.stat(itemPath); if (stat.isDirectory()) { await walk(itemPath, itemRelPath, currentDepth + 1); } else if (stat.isFile() && isTextFile(itemPath)) { if (options.types && options.types.length > 0) { if (!matchesType(item, options.types)) continue; } filesSearched++; // Check if filename matches the pattern (case-insensitive, skip for presets) const filenameMatches = !options.isPreset && item.toLowerCase().includes(patternOrPreset.toLowerCase()); const content = await fs.readFile(itemPath, 'utf8'); const fileMatches = await searchInFile(itemPath, itemRelPath, content, patternOrPreset, { patternMode: options.patternMode, multiline: options.multiline, wholeWord: options.wholeWord, caseInsensitive: options.caseInsensitive, context: options.context, maxMatches: options.maxMatches - allMatches.length, isPreset: options.isPreset, }); if (fileMatches.length > 0) { fileCounts[itemRelPath] = fileMatches.length; allMatches.push(...fileMatches); } else if (filenameMatches) { // Filename matches but no content matches — add a filename-only result fileCounts[itemRelPath] = 1; allMatches.push({ file: itemRelPath, line: 0, endLine: 0, matchCount: 1, matchLines: [], text: `Filename contains "${patternOrPreset}"`, context: { before: [], match: [], after: [] }, }); } } } catch { // Skip errors } } } await walk(absPath, relativePath === '.' ? '' : relativePath, 1); const filesMatched = Object.keys(fileCounts).length; // Build result based on output mode if (options.output === 'summary') { return { success: true, path: relativePath, type: 'search', stats: { filesSearched, filesMatched, totalMatches: allMatches.length, }, truncated, hint: `Searched ${filesSearched} files, found ${allMatches.length} matches in ${filesMatched} files.${truncated ? ' Results truncated — narrow your search for complete results.' : ''} Use output="full" to see match details.`, }; } if (options.output === 'count') { return { success: true, path: relativePath, type: 'search', matchCount: allMatches.length, filesSearched, matches: Object.entries(fileCounts).map(([file, count]) => ({ file, line: 0, endLine: 0, matchCount: count, matchLines: [], text: `${count} matches`, context: { before: [], match: [], after: [] }, })), truncated, hint: `Found ${allMatches.length} matches across ${filesMatched} files. Use output="full" to see match content with context.`, }; } if (options.output === 'list') { return { success: true, path: relativePath, type: 'search', matchCount: allMatches.length, filesSearched, matches: Object.keys(fileCounts).map((file) => ({ file, line: 0, endLine: 0, matchCount: fileCounts[file] ?? 0, matchLines: [], text: '', context: { before: [], match: [], after: [] }, })), truncated, hint: `Found matches in ${filesMatched} files. Use output="full" to see match details, or fs_read on a specific file.`, }; } // Full output - build informative hint const totalRawMatches = allMatches.reduce((sum, m) => sum + m.matchCount, 0); let hint: string; if (allMatches.length === 0) { hint = `No matches found for "${patternOrPreset}" in ${filesSearched} files. Try a different pattern or check the path.`; } else if (allMatches.length === 1 && allMatches[0]?.matchCount === 1) { const m = allMatches[0]; const contextStart = Math.max(1, m.line - 20); const contextEnd = m.line + 20; hint = `Found 1 match in ${m.file} at line ${m.line}. ` + `To see more context: fs_read with lines="${contextStart}-${contextEnd}". ` + `To edit: use fs_write with checksum and lines="${m.line}".`; } else { // Build example for first match const firstMatch = allMatches.at(0); const exampleLine = firstMatch?.line ?? 1; const exampleStart = Math.max(1, exampleLine - 20); const exampleEnd = exampleLine + 20; const clusterInfo = allMatches.length < totalRawMatches ? ` (${totalRawMatches} total occurrences grouped into ${allMatches.length} regions - nearby matches are clustered)` : ''; hint = `Found ${allMatches.length} match regions${clusterInfo} in ${filesMatched} files. ` + `Each result shows: line (start), endLine (end of cluster), matchCount, matchLines (exact line numbers). ` + `To expand context around a match, e.g. line ${exampleLine}: use lines="${exampleStart}-${exampleEnd}".`; } return { success: true, path: relativePath, type: 'search', matches: allMatches, matchCount: totalRawMatches, filesSearched, truncated, hint, }; } async function findFiles( absPath: string, relativePath: string, findPattern: string, options: { depth: number; exclude?: string[]; respectIgnore: boolean; maxFiles: number; }, ): Promise<FsReadResult> { const foundFiles: TreeEntry[] = []; let truncated = false; const ignoreMatcher = options.respectIgnore ? await createIgnoreMatcherForDir(absPath) : null; // Convert find pattern to regex // Support simple wildcards: * matches anything, ? matches single char // If no wildcards AND no extension, do partial matching (wrap in *...*) // If pattern has extension (contains .), treat as exact match const hasWildcards = /[*?]/.test(findPattern); const hasExtension = /\.[a-zA-Z0-9]+$/.test(findPattern); const effectivePattern = hasWildcards || hasExtension ? findPattern : `*${findPattern}*`; const regexPattern = effectivePattern .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars except * and ? .replace(/\*/g, '.*') .replace(/\?/g, '.'); const findRegex = new RegExp(`^${regexPattern}$`, 'i'); async function walk(dir: string, relDir: string, currentDepth: number): Promise<void> { if (currentDepth > options.depth || foundFiles.length >= options.maxFiles) { if (foundFiles.length >= options.maxFiles) truncated = true; return; } let items: string[]; try { items = await fs.readdir(dir); } catch { return; } for (const item of items) { if (foundFiles.length >= options.maxFiles) { truncated = true; break; } const itemPath = path.join(dir, item); const itemRelPath = relDir ? path.join(relDir, item) : item; if (ignoreMatcher?.isIgnored(itemRelPath)) continue; if (options.exclude && shouldExclude(itemRelPath, options.exclude)) continue; try { const stat = await fs.stat(itemPath); if (stat.isDirectory()) { // Check if directory name matches if (findRegex.test(item)) { foundFiles.push({ path: itemRelPath, kind: 'directory', modified: formatRelativeTime(stat.mtime), }); } await walk(itemPath, itemRelPath, currentDepth + 1); } else if (stat.isFile()) { // Check if filename matches if (findRegex.test(item)) { foundFiles.push({ path: itemRelPath, kind: 'file', size: formatSize(stat.size), modified: formatRelativeTime(stat.mtime), }); } } } catch { // Skip errors } } } await walk(absPath, relativePath === '.' ? '' : relativePath, 1); return { success: true, path: relativePath, type: 'directory', tree: { entries: foundFiles, summary: `Found ${foundFiles.length} item(s) matching "${findPattern}"`, }, truncated, hint: foundFiles.length === 0 ? `No files matching "${findPattern}" found. Try a different pattern or increase depth.` : foundFiles.length === 1 ? `Found "${foundFiles[0]?.path}". Use fs_read with this path to see its content.` : `Found ${foundFiles.length} matching files. Use fs_read on a specific path to see its content.`, }; } // ───────────────────────────────────────────────────────────── // Handler // ───────────────────────────────────────────────────────────── export const fsReadTool = { name: 'fs_read', description: `Explore directories, read files, find files by name, or search content. 🔒 SANDBOXED FILESYSTEM — This tool can ONLY access specific mounted directories. You CANNOT access arbitrary system paths like /Users or C:\\. Always start with fs_read(".") to see available mounts. ⚠️ ALWAYS read a file BEFORE answering questions about its content. ⚠️ ALWAYS read a file BEFORE modifying it (you need the checksum). MODES (automatically detected): 1. DIRECTORY EXPLORATION — path to directory Returns: Tree structure with file sizes and modification times. Use to: Understand layout, plan navigation. 2. FILE READING — path to file Returns: Full content with LINE NUMBERS and CHECKSUM. Use to: See exact content before editing, get line numbers for precise edits. 3. FIND FILES BY NAME — path + find Example: { path: ".", find: "music" } finds music.md, my-music.txt, etc. (partial match) Example: { path: ".", find: "*.md" } finds all markdown files (wildcard) Returns: List of matching file paths anywhere under the directory. Use to: Locate a file when you know part of the filename but not its location. 4. SEARCH CONTENT — path + pattern OR path + preset Returns: Matching lines with context and line numbers. Use to: Find specific content inside files. PATTERN MODES (critical for correct searching): - patternMode="literal" (DEFAULT): Exact text. "foo|bar" matches literal "foo|bar". - patternMode="regex": Regular expression. "foo|bar" matches "foo" OR "bar". - patternMode="fuzzy": Flexible whitespace literal. - caseInsensitive=true: Ignore case (works with any mode). ⚠️ COMMON MISTAKE: Using literal mode with "term1|term2" expecting OR search. This will search for the literal string "term1|term2" and find NOTHING! For OR searches, ALWAYS use: patternMode="regex", pattern="term1|term2" PRESET PATTERNS (for Obsidian/Markdown): - preset="wikilinks" → find [[links]] - preset="tags" → find #tags - preset="tasks" → find all tasks (- [ ] and - [x]) - preset="tasks_open" → find incomplete tasks only - preset="tasks_done" → find completed tasks only - preset="headings" → find # headings - preset="codeblocks" → find \`\`\` blocks - preset="frontmatter" → find YAML --- TIPS: - Use 'find' to locate files: { path: ".", find: "config" } finds config.json, config.yaml, etc. - Use 'pattern' for content search: { path: ".", pattern: "TODO" } - Use 'preset' for common patterns: { path: ".", preset: "tasks_open" } - Note the CHECKSUM when reading a file you plan to edit - Line numbers are 1-indexed (first line is 1)`, inputSchema: fsReadInputSchema, handler: async (args: unknown, _extra: HandlerExtra): Promise<CallToolResult> => { // Validate const parsed = fsReadInputSchema.safeParse(args); if (!parsed.success) { return { isError: true, content: [ { type: 'text', text: `Invalid input: ${parsed.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ')}`, }, ], }; } const input = parsed.data; // Special case: root path shows mount listing (or single mount contents) if (isRootPath(input.path) && !input.find && !input.pattern && !input.preset) { const result = await listMountsOrSingleMount(input.depth, { types: input.types, exclude: input.exclude, respectIgnore: input.respectIgnore, maxFiles: input.maxFiles, details: input.details, }); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; } // Determine effective pattern (from pattern or preset) const searchPattern = input.pattern ?? input.preset; const isPreset = Boolean(input.preset); // For find/pattern/preset on root, search all mounts if (isRootPath(input.path) && (input.find || searchPattern)) { const mounts = getMounts(); let result: FsReadResult; if (input.find) { // Find across all mounts const allEntries: TreeEntry[] = []; let anyTruncated = false; for (const mount of mounts) { const findResult = await findFiles(mount.absolutePath, mount.name, input.find, { depth: input.depth, exclude: input.exclude, respectIgnore: input.respectIgnore, maxFiles: Math.floor(input.maxFiles / mounts.length), }); if (findResult.tree?.entries) { allEntries.push(...findResult.tree.entries); } if (findResult.truncated) anyTruncated = true; } result = { success: true, path: '.', type: 'directory', tree: { entries: allEntries, summary: `Found ${allEntries.length} item(s) matching "${input.find}" across all mounts`, }, truncated: anyTruncated, hint: allEntries.length === 0 ? `No files matching "${input.find}" found. Try a different pattern or increase depth.` : allEntries.length === 1 ? `Found "${allEntries[0]?.path}". Use fs_read with this path to see its content.` : `Found ${allEntries.length} matching files. Use fs_read on a specific path to see its content.`, }; } else if (searchPattern) { // Search content across all mounts (pattern or preset) const displayPattern = isPreset ? `preset:${searchPattern}` : searchPattern; const allMatches: SearchMatch[] = []; let totalFilesSearched = 0; let anyTruncated = false; for (const mount of mounts) { const searchResult = await searchDirectory( mount.absolutePath, mount.name, searchPattern, { patternMode: input.patternMode as PatternMode, multiline: input.multiline, wholeWord: input.wholeWord, caseInsensitive: input.caseInsensitive, context: input.context, depth: input.depth, types: input.types, exclude: input.exclude, respectIgnore: input.respectIgnore, output: input.output, maxMatches: Math.floor(input.maxMatches / mounts.length), maxFiles: Math.floor(input.maxFiles / mounts.length), isPreset, }, ); if (searchResult.matches) { allMatches.push(...searchResult.matches); } if (searchResult.filesSearched) totalFilesSearched += searchResult.filesSearched; if (searchResult.truncated) anyTruncated = true; } result = { success: true, path: '.', type: 'search', matches: allMatches, matchCount: allMatches.length, filesSearched: totalFilesSearched, truncated: anyTruncated, hint: allMatches.length === 0 ? `No matches found for ${displayPattern} in ${totalFilesSearched} files across all mounts.` : `Found ${allMatches.length} matches for ${displayPattern} in ${totalFilesSearched} files across all mounts.`, }; } else { // Should not reach here, but satisfy TypeScript result = await listMountsOrSingleMount(input.depth, { types: input.types, exclude: input.exclude, respectIgnore: input.respectIgnore, maxFiles: input.maxFiles, details: input.details, }); } return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; } // Resolve virtual path to real path const resolved = resolveVirtualPath(input.path); if (!resolved.ok) { const mounts = getMounts(); const mountExamples = mounts .slice(0, 2) .map((m) => `"${m.name}/"`) .join(' or '); // Detect if user tried an absolute path const isAbsolute = input.path.startsWith('/') || /^[a-zA-Z]:[/\\]/.test(input.path); const result: FsReadResult = { success: false, path: input.path, type: 'file', error: { code: 'OUT_OF_SCOPE', message: resolved.error }, hint: isAbsolute ? `This is a SANDBOXED filesystem — you cannot access arbitrary system paths. ` + `Start with fs_read(".") to see available mounts, then explore from there.` : `Path not found. Try fs_read(".") to see available mounts, or fs_read(${mountExamples}) to explore a mount.`, }; return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; } const { absolutePath, virtualPath } = resolved.resolved; // Check if path exists let stat: Awaited<ReturnType<typeof fs.stat>>; try { stat = await fs.stat(absolutePath); } catch { const result: FsReadResult = { success: false, path: virtualPath, type: 'file', error: { code: 'NOT_FOUND', message: `Path does not exist: ${virtualPath}` }, hint: 'Use fs_read on the parent directory to see what exists, or fs_read(".") to see mount points.', }; return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; } let result: FsReadResult; if (stat.isDirectory()) { if (input.find) { // Find files by name result = await findFiles(absolutePath, virtualPath, input.find, { depth: input.depth, exclude: input.exclude, respectIgnore: input.respectIgnore, maxFiles: input.maxFiles, }); } else if (searchPattern) { // Search in directory (pattern or preset) result = await searchDirectory(absolutePath, virtualPath, searchPattern, { patternMode: input.patternMode as PatternMode, multiline: input.multiline, wholeWord: input.wholeWord, caseInsensitive: input.caseInsensitive, context: input.context, depth: input.depth, types: input.types, exclude: input.exclude, respectIgnore: input.respectIgnore, output: input.output, maxMatches: input.maxMatches, maxFiles: input.maxFiles, isPreset, }); } else { // List directory const { entries, truncated } = await listDirectory(absolutePath, virtualPath, input.depth, { types: input.types, exclude: input.exclude, respectIgnore: input.respectIgnore, maxFiles: input.maxFiles, details: input.details, }); const fileCount = entries.filter((e) => e.kind === 'file').length; const dirCount = entries.filter((e) => e.kind === 'directory').length; result = { success: true, path: input.path, type: 'directory', tree: { entries, summary: `${entries.length} items (${fileCount} files, ${dirCount} directories)`, }, truncated, hint: entries.length === 0 ? 'Directory is empty or all files are ignored.' : `Found ${entries.length} items. Use fs_read on a file path to see its content, or on a subdirectory to explore deeper.`, }; } } else if (stat.isFile()) { if (searchPattern) { // Search in single file (pattern or preset) if (!isTextFile(absolutePath)) { result = { success: false, path: virtualPath, type: 'file', error: { code: 'NOT_TEXT', message: 'Cannot search in binary files' }, hint: 'Only text files can be searched.', }; } else { const content = await fs.readFile(absolutePath, 'utf8'); const matches = await searchInFile(absolutePath, virtualPath, content, searchPattern, { patternMode: input.patternMode as PatternMode, multiline: input.multiline, wholeWord: input.wholeWord, caseInsensitive: input.caseInsensitive, context: input.context, maxMatches: input.maxMatches, isPreset, }); const checksum = generateChecksum(content); const displayPattern = isPreset ? `preset:${searchPattern}` : `"${searchPattern}"`; const totalRawMatches = matches.reduce((sum, m) => sum + m.matchCount, 0); let searchHint: string; if (matches.length === 0) { searchHint = `No matches for ${displayPattern} in this file.${isPreset ? '' : ' Try a different pattern or patternMode="fuzzy".'}`; } else { const firstMatch = matches.at(0); const exampleLine = firstMatch?.line ?? 1; const exampleStart = Math.max(1, exampleLine - 20); const exampleEnd = exampleLine + 20; const clusterInfo = matches.length < totalRawMatches ? ` (${totalRawMatches} total occurrences grouped into ${matches.length} regions)` : ''; searchHint = `Found ${matches.length} match region(s)${clusterInfo}. ` + `Each result has: line, endLine, matchCount, matchLines (exact positions). ` + `To expand context around line ${exampleLine}: use lines="${exampleStart}-${exampleEnd}". ` + `Checksum: ${checksum} (required for fs_write).`; } result = { success: true, path: input.path, type: 'search', matches, matchCount: totalRawMatches, filesSearched: 1, truncated: false, hint: searchHint, }; } } else { // Read file result = await readFile(absolutePath, virtualPath, { lines: input.lines }); } } else { result = { success: false, path: virtualPath, type: 'file', error: { code: 'INVALID_TYPE', message: 'Path is not a file or directory' }, hint: 'Only files and directories can be read.', }; } return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; }, };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/iceener/files-stdio-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server