mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-19 14:41:24 +08:00
* refactor: rewrite filesystem MCP server with new tool set - Replace existing filesystem MCP with modular architecture - Implement 6 new tools: glob, ls, grep, read, write, delete - Add comprehensive TypeScript types and Zod schemas - Maintain security with path validation and allowed directories - Improve error handling and user feedback - Add result limits for performance (100 files/matches max) - Format output with clear, helpful messages - Keep backward compatibility with existing import patterns BREAKING CHANGE: Tools renamed from snake_case to lowercase - read_file → read - write_file → write - list_directory → ls - search_files → glob - New tools: grep, delete - Removed: edit_file, create_directory, directory_tree, move_file, get_file_info * 🐛 fix: remove filesystem allowed directories restriction * 🐛 fix: relax binary detection for text files * ✨ feat: add edit tool with fuzzy matching to filesystem MCP server - Add edit tool with 9 fallback replacers from opencode for robust string replacement (SimpleReplacer, LineTrimmedReplacer, BlockAnchorReplacer, WhitespaceNormalizedReplacer, etc.) - Add Levenshtein distance algorithm for similarity matching - Improve descriptions for all tools (read, write, glob, grep, ls, delete) following opencode patterns for better LLM guidance - Register edit tool in server and export from tools index * ♻️ refactor: replace allowedDirectories with baseDir in filesystem MCP server - Change server to use single baseDir (from WORKSPACE_ROOT env or userData/workspace default) - Remove list_allowed_directories tool as restriction mechanism is removed - Add ripgrep integration for faster grep searches with JS fallback - Simplify validatePath() by removing allowlist checks - Display paths relative to baseDir in tool outputs * 📝 docs: standardize filesystem MCP server tool descriptions - Unify description format to bullet-point style across all tools - Add absolute path requirement to ls, glob, grep schemas and descriptions - Update glob and grep to output absolute paths instead of relative paths - Add missing error case documentation for edit tool (old_string === new_string) - Standardize optional path parameter descriptions * ♻️ refactor: use ripgrep for glob tool and extract shared utilities - Extract shared ripgrep utilities (runRipgrep, getRipgrepAddonPath) to types.ts - Rewrite glob tool to use `rg --files --glob` for reliable file matching - Update grep tool to import shared ripgrep utilities * 🐛 fix: handle ripgrep exit code 2 with valid results in glob tool - Process ripgrep stdout when content exists, regardless of exit code - Exit code 2 can indicate partial errors while still returning valid results - Remove fallback directory listing (had buggy regex for root-level files) - Update tool description to clarify patterns without "/" match at any depth * 🔥 chore: remove filesystem.ts.backup file Remove unnecessary backup file from mcpServers directory * 🐛 fix: use correct default workspace path in filesystem MCP server Change default baseDir from userData/workspace to userData/Data/Workspace to match the app's data storage convention (Data/Files, Data/Notes, etc.) Addresses PR #11937 review feedback. * 🐛 fix: pass WORKSPACE_ROOT to FileSystemServer constructor The envs object passed to createInMemoryMCPServer was not being used for the filesystem server. Now WORKSPACE_ROOT is passed as a constructor parameter, following the same pattern as other MCP servers. * \feat: add link to documentation for MCP server configuration requirement Wrap the configuration requirement tag in a link to the documentation for better user guidance on MCP server settings. --------- Co-authored-by: kangfenmao <kangfenmao@qq.com>
102 lines
3.2 KiB
TypeScript
102 lines
3.2 KiB
TypeScript
import fs from 'fs/promises'
|
|
import path from 'path'
|
|
import * as z from 'zod'
|
|
|
|
import { DEFAULT_READ_LIMIT, isBinaryFile, MAX_LINE_LENGTH, validatePath } from '../types'
|
|
|
|
// Schema definition
|
|
export const ReadToolSchema = z.object({
|
|
file_path: z.string().describe('The path to the file to read'),
|
|
offset: z.number().optional().describe('The line number to start reading from (1-based)'),
|
|
limit: z.number().optional().describe('The number of lines to read (defaults to 2000)')
|
|
})
|
|
|
|
// Tool definition with detailed description
|
|
export const readToolDefinition = {
|
|
name: 'read',
|
|
description: `Reads a file from the local filesystem.
|
|
|
|
- Assumes this tool can read all files on the machine
|
|
- The file_path parameter must be an absolute path, not a relative path
|
|
- By default, reads up to 2000 lines starting from the beginning
|
|
- You can optionally specify a line offset and limit for long files
|
|
- Any lines longer than 2000 characters will be truncated
|
|
- Results are returned with line numbers starting at 1
|
|
- Binary files are detected and rejected with an error
|
|
- Empty files return a warning`,
|
|
inputSchema: z.toJSONSchema(ReadToolSchema)
|
|
}
|
|
|
|
// Handler implementation
|
|
export async function handleReadTool(args: unknown, baseDir: string) {
|
|
const parsed = ReadToolSchema.safeParse(args)
|
|
if (!parsed.success) {
|
|
throw new Error(`Invalid arguments for read: ${parsed.error}`)
|
|
}
|
|
|
|
const filePath = parsed.data.file_path
|
|
const validPath = await validatePath(filePath, baseDir)
|
|
|
|
// Check if file exists
|
|
try {
|
|
const stats = await fs.stat(validPath)
|
|
if (!stats.isFile()) {
|
|
throw new Error(`Path is not a file: ${filePath}`)
|
|
}
|
|
} catch (error: any) {
|
|
if (error.code === 'ENOENT') {
|
|
throw new Error(`File not found: ${filePath}`)
|
|
}
|
|
throw error
|
|
}
|
|
|
|
// Check if file is binary
|
|
if (await isBinaryFile(validPath)) {
|
|
throw new Error(`Cannot read binary file: ${filePath}`)
|
|
}
|
|
|
|
// Read file content
|
|
const content = await fs.readFile(validPath, 'utf-8')
|
|
const lines = content.split('\n')
|
|
|
|
// Apply offset and limit
|
|
const offset = (parsed.data.offset || 1) - 1 // Convert to 0-based
|
|
const limit = parsed.data.limit || DEFAULT_READ_LIMIT
|
|
|
|
if (offset < 0 || offset >= lines.length) {
|
|
throw new Error(`Invalid offset: ${offset + 1}. File has ${lines.length} lines.`)
|
|
}
|
|
|
|
const selectedLines = lines.slice(offset, offset + limit)
|
|
|
|
// Format output with line numbers and truncate long lines
|
|
const output: string[] = []
|
|
const relativePath = path.relative(baseDir, validPath)
|
|
|
|
output.push(`File: ${relativePath}`)
|
|
if (offset > 0 || limit < lines.length) {
|
|
output.push(`Lines ${offset + 1} to ${Math.min(offset + limit, lines.length)} of ${lines.length}`)
|
|
}
|
|
output.push('')
|
|
|
|
selectedLines.forEach((line, index) => {
|
|
const lineNumber = offset + index + 1
|
|
const truncatedLine = line.length > MAX_LINE_LENGTH ? line.substring(0, MAX_LINE_LENGTH) + '...' : line
|
|
output.push(`${lineNumber.toString().padStart(6)}\t${truncatedLine}`)
|
|
})
|
|
|
|
if (offset + limit < lines.length) {
|
|
output.push('')
|
|
output.push(`(${lines.length - (offset + limit)} more lines not shown)`)
|
|
}
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: output.join('\n')
|
|
}
|
|
]
|
|
}
|
|
}
|