Nico ccee249618 v0.6.42: Hermes chat UI — Vue3/TS/Vite, audio STT/TTS, sidebar rail, MCP event loop
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:35:10 +02:00

189 lines
7.6 KiB
TypeScript

/**
* 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());
}