Initial commit: forge-tools-ssh — MCP-сервер для администрирования по SSH
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user