Initial commit: forge-tools-ssh — MCP-сервер для администрирования по SSH

This commit is contained in:
Maksim Totmin
2026-10-01 10:13:48 +07:00
commit 1821fe7968
30 changed files with 5342 additions and 0 deletions
+375
View File
@@ -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
}