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
+186
View File
@@ -0,0 +1,186 @@
package tools
import (
"context"
"fmt"
"time"
"forge-tools-ssh/internal/ssh"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
var mgr *ssh.Manager
// SetManager binds the single SSH Manager for the process.
// forge держит 1 MCP-подпроцесс/агента (stdio), поэтому менеджер один.
func SetManager(m *ssh.Manager) {
mgr = m
}
// manager возвращает одиночный SSH Manager процесса.
func manager() *ssh.Manager {
return mgr
}
// registerCoreTools регистрирует базовые SSH-инструменты.
func registerCoreTools(s *mcp.Server) {
// connect
s.AddTool(&mcp.Tool{
Name: "connect",
Description: "Establish an SSH connection to a remote host. Используй profile (имя из ssh.json), чтобы подключиться по предопределённому профилю; либо host+username для ad-hoc (host должен пройти allowlist политики).",
InputSchema: schema(map[string]any{
"profile": strProps("Имя профиля из per-agent ssh.json (креды задаёт оператор)", false),
"host": strProps("Hostname or IP address", false),
"username": strProps("SSH username", false),
"port": intProps("SSH port (default: 22)", false),
"password": strProps("SSH password (optional if using key)", false),
"private_key_path": strProps("Path to private key file", false),
"alias": strProps("Connection alias (auto-generated if not provided)", false),
"via": strProps("Jump host alias for tunneling", false),
"target": strProps("Optional destination host routed through a PAM gateway (SafeInspect). The gateway receives 'username@target' and proxies the session.", false),
"target_password": strProps("Password of the target server account, requested by the PAM gateway as a second authentication stage", false),
}, nil),
}, connectHandler)
// disconnect
s.AddTool(&mcp.Tool{
Name: "disconnect",
Description: "Close an SSH connection",
InputSchema: schema(map[string]any{
"alias": strProps("Connection alias to disconnect (all if empty)", false),
}, nil),
}, disconnectHandler)
// run
registerPatternTool(s, &mcp.Tool{
Name: "run",
Description: "Execute a shell command on the remote host. Use timeout for long-running tasks.",
InputSchema: schema(map[string]any{
"command": strProps("Shell command to execute", true),
"target": strProps("Connection alias (default: primary)", false),
"timeout": intProps("Command timeout in seconds (default: 120)", false),
}, []string{"command"}),
}, runPatternsFn, runHandler)
// identity
s.AddTool(&mcp.Tool{
Name: "identity",
Description: "Get the server's public SSH key for authorized_keys",
InputSchema: schema(nil, nil),
}, identityHandler)
// info
s.AddTool(&mcp.Tool{
Name: "info",
Description: "Get remote system information (OS, kernel, hostname)",
InputSchema: schema(map[string]any{
"target": strProps("Connection alias (default: primary)", false),
}, nil),
}, infoHandler)
}
func connectHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
args := requestArgs(req)
opts := ssh.ConnectOptions{
Host: getString(args, "host", ""),
Username: getString(args, "username", ""),
Port: getInt(args, "port", 22),
Password: getString(args, "password", ""),
PrivateKeyPath: getString(args, "private_key_path", ""),
Alias: getString(args, "alias", ""),
Via: getString(args, "via", ""),
Target: getString(args, "target", ""),
TargetPassword: getString(args, "target_password", ""),
Profile: getString(args, "profile", ""),
}
resultAlias, err := m.Connect(ctx, opts)
if err != nil {
return errorResult(err.Error()), nil
}
dest := opts.Host
if opts.Target != "" {
dest = fmt.Sprintf("%s via PAM -> %s", opts.Host, opts.Target)
}
return textResult(fmt.Sprintf("Connected to %s@%s (alias: %s)", opts.Username, dest, resultAlias)), nil
}
func disconnectHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
alias := getString(requestArgs(req), "alias", "")
msg, err := m.Disconnect(alias)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(msg), nil
}
func runHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
args := requestArgs(req)
command := getString(args, "command", "")
if command == "" {
return errorResult("command is required"), nil
}
target := getString(args, "target", "primary")
timeout := getInt(args, "timeout", 120)
if timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
defer cancel()
}
output, err := m.Execute(ctx, command, target)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return errorResult(fmt.Sprintf("Command timed out after %ds", timeout)), nil
}
return errorResult(err.Error()), nil
}
return textResult(output), nil
}
func identityHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
pubKey, err := m.GetPublicKey()
if err != nil {
return errorResult(err.Error()), nil
}
formatted := fmt.Sprintf("SSH Public Key:\n\n```\n%s```\n\nAdd this to ~/.ssh/authorized_keys on remote servers.", pubKey)
return textResult(formatted), nil
}
func infoHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
target := getString(requestArgs(req), "target", "primary")
cmd := `echo "Hostname: $(hostname)"; echo "OS: $(cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d'"' -f2 || uname -s)"; echo "Kernel: $(uname -r)"; echo "Arch: $(uname -m)"; echo "Shell: $SHELL"`
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(output), nil
}
+251
View File
@@ -0,0 +1,251 @@
package tools
import (
"context"
"fmt"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func registerDBTools(s *mcp.Server) {
// db_query
s.AddTool(&mcp.Tool{
Name: "db_query",
Description: "Execute SQL/CQL/MongoDB query inside a database container",
InputSchema: schema(map[string]any{
"container": strProps("Docker container name running the database", true),
"db_type": strProps("Database engine type", true, "postgres", "mysql", "scylladb", "cassandra", "mongodb"),
"query": strProps("Query to execute", true),
"database": strProps("Database/keyspace name", false),
"username": strProps("Database username", false),
"password": strProps("Database password", false),
"timeout": intProps("Query timeout in seconds (default: 60)", false),
"target": strProps("Connection alias (default: primary)", false),
}, []string{"container", "db_type", "query"}),
}, dbQueryHandler)
// db_schema
s.AddTool(&mcp.Tool{
Name: "db_schema",
Description: "Get database schema (tables/collections list)",
InputSchema: schema(map[string]any{
"container": strProps("Docker container name", true),
"db_type": strProps("Database engine type", true, "postgres", "mysql", "scylladb", "cassandra", "mongodb"),
"database": strProps("Database/keyspace name", false),
"username": strProps("Database username", false),
"password": strProps("Database password", false),
"target": strProps("Connection alias (default: primary)", false),
}, []string{"container", "db_type"}),
}, dbSchemaHandler)
// list_db_containers
s.AddTool(&mcp.Tool{
Name: "list_db_containers",
Description: "Find Docker containers that look like databases",
InputSchema: schema(map[string]any{
"target": strProps("Connection alias (default: primary)", false),
}, nil),
}, listDBContainersHandler)
}
func dbQueryHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
args := requestArgs(req)
container, _ := requireString(args, "container")
dbType, _ := requireString(args, "db_type")
query, _ := requireString(args, "query")
database := getString(args, "database", "")
username := getString(args, "username", "")
password := getString(args, "password", "")
timeout := getInt(args, "timeout", 60)
target := getString(args, "target", "primary")
if err := checkDockerAvailable(ctx, m, target); err != nil {
return errorResult(err.Error()), nil
}
var cmd string
switch dbType {
case "postgres":
user := "postgres"
if username != "" {
user = username
}
db := database
if db == "" {
db = "postgres"
}
cmd = fmt.Sprintf("docker exec -e PGPASSWORD=%s %s timeout %d psql -U %s -d %s -c %s 2>&1",
shellQuote(password), shellQuote(container), timeout, shellQuote(user), shellQuote(db), shellQuote(query))
case "mysql":
user := "root"
if username != "" {
user = username
}
cmd = fmt.Sprintf("docker exec %s timeout %d mysql -u%s", shellQuote(container), timeout, shellQuote(user))
if password != "" {
cmd += fmt.Sprintf(" -p%s", shellQuote(password))
}
if database != "" {
cmd += fmt.Sprintf(" %s", shellQuote(database))
}
cmd += fmt.Sprintf(" -e %s 2>&1", shellQuote(query))
case "scylladb", "cassandra":
cmd = fmt.Sprintf("docker exec %s timeout %d cqlsh", shellQuote(container), timeout)
if username != "" {
cmd += fmt.Sprintf(" -u %s", shellQuote(username))
}
if password != "" {
cmd += fmt.Sprintf(" -p %s", shellQuote(password))
}
cmd += fmt.Sprintf(" -e %s 2>&1", shellQuote(query))
case "mongodb":
db := database
if db == "" {
db = "admin"
}
cmd = fmt.Sprintf("docker exec %s timeout %d mongosh --quiet %s", shellQuote(container), timeout, shellQuote(db))
if username != "" && password != "" {
cmd += fmt.Sprintf(" -u %s -p %s --authenticationDatabase admin", shellQuote(username), shellQuote(password))
}
cmd += fmt.Sprintf(" --eval %s 2>&1", shellQuote(query))
default:
return errorResult(fmt.Sprintf("Unsupported database type: %s. Supported: postgres, mysql, scylladb, cassandra, mongodb", dbType)), nil
}
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(output), nil
}
func dbSchemaHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
args := requestArgs(req)
container, _ := requireString(args, "container")
dbType, _ := requireString(args, "db_type")
database := getString(args, "database", "")
username := getString(args, "username", "")
password := getString(args, "password", "")
target := getString(args, "target", "primary")
if err := checkDockerAvailable(ctx, m, target); err != nil {
return errorResult(err.Error()), nil
}
var query string
switch dbType {
case "postgres":
query = "\\dt"
case "mysql":
query = "SHOW TABLES;"
case "scylladb", "cassandra":
if database != "" {
query = fmt.Sprintf("DESCRIBE KEYSPACE %s;", database)
} else {
query = "DESCRIBE KEYSPACES;"
}
case "mongodb":
query = "db.getCollectionNames()"
default:
return errorResult(fmt.Sprintf("Unsupported database type: %s", dbType)), nil
}
var cmd string
switch dbType {
case "postgres":
user := "postgres"
if username != "" {
user = username
}
db := database
if db == "" {
db = "postgres"
}
cmd = fmt.Sprintf("docker exec -e PGPASSWORD=%s %s psql -U %s -d %s -c %s 2>&1",
shellQuote(password), shellQuote(container), shellQuote(user), shellQuote(db), shellQuote(query))
case "mysql":
user := "root"
if username != "" {
user = username
}
cmd = fmt.Sprintf("docker exec %s mysql -u%s", shellQuote(container), shellQuote(user))
if password != "" {
cmd += fmt.Sprintf(" -p%s", shellQuote(password))
}
if database != "" {
cmd += fmt.Sprintf(" %s", shellQuote(database))
}
cmd += fmt.Sprintf(" -e %s 2>&1", shellQuote(query))
case "scylladb", "cassandra":
cmd = fmt.Sprintf("docker exec %s cqlsh", shellQuote(container))
if username != "" {
cmd += fmt.Sprintf(" -u %s", shellQuote(username))
}
if password != "" {
cmd += fmt.Sprintf(" -p %s", shellQuote(password))
}
cmd += fmt.Sprintf(" -e %s 2>&1", shellQuote(query))
case "mongodb":
db := database
if db == "" {
db = "admin"
}
cmd = fmt.Sprintf("docker exec %s mongosh --quiet %s", shellQuote(container), shellQuote(db))
if username != "" && password != "" {
cmd += fmt.Sprintf(" -u %s -p %s --authenticationDatabase admin", shellQuote(username), shellQuote(password))
}
cmd += fmt.Sprintf(" --eval %s 2>&1", shellQuote(query))
}
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(output), nil
}
func listDBContainersHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
target := getString(requestArgs(req), "target", "primary")
if err := checkDockerAvailable(ctx, m, target); err != nil {
return errorResult(err.Error()), nil
}
cmd := `docker ps --format '{{.Names}}|{{.Image}}' | while read line; do
name=$(echo "$line" | cut -d'|' -f1)
image=$(echo "$line" | cut -d'|' -f2)
case "$image" in
*postgres*) echo "$name|$image|postgres" ;;
*mysql*|*mariadb*) echo "$name|$image|mysql" ;;
*scylla*) echo "$name|$image|scylladb" ;;
*cassandra*) echo "$name|$image|cassandra" ;;
*mongo*) echo "$name|$image|mongodb" ;;
*redis*) echo "$name|$image|redis" ;;
esac
done 2>/dev/null`
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
if trimOutput(output) == "" {
return textResult("No database containers found"), nil
}
return textResult(output), nil
}
+95
View File
@@ -0,0 +1,95 @@
package tools
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// registerDiskTools добавляет точные инструменты диска: по конкретному пути
// (безпотерьный ответ "сколько места на X") и по всем точкам монтирования.
// Отличается от ssh__usage, который исторически отдавал только корень /.
func registerDiskTools(s *mcp.Server) {
s.AddTool(&mcp.Tool{
Name: "disk_usage",
Description: "Get free/used space for the filesystem that contains the given path (df equivalent). Resolves the MOUND, not just root: returns the mount point, device, size, used, available and use% for the volume holding <path>. Use for 'how much space/free on <path>'.",
InputSchema: schema(map[string]any{
"path": strProps("Absolute path; reports the filesystem that contains it", true),
"target": strProps("Connection alias (default: primary)", false),
}, []string{"path"}),
}, diskUsageHandler)
s.AddTool(&mcp.Tool{
Name: "disk_usage_all",
Description: "Get free/used space for ALL mounted filesystems (df over every mount point) with a marker on partitions that are >=80% used. Use for an overview of disk pressure.",
InputSchema: schema(map[string]any{
"target": strProps("Connection alias (default: primary)", false),
}, nil),
}, diskUsageAllHandler)
}
func diskUsageHandler(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, err := requireString(args, "path")
if err != nil {
return errorResult(err.Error()), nil
}
target := getString(args, "target", "primary")
// df -hP <path> reports the FS containing <path> (mount point), not just
// root. shellQuote guards the path; if the path doesn't exist yet (e.g., a
// not-yet-mounted volume) we degrade to the full mount list rather than fail.
cmd := diskUsageCmd(path)
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(output), nil
}
func diskUsageAllHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
target := getString(requestArgs(req), "target", "primary")
output, err := m.Execute(ctx, "df -hP 2>/dev/null", target)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(highlightHighUsage(output)), nil
}
// diskUsageCmd собирает команду "место на томе, содержащем path".
func diskUsageCmd(path string) string {
return fmt.Sprintf("df -hP %s 2>/dev/null || df -hP 2>/dev/null", shellQuote(path))
}
// highlightHighUsage помечает строки df с заполненностью >=80% (иначе
// "почти полный" раздел выпадал у ssh__diagnose_system из-за фильтра >90%).
// Парсим в Go, а не awk: тестируемо и не зависит от полей awk.
func highlightHighUsage(dfOut string) string {
if strings.TrimSpace(dfOut) == "" {
return dfOut
}
var b strings.Builder
for _, line := range strings.Split(dfOut, "\n") {
b.WriteString(line)
fields := strings.Fields(line)
// POSIX df -hP: колонка 5 (индекс 4) — "Use%" вроде "84%".
if len(fields) >= 5 {
if pct, err := strconv.Atoi(strings.TrimSuffix(fields[4], "%")); err == nil && pct >= 80 {
b.WriteString(" << USE>=80%")
}
}
b.WriteByte('\n')
}
return strings.TrimRight(b.String(), "\n")
}
+56
View File
@@ -0,0 +1,56 @@
package tools
import (
"strings"
"testing"
)
// TestDiskUsageCmd — команда точного disk_usage обязана квотировать путь и
// не путать "место на /opt" с корнем (df по конкретному пути, не "df -h /").
func TestDiskUsageCmd(t *testing.T) {
cmd := diskUsageCmd("/opt")
for _, want := range []string{"df -hP", "'/opt'"} {
if !strings.Contains(cmd, want) {
t.Errorf("diskUsageCmd(/opt) = %q: missing %q", cmd, want)
}
}
if strings.Contains(cmd, "df -h /") {
t.Errorf("diskUsageCmd(/opt) = %q: must not fall back to root-only 'df -h /'", cmd)
}
// Апостроф/спецсимвол в пути не должен ломать квотирование (инъекция).
cmd2 := diskUsageCmd("/opt/x' ;rm -rf /")
if !strings.Contains(cmd2, shellQuote("/opt/x' ;rm -rf /")) {
t.Errorf("diskUsageCmd does not shellQuote path: %q", cmd2)
}
}
// TestHighlightHighUsage — регрессия на "почти полный" раздел: >=80% должен
// получить маркер, а <80% и заголовок — нет (раньше фильтр >90% скрывал 84%).
func TestHighlightHighUsage(t *testing.T) {
in := "Filesystem 1024-blocks Used Available Capacity Mounted-on\n" +
"/dev/mapper/opt-opt 128G 102G 20G 84% /opt\n" +
"/dev/sda1 30G 25G 3.6G 84% /\n"
out := highlightHighUsage(in)
if !strings.Contains(out, "/opt") {
t.Fatalf("highlightHighUsage dropped /opt: %q", out)
}
if !strings.Contains(out, "<< USE>=80%") {
t.Errorf("highlightHighUsage should mark >=80%% partition: %q", out)
}
// Колонка 5 у /opt — "84%"; маркер должен стоять на этой строке.
var marked bool
for _, line := range strings.Split(out, "\n") {
if strings.Contains(line, "/opt") && strings.Contains(line, "84%") {
if !strings.Contains(line, "<< USE>=80%") {
t.Errorf("84%% partition not marked on its own line: %q", line)
}
marked = true
}
if strings.Contains(line, "Filesystem") && strings.Contains(line, "<< USE>=80%") {
t.Errorf("header must not be marked: %q", line)
}
}
if !marked {
t.Errorf("the /opt line was not marked: %q", out)
}
}
+285
View File
@@ -0,0 +1,285 @@
package tools
import (
"context"
"fmt"
"forge-tools-ssh/internal/ssh"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func registerDockerTools(s *mcp.Server) {
// docker_ps
s.AddTool(&mcp.Tool{
Name: "docker_ps",
Description: "List Docker containers",
InputSchema: schema(map[string]any{
"all": boolProps("Show all containers (default: only running)", false),
"target": strProps("Connection alias (default: primary)", false),
}, nil),
}, dockerPsHandler)
// docker_logs
s.AddTool(&mcp.Tool{
Name: "docker_logs",
Description: "Get logs from a Docker container",
InputSchema: schema(map[string]any{
"container": strProps("Container name or ID", true),
"lines": intProps("Number of lines (default: 50)", false),
"target": strProps("Connection alias (default: primary)", false),
}, []string{"container"}),
}, dockerLogsHandler)
// docker_op
s.AddTool(&mcp.Tool{
Name: "docker_op",
Description: "Start, stop, or restart a Docker container",
InputSchema: schema(map[string]any{
"container": strProps("Container name or ID", true),
"action": strProps("Action to perform on the container", true, "start", "stop", "restart"),
"target": strProps("Connection alias (default: primary)", false),
}, []string{"container", "action"}),
}, dockerOpHandler)
// docker_ip
s.AddTool(&mcp.Tool{
Name: "docker_ip",
Description: "Get IP address(es) of a Docker container",
InputSchema: schema(map[string]any{
"container": strProps("Container name", true),
"target": strProps("Connection alias (default: primary)", false),
}, []string{"container"}),
}, dockerIPHandler)
// docker_find_by_ip
s.AddTool(&mcp.Tool{
Name: "docker_find_by_ip",
Description: "Find which Docker container has a specific IP",
InputSchema: schema(map[string]any{
"ip": strProps("IP address to search", true),
"target": strProps("Connection alias (default: primary)", false),
}, []string{"ip"}),
}, dockerFindByIPHandler)
// docker_networks
s.AddTool(&mcp.Tool{
Name: "docker_networks",
Description: "List all Docker networks and their containers",
InputSchema: schema(map[string]any{
"target": strProps("Connection alias (default: primary)", false),
}, nil),
}, dockerNetworksHandler)
// docker_cp_from
s.AddTool(&mcp.Tool{
Name: "docker_cp_from",
Description: "Copy file from Docker container to host",
InputSchema: schema(map[string]any{
"container": strProps("Container name", true),
"container_path": strProps("Path inside container", true),
"host_path": strProps("Destination path on host", true),
"target": strProps("Connection alias (default: primary)", false),
}, []string{"container", "container_path", "host_path"}),
}, dockerCpFromHandler)
// docker_cp_to
s.AddTool(&mcp.Tool{
Name: "docker_cp_to",
Description: "Copy file from host to Docker container",
InputSchema: schema(map[string]any{
"host_path": strProps("Source path on host", true),
"container": strProps("Container name", true),
"container_path": strProps("Destination path inside container", true),
"target": strProps("Connection alias (default: primary)", false),
}, []string{"host_path", "container", "container_path"}),
}, dockerCpToHandler)
}
func dockerPsHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
args := requestArgs(req)
all := getBool(args, "all", false)
target := getString(args, "target", "primary")
if err := checkDockerAvailable(ctx, m, target); err != nil {
return errorResult(err.Error()), nil
}
flag := ""
if all {
flag = "-a"
}
cmd := fmt.Sprintf("docker ps %s --format 'table {{.ID}}\t{{.Image}}\t{{.Status}}\t{{.Names}}'", flag)
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(output), nil
}
func dockerLogsHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
args := requestArgs(req)
container, _ := requireString(args, "container")
lines := getInt(args, "lines", 50)
target := getString(args, "target", "primary")
if err := checkDockerAvailable(ctx, m, target); err != nil {
return errorResult(err.Error()), nil
}
cmd := fmt.Sprintf("docker logs --tail %d %s 2>&1", lines, shellQuote(container))
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(output), nil
}
func dockerOpHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
args := requestArgs(req)
container, _ := requireString(args, "container")
action, _ := requireString(args, "action")
target := getString(args, "target", "primary")
if action != "start" && action != "stop" && action != "restart" {
return errorResult("Invalid action. Use: start, stop, restart"), nil
}
if err := checkDockerAvailable(ctx, m, target); err != nil {
return errorResult(err.Error()), nil
}
cmd := fmt.Sprintf("docker %s %s 2>&1", shellQuote(action), shellQuote(container))
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(fmt.Sprintf("%s: %s\n%s", action, container, output)), nil
}
func dockerIPHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
args := requestArgs(req)
container, _ := requireString(args, "container")
target := getString(args, "target", "primary")
if err := checkDockerAvailable(ctx, m, target); err != nil {
return errorResult(err.Error()), nil
}
cmd := fmt.Sprintf("docker inspect --format '{{range $net, $conf := .NetworkSettings.Networks}}{{$net}}:{{$conf.IPAddress}}|{{end}}' %s 2>/dev/null", shellQuote(container))
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(fmt.Sprintf("Container: %s\nNetworks: %s", container, output)), nil
}
func dockerFindByIPHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
args := requestArgs(req)
ip, _ := requireString(args, "ip")
target := getString(args, "target", "primary")
if err := checkDockerAvailable(ctx, m, target); err != nil {
return errorResult(err.Error()), nil
}
cmd := fmt.Sprintf(`docker ps -q | xargs -I {} docker inspect --format '{{.Name}}|{{range $net, $conf := .NetworkSettings.Networks}}{{$net}}:{{$conf.IPAddress}},{{end}}' {} 2>/dev/null | grep %s`, shellQuote(ip))
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return textResult(fmt.Sprintf("No container found with IP: %s", ip)), nil
}
return textResult(output), nil
}
func dockerNetworksHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
target := getString(requestArgs(req), "target", "primary")
if err := checkDockerAvailable(ctx, m, target); err != nil {
return errorResult(err.Error()), nil
}
cmd := "docker network ls --format '{{.Name}} ({{.Driver}})'"
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(output), nil
}
func dockerCpFromHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
args := requestArgs(req)
container, _ := requireString(args, "container")
containerPath, _ := requireString(args, "container_path")
hostPath, _ := requireString(args, "host_path")
target := getString(args, "target", "primary")
if err := checkDockerAvailable(ctx, m, target); err != nil {
return errorResult(err.Error()), nil
}
cmd := fmt.Sprintf("docker cp %s:%s %s 2>&1", shellQuote(container), shellQuote(containerPath), shellQuote(hostPath))
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
if containsString(output, "Error") || containsString(output, "No such") {
return errorResult(output), nil
}
return textResult(fmt.Sprintf("Copied %s:%s to %s", container, containerPath, hostPath)), nil
}
func dockerCpToHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
args := requestArgs(req)
hostPath, _ := requireString(args, "host_path")
container, _ := requireString(args, "container")
containerPath, _ := requireString(args, "container_path")
target := getString(args, "target", "primary")
if err := checkDockerAvailable(ctx, m, target); err != nil {
return errorResult(err.Error()), nil
}
cmd := fmt.Sprintf("docker cp %s %s:%s 2>&1", shellQuote(hostPath), shellQuote(container), shellQuote(containerPath))
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
if containsString(output, "Error") || containsString(output, "No such") {
return errorResult(output), nil
}
return textResult(fmt.Sprintf("Copied %s to %s:%s", hostPath, container, containerPath)), nil
}
func checkDockerAvailable(ctx context.Context, m *ssh.Manager, target string) error {
available, err := m.IsDockerAvailable(ctx, target)
if err != nil {
return err
}
if !available {
return fmt.Errorf("docker command not found on target")
}
return nil
}
+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
}
+316
View File
@@ -0,0 +1,316 @@
package tools
import (
"context"
"fmt"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func registerMonitoringTools(s *mcp.Server) {
// usage
s.AddTool(&mcp.Tool{
Name: "usage",
Description: "Get CPU/RAM/Disk usage summary",
InputSchema: schema(map[string]any{
"target": strProps("Connection alias (default: primary)", false),
}, nil),
}, usageHandler)
// ps
s.AddTool(&mcp.Tool{
Name: "ps",
Description: "List top processes sorted by CPU or memory",
InputSchema: schema(map[string]any{
"sort_by": strProps("Sort field (default: cpu)", false, "cpu", "mem"),
"limit": intProps("Number of processes to show (default: 10)", false),
"target": strProps("Connection alias (default: primary)", false),
}, nil),
}, psHandler)
// logs
s.AddTool(&mcp.Tool{
Name: "logs",
Description: "Read the tail of a log file",
InputSchema: schema(map[string]any{
"path": strProps("Path to log file", true),
"lines": intProps("Number of lines to read (default: 50, max: 500)", false),
"grep": strProps("Optional filter pattern", false),
"target": strProps("Connection alias (default: primary)", false),
}, []string{"path"}),
}, logsHandler)
// journal_read
s.AddTool(&mcp.Tool{
Name: "journal_read",
Description: "Read system logs (journalctl/syslog)",
InputSchema: schema(map[string]any{
"service": strProps("Service name to filter (e.g., nginx, sshd)", false),
"since": strProps("Time filter (e.g., '1 hour ago')", false),
"lines": intProps("Number of lines (default: 100, max: 500)", false),
"priority": strProps("Minimum log priority level", false, "emerg", "alert", "crit", "err", "warning", "notice", "info", "debug"),
"target": strProps("Connection alias (default: primary)", false),
}, nil),
}, journalReadHandler)
// dmesg_read
s.AddTool(&mcp.Tool{
Name: "dmesg_read",
Description: "Read kernel ring buffer (dmesg)",
InputSchema: schema(map[string]any{
"grep": strProps("Optional pattern to filter messages", false),
"lines": intProps("Number of lines (default: 100)", false),
"target": strProps("Connection alias (default: primary)", false),
}, nil),
}, dmesgReadHandler)
// diagnose_system
s.AddTool(&mcp.Tool{
Name: "diagnose_system",
Description: "One-click SRE health check: load, OOM, disk, failed services",
InputSchema: schema(map[string]any{
"target": strProps("Connection alias (default: primary)", false),
}, nil),
}, diagnoseHandler)
// list_services
s.AddTool(&mcp.Tool{
Name: "list_services",
Description: "List system services (systemd/OpenRC)",
InputSchema: schema(map[string]any{
"failed_only": boolProps("Show only failed services", false),
"target": strProps("Connection alias (default: primary)", false),
}, nil),
}, listServicesHandler)
}
func usageHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
target := getString(requestArgs(req), "target", "primary")
cmd := `
echo "=== LOAD AVERAGE ==="
uptime 2>/dev/null
echo ""
echo "=== MEMORY ==="
free -h 2>/dev/null || top -l 1 -s 0 2>/dev/null | grep -i phys || cat /proc/meminfo 2>/dev/null | head -5
echo ""
echo "=== DISK (all mounts) ==="
df -hP 2>/dev/null
`
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(output), nil
}
func psHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
args := requestArgs(req)
sortBy := getString(args, "sort_by", "cpu")
limit := getInt(args, "limit", 10)
target := getString(args, "target", "primary")
if limit > 50 {
limit = 50
}
sortCol := "3"
if sortBy == "mem" {
sortCol = "4"
}
cmd := fmt.Sprintf("ps -eo pid,user,%%cpu,%%mem,comm | awk 'NR==1{print} NR>1{print | \"sort -k%s -rn\"}' | head -n %d", sortCol, limit+1)
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(output), nil
}
func logsHandler(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")
lines := getInt(args, "lines", 50)
grep := getString(args, "grep", "")
target := getString(args, "target", "primary")
if lines > 500 {
lines = 500
}
cmd := fmt.Sprintf("tail -n %d %s", lines, shellQuote(path))
if grep != "" {
cmd += fmt.Sprintf(" | grep %s", shellQuote(grep))
}
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(output), nil
}
func journalReadHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
args := requestArgs(req)
service := getString(args, "service", "")
since := getString(args, "since", "")
lines := getInt(args, "lines", 100)
priority := getString(args, "priority", "")
target := getString(args, "target", "primary")
if lines > 500 {
lines = 500
}
checkCmd := "command -v journalctl >/dev/null 2>&1 && echo 'systemd' || echo 'syslog'"
checkOutput, err := m.Execute(ctx, checkCmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
var cmd string
if containsString(checkOutput, "systemd") {
cmd = "journalctl --no-pager"
if service != "" {
cmd += fmt.Sprintf(" -u %s", shellQuote(service))
}
if since != "" {
cmd += fmt.Sprintf(" --since %s", shellQuote(since))
}
if priority != "" {
cmd += fmt.Sprintf(" -p %s", shellQuote(priority))
}
cmd += fmt.Sprintf(" -n %d 2>/dev/null", lines)
} else {
cmd = fmt.Sprintf("cat /var/log/syslog /var/log/messages /var/log/system.log 2>/dev/null | tail -n %d", lines)
if service != "" {
cmd += fmt.Sprintf(" | grep -i %s", shellQuote(service))
}
}
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(output), nil
}
func dmesgReadHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
args := requestArgs(req)
grep := getString(args, "grep", "")
lines := getInt(args, "lines", 100)
target := getString(args, "target", "primary")
if lines > 500 {
lines = 500
}
cmd := "dmesg --time-format iso 2>/dev/null || dmesg 2>/dev/null"
if grep != "" {
cmd += fmt.Sprintf(" | grep -i %s", shellQuote(grep))
}
cmd += fmt.Sprintf(" | tail -n %d", lines)
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(output), nil
}
func diagnoseHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
target := getString(requestArgs(req), "target", "primary")
cmd := `
echo "=== SYSTEM HEALTH DIAGNOSTIC ==="
echo ""
echo "--- LOAD AVERAGE ---"
LOAD=$(uptime 2>/dev/null | awk -F'load average[s]?: ' '{print $2}' | awk -F'[, ]' '{print $1}')
CPUS=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || echo 1)
echo "Load (1min): $LOAD (CPUs: $CPUS)"
if echo "$LOAD $CPUS" | awk '{if ($1 > $2 * 2) exit 0; else exit 1}'; then
echo "WARNING: High load detected!"
fi
echo ""
echo "--- TOP CPU CONSUMERS ---"
ps -eo pid,user,%cpu,%mem,comm | awk 'NR==1{print} NR>1{print | "sort -k3 -rn"}' | head -n 6
echo ""
echo "--- OOM EVENTS ---"
OOM=$(dmesg 2>/dev/null | grep -i 'out of memory' | tail -n 3)
if [ -n "$OOM" ]; then
echo "$OOM"
echo "WARNING: OOM events found!"
else
echo "No OOM events in dmesg"
fi
echo ""
echo "--- DISK PRESSURE (>=80%) ---"
df -hP 2>/dev/null | awk 'NR==1{print; next} {c=$5; sub(/%/,"",c); m=(c+0>=80 ? " <<ALERT" : ""); print $0 m}'
echo ""
echo "--- FAILED SERVICES ---"
if command -v systemctl >/dev/null 2>&1; then
FAILED=$(systemctl --failed --no-legend --no-pager 2>/dev/null | head -n 5)
if [ -n "$FAILED" ]; then
echo "$FAILED"
else
echo "No failed services"
fi
elif command -v rc-status >/dev/null 2>&1; then
rc-status --crashed 2>/dev/null | head -n 5 || echo "No crashed services"
else
echo "Init system not detected"
fi
echo ""
echo "=== END DIAGNOSTIC ==="
`
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(output), nil
}
func listServicesHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
args := requestArgs(req)
failedOnly := getBool(args, "failed_only", false)
target := getString(args, "target", "primary")
var cmd string
if failedOnly {
cmd = `if command -v systemctl >/dev/null 2>&1; then systemctl --failed --no-pager 2>/dev/null; elif command -v rc-status >/dev/null 2>&1; then rc-status --crashed 2>/dev/null; elif command -v launchctl >/dev/null 2>&1; then launchctl list 2>/dev/null | head -50; else echo "No supported init system detected"; fi`
} else {
cmd = `if command -v systemctl >/dev/null 2>&1; then systemctl list-units --type=service --no-pager 2>/dev/null | head -50; elif command -v rc-status >/dev/null 2>&1; then rc-status 2>/dev/null; elif command -v launchctl >/dev/null 2>&1; then launchctl list 2>/dev/null | head -50; elif [ -x /usr/sbin/service ]; then service -e 2>/dev/null | head -50; else echo "No supported init system detected"; fi`
}
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(output), nil
}
+214
View File
@@ -0,0 +1,214 @@
package tools
import (
"context"
"fmt"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func registerNetworkTools(s *mcp.Server) {
// net_stat
s.AddTool(&mcp.Tool{
Name: "net_stat",
Description: "Check listening ports (ss/netstat)",
InputSchema: schema(map[string]any{
"port": intProps("Filter by specific port", false),
"target": strProps("Connection alias (default: primary)", false),
}, nil),
}, netStatHandler)
// search_files
s.AddTool(&mcp.Tool{
Name: "search_files",
Description: "Find files using POSIX find",
InputSchema: schema(map[string]any{
"pattern": strProps("File name pattern (supports wildcards)", true),
"path": strProps("Search path (default: /)", false),
"max_depth": intProps("Maximum directory depth", false),
"type": strProps("Filter by type: f (file), d (directory)", false, "f", "d"),
"target": strProps("Connection alias (default: primary)", false),
}, []string{"pattern"}),
}, searchFilesHandler)
// search_text
s.AddTool(&mcp.Tool{
Name: "search_text",
Description: "Search text in files using grep",
InputSchema: schema(map[string]any{
"pattern": strProps("Search pattern", true),
"path": strProps("File or directory path", true),
"recursive": boolProps("Search recursively", false),
"ignore_case": boolProps("Case-insensitive search", false),
"context": intProps("Lines of context around matches", false),
"target": strProps("Connection alias (default: primary)", false),
}, []string{"pattern", "path"}),
}, searchTextHandler)
// package_manage
s.AddTool(&mcp.Tool{
Name: "package_manage",
Description: "Install/remove/check packages (apt, apk, dnf, yum)",
InputSchema: schema(map[string]any{
"action": strProps("Package management action", true, "install", "remove", "check", "list"),
"package": strProps("Package name (required for install/remove/check)", false),
"target": strProps("Connection alias (default: primary)", false),
}, []string{"action"}),
}, packageManageHandler)
}
func netStatHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
args := requestArgs(req)
port := getInt(args, "port", 0)
target := getString(args, "target", "primary")
var cmd string
if port > 0 {
cmd = fmt.Sprintf("ss -tlnp 2>/dev/null | grep ':%d ' || netstat -an 2>/dev/null | grep -i listen | grep '[\\.: ]%d '", port, port)
} else {
cmd = "ss -tlnp 2>/dev/null || netstat -an 2>/dev/null | grep -i listen"
}
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(output), nil
}
func searchFilesHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
args := requestArgs(req)
pattern, _ := requireString(args, "pattern")
path := getString(args, "path", "/")
maxDepth := getInt(args, "max_depth", 0)
fileType := getString(args, "type", "")
target := getString(args, "target", "primary")
cmd := fmt.Sprintf("find %s", shellQuote(path))
if maxDepth > 0 {
cmd += fmt.Sprintf(" -maxdepth %d", maxDepth)
}
if fileType == "f" || fileType == "d" {
cmd += fmt.Sprintf(" -type %s", fileType)
}
cmd += fmt.Sprintf(" -name %s 2>/dev/null | head -100", shellQuote(pattern))
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(output), nil
}
func searchTextHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
args := requestArgs(req)
pattern, _ := requireString(args, "pattern")
path, _ := requireString(args, "path")
recursive := getBool(args, "recursive", false)
ignoreCase := getBool(args, "ignore_case", false)
ctxLines := getInt(args, "context", 0)
target := getString(args, "target", "primary")
cmd := "grep"
if recursive {
cmd += " -r"
}
if ignoreCase {
cmd += " -i"
}
if ctxLines > 0 {
cmd += fmt.Sprintf(" -C %d", ctxLines)
}
cmd += fmt.Sprintf(" -n %s %s 2>/dev/null | head -100", shellQuote(pattern), shellQuote(path))
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(output), nil
}
func packageManageHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
args := requestArgs(req)
action, _ := requireString(args, "action")
pkg := getString(args, "package", "")
target := getString(args, "target", "primary")
if action != "list" && pkg == "" {
return errorResult("Package name required for this action"), nil
}
detectCmd := `
if command -v apt-get >/dev/null 2>&1; then echo "apt"
elif command -v apk >/dev/null 2>&1; then echo "apk"
elif command -v dnf >/dev/null 2>&1; then echo "dnf"
elif command -v yum >/dev/null 2>&1; then echo "yum"
else echo "unknown"
fi`
pkgMgr, err := m.Execute(ctx, detectCmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
pkgMgr = trimOutput(pkgMgr)
var cmd string
switch pkgMgr {
case "apt":
switch action {
case "install":
cmd = fmt.Sprintf("apt-get update && apt-get install -y %s", shellQuote(pkg))
case "remove":
cmd = fmt.Sprintf("apt-get remove -y %s", shellQuote(pkg))
case "check":
cmd = fmt.Sprintf("dpkg -s %s 2>/dev/null", shellQuote(pkg))
case "list":
cmd = "dpkg -l | head -50"
}
case "apk":
switch action {
case "install":
cmd = fmt.Sprintf("apk add %s", shellQuote(pkg))
case "remove":
cmd = fmt.Sprintf("apk del %s", shellQuote(pkg))
case "check":
cmd = fmt.Sprintf("apk info %s 2>/dev/null", shellQuote(pkg))
case "list":
cmd = "apk list --installed | head -50"
}
case "dnf", "yum":
switch action {
case "install":
cmd = fmt.Sprintf("%s install -y %s", pkgMgr, shellQuote(pkg))
case "remove":
cmd = fmt.Sprintf("%s remove -y %s", pkgMgr, shellQuote(pkg))
case "check":
cmd = fmt.Sprintf("rpm -qi %s 2>/dev/null", shellQuote(pkg))
case "list":
cmd = fmt.Sprintf("%s list installed 2>/dev/null | head -50", pkgMgr)
}
default:
return errorResult("No supported package manager found"), nil
}
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(output), nil
}
+14
View File
@@ -0,0 +1,14 @@
package tools
// patterns.go — доменные forge-probe эмиттеры ssh-модуля. Механика probe
// (wrapProbe/registerPatternTool/isProbe) — в forge-tools/toolkit.
// runPatternsFn - patternsFn для инструмента run: эмитирует по паттерну на
// каждую команду в shell-цепочке (см. shellSplitPatterns).
func runPatternsFn(args map[string]any) (patterns, always []string) {
command := getString(args, "command", "")
if command == "" {
return nil, nil
}
return shellSplitPatterns(command)
}
+23
View File
@@ -0,0 +1,23 @@
// Package tools implements MCP tool handlers for the forge-tools-ssh server.
package tools
import (
"forge-tools-ssh/internal/ssh"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// RegisterAll регистрирует все 45 tool-инструмента SSH-сервера
// (core 5 + files 6 + monitoring 7 + disk 2 + network 4 + docker 8 + db 3 + voip 10).
// m — одиночный SSH Manager процесса (forge: 1 подпроцесс/агента).
func RegisterAll(s *mcp.Server, m *ssh.Manager) {
SetManager(m)
registerCoreTools(s)
registerFileTools(s)
registerMonitoringTools(s)
registerDiskTools(s)
registerNetworkTools(s)
registerDockerTools(s)
registerDBTools(s)
registerVoIPTools(s)
}
+309
View File
@@ -0,0 +1,309 @@
package tools
import (
"strings"
)
// shellSplitPatterns разбивает shell-команду на отдельные "команды" и
// возвращает permission-паттерн на каждую + безопасный префикс "always".
//
// Зачем: ядро forge не умеет парсить shell, поэтому безопасность rule-
// matching (напр. "ssh__run": {"rm -rf *": deny}) обеспечивает тул - он
// режет цепочки `cmd1 && cmd2`, `cmd1; cmd2`, `cmd1 | cmd2`, редиректы и
// подстановки `$(...)`/backtick/`sh -c` на отдельные команды, чтобы
// деструктив не "прятался" за безопасным префиксом (opencode делает то же
// самое в tool/shell.ts, расщепляя AST).
//
// Возврат nil, если команда не может быть разобрана (несбалансированные
// кавычки/скобки) - тогда ядро трактует как "паттернов нет" и решает по
// default (ask - безопасно). Это консервативный отказ на неоднозначность.
func shellSplitPatterns(command string) (patterns, always []string) {
segs, ok := splitTopLevel(command)
if !ok {
return nil, nil
}
for _, seg := range segs {
seg = strings.TrimSpace(seg)
if seg == "" {
continue
}
patterns = append(patterns, seg)
always = append(always, safeAlwaysPrefix(seg))
// Вложенные команды: $(...), backtick, sh -c '...', python -c, etc.
for _, inner := range extractNested(seg) {
inner = strings.TrimSpace(inner)
if inner == "" {
continue
}
patterns = append(patterns, inner)
always = append(always, safeAlwaysPrefix(inner))
}
}
return patterns, always
}
// splitTopLevel разбивает строку на сегменты по command-разделителям
// `;`, `&&`, `||`, `|`, `|&`, `&`, перевод строки - на верхнем уровне
// (вне кавычек и $()-скобок). Возвращает ok=false при несбалансированных
// кавычках/скобках.
func splitTopLevel(s string) ([]string, bool) {
var segs []string
var b strings.Builder
inSingle := false
inDouble := false
escaped := false
depth := 0
for i := 0; i < len(s); i++ {
c := s[i]
if escaped {
b.WriteByte(c)
escaped = false
continue
}
if c == '\\' {
b.WriteByte(c)
escaped = true
continue
}
if inSingle {
b.WriteByte(c)
if c == '\'' {
inSingle = false
}
continue
}
if inDouble {
b.WriteByte(c)
if c == '"' {
inDouble = false
}
continue
}
switch c {
case '\'':
b.WriteByte(c)
inSingle = true
case '"':
b.WriteByte(c)
inDouble = true
case '(':
depth++
b.WriteByte(c)
case ')':
if depth == 0 {
return nil, false // несбалансированная скобка
}
depth--
b.WriteByte(c)
case ';', '\n':
if depth == 0 {
segs = append(segs, b.String())
b.Reset()
} else {
b.WriteByte(c)
}
case '&':
if depth == 0 {
// && - разделитель; одиночный & - тоже разделитель
// (background), поглощаем возможный второй &
if i+1 < len(s) && s[i+1] == '&' {
i++
}
segs = append(segs, b.String())
b.Reset()
} else {
b.WriteByte(c)
}
case '|':
if depth == 0 {
// |& или | - разделитель
if i+1 < len(s) && s[i+1] == '&' {
i++
}
segs = append(segs, b.String())
b.Reset()
} else {
b.WriteByte(c)
}
default:
b.WriteByte(c)
}
}
if inSingle || inDouble || depth != 0 {
return nil, false
}
segs = append(segs, b.String())
return segs, true
}
// extractNested извлекает вложенные команды из сегмента: содержимое
// `$(...)`, backtick-подстановки и аргументы `-c`/`-e` для
// sh/bash/python/node. Это позволяет deny-правилам ловить деструктив,
// спрятанный внутри подстановки или внешнего интерпретатора.
func extractNested(seg string) []string {
var out []string
out = append(out, extractDollarParen(seg)...)
out = append(out, extractBackticks(seg)...)
out = append(out, extractInterpreterArg(seg)...)
return out
}
// extractDollarParen извлекает содержимое `$( ... )` с учётом вложенности
// и кавычек.
func extractDollarParen(s string) []string {
var out []string
for i := 0; i < len(s); i++ {
if i+1 < len(s) && s[i] == '$' && s[i+1] == '(' {
end, inner, ok := matchBalanced(s, i+1, '(', ')')
if !ok {
return out
}
out = append(out, inner)
i = end - 1
}
}
return out
}
// extractBackticks извлекает содержимое “ ` ... ` “.
func extractBackticks(s string) []string {
var out []string
for i := 0; i < len(s); i++ {
if s[i] == '`' {
end := strings.IndexByte(s[i+1:], '`')
if end < 0 {
return out
}
out = append(out, s[i+1:i+1+end])
i += end + 1
}
}
return out
}
// extractInterpreterArg извлекает строку-аргумент у интерпретаторов,
// исполняющих код: sh -c '...', bash -c '...', python -c '...',
// node -e '...'. Это отдельная команда (её содержимое), которую нельзя
// отождествлять с безопасным префиксом `sh -c`.
func extractInterpreterArg(s string) []string {
// Паттерн: <bin> -c <quote>...<quote> или <bin> -e <quote>...<quote>
idx := strings.Index(s, " -c ")
if idx < 0 {
idx = strings.Index(s, " -e ")
}
if idx < 0 {
return nil
}
rest := s[idx+4:]
rest = strings.TrimSpace(rest)
if rest == "" {
return nil
}
// Разбираем аргумент: кавычки '...' или "..."
var inner string
switch rest[0] {
case '\'':
end := strings.IndexByte(rest[1:], '\'')
if end < 0 {
return nil
}
inner = rest[1 : 1+end]
case '"':
end := strings.IndexByte(rest[1:], '"')
if end < 0 {
return nil
}
inner = rest[1 : 1+end]
default:
// Без кавычек - берём до конца слова (грубая эвристика).
end := strings.IndexAny(rest, " \t;|&")
if end < 0 {
end = len(rest)
}
inner = rest[:end]
}
if strings.TrimSpace(inner) == "" {
return nil
}
return []string{inner}
}
// matchBalanced находит закрывающую скобку для открывающей на позиции
// open (open - индекс символа '('), уважая кавычки и вложенность.
// Возвращает (endIndex, innerContent, ok), где endIndex - индекс ')'.
func matchBalanced(s string, open int, openCh, closeCh byte) (int, string, bool) {
depth := 0
inSingle := false
inDouble := false
escaped := false
for i := open; i < len(s); i++ {
c := s[i]
if escaped {
escaped = false
continue
}
if c == '\\' {
escaped = true
continue
}
if inSingle {
if c == '\'' {
inSingle = false
}
continue
}
if inDouble {
if c == '"' {
inDouble = false
}
continue
}
switch c {
case '\'':
inSingle = true
case '"':
inDouble = true
case openCh:
depth++
case closeCh:
depth--
if depth == 0 {
return i, s[open+1 : i], true
}
}
}
return 0, "", false
}
// safeAlwaysPrefix строит безопасный префикс "always" для команды:
// "<команда>[ <подкоманда>] *". Подкоманда учитывается только для ПРОСТЫХ
// команд (без кавычек/подстановок/редиректов/разделителей) и если второй
// токен не флаг и не путь. Так одобрение `git status` запоминается как
// `git status *` (будущие status-вызовы без вопроса), а `cat /etc/hosts`
// и `echo $(rm -rf /)` - как `cat *` / `echo *` (без подкоманды: это
// аргументы/подстановки, а не подкоманды). Консервативно: при
// сомнении оставляем только имя команды.
func safeAlwaysPrefix(command string) string {
tokens := strings.Fields(command)
if len(tokens) == 0 {
return "*"
}
prefix := []string{tokens[0]}
if len(tokens) > 1 && isSimpleCommand(command) {
t := tokens[1]
if !strings.HasPrefix(t, "-") && !strings.Contains(t, "/") {
prefix = append(prefix, t)
}
}
return strings.Join(prefix, " ") + " *"
}
// isSimpleCommand сообщает, является ли сегмент простой командой (без
// кавычек, подстановок, редиректов и разделителей). Для таких команд
// безопасный префикс может включать подкоманду; для сложных - нет.
func isSimpleCommand(s string) bool {
return !strings.ContainsAny(s, "`'\"$(;|<>") && !strings.Contains(s, "&")
}
+105
View File
@@ -0,0 +1,105 @@
package tools
import (
"reflect"
"testing"
)
func TestShellSplitPatterns(t *testing.T) {
tests := []struct {
name string
command string
patterns []string
always []string
ok bool // false = ожидаем nil (непарсится -> безопасный отказ)
}{
{
name: "single command",
command: "cat /etc/hosts",
patterns: []string{"cat /etc/hosts"},
always: []string{"cat *"},
ok: true,
},
{
name: "chain with &&",
command: "git status && git log",
patterns: []string{"git status", "git log"},
always: []string{"git status *", "git log *"},
ok: true,
},
{
name: "chain with semicolon hides destructive",
command: "cat /etc/hosts; rm -rf /",
patterns: []string{"cat /etc/hosts", "rm -rf /"},
always: []string{"cat *", "rm *"},
ok: true,
},
{
name: "pipe",
command: "ps aux | grep sshd",
patterns: []string{"ps aux", "grep sshd"},
always: []string{"ps aux *", "grep sshd *"},
ok: true,
},
{
name: "command substitution",
command: "echo $(rm -rf /tmp)",
patterns: []string{"echo $(rm -rf /tmp)", "rm -rf /tmp"},
always: []string{"echo *", "rm *"},
ok: true,
},
{
name: "sh -c hides destructive",
command: "sh -c 'rm -rf /'",
patterns: []string{"sh -c 'rm -rf /'", "rm -rf /"},
always: []string{"sh *", "rm *"},
ok: true,
},
{
name: "quoted semicolon not split",
command: "echo 'a;b'",
patterns: []string{"echo 'a;b'"},
always: []string{"echo *"},
ok: true,
},
{
name: "redirection",
command: "echo hi > /etc/foo",
patterns: []string{"echo hi > /etc/foo"},
always: []string{"echo *"},
ok: true,
},
{
name: "unbalanced quote -> nil",
command: "echo 'unterminated",
patterns: nil,
always: nil,
ok: false,
},
{
name: "empty",
command: "",
patterns: nil,
always: nil,
ok: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p, a := shellSplitPatterns(tt.command)
if !tt.ok {
if p != nil || a != nil {
t.Fatalf("expected nil patterns/always, got %v / %v", p, a)
}
return
}
if !reflect.DeepEqual(p, tt.patterns) {
t.Fatalf("patterns = %v, want %v", p, tt.patterns)
}
if !reflect.DeepEqual(a, tt.always) {
t.Fatalf("always = %v, want %v", a, tt.always)
}
})
}
}
+88
View File
@@ -0,0 +1,88 @@
package tools
import (
"git.totmin.ru/en2zmax/forge-toolkit"
"git.totmin.ru/en2zmax/forge-toolkit/validate"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// toolkit.go — тонкие адаптеры к общему слою forge-tools/toolkit.
// Локальные имена сохранены, чтобы хендлеры не зависели от пакета-источника.
func requestArgs(req *mcp.CallToolRequest) map[string]any {
return toolkit.RequestArgs(req)
}
func getString(args map[string]any, key, def string) string {
return toolkit.GetString(args, key, def)
}
func requireString(args map[string]any, key string) (string, error) {
return toolkit.RequireString(args, key)
}
func getInt(args map[string]any, key string, def int) int {
return toolkit.GetInt(args, key, def)
}
func getBool(args map[string]any, key string, def bool) bool {
return toolkit.GetBool(args, key, def)
}
func getStringArray(args map[string]any, key string) ([]string, bool) {
return toolkit.GetStringArray(args, key)
}
func getIntArray(args map[string]any, key string) ([]int, bool) {
return toolkit.GetIntArray(args, key)
}
func schema(properties map[string]any, required []string) map[string]any {
return toolkit.Schema(properties, required)
}
func strProps(desc string, required bool, enum ...string) map[string]any {
return toolkit.StrProps(desc, required, enum...)
}
func intProps(desc string, required bool) map[string]any {
return toolkit.IntProps(desc, required)
}
func boolProps(desc string, required bool) map[string]any {
return toolkit.BoolProps(desc, required)
}
func arrayStringProps(desc string, required bool) map[string]any {
return toolkit.ArrayStringProps(desc, required)
}
func arrayNumberProps(desc string, required bool) map[string]any {
return toolkit.ArrayNumberProps(desc, required)
}
func textResult(text string) *mcp.CallToolResult {
return toolkit.Text(text)
}
func errorResult(msg string) *mcp.CallToolResult {
return toolkit.Error(msg)
}
// patternsFn — forge-probe эмиттер паттернов (см. toolkit.PatternsFn).
type patternsFn = toolkit.PatternsFn
func registerPatternTool(s *mcp.Server, tool *mcp.Tool, fn patternsFn, h mcp.ToolHandler) {
toolkit.RegisterPatternTool(s, tool, fn, h)
}
// ValidationResult — результат серверной валидации синтаксиса.
type ValidationResult = validate.Result
func ValidateContent(content, fileType string) *ValidationResult {
return validate.Content(content, fileType)
}
func detectFileType(path string) string {
return validate.DetectFileType(path)
}
+136
View File
@@ -0,0 +1,136 @@
package tools
import (
"fmt"
"strings"
)
// shellQuote quotes a string for safe shell use.
func shellQuote(s string) string {
if s == "" {
return "''"
}
// Simple single-quote escaping
escaped := strings.ReplaceAll(s, "'", "'\"'\"'")
return "'" + escaped + "'"
}
// containsString checks if s contains substr.
func containsString(s, substr string) bool {
return strings.Contains(s, substr)
}
// trimOutput trims whitespace from output.
func trimOutput(s string) string {
return strings.TrimSpace(s)
}
// sedEscapeLiteral escapes a literal string for use in a sed s/pattern/ context.
// Escapes: / \ & . * [ ] ^ $ and newlines.
func sedEscapeLiteral(s string) string {
replacer := strings.NewReplacer(
`\`, `\\`,
`/`, `\/`,
`&`, `\&`,
`.`, `\.`,
`*`, `\*`,
`[`, `\[`,
`]`, `\]`,
`^`, `\^`,
`$`, `\$`,
"\n", `\n`,
)
return replacer.Replace(s)
}
// sedEscapePattern escapes a regex pattern for use in sed, only escaping the delimiter.
// The pattern is passed as-is for regex matching, only / and newlines are escaped.
func sedEscapePattern(s string) string {
replacer := strings.NewReplacer(
`/`, `\/`,
"\n", `\n`,
)
return replacer.Replace(s)
}
// sedEscapeReplacement escapes a replacement string for sed s//replacement/ context.
// Only escapes: / \ & and newlines (these have special meaning in sed replacements).
func sedEscapeReplacement(s string) string {
replacer := strings.NewReplacer(
`\`, `\\`,
`/`, `\/`,
`&`, `\&`,
"\n", `\n`,
)
return replacer.Replace(s)
}
// sedEscapeInsertText escapes text for sed i\ or a\ commands.
// Newlines need to be escaped with backslash continuation for multi-line inserts.
func sedEscapeInsertText(s string) string {
return strings.ReplaceAll(s, "\n", `\n`)
}
// sedInPlace builds a portable sed in-place edit command that works on
// GNU sed (Linux), BSD sed (macOS/FreeBSD), and BusyBox sed (Alpine).
// Uses sed -i.bak + rm for universal portability.
//
// Parameters:
// - flags: extra sed flags like "-E", or "" for none
// - expr: the sed expression WITHOUT single-quote wrapping (e.g.,
// "s/foo/bar/g"). sedInPlace shell-quotes it itself (via shellQuote),
// so apostrophes in user content can't break the shell quoting.
// - path: the raw file path (will be shell-quoted internally)
func sedInPlace(flags, expr, path string) string {
quotedExpr := shellQuote(expr)
quotedPath := shellQuote(path)
quotedBak := shellQuote(path + ".bak")
if flags != "" {
flags = " " + flags
}
return fmt.Sprintf("sed -i.bak%s %s %s 2>&1 && rm -f %s",
flags, quotedExpr, quotedPath, quotedBak)
}
// sanitizeTsharkValue removes characters that could break tshark display filters.
// Allows alphanumeric, dash, dot, @, underscore, plus, colon, and space.
func sanitizeTsharkValue(s string) string {
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') ||
r == '-' || r == '.' || r == '@' || r == '_' || r == '+' || r == ':' || r == ' ' {
b.WriteRune(r)
}
}
return b.String()
}
// sanitizeAlphanumeric validates that a string contains only safe characters.
// Allows alphanumeric, dash, dot, and underscore. Used for network interface names,
// grep keywords, and other values embedded inside sh -c strings.
func sanitizeAlphanumeric(s string) string {
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') ||
r == '-' || r == '.' || r == '_' {
b.WriteRune(r)
}
}
return b.String()
}
// sanitizeShellInnerPath validates a file path used inside sh -c '...' strings.
// Rejects characters that could break out of single-quoted shell context or
// enable command injection. Returns error for unsafe paths.
func sanitizeShellInnerPath(s string) (string, error) {
if s == "" {
return "", fmt.Errorf("path cannot be empty")
}
for _, r := range s {
if r == '\'' || r == '`' || r == ';' || r == '&' || r == '|' ||
r == '$' || r == '!' || r == '\n' || r == '\r' || r < 32 {
return "", fmt.Errorf("invalid characters in path")
}
}
return s, nil
}
+46
View File
@@ -0,0 +1,46 @@
package tools
import (
"os"
"os/exec"
"strings"
"testing"
)
// TestShellQuote covers the standard case and apostrophes: a value
// containing ' must still be safely shell-quoted (the '\” trick).
func TestShellQuote(t *testing.T) {
for _, in := range []string{"", "hello", "don't", "it's a 'test'", "a'b'c"} {
got := shellQuote(in)
if !strings.HasPrefix(got, "'") || !strings.HasSuffix(got, "'") {
t.Errorf("shellQuote(%q) = %q: not single-quoted", in, got)
}
}
}
// TestSedInPlaceEscapesApostrophe ensures that a replace whose text
// contains an apostrophe doesn't break the shell quoting. This runs the
// generated command for real against a temp file.
func TestSedInPlaceEscapesApostrophe(t *testing.T) {
dir := t.TempDir()
path := dir + "/app.conf"
if err := os.WriteFile(path, []byte("name: don't panic\n"), 0o644); err != nil {
t.Fatal(err)
}
expr := "s/don't/can't/"
cmd := sedInPlace("", expr, path)
out, err := exec.Command("sh", "-c", cmd).CombinedOutput()
if err != nil {
t.Fatalf("sed command failed: %v\n%s\ncmd=%s", err, out, cmd)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), "can't panic") {
t.Errorf("expected replacement with apostrophe, got %q", content)
}
}
+609
View File
@@ -0,0 +1,609 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// VoIP constants
const (
SIPUDPPort = 5060
SIPTCPPort = 5060
SIPTLSPort = 5061
RTPPortRange = "50000-60000"
DefaultPCAPLimit = 5 * 1024 * 1024
)
func registerVoIPTools(s *mcp.Server) {
// voip_discover_containers
s.AddTool(&mcp.Tool{
Name: "voip_discover_containers",
Description: "Find VoIP-related containers by name/image keywords",
InputSchema: schema(map[string]any{
"keywords": arrayStringProps("Keywords to match (default: gw, media, fs, sbc, sw)", false),
"target": strProps("Connection alias (default: primary)", false),
}, nil),
}, voipDiscoverHandler)
// voip_sip_capture
s.AddTool(&mcp.Tool{
Name: "voip_sip_capture",
Description: "Capture SIP signaling to PCAP using sngrep inside container",
InputSchema: schema(map[string]any{
"container": strProps("Container name", true),
"duration": intProps("Capture duration in seconds (default: 30)", false),
"port": intProps("SIP port to filter (default: 5060)", false),
"protocol": strProps("Protocol filter (default: all)", false, "udp", "tcp", "tls"),
"target": strProps("Connection alias (default: primary)", false),
}, []string{"container"}),
}, sipCaptureHandler)
// voip_call_flow
s.AddTool(&mcp.Tool{
Name: "voip_call_flow",
Description: "Parse SIP call flow from a PCAP file",
InputSchema: schema(map[string]any{
"container": strProps("Container name", true),
"pcap_file": strProps("Path to PCAP file in container", true),
"call_id": strProps("Filter by Call-ID", false),
"phone_number": strProps("Filter by phone number", false),
"summary_only": boolProps("Return summary only, no message details", false),
"target": strProps("Connection alias (default: primary)", false),
}, []string{"container", "pcap_file"}),
}, callFlowHandler)
// voip_registrations
s.AddTool(&mcp.Tool{
Name: "voip_registrations",
Description: "Extract REGISTER dialogs and outcomes from SIP PCAP",
InputSchema: schema(map[string]any{
"container": strProps("Container name", true),
"pcap_file": strProps("Path to PCAP file", true),
"target": strProps("Connection alias (default: primary)", false),
}, []string{"container", "pcap_file"}),
}, registrationsHandler)
// voip_call_stats
s.AddTool(&mcp.Tool{
Name: "voip_call_stats",
Description: "Aggregate SIP call statistics from PCAP",
InputSchema: schema(map[string]any{
"container": strProps("Container name", true),
"pcap_file": strProps("Path to PCAP file", true),
"target": strProps("Connection alias (default: primary)", false),
}, []string{"container", "pcap_file"}),
}, callStatsHandler)
// voip_extract_sdp
s.AddTool(&mcp.Tool{
Name: "voip_extract_sdp",
Description: "Extract SDP (codecs, RTP ports) from SIP messages",
InputSchema: schema(map[string]any{
"container": strProps("Container name", true),
"pcap_file": strProps("Path to PCAP file", true),
"call_id": strProps("Filter by specific Call-ID", false),
"target": strProps("Connection alias (default: primary)", false),
}, []string{"container", "pcap_file"}),
}, extractSDPHandler)
// voip_packet_check
s.AddTool(&mcp.Tool{
Name: "voip_packet_check",
Description: "Quick SIP packet presence check on standard ports",
InputSchema: schema(map[string]any{
"container": strProps("Container name", true),
"duration": intProps("Check duration in seconds (default: 5)", false),
"interface": strProps("Network interface (default: any)", false),
"target": strProps("Connection alias (default: primary)", false),
}, []string{"container"}),
}, packetCheckHandler)
// voip_network_capture
s.AddTool(&mcp.Tool{
Name: "voip_network_capture",
Description: "Capture SIP packets with tcpdump for analysis",
InputSchema: schema(map[string]any{
"container": strProps("Container name", true),
"duration": intProps("Capture duration in seconds (default: 30)", false),
"interface": strProps("Network interface (default: any)", false),
"target": strProps("Connection alias (default: primary)", false),
}, []string{"container"}),
}, networkCaptureHandler)
// voip_rtp_capture
s.AddTool(&mcp.Tool{
Name: "voip_rtp_capture",
Description: "Capture RTP packets to verify media flow",
InputSchema: schema(map[string]any{
"container": strProps("Container name", true),
"duration": intProps("Capture duration in seconds (default: 10)", false),
"port_range": strProps("RTP port range (default: 50000-60000)", false),
"interface": strProps("Network interface (default: any)", false),
"target": strProps("Connection alias (default: primary)", false),
}, []string{"container"}),
}, rtpCaptureHandler)
// voip_network_diagnostics
s.AddTool(&mcp.Tool{
Name: "voip_network_diagnostics",
Description: "Run network diagnostics: ping, traceroute, TCP port checks",
InputSchema: schema(map[string]any{
"host": strProps("Target host for diagnostics", true),
"ports": arrayNumberProps("TCP ports to check (default: 5060, 5061)", false),
"ping_count": intProps("Number of pings (default: 3)", false),
"traceroute": boolProps("Include traceroute (default: true)", false),
"timeout": intProps("Timeout in seconds (default: 15)", false),
"target": strProps("Connection alias (default: primary)", false),
}, []string{"host"}),
}, networkDiagnosticsHandler)
}
func voipDiscoverHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
args := requestArgs(req)
target := getString(args, "target", "primary")
if err := checkDockerAvailable(ctx, m, target); err != nil {
return errorResult(err.Error()), nil
}
// Default VoIP keywords
defaultKeywords := []string{"gw", "media", "fs", "sbc", "sw", "freeswitch", "asterisk", "kamailio", "opensips", "rtpengine"}
keywords := defaultKeywords
if kws, ok := getStringArray(args, "keywords"); ok {
var sanitized []string
for _, kw := range kws {
if safe := sanitizeAlphanumeric(kw); safe != "" {
sanitized = append(sanitized, safe)
}
}
if len(sanitized) > 0 {
keywords = sanitized
}
}
pattern := strings.Join(keywords, "|")
cmd := fmt.Sprintf(`docker ps --format '{{.Names}}|{{.Image}}' | grep -iE %s 2>/dev/null || echo ''`, shellQuote(pattern))
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
if trimOutput(output) == "" {
return textResult("No VoIP containers found"), nil
}
var containers []map[string]string
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.SplitN(line, "|", 2)
if len(parts) == 2 {
containers = append(containers, map[string]string{
"name": parts[0],
"image": parts[1],
})
}
}
jsonBytes, _ := json.MarshalIndent(containers, "", " ")
return textResult(string(jsonBytes)), nil
}
func sipCaptureHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
args := requestArgs(req)
container, _ := requireString(args, "container")
duration := getInt(args, "duration", 30)
port := getInt(args, "port", 0)
protocol := getString(args, "protocol", "")
target := getString(args, "target", "primary")
if err := checkDockerAvailable(ctx, m, target); err != nil {
return errorResult(err.Error()), nil
}
checkCmd := fmt.Sprintf("docker exec %s command -v sngrep >/dev/null 2>&1 && echo 'ok' || echo 'missing'", shellQuote(container))
checkOutput, err := m.Execute(ctx, checkCmd, target)
if err != nil || !containsString(checkOutput, "ok") {
return errorResult("sngrep not available in container. Install with: apt-get install sngrep"), nil
}
bpfFilter := buildSIPFilter(port, protocol)
pcapPath := fmt.Sprintf("/tmp/voip_sip_%d.pcap", time.Now().Unix())
cmd := fmt.Sprintf("docker exec %s timeout %ds sngrep -N -q -d any -O %s '%s' 2>&1 || true",
shellQuote(container), duration, shellQuote(pcapPath), bpfFilter)
m.Execute(ctx, cmd, target)
checkFile := fmt.Sprintf("docker exec %s test -f %s && echo 'exists' || echo 'missing'", shellQuote(container), shellQuote(pcapPath))
checkResult, _ := m.Execute(ctx, checkFile, target)
fileStatus := "created"
if !containsString(checkResult, "exists") {
fileStatus = "not created (capture may have failed)"
}
result := map[string]any{
"container": container,
"pcap_file": pcapPath,
"duration": duration,
"filter": bpfFilter,
"file_status": fileStatus,
"message": fmt.Sprintf("SIP capture completed. Use voip_call_flow to analyze %s", pcapPath),
}
jsonBytes, _ := json.MarshalIndent(result, "", " ")
return textResult(string(jsonBytes)), nil
}
func callFlowHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
args := requestArgs(req)
container, _ := requireString(args, "container")
rawPcapFile, _ := requireString(args, "pcap_file")
callID := getString(args, "call_id", "")
phoneNumber := getString(args, "phone_number", "")
summaryOnly := getBool(args, "summary_only", false)
target := getString(args, "target", "primary")
pcapFile, err := sanitizeShellInnerPath(rawPcapFile)
if err != nil {
return errorResult("invalid pcap_file path"), nil
}
if err := checkDockerAvailable(ctx, m, target); err != nil {
return errorResult(err.Error()), nil
}
var filter string
if callID != "" {
filter = fmt.Sprintf("-Y 'sip.Call-ID == \"%s\"'", sanitizeTsharkValue(callID))
} else if phoneNumber != "" {
filter = fmt.Sprintf("-Y 'sip contains \"%s\"'", sanitizeTsharkValue(phoneNumber))
}
quotedPcap := shellQuote(pcapFile)
var cmd string
if summaryOnly {
cmd = fmt.Sprintf(`docker exec %s sh -c 'if command -v tshark >/dev/null 2>&1; then tshark -r %s -T fields -e frame.time -e ip.src -e ip.dst -e sip.Method -e sip.Status-Code -e sip.Call-ID %s 2>/dev/null | head -100; else sngrep -I %s -q 2>/dev/null | head -50 || echo "No analysis tool available"; fi'`,
shellQuote(container), quotedPcap, filter, quotedPcap)
} else {
cmd = fmt.Sprintf(`docker exec %s sh -c 'if command -v tshark >/dev/null 2>&1; then tshark -r %s -V -Y sip %s 2>/dev/null | head -500; else cat %s 2>/dev/null | strings | grep -E "^(INVITE|REGISTER|BYE|ACK|CANCEL|SIP/2.0)" | head -100 || echo "No analysis tool available"; fi'`,
shellQuote(container), quotedPcap, filter, quotedPcap)
}
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(output), nil
}
func registrationsHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
args := requestArgs(req)
container, _ := requireString(args, "container")
rawPcapFile, _ := requireString(args, "pcap_file")
target := getString(args, "target", "primary")
pcapFile, err := sanitizeShellInnerPath(rawPcapFile)
if err != nil {
return errorResult("invalid pcap_file path"), nil
}
if err := checkDockerAvailable(ctx, m, target); err != nil {
return errorResult(err.Error()), nil
}
quotedPcap := shellQuote(pcapFile)
cmd := fmt.Sprintf(`docker exec %s sh -c 'if command -v tshark >/dev/null 2>&1; then tshark -r %s -Y "sip.Method == REGISTER or (sip.CSeq.method == REGISTER and sip.Status-Code)" -T fields -e frame.time -e sip.from.user -e sip.to.user -e sip.contact.uri -e sip.Status-Code -E header=y 2>/dev/null; else cat %s 2>/dev/null | strings | grep -E "(REGISTER|200 OK|401|403)" | head -50; fi'`,
shellQuote(container), quotedPcap, quotedPcap)
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(output), nil
}
func callStatsHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
args := requestArgs(req)
container, _ := requireString(args, "container")
rawPcapFile, _ := requireString(args, "pcap_file")
target := getString(args, "target", "primary")
pcapFile, err := sanitizeShellInnerPath(rawPcapFile)
if err != nil {
return errorResult("invalid pcap_file path"), nil
}
if err := checkDockerAvailable(ctx, m, target); err != nil {
return errorResult(err.Error()), nil
}
quotedPcap := shellQuote(pcapFile)
cmd := fmt.Sprintf(`docker exec %s sh -c '
if command -v tshark >/dev/null 2>&1; then
echo "=== SIP STATISTICS ==="
echo ""
echo "--- Request Methods ---"
tshark -r %s -Y sip.Method -T fields -e sip.Method 2>/dev/null | sort | uniq -c | sort -rn
echo ""
echo "--- Response Codes ---"
tshark -r %s -Y sip.Status-Code -T fields -e sip.Status-Code 2>/dev/null | sort | uniq -c | sort -rn
echo ""
echo "--- Unique Call-IDs ---"
tshark -r %s -Y sip -T fields -e sip.Call-ID 2>/dev/null | sort -u | wc -l | xargs echo "Total calls:"
else
cat %s 2>/dev/null | strings | grep -oE "^(INVITE|REGISTER|BYE|ACK|CANCEL|OPTIONS|SIP/2.0 [0-9]+)" | sort | uniq -c | sort -rn
fi'`, shellQuote(container), quotedPcap, quotedPcap, quotedPcap, quotedPcap)
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(output), nil
}
func extractSDPHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
args := requestArgs(req)
container, _ := requireString(args, "container")
rawPcapFile, _ := requireString(args, "pcap_file")
callID := getString(args, "call_id", "")
target := getString(args, "target", "primary")
pcapFile, err := sanitizeShellInnerPath(rawPcapFile)
if err != nil {
return errorResult("invalid pcap_file path"), nil
}
if err := checkDockerAvailable(ctx, m, target); err != nil {
return errorResult(err.Error()), nil
}
var filter string
if callID != "" {
filter = fmt.Sprintf("-Y 'sip.Call-ID == \"%s\" and sdp'", sanitizeTsharkValue(callID))
} else {
filter = "-Y 'sdp'"
}
quotedPcap := shellQuote(pcapFile)
cmd := fmt.Sprintf(`docker exec %s sh -c 'if command -v tshark >/dev/null 2>&1; then tshark -r %s %s -T fields -e sdp.connection_info -e sdp.media -e sdp.media.port -e sdp.media.format -E header=y 2>/dev/null | head -50; else cat %s 2>/dev/null | strings | grep -E "^(c=|m=|a=rtpmap)" | head -50; fi'`,
shellQuote(container), quotedPcap, filter, quotedPcap)
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(output), nil
}
func packetCheckHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
args := requestArgs(req)
container, _ := requireString(args, "container")
duration := getInt(args, "duration", 5)
iface := sanitizeAlphanumeric(getString(args, "interface", "any"))
target := getString(args, "target", "primary")
if iface == "" {
iface = "any"
}
if duration < 1 || duration > 300 {
duration = 5
}
if err := checkDockerAvailable(ctx, m, target); err != nil {
return errorResult(err.Error()), nil
}
cmd := fmt.Sprintf(`docker exec %s sh -c 'if command -v tcpdump >/dev/null 2>&1; then timeout %ds tcpdump -i %s -c 20 port 5060 or port 5061 2>&1 | tail -25; else echo "tcpdump not available"; fi'`,
shellQuote(container), duration, shellQuote(iface))
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
hasPackets := containsString(output, "UDP") || containsString(output, "TCP") || containsString(output, "SIP")
summary := "SIP packets detected: NO"
if hasPackets {
summary = "SIP packets detected: YES"
}
return textResult(fmt.Sprintf("%s\n\n%s", summary, output)), nil
}
func networkCaptureHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
args := requestArgs(req)
container, _ := requireString(args, "container")
duration := getInt(args, "duration", 30)
iface := sanitizeAlphanumeric(getString(args, "interface", "any"))
target := getString(args, "target", "primary")
if iface == "" {
iface = "any"
}
if duration < 1 || duration > 300 {
duration = 30
}
if err := checkDockerAvailable(ctx, m, target); err != nil {
return errorResult(err.Error()), nil
}
pcapPath := fmt.Sprintf("/tmp/voip_net_%d.pcap", time.Now().Unix())
cmd := fmt.Sprintf(`docker exec %s sh -c 'if command -v tcpdump >/dev/null 2>&1; then timeout %ds tcpdump -i %s -w %s port 5060 or port 5061 2>&1 || true; else echo "tcpdump not available"; fi'`,
shellQuote(container), duration, shellQuote(iface), shellQuote(pcapPath))
m.Execute(ctx, cmd, target)
checkFile := fmt.Sprintf("docker exec %s test -f %s && echo 'exists' || echo 'missing'", shellQuote(container), shellQuote(pcapPath))
checkResult, _ := m.Execute(ctx, checkFile, target)
fileStatus := "created"
if !containsString(checkResult, "exists") {
fileStatus = "not created (capture may have failed)"
}
result := map[string]any{
"container": container,
"pcap_file": pcapPath,
"duration": duration,
"interface": iface,
"file_status": fileStatus,
"message": "Network capture complete. Analyze with voip_call_flow or copy with docker_cp_from",
}
jsonBytes, _ := json.MarshalIndent(result, "", " ")
return textResult(string(jsonBytes)), nil
}
func rtpCaptureHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
args := requestArgs(req)
container, _ := requireString(args, "container")
duration := getInt(args, "duration", 10)
portRange := getString(args, "port_range", RTPPortRange)
iface := sanitizeAlphanumeric(getString(args, "interface", "any"))
target := getString(args, "target", "primary")
if iface == "" {
iface = "any"
}
if duration < 1 || duration > 300 {
duration = 10
}
if err := checkDockerAvailable(ctx, m, target); err != nil {
return errorResult(err.Error()), nil
}
startPort := 50000
endPort := 60000
ports := strings.Split(portRange, "-")
if len(ports) == 2 {
if sp, err := strconv.Atoi(strings.TrimSpace(ports[0])); err == nil && sp > 0 && sp <= 65535 {
startPort = sp
}
if ep, err := strconv.Atoi(strings.TrimSpace(ports[1])); err == nil && ep > 0 && ep <= 65535 {
endPort = ep
}
}
cmd := fmt.Sprintf(`docker exec %s sh -c 'if command -v tcpdump >/dev/null 2>&1; then timeout %ds tcpdump -i %s -c 100 "udp portrange %d-%d" 2>&1 | tail -20; else echo "tcpdump not available"; fi'`,
shellQuote(container), duration, shellQuote(iface), startPort, endPort)
output, err := m.Execute(ctx, cmd, target)
if err != nil {
return errorResult(err.Error()), nil
}
hasRTP := containsString(output, "UDP") && containsString(output, "packets")
summary := "RTP packets detected: NO"
if hasRTP {
summary = "RTP packets detected: YES"
}
return textResult(fmt.Sprintf("%s\nPort range: %s\n\n%s", summary, portRange, output)), nil
}
func networkDiagnosticsHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
m := manager()
if m == nil {
return errorResult("no SSH manager initialized"), nil
}
args := requestArgs(req)
host, _ := requireString(args, "host")
pingCount := getInt(args, "ping_count", 3)
doTraceroute := getBool(args, "traceroute", true)
timeout := getInt(args, "timeout", 15)
target := getString(args, "target", "primary")
ports := []int{5060, 5061}
if ps, ok := getIntArray(args, "ports"); ok {
ports = ps
}
var sb strings.Builder
fmt.Fprintf(&sb, "=== NETWORK DIAGNOSTICS: %s ===\n\n", host)
sb.WriteString("--- PING ---\n")
pingCmd := fmt.Sprintf("ping -c %d -W 3 %s 2>&1 || echo 'Ping failed'", pingCount, shellQuote(host))
pingOutput, _ := m.Execute(ctx, pingCmd, target)
sb.WriteString(pingOutput)
sb.WriteString("\n\n")
if doTraceroute {
sb.WriteString("--- TRACEROUTE ---\n")
traceCmd := fmt.Sprintf("timeout %ds traceroute -m 15 %s 2>&1 || tracepath %s 2>&1 || echo 'Traceroute not available'",
timeout, shellQuote(host), shellQuote(host))
traceOutput, _ := m.Execute(ctx, traceCmd, target)
sb.WriteString(traceOutput)
sb.WriteString("\n\n")
}
sb.WriteString("--- TCP PORT CHECKS ---\n")
for _, port := range ports {
checkCmd := fmt.Sprintf("timeout 3 bash -c 'echo >/dev/tcp/%s/%d' 2>&1 && echo 'Port %d: OPEN' || echo 'Port %d: CLOSED/FILTERED'",
shellQuote(host), port, port, port)
checkOutput, _ := m.Execute(ctx, checkCmd, target)
fmt.Fprintf(&sb, "%s\n", trimOutput(checkOutput))
}
return textResult(sb.String()), nil
}
// buildSIPFilter создаёт BPF-фильтр для SIP-трафика.
func buildSIPFilter(port int, protocol string) string {
if protocol != "" {
proto := strings.ToLower(protocol)
switch proto {
case "tls":
if port == 0 {
port = SIPTLSPort
}
return fmt.Sprintf("tcp port %d", port)
case "tcp":
if port == 0 {
port = SIPTCPPort
}
return fmt.Sprintf("tcp port %d", port)
case "udp":
if port == 0 {
port = SIPUDPPort
}
return fmt.Sprintf("udp port %d", port)
}
}
if port != 0 {
return fmt.Sprintf("udp port %d or tcp port %d", port, port)
}
return "udp port 5060 or tcp port 5060 or tcp port 5061"
}