/** * mcp/fs.ts — File system read + grep tools for openclaw VM * * Read-only operations scoped to /home/openclaw/. * No auth beyond MCP key — these are read-only. */ import * as path from 'path'; const ALLOWED_ROOT = '/home/openclaw/'; function assertAllowed(absPath: string): string { const resolved = path.resolve(absPath); // resolved won't have trailing slash, so check both /home/openclaw and /home/openclaw/... if (resolved !== '/home/openclaw' && !resolved.startsWith(ALLOWED_ROOT)) { throw new Error(`Access denied: path must be under ${ALLOWED_ROOT}`); } return resolved; } const text = (s: string) => ({ content: [{ type: 'text' as const, text: s }] }); export const tools = [ { name: 'fs_read', description: 'Read a file from the openclaw VM. Returns file contents. Supports offset/limit for large files.', inputSchema: { type: 'object' as const, properties: { path: { type: 'string', description: 'Absolute path on openclaw (must be under /home/openclaw/)' }, offset: { type: 'number', description: 'Start line (1-based, default 1)' }, limit: { type: 'number', description: 'Max lines to return (default 200)' }, }, required: ['path'], }, }, { name: 'fs_grep', description: 'Search file contents using ripgrep on the openclaw VM. Returns matching lines with file:line: prefix.', inputSchema: { type: 'object' as const, properties: { pattern: { type: 'string', description: 'Regex pattern to search for' }, path: { type: 'string', description: 'File or directory to search (must be under /home/openclaw/)' }, glob: { type: 'string', description: 'File glob filter (e.g. "*.ts", "*.js")' }, context: { type: 'number', description: 'Lines of context around matches (default 0)' }, maxResults: { type: 'number', description: 'Max matches to return (default 50)' }, }, required: ['pattern', 'path'], }, }, { name: 'fs_write', description: 'Write content to a file on the openclaw VM. Creates parent dirs if needed. Returns bytes written.', inputSchema: { type: 'object' as const, properties: { path: { type: 'string', description: 'Absolute path on openclaw (must be under /home/openclaw/)' }, content: { type: 'string', description: 'File content to write' }, }, required: ['path', 'content'], }, }, { name: 'fs_edit', description: 'Edit a file on the openclaw VM by replacing old_string with new_string (exact match). Fails if old_string not found or not unique. Use replace_all to replace every occurrence.', inputSchema: { type: 'object' as const, properties: { path: { type: 'string', description: 'Absolute path on openclaw (must be under /home/openclaw/)' }, old_string: { type: 'string', description: 'Exact text to find and replace' }, new_string: { type: 'string', description: 'Replacement text' }, replace_all: { type: 'boolean', description: 'Replace all occurrences (default false)' }, }, required: ['path', 'old_string', 'new_string'], }, }, { name: 'fs_ls', description: 'List directory contents on the openclaw VM.', inputSchema: { type: 'object' as const, properties: { path: { type: 'string', description: 'Absolute directory path (must be under /home/openclaw/)' }, }, required: ['path'], }, }, ]; export async function handle(name: string, args: any) { switch (name) { case 'fs_read': return fsRead(args); case 'fs_write': return fsWrite(args); case 'fs_edit': return fsEdit(args); case 'fs_grep': return fsGrep(args); case 'fs_ls': return fsLs(args); default: throw new Error(`Unknown fs tool: ${name}`); } } async function fsRead(args: any) { const filePath = assertAllowed(args.path); const offset = Math.max(1, args.offset ?? 1); const limit = Math.min(2000, Math.max(1, args.limit ?? 200)); const file = Bun.file(filePath); if (!await file.exists()) return text(`File not found: ${filePath}`); const content = await file.text(); const lines = content.split('\n'); const slice = lines.slice(offset - 1, offset - 1 + limit); const numbered = slice.map((line, i) => `${offset + i}: ${line}`).join('\n'); const header = `${filePath} (${lines.length} lines, showing ${offset}-${Math.min(offset + limit - 1, lines.length)})`; return text(`${header}\n${numbered}`); } async function fsWrite(args: any) { const filePath = assertAllowed(args.path); const dir = path.dirname(filePath); const { mkdirSync } = await import('fs'); mkdirSync(dir, { recursive: true }); await Bun.write(filePath, args.content); return text(`Written ${args.content.length} chars to ${filePath}`); } async function fsEdit(args: any) { const filePath = assertAllowed(args.path); const file = Bun.file(filePath); if (!await file.exists()) return text(`File not found: ${filePath}`); const content = await file.text(); const { old_string, new_string, replace_all } = args; if (old_string === new_string) return text('old_string and new_string are identical'); if (replace_all) { if (!content.includes(old_string)) return text('old_string not found in file'); const updated = content.replaceAll(old_string, new_string); const count = (content.split(old_string).length - 1); await Bun.write(filePath, updated); return text(`Replaced ${count} occurrence(s) in ${filePath}`); } const idx = content.indexOf(old_string); if (idx === -1) return text('old_string not found in file'); if (content.indexOf(old_string, idx + 1) !== -1) return text('old_string is not unique — provide more context or use replace_all'); const updated = content.slice(0, idx) + new_string + content.slice(idx + old_string.length); await Bun.write(filePath, updated); return text(`Edited ${filePath}`); } async function fsGrep(args: any) { const searchPath = assertAllowed(args.path); const maxResults = Math.min(200, Math.max(1, args.maxResults ?? 50)); const context = Math.min(10, Math.max(0, args.context ?? 0)); const rgArgs = ['grep', '-rn', '-E', '--include=' + (args.glob || '*')]; if (context > 0) rgArgs.push('-C', String(context)); rgArgs.push('-m', String(maxResults)); rgArgs.push(args.pattern, searchPath); const proc = Bun.spawn(rgArgs, { stdout: 'pipe', stderr: 'pipe' }); const [out, err] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), ]); const code = await proc.exited; if (code === 1) return text('No matches found.'); if (code !== 0 && err.trim()) return text(`rg error (code ${code}): ${err.trim()}`); return text(out.trim() || 'No matches found.'); } async function fsLs(args: any) { const dirPath = assertAllowed(args.path); const proc = Bun.spawn(['ls', '-la', dirPath], { stdout: 'pipe', stderr: 'pipe' }); const [out, err] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), ]); await proc.exited; if (err.trim()) return text(`ls error: ${err.trim()}`); return text(out.trim()); }