Initial commit: Agentic OS troubleshooting platform
Self-hosted, Docker-based agentic troubleshooting platform: FastAPI backend + LangGraph agent, Next.js UI, tiered LLM routing (local Ollama -> Gemini -> DeepSeek -> OpenRouter), MCP server manager, encrypted device credentials, RBAC, audit log, project-memory + Obsidian integrations, and editable troubleshooting decision rules tuned for the GeneseasX vessel stack. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
12
mcp-servers/fortigate-mcp/pyproject.toml
Normal file
12
mcp-servers/fortigate-mcp/pyproject.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[project]
|
||||
name = "fortigate-mcp"
|
||||
version = "0.1.0"
|
||||
description = "FortiGate (FortiOS REST API) MCP server"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"mcp>=1.2.0",
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
108
mcp-servers/fortigate-mcp/server.py
Normal file
108
mcp-servers/fortigate-mcp/server.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""FortiGate MCP server (FortiOS REST API).
|
||||
|
||||
Read-only diagnostics by default. Token (API key) auth. Self-signed certs are
|
||||
accepted (typical for standalone FortiGates). Writes require FORTIGATE_ALLOW_WRITES=true.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("fortigate")
|
||||
|
||||
ALLOW_WRITES = os.environ.get("FORTIGATE_ALLOW_WRITES", "false").lower() == "true"
|
||||
|
||||
_active: dict | None = None # {"host","api_key","port"}
|
||||
|
||||
|
||||
def _resolve(host, api_key, port) -> dict:
|
||||
if host:
|
||||
return {"host": host, "api_key": api_key, "port": port or 443}
|
||||
if _active:
|
||||
return _active
|
||||
raise RuntimeError("No FortiGate target. Call fortigate_connect first.")
|
||||
|
||||
|
||||
def _base(target: dict) -> str:
|
||||
return f"https://{target['host']}:{target.get('port', 443)}"
|
||||
|
||||
|
||||
def _get(target: dict, path: str, params: dict | None = None) -> dict:
|
||||
url = f"{_base(target)}{path}"
|
||||
headers = {"Authorization": f"Bearer {target.get('api_key')}"}
|
||||
with httpx.Client(verify=False, timeout=20) as client: # noqa: S501 (self-signed device)
|
||||
r = client.get(url, headers=headers, params=params)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def fortigate_connect(host: str, api_key: str, port: int = 443, username: str | None = None) -> str:
|
||||
"""Set the active FortiGate target (token/API-key auth) and verify connectivity."""
|
||||
global _active
|
||||
target = {"host": host, "api_key": api_key, "port": port}
|
||||
try:
|
||||
_get(target, "/api/v2/monitor/system/status")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return f"connect failed: {exc}"
|
||||
_active = target
|
||||
return f"connected to FortiGate {host}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def fortigate_test_connection(host: str | None = None, api_key: str | None = None, port: int = 443) -> str:
|
||||
"""Test connectivity to a FortiGate."""
|
||||
target = _resolve(host, api_key, port)
|
||||
try:
|
||||
_get(target, "/api/v2/monitor/system/status")
|
||||
return "ok"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return f"failed: {exc}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def fortigate_get_system_status(host: str | None = None, api_key: str | None = None, port: int = 443) -> str:
|
||||
"""System status: model, version, serial, uptime."""
|
||||
target = _resolve(host, api_key, port)
|
||||
return json.dumps(_get(target, "/api/v2/monitor/system/status"), indent=2)[:6000]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def fortigate_list_interfaces(host: str | None = None, api_key: str | None = None, port: int = 443) -> str:
|
||||
"""List interfaces and their link/IP state."""
|
||||
target = _resolve(host, api_key, port)
|
||||
data = _get(target, "/api/v2/monitor/system/interface")
|
||||
return json.dumps(data, indent=2)[:8000]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def fortigate_list_policies(host: str | None = None, api_key: str | None = None, port: int = 443) -> str:
|
||||
"""List firewall policies (cmdb)."""
|
||||
target = _resolve(host, api_key, port)
|
||||
data = _get(target, "/api/v2/cmdb/firewall/policy")
|
||||
return json.dumps(data, indent=2)[:8000]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def fortigate_get_routing(host: str | None = None, api_key: str | None = None, port: int = 443) -> str:
|
||||
"""Active routing table."""
|
||||
target = _resolve(host, api_key, port)
|
||||
return json.dumps(_get(target, "/api/v2/monitor/router/ipv4"), indent=2)[:8000]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def fortigate_get_logs(lines: int = 50, host: str | None = None, api_key: str | None = None, port: int = 443) -> str:
|
||||
"""Recent event log (best-effort; depends on logging config)."""
|
||||
target = _resolve(host, api_key, port)
|
||||
try:
|
||||
data = _get(target, "/api/v2/log/memory/event", params={"rows": lines})
|
||||
return json.dumps(data, indent=2)[:8000]
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return f"log fetch failed (logging may be disk/forticloud): {exc}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
12
mcp-servers/fortiswitch-mcp/pyproject.toml
Normal file
12
mcp-servers/fortiswitch-mcp/pyproject.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[project]
|
||||
name = "fortiswitch-mcp"
|
||||
version = "0.1.0"
|
||||
description = "FortiSwitch (FortiSwitchOS REST API) MCP server"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"mcp>=1.2.0",
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
136
mcp-servers/fortiswitch-mcp/server.py
Normal file
136
mcp-servers/fortiswitch-mcp/server.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""FortiSwitch MCP server (standalone FortiSwitchOS REST API).
|
||||
|
||||
Read-only diagnostics. Supports token (API key) auth, with optional
|
||||
username/password session login as a fallback. Writes require
|
||||
FORTISWITCH_ALLOW_WRITES=true. Endpoints are best-effort and may vary by
|
||||
FortiSwitchOS version.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("fortiswitch")
|
||||
|
||||
ALLOW_WRITES = os.environ.get("FORTISWITCH_ALLOW_WRITES", "false").lower() == "true"
|
||||
|
||||
_active: dict | None = None # {"host","api_key","username","password","port"}
|
||||
|
||||
|
||||
def _resolve(host, api_key, username, password, port) -> dict:
|
||||
if host:
|
||||
return {
|
||||
"host": host,
|
||||
"api_key": api_key,
|
||||
"username": username,
|
||||
"password": password,
|
||||
"port": port or 443,
|
||||
}
|
||||
if _active:
|
||||
return _active
|
||||
raise RuntimeError("No FortiSwitch target. Call fortiswitch_connect first.")
|
||||
|
||||
|
||||
def _base(target: dict) -> str:
|
||||
return f"https://{target['host']}:{target.get('port', 443)}"
|
||||
|
||||
|
||||
def _client(target: dict) -> httpx.Client:
|
||||
client = httpx.Client(verify=False, timeout=20) # noqa: S501 (self-signed device)
|
||||
if not target.get("api_key") and target.get("username"):
|
||||
# Session login fallback
|
||||
try:
|
||||
client.post(
|
||||
f"{_base(target)}/logincheck",
|
||||
data={"username": target["username"], "secretkey": target.get("password", "")},
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return client
|
||||
|
||||
|
||||
def _headers(target: dict) -> dict:
|
||||
if target.get("api_key"):
|
||||
return {"Authorization": f"Bearer {target['api_key']}"}
|
||||
return {}
|
||||
|
||||
|
||||
def _get(target: dict, path: str, params: dict | None = None) -> dict:
|
||||
client = _client(target)
|
||||
try:
|
||||
r = client.get(f"{_base(target)}{path}", headers=_headers(target), params=params)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def fortiswitch_connect(host: str, api_key: str | None = None, username: str | None = None,
|
||||
password: str | None = None, port: int = 443) -> str:
|
||||
"""Set the active FortiSwitch target and verify connectivity."""
|
||||
global _active
|
||||
target = {"host": host, "api_key": api_key, "username": username, "password": password, "port": port}
|
||||
try:
|
||||
_get(target, "/api/v2/monitor/system/status")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return f"connect failed: {exc}"
|
||||
_active = target
|
||||
return f"connected to FortiSwitch {host}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def fortiswitch_test_connection(host: str | None = None, api_key: str | None = None,
|
||||
username: str | None = None, password: str | None = None,
|
||||
port: int = 443) -> str:
|
||||
"""Test connectivity to a FortiSwitch."""
|
||||
target = _resolve(host, api_key, username, password, port)
|
||||
try:
|
||||
_get(target, "/api/v2/monitor/system/status")
|
||||
return "ok"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return f"failed: {exc}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def fortiswitch_get_system_status(host: str | None = None, api_key: str | None = None,
|
||||
username: str | None = None, password: str | None = None,
|
||||
port: int = 443) -> str:
|
||||
"""System status: model, version, serial, uptime."""
|
||||
target = _resolve(host, api_key, username, password, port)
|
||||
return json.dumps(_get(target, "/api/v2/monitor/system/status"), indent=2)[:6000]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def fortiswitch_list_ports(host: str | None = None, api_key: str | None = None,
|
||||
username: str | None = None, password: str | None = None,
|
||||
port: int = 443) -> str:
|
||||
"""List switch ports and their status."""
|
||||
target = _resolve(host, api_key, username, password, port)
|
||||
for path in ("/api/v2/monitor/switch/port", "/api/v2/cmdb/switch/interface"):
|
||||
try:
|
||||
return json.dumps(_get(target, path), indent=2)[:8000]
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
return "could not read switch ports on known endpoints"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def fortiswitch_get_port_stats(host: str | None = None, api_key: str | None = None,
|
||||
username: str | None = None, password: str | None = None,
|
||||
port: int = 443) -> str:
|
||||
"""Per-port statistics / counters (errors, traffic)."""
|
||||
target = _resolve(host, api_key, username, password, port)
|
||||
for path in ("/api/v2/monitor/switch/port-statistics", "/api/v2/monitor/switch/port"):
|
||||
try:
|
||||
return json.dumps(_get(target, path), indent=2)[:8000]
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
return "could not read port statistics on known endpoints"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
12
mcp-servers/ssh-generic-mcp/pyproject.toml
Normal file
12
mcp-servers/ssh-generic-mcp/pyproject.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[project]
|
||||
name = "ssh-generic-mcp"
|
||||
version = "0.1.0"
|
||||
description = "Generic SSH MCP server for Debian/Linux + GeneseasX hosts"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"mcp>=1.2.0",
|
||||
"paramiko>=3.4.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
135
mcp-servers/ssh-generic-mcp/server.py
Normal file
135
mcp-servers/ssh-generic-mcp/server.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""Generic SSH MCP server.
|
||||
|
||||
Read-only by default. Mutating commands are blocked unless SSH_ALLOW_WRITES=true.
|
||||
Targets can be set with ssh_connect and reused, or passed per-call.
|
||||
|
||||
Conventions mirror the other device MCPs in this stack (saved active target,
|
||||
*_ALLOW_WRITES gate for writes).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
|
||||
import paramiko
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("ssh-generic")
|
||||
|
||||
ALLOW_WRITES = os.environ.get("SSH_ALLOW_WRITES", "false").lower() == "true"
|
||||
|
||||
# Heuristic denylist for mutating operations when writes are not allowed.
|
||||
_WRITE_TOKENS = (
|
||||
"rm ", "mv ", "dd ", "mkfs", "systemctl start", "systemctl stop", "systemctl restart",
|
||||
"systemctl disable", "systemctl enable", "iptables", "nft ", "ip route add",
|
||||
"ip route del", "ip addr add", "ip link set", "apt", "yum", "dnf", "tee ",
|
||||
"chmod", "chown", "passwd", "useradd", "userdel", "mount", "umount",
|
||||
)
|
||||
# Whole-word matches only (avoid false positives e.g. "halt" inside "head").
|
||||
_WRITE_WORDS = re.compile(r"\b(reboot|shutdown|poweroff|halt|kill|pkill)\b")
|
||||
# Shell output redirect to a file (allow stderr redirects like 2>/dev/null).
|
||||
_WRITE_REDIRECT = re.compile(r"(?<![0-9])>(?!>)|>>")
|
||||
|
||||
# Active target: {"host","port","username","password"}
|
||||
_active: dict | None = None
|
||||
|
||||
|
||||
def _resolve(host, port, username, password) -> dict:
|
||||
if host:
|
||||
return {
|
||||
"host": host,
|
||||
"port": port or 22,
|
||||
"username": username or "root",
|
||||
"password": password,
|
||||
}
|
||||
if _active:
|
||||
return _active
|
||||
raise RuntimeError("No SSH target. Call ssh_connect first or pass host/username.")
|
||||
|
||||
|
||||
def _client(target: dict) -> paramiko.SSHClient:
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
client.connect(
|
||||
hostname=target["host"],
|
||||
port=int(target.get("port", 22)),
|
||||
username=target.get("username"),
|
||||
password=target.get("password"),
|
||||
timeout=15,
|
||||
allow_agent=False,
|
||||
look_for_keys=bool(not target.get("password")),
|
||||
)
|
||||
return client
|
||||
|
||||
|
||||
def _is_write(command: str) -> bool:
|
||||
low = command.lower()
|
||||
if _WRITE_WORDS.search(low):
|
||||
return True
|
||||
if _WRITE_REDIRECT.search(command):
|
||||
return True
|
||||
return any(tok in low for tok in _WRITE_TOKENS)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ssh_connect(host: str, username: str = "root", password: str | None = None, port: int = 22) -> str:
|
||||
"""Set the active SSH target and verify connectivity."""
|
||||
global _active
|
||||
target = {"host": host, "port": port, "username": username, "password": password}
|
||||
try:
|
||||
c = _client(target)
|
||||
c.close()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return f"connect failed: {exc}"
|
||||
_active = target
|
||||
return f"connected to {username}@{host}:{port}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ssh_test_connection(host: str | None = None, username: str | None = None,
|
||||
password: str | None = None, port: int = 22) -> str:
|
||||
"""Test SSH connectivity to a host."""
|
||||
target = _resolve(host, port, username, password)
|
||||
try:
|
||||
c = _client(target)
|
||||
c.close()
|
||||
return "ok"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return f"failed: {exc}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ssh_run(command: str, host: str | None = None, username: str | None = None,
|
||||
password: str | None = None, port: int = 22, timeout: int = 30) -> str:
|
||||
"""Run a shell command over SSH. Mutating commands require SSH_ALLOW_WRITES=true."""
|
||||
if _is_write(command) and not ALLOW_WRITES:
|
||||
return (
|
||||
"blocked: this looks like a mutating command and SSH_ALLOW_WRITES is false. "
|
||||
"Use the approval flow / enable writes to proceed."
|
||||
)
|
||||
target = _resolve(host, port, username, password)
|
||||
client = _client(target)
|
||||
try:
|
||||
_, stdout, stderr = client.exec_command(command, timeout=timeout)
|
||||
out = stdout.read().decode(errors="replace")
|
||||
err = stderr.read().decode(errors="replace")
|
||||
code = stdout.channel.recv_exit_status()
|
||||
result = out
|
||||
if err:
|
||||
result += f"\n[stderr]\n{err}"
|
||||
return f"[exit {code}]\n{result}".strip()
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ssh_service_status(service: str, host: str | None = None, username: str | None = None,
|
||||
password: str | None = None, port: int = 22) -> str:
|
||||
"""Read-only systemd service status."""
|
||||
return ssh_run(f"systemctl status {shlex.quote(service)} --no-pager",
|
||||
host=host, username=username, password=password, port=port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
Reference in New Issue
Block a user