package tools import ( "context" "encoding/json" "fmt" "strings" "github.com/modelcontextprotocol/go-sdk/mcp" ) func registerFileTools(s *mcp.Server) { // read s.AddTool(&mcp.Tool{ Name: "read", Description: "Read the contents of a remote file", InputSchema: schema(map[string]any{ "path": strProps("File path to read", true), "target": strProps("Connection alias (default: primary)", false), }, []string{"path"}), }, readHandler) // write s.AddTool(&mcp.Tool{ Name: "write", Description: "Write content to a remote file. Validates syntax BEFORE writing for known file types (JSON, YAML, TOML, XML, INI, Dockerfile). Validation is server-side with zero remote dependencies. Set skip_validate=true to bypass.", InputSchema: schema(map[string]any{ "path": strProps("File path to write", true), "content": strProps("Content to write", true), "skip_validate": boolProps("Skip syntax validation before write (default: false)", false), "target": strProps("Connection alias (default: primary)", false), }, []string{"path", "content"}), }, writeHandler) // edit s.AddTool(&mcp.Tool{ Name: "edit", Description: `Powerful sed-like file editor. Supports multiple operations on any file type (YAML, JSON, conf, etc). Operations (set via 'operation' parameter): replace — Find and replace text (default). Exact literal match. regex — Regex find and replace (sed-style). Use capture groups \1, \2, etc. insert — Insert text at a specific line number (pushes existing content down). append — Append text after a line matching a pattern, or at end of file if no pattern. prepend — Prepend text before a line matching a pattern, or at start of file if no pattern. delete — Delete lines matching a pattern or a line range. replace_line — Replace entire line(s) matching a pattern with new text. Examples: operation=replace, old_text="port: 80", new_text="port: 443" operation=regex, pattern="timeout:\\s*\\d+", replacement="timeout: 30" operation=insert, line=5, content="new line here" operation=append, pattern="\\[section\\]", content="key = value" operation=delete, pattern="^#.*comment" operation=delete, start_line=10, end_line=15 operation=replace_line, pattern="^server_name.*", content="server_name example.com;" `, InputSchema: schema(map[string]any{ "path": strProps("File path to edit", true), "operation": strProps("Edit operation (default: replace)", false, "replace", "regex", "insert", "append", "prepend", "delete", "replace_line"), "old_text": strProps("Text to find (for 'replace' operation)", false), "new_text": strProps("Replacement text (for 'replace' operation)", false), "pattern": strProps("Regex pattern (for regex/append/prepend/delete/replace_line operations)", false), "replacement": strProps("Replacement string with \\1 \\2 backrefs (for 'regex' operation)", false), "content": strProps("Content to insert/append/prepend/replace_line", false), "line": intProps("Line number for 'insert' operation (1-based)", false), "start_line": intProps("Start line for range delete (1-based, inclusive)", false), "end_line": intProps("End line for range delete (1-based, inclusive)", false), "global": boolProps("Replace all occurrences (default: false for replace, true for regex)", false), "target": strProps("Connection alias (default: primary)", false), }, []string{"path"}), }, editHandler) // validate s.AddTool(&mcp.Tool{ Name: "validate", Description: `Validate file syntax server-side (zero remote host dependencies). Auto-detects type from extension. Supported formats: .json — JSON syntax .yaml, .yml — YAML syntax (multi-document) .toml — TOML syntax .xml, .svg, .xhtml — XML well-formedness .ini, .cfg, .conf — INI key=value structure .env — Dotenv KEY=VALUE format Dockerfile — Instruction validation All validation runs on the MCP server using Go parsers. No python3, jq, or other tools needed on the remote host.`, InputSchema: schema(map[string]any{ "path": strProps("File path to validate", true), "type": strProps("Force file type (auto-detected from extension if omitted)", false, "json", "yaml", "toml", "xml", "ini", "env", "dockerfile"), "target": strProps("Connection alias (default: primary)", false), }, []string{"path"}), }, validateHandler) // list_dir s.AddTool(&mcp.Tool{ Name: "list_dir", Description: "List contents of a remote directory", InputSchema: schema(map[string]any{ "path": strProps("Directory path to list", true), "target": strProps("Connection alias (default: primary)", false), }, []string{"path"}), }, listDirHandler) // sync s.AddTool(&mcp.Tool{ Name: "sync", Description: "Stream a file directly between two remote nodes", InputSchema: schema(map[string]any{ "source_node": strProps("Source connection alias", true), "source_path": strProps("Source file path", true), "dest_node": strProps("Destination connection alias", true), "dest_path": strProps("Destination file path", true), }, []string{"source_node", "source_path", "dest_node", "dest_path"}), }, syncHandler) } func readHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { m := manager() if m == nil { return errorResult("no SSH manager initialized"), nil } path, _ := requireString(requestArgs(req), "path") target := getString(requestArgs(req), "target", "primary") content, err := m.ReadFile(ctx, path, target) if err != nil { return errorResult(err.Error()), nil } return textResult(content), nil } func writeHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { m := manager() if m == nil { return errorResult("no SSH manager initialized"), nil } args := requestArgs(req) path, _ := requireString(args, "path") content, _ := requireString(args, "content") skipValidate := getBool(args, "skip_validate", false) target := getString(args, "target", "primary") if !skipValidate { if fileType := detectFileType(path); fileType != "" { if result := ValidateContent(content, fileType); result != nil && !result.Valid { return errorResult(fmt.Sprintf( "Syntax validation failed — file NOT written.\n%s\n\nFix the errors above or set skip_validate=true to force write.", result.FormatResult(path))), nil } } } if err := m.WriteFile(ctx, path, content, target); err != nil { return errorResult(err.Error()), nil } msg := fmt.Sprintf("Successfully wrote %d bytes to %s", len(content), path) if !skipValidate { if fileType := detectFileType(path); fileType != "" { msg += fmt.Sprintf("\n✓ Syntax (%s): OK", fileType) } } return textResult(msg), nil } func editHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { m := manager() if m == nil { return errorResult("no SSH manager initialized"), nil } args := requestArgs(req) path, _ := requireString(args, "path") operation := getString(args, "operation", "replace") target := getString(args, "target", "primary") var cmd string switch operation { case "replace": oldText := getString(args, "old_text", "") newText := getString(args, "new_text", "") if oldText == "" { return errorResult("'old_text' is required for replace operation"), nil } globalFlag := "" if getBool(args, "global", false) { globalFlag = "g" } expr := fmt.Sprintf("s/%s/%s/%s", sedEscapeLiteral(oldText), sedEscapeReplacement(newText), globalFlag) cmd = sedInPlace("", expr, path) case "regex": pattern := getString(args, "pattern", "") replacement := getString(args, "replacement", "") if pattern == "" { return errorResult("'pattern' is required for regex operation"), nil } globalFlag := "g" if !getBool(args, "global", true) { globalFlag = "" } expr := fmt.Sprintf("s/%s/%s/%s", sedEscapePattern(pattern), sedEscapeReplacement(replacement), globalFlag) cmd = sedInPlace("-E", expr, path) case "insert": lineNum := getInt(args, "line", 0) content := getString(args, "content", "") if lineNum <= 0 { return errorResult("'line' (positive integer) is required for insert operation"), nil } if content == "" { return errorResult("'content' is required for insert operation"), nil } expr := fmt.Sprintf("%di\\%s", lineNum, sedEscapeInsertText(content)) cmd = sedInPlace("", expr, path) case "append": content := getString(args, "content", "") pattern := getString(args, "pattern", "") if content == "" { return errorResult("'content' is required for append operation"), nil } if pattern != "" { expr := fmt.Sprintf("/%s/a\\%s", sedEscapePattern(pattern), sedEscapeInsertText(content)) cmd = sedInPlace("", expr, path) } else { cmd = fmt.Sprintf("printf '\\n%%s' %s >> %s 2>&1", shellQuote(content), shellQuote(path)) } case "prepend": content := getString(args, "content", "") pattern := getString(args, "pattern", "") if content == "" { return errorResult("'content' is required for prepend operation"), nil } if pattern != "" { expr := fmt.Sprintf("/%s/i\\%s", sedEscapePattern(pattern), sedEscapeInsertText(content)) cmd = sedInPlace("", expr, path) } else { expr := fmt.Sprintf("1i\\%s", sedEscapeInsertText(content)) cmd = sedInPlace("", expr, path) } case "delete": pattern := getString(args, "pattern", "") startLine := getInt(args, "start_line", 0) endLine := getInt(args, "end_line", 0) if pattern != "" { expr := fmt.Sprintf("/%s/d", sedEscapePattern(pattern)) cmd = sedInPlace("", expr, path) } else if startLine > 0 && endLine > 0 { expr := fmt.Sprintf("%d,%dd", startLine, endLine) cmd = sedInPlace("", expr, path) } else if startLine > 0 { expr := fmt.Sprintf("%dd", startLine) cmd = sedInPlace("", expr, path) } else { return errorResult("'pattern' or 'start_line' is required for delete operation"), nil } case "replace_line": pattern := getString(args, "pattern", "") content := getString(args, "content", "") if pattern == "" { return errorResult("'pattern' is required for replace_line operation"), nil } expr := fmt.Sprintf("s/%s/%s/", sedEscapePattern(pattern), sedEscapeReplacement(content)) cmd = sedInPlace("-E", expr, path) default: return errorResult(fmt.Sprintf( "Unknown operation: '%s'. Supported: replace, regex, insert, append, prepend, delete, replace_line", operation)), nil } output, err := m.Execute(ctx, cmd, target) if err != nil { return errorResult(err.Error()), nil } msg := "" if output == "(No output)" || strings.TrimSpace(output) == "" { msg = fmt.Sprintf("Successfully applied '%s' operation to %s", operation, path) } else { msg = output } if fileType := detectFileType(path); fileType != "" { if updated, readErr := m.ReadFile(ctx, path, target); readErr == nil { if result := ValidateContent(updated, fileType); result != nil { if result.Valid { msg += fmt.Sprintf("\n✓ Syntax (%s): OK", fileType) } else { msg += fmt.Sprintf("\n\n⚠ Syntax (%s): BROKEN after edit\n%s", fileType, result.FormatResult(path)) } } } } return textResult(msg), nil } func listDirHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { m := manager() if m == nil { return errorResult("no SSH manager initialized"), nil } path, _ := requireString(requestArgs(req), "path") target := getString(requestArgs(req), "target", "primary") files, err := m.ListDir(ctx, path, target) if err != nil { return errorResult(err.Error()), nil } jsonBytes, err := json.MarshalIndent(files, "", " ") if err != nil { return errorResult("Failed to format directory listing"), nil } return textResult(string(jsonBytes)), nil } func syncHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { m := manager() if m == nil { return errorResult("no SSH manager initialized"), nil } args := requestArgs(req) sourceNode, _ := requireString(args, "source_node") sourcePath, _ := requireString(args, "source_path") destNode, _ := requireString(args, "dest_node") destPath, _ := requireString(args, "dest_path") content, err := m.ReadFile(ctx, sourcePath, sourceNode) if err != nil { return errorResult(fmt.Sprintf("Failed to read from source: %v", err)), nil } if err := m.WriteFile(ctx, destPath, content, destNode); err != nil { return errorResult(fmt.Sprintf("Failed to write to destination: %v", err)), nil } return textResult(fmt.Sprintf("Successfully synced %d bytes from %s to %s", len(content), sourceNode, destNode)), nil } func validateHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { m := manager() if m == nil { return errorResult("no SSH manager initialized"), nil } args := requestArgs(req) path, _ := requireString(args, "path") forceType := getString(args, "type", "") target := getString(args, "target", "primary") fileType := forceType if fileType == "" { fileType = detectFileType(path) } if fileType == "" { return errorResult(fmt.Sprintf( "Cannot detect file type for '%s'. Use the 'type' parameter to specify: json, yaml, toml, xml, ini, env, dockerfile", path)), nil } content, err := m.ReadFile(ctx, path, target) if err != nil { return errorResult(err.Error()), nil } result := ValidateContent(content, fileType) if result == nil { return errorResult(fmt.Sprintf("No server-side validator for type '%s'", fileType)), nil } return textResult(result.FormatResult(path)), nil }