189 lines
7.6 KiB
TypeScript
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());
|
|
}
|