376 lines
14 KiB
Go
376 lines
14 KiB
Go
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
|
|
}
|