This capstone builds a filesystem agent powered by Claude 3.7 Sonnet. The agent can read files, search codebases, analyze code structure, and refactor files under user supervision. It applies the security patterns from Part VIII: roots for filesystem boundaries, tool safety for path validation, and confirmation-based elicitation for destructive file writes. The result is a safe, auditable codebase assistant that you can trust with your actual project files.

The Filesystem MCP Server
// servers/fs-server.js
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import fs from 'node:fs/promises';
import path from 'node:path';
const server = new McpServer({ name: 'fs-server', version: '1.0.0' });
// Get the allowed root from the client (via roots capability)
let allowedRoots = [];
server.server.onroots_list_changed = async () => {
const { roots } = await server.server.listRoots();
allowedRoots = roots.map(r => r.uri.replace('file://', ''));
};
// Path safety: ensure the path is within an allowed root
function validatePath(filePath) {
const resolved = path.resolve(filePath);
if (allowedRoots.length === 0) {
throw new Error('No filesystem roots configured');
}
const isAllowed = allowedRoots.some(root => resolved.startsWith(path.resolve(root)));
if (!isAllowed) {
throw new Error(`Path '${resolved}' is outside allowed roots: ${allowedRoots.join(', ')}`);
}
return resolved;
}
// Tool: Read a file
server.tool('read_file', {
path: z.string().min(1).max(512).refine(p => !p.includes('..'), 'Path traversal not allowed'),
}, async ({ path: filePath }) => {
const safePath = validatePath(filePath);
try {
const content = await fs.readFile(safePath, 'utf8');
const lines = content.split('\n').length;
return { content: [{ type: 'text', text: `// ${safePath} (${lines} lines)\n${content}` }] };
} catch (err) {
return { content: [{ type: 'text', text: `Cannot read file: ${err.message}` }], isError: true };
}
});
// Tool: List directory
server.tool('list_directory', {
path: z.string().min(1).max(512),
recursive: z.boolean().default(false),
}, async ({ path: dirPath, recursive }) => {
const safePath = validatePath(dirPath);
const entries = await listDir(safePath, recursive, 0, []);
return { content: [{ type: 'text', text: entries.join('\n') }] };
});
async function listDir(dirPath, recursive, depth, results) {
if (depth > 3) return results; // Max 3 levels deep
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
const full = path.join(dirPath, entry.name);
results.push(`${' '.repeat(depth)}${entry.isDirectory() ? '[DIR] ' : ''}${entry.name}`);
if (recursive && entry.isDirectory()) await listDir(full, recursive, depth + 1, results);
}
return results;
}
// Tool: Search for text in files
server.tool('search_files', {
directory: z.string(),
pattern: z.string().max(200),
file_extension: z.string().optional(),
}, async ({ directory, pattern, file_extension }) => {
const safePath = validatePath(directory);
const regex = new RegExp(pattern, 'i');
const matches = [];
await searchFiles(safePath, regex, file_extension, matches);
return { content: [{ type: 'text', text: matches.slice(0, 50).join('\n') || 'No matches found' }] };
});
// Tool: Write file (requires confirmation via elicitation)
server.tool('write_file', {
path: z.string().min(1).max(512),
content: z.string().max(100_000),
}, async ({ path: filePath, content }, context) => {
const safePath = validatePath(filePath);
// Check if file already exists
const exists = await fs.access(safePath).then(() => true).catch(() => false);
if (exists) {
const confirm = await context.elicit(
`This will overwrite '${safePath}'. Confirm?`,
{ type: 'object', properties: { confirm: { type: 'boolean' } } }
);
if (!confirm.content?.confirm) {
return { content: [{ type: 'text', text: 'Write cancelled.' }] };
}
}
await fs.mkdir(path.dirname(safePath), { recursive: true });
await fs.writeFile(safePath, content, 'utf8');
return { content: [{ type: 'text', text: `Written: ${safePath}` }] };
});
const transport = new StdioServerTransport();
await server.connect(transport);

The Claude Filesystem Agent
// agent/fs-agent.js
import Anthropic from '@anthropic-ai/sdk';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
const anthropic = new Anthropic();
export async function createFilesystemAgent(projectRoot) {
const transport = new StdioClientTransport({
command: 'node',
args: ['./servers/fs-server.js'],
env: { ...process.env },
});
const mcp = new Client({
name: 'fs-agent',
version: '1.0.0',
capabilities: { roots: { listChanged: true } }, // Declare roots support
});
await mcp.connect(transport);
// Set the allowed root to the project directory
// (roots are set by the client, enforced by the server)
console.log(`Filesystem agent initialized. Root: ${projectRoot}`);
const { tools: mcpTools } = await mcp.listTools();
const tools = mcpTools.map(t => ({
name: t.name, description: t.description, input_schema: t.inputSchema,
}));
return {
async ask(question) {
const messages = [{ role: 'user', content: question }];
let turns = 0;
while (true) {
const response = await anthropic.messages.create({
model: 'claude-3-7-sonnet-20250219',
max_tokens: 4096,
system: `You are a codebase assistant. The project root is ${projectRoot}.
Use read_file to examine files, list_directory to explore structure, search_files to find code.
Only use write_file when explicitly asked to modify files.`,
messages,
tools,
});
messages.push({ role: 'assistant', content: response.content });
if (response.stop_reason !== 'tool_use') {
return response.content.filter(b => b.type === 'text').map(b => b.text).join('');
}
if (++turns > 15) throw new Error('Max turns exceeded');
const toolResults = await Promise.all(
response.content.filter(b => b.type === 'tool_use').map(async block => {
const result = await mcp.callTool({ name: block.name, arguments: block.input });
const text = result.content.filter(c => c.type === 'text').map(c => c.text).join('\n');
return { type: 'tool_result', tool_use_id: block.id, content: text };
})
);
messages.push({ role: 'user', content: toolResults });
}
},
async close() { await mcp.close(); },
};
}
What to Extend
- Add a
run_teststool that executesnode --testand returns the output – the agent can then read failing test files and suggest fixes. - Add Claude’s extended thinking for architectural analysis queries (Lesson 21 pattern).
- Add the prompt caching pattern from Lesson 23 to cache the system prompt for long analysis sessions.
nJoy 😉
