terminal_server.py 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  1. import os
  2. import subprocess
  3. from pathlib import Path
  4. from mcp.server.fastmcp import FastMCP
  5. mcp = FastMCP("terminal", host="0.0.0.0", port=8011)
  6. # Allowed directories for file operations
  7. ALLOWED_DIRS = ["/projects", "/data", "/scripts", "/backups", "/tmp"]
  8. ALLOWED_PREFIXES = [
  9. "ls", "cat", "head", "tail", "wc", "find", "grep", "rg", "which",
  10. "pwd", "whoami", "id", "date", "uname", "df", "du", "ps",
  11. "free", "uptime", "env", "echo", "sort", "uniq", "cut", "tr",
  12. "diff", "cmp", "stat", "file", "tree",
  13. "mkdir", "touch", "npm", "npx", "node", "python3", "ng",
  14. "docker ps", "docker images", "docker compose ps",
  15. "docker inspect", "docker logs",
  16. "curl", "ping", "traceroute", "nslookup", "dig", "host",
  17. ]
  18. BLOCKED = [
  19. "rm ", "dd ", "mkfs.", "shutdown", "reboot", "halt",
  20. "sudo", "su", "chmod", "chown", "passwd", "kill ",
  21. "|", "&&", "||", "`", "$(",
  22. "docker rm", "docker rmi", "docker stop", "docker kill",
  23. "docker compose down", "docker compose rm",
  24. ]
  25. def _is_path_allowed(path: str) -> bool:
  26. resolved = Path(path).resolve()
  27. for allowed in ALLOWED_DIRS:
  28. allowed_path = Path(allowed).resolve()
  29. if allowed_path in resolved.parents or allowed_path == resolved:
  30. return True
  31. return False
  32. @mcp.tool()
  33. def run_command(command: str, timeout: int = 60) -> str:
  34. """Execute a shell command.
  35. Allowed: development commands (npm, npx, node, python3, ng, mkdir, touch),
  36. read-only commands (ls, cat, head, tail, grep, find, etc.), and docker inspect.
  37. File redirect '>' is allowed only when writing to /projects/ paths.
  38. """
  39. if not command or len(command) > 4000:
  40. return "Error: command is empty or too long"
  41. lowered = command.strip().lower()
  42. # Check for blocked patterns first
  43. for b in BLOCKED:
  44. if b in lowered:
  45. return f"Error: blocked pattern: {b}"
  46. # For redirects, check allowed path
  47. if ">" in lowered or ">>" in lowered:
  48. # Extract the redirect target path (last argument after >)
  49. parts = command.split()
  50. for i, p in enumerate(parts):
  51. if p in (">", ">>"):
  52. target = parts[i + 1] if i + 1 < len(parts) else ""
  53. if target and not _is_path_allowed(target):
  54. return f"Error: redirect target not in allowed directories: {target}"
  55. # Allow redirect commands without checking prefix
  56. else:
  57. ok = any(lowered.startswith(p) for p in ALLOWED_PREFIXES)
  58. if not ok:
  59. return "Error: command not in allowed list"
  60. try:
  61. r = subprocess.run(
  62. ["sh", "-c", command],
  63. capture_output=True, text=True, timeout=min(timeout, 120),
  64. )
  65. out = r.stdout or ""
  66. if r.stderr:
  67. out += "\n--- stderr ---\n" + r.stderr
  68. if r.returncode != 0:
  69. out += f"\n(exit code: {r.returncode})"
  70. # Truncate output
  71. if len(out) > 50000:
  72. out = out[:50000] + "\n... (output truncated)"
  73. return out or "(no output)"
  74. except subprocess.TimeoutExpired:
  75. return f"Error: timed out after {timeout}s"
  76. except Exception as e:
  77. return f"Error: {e}"
  78. @mcp.tool()
  79. def write_file(path: str, content: str) -> str:
  80. """Write or overwrite a file with the given content. Creates parent directories.
  81. Only allowed within: /projects, /data, /scripts, /backups, /tmp
  82. """
  83. if not _is_path_allowed(path):
  84. return f"Error: path not in allowed directories: {', '.join(ALLOWED_DIRS)}"
  85. try:
  86. p = Path(path).resolve()
  87. p.parent.mkdir(parents=True, exist_ok=True)
  88. p.write_text(content)
  89. return f"OK - wrote {len(content)} bytes to {path}"
  90. except Exception as e:
  91. return f"Error writing file: {e}"
  92. @mcp.tool()
  93. def create_directory(path: str) -> str:
  94. """Create a directory and all parent directories if they do not exist.
  95. Only allowed within: /projects, /data, /scripts, /backups, /tmp
  96. """
  97. if not _is_path_allowed(path):
  98. return f"Error: path not in allowed directories: {', '.join(ALLOWED_DIRS)}"
  99. try:
  100. p = Path(path).resolve()
  101. p.mkdir(parents=True, exist_ok=True)
  102. return f"OK - created directory {path}"
  103. except Exception as e:
  104. return f"Error creating directory: {e}"
  105. if __name__ == "__main__":
  106. mcp.run(transport="stdio")