From eb6a9d855db39086cb8a21f8c8e7568724131de8 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Mon, 9 Mar 2026 09:36:36 -0300 Subject: [PATCH] feat: add infracloud mcp server --- mcp/README.md | 33 ++ mcp/__pycache__/server.cpython-312.pyc | Bin 0 -> 19579 bytes mcp/claude_desktop_config.infracloud.json | 11 + mcp/requirements.txt | 1 + mcp/server.py | 394 ++++++++++++++++++++++ 5 files changed, 439 insertions(+) create mode 100644 mcp/README.md create mode 100644 mcp/__pycache__/server.cpython-312.pyc create mode 100644 mcp/claude_desktop_config.infracloud.json create mode 100644 mcp/requirements.txt create mode 100644 mcp/server.py diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 0000000..493addb --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,33 @@ +# Infracloud MCP + +MCP server for the actual `infracloud` repository layout. + +## Scope + +- Reads `vps/*/services_inventory.md` and `k3s/services_inventory.md` +- Lists and reads `containers/*.container` and `containers/*.service` +- Lists scripts in `scripts/auto-organized` +- Executes only scripts classified as read-only diagnostics +- Provides repo grep and safe document reads + +## Install + +```powershell +pip install -r C:\dev\infracloud\mcp\requirements.txt +``` + +## Run + +```powershell +python C:\dev\infracloud\mcp\server.py +``` + +## Claude Desktop + +Use `claude_desktop_config.infracloud.json` as a base and merge it into your Claude Desktop MCP config. + +## Notes + +- This repo currently uses `scripts/auto-organized`, not `dev-scripts`. +- This repo currently does not have `docs/openproject`. +- `AGENT.md` includes secrets in plaintext. The MCP server does not expose that file. diff --git a/mcp/__pycache__/server.cpython-312.pyc b/mcp/__pycache__/server.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..21446a201b310079ebdee89879f322275f30711a GIT binary patch literal 19579 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 literal 0 HcmV?d00001 diff --git a/mcp/claude_desktop_config.infracloud.json b/mcp/claude_desktop_config.infracloud.json new file mode 100644 index 0000000..fd32528 --- /dev/null +++ b/mcp/claude_desktop_config.infracloud.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "infracloud-sustentacao": { + "command": "python", + "args": [ + "C:\\dev\\infracloud\\mcp\\server.py" + ], + "cwd": "C:\\dev\\infracloud" + } + } +} diff --git a/mcp/requirements.txt b/mcp/requirements.txt new file mode 100644 index 0000000..a0b21a7 --- /dev/null +++ b/mcp/requirements.txt @@ -0,0 +1 @@ +mcp>=1.6.0 diff --git a/mcp/server.py b/mcp/server.py new file mode 100644 index 0000000..34da03a --- /dev/null +++ b/mcp/server.py @@ -0,0 +1,394 @@ +from __future__ import annotations + +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from mcp.server.fastmcp import FastMCP + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPTS_ROOT = REPO_ROOT / "scripts" / "auto-organized" +VPS_ROOT = REPO_ROOT / "vps" +CONTAINERS_ROOT = REPO_ROOT / "containers" +DATABASES_ROOT = REPO_ROOT / "databases" +K3S_ROOT = REPO_ROOT / "k3s" +DOC_ALLOWLIST = ( + REPO_ROOT / "README.md", + VPS_ROOT, + CONTAINERS_ROOT, + DATABASES_ROOT, + K3S_ROOT, +) + +READ_ONLY_SCRIPT_PREFIXES = ( + "check_", + "fetch_", + "get_", + "inspect_", + "verify_", + "final_status", + "watch_", +) +MUTATING_SCRIPT_PREFIXES = ( + "approve_", + "complete_", + "fix_", + "merge_", + "retrigger_", + "revert_", +) + +mcp = FastMCP( + "infracloud-sustentacao", + instructions=( + "Use the real infracloud repository as the source of truth. " + "Prefer inventory markdown, container unit files, and existing scripts. " + "Do not assume paths like dev-scripts or docs/openproject if they do not exist." + ), +) + + +@dataclass(frozen=True) +class ScriptInfo: + path: Path + relative_path: str + is_read_only: bool + kind: str + + +def _ensure_in_repo(path: Path) -> Path: + resolved = path.resolve() + if REPO_ROOT not in resolved.parents and resolved != REPO_ROOT: + raise ValueError(f"path escapes repository root: {path}") + return resolved + + +def _script_kind(name: str) -> str: + lower = name.lower() + if lower.endswith(".ps1"): + return "powershell" + if lower.endswith(".sh"): + return "shell" + return "other" + + +def _is_read_only_script(name: str) -> bool: + lower = name.lower() + if lower.endswith((".json", ".yaml", ".yml", ".txt", ".pem")): + return False + if lower.startswith(MUTATING_SCRIPT_PREFIXES): + return False + return lower.startswith(READ_ONLY_SCRIPT_PREFIXES) + + +def _list_scripts() -> list[ScriptInfo]: + if not SCRIPTS_ROOT.exists(): + return [] + + results: list[ScriptInfo] = [] + for path in sorted(SCRIPTS_ROOT.rglob("*")): + if not path.is_file(): + continue + relative = path.relative_to(REPO_ROOT).as_posix() + results.append( + ScriptInfo( + path=path, + relative_path=relative, + is_read_only=_is_read_only_script(path.name), + kind=_script_kind(path.name), + ) + ) + return results + + +def _resolve_script(script_name: str) -> ScriptInfo: + script_name = script_name.replace("\\", "/").strip() + candidates = _list_scripts() + + exact = [item for item in candidates if item.relative_path == script_name or item.path.name == script_name] + if len(exact) == 1: + return exact[0] + if len(exact) > 1: + raise ValueError(f"multiple scripts matched '{script_name}', use a repo-relative path") + + fuzzy = [item for item in candidates if script_name.lower() in item.relative_path.lower()] + if len(fuzzy) == 1: + return fuzzy[0] + if len(fuzzy) > 1: + names = ", ".join(item.relative_path for item in fuzzy[:10]) + raise ValueError(f"multiple scripts matched '{script_name}': {names}") + + raise ValueError(f"script not found: {script_name}") + + +def _read_text(path: Path, max_chars: int = 20000) -> str: + resolved = _ensure_in_repo(path) + text = resolved.read_text(encoding="utf-8", errors="replace") + if len(text) > max_chars: + return text[:max_chars] + "\n... [truncated]" + return text + + +def _parse_markdown_table(lines: list[str], start_index: int) -> tuple[list[dict[str, str]], int]: + header_line = lines[start_index].strip() + separator_index = start_index + 1 + if separator_index >= len(lines): + return [], start_index + 1 + + separator_line = lines[separator_index].strip() + if "|" not in header_line or "|" not in separator_line: + return [], start_index + 1 + + headers = [part.strip(" `") for part in header_line.strip("|").split("|")] + rows: list[dict[str, str]] = [] + index = start_index + 2 + while index < len(lines): + line = lines[index].rstrip() + if "|" not in line or not line.strip().startswith("|"): + break + values = [part.strip() for part in line.strip().strip("|").split("|")] + if len(values) == len(headers): + rows.append(dict(zip(headers, values))) + index += 1 + + return rows, index + + +def _parse_inventory_file(path: Path) -> dict[str, Any]: + lines = _read_text(path, max_chars=120000).splitlines() + parsed: dict[str, Any] = {"file": path.relative_to(REPO_ROOT).as_posix(), "sections": {}} + current_section = "root" + parsed["sections"][current_section] = {"tables": [], "paragraphs": []} + + index = 0 + while index < len(lines): + line = lines[index].rstrip() + if line.startswith("#"): + current_section = line.lstrip("#").strip() + parsed["sections"].setdefault(current_section, {"tables": [], "paragraphs": []}) + index += 1 + continue + + if line.strip().startswith("|"): + rows, next_index = _parse_markdown_table(lines, index) + if rows: + parsed["sections"][current_section]["tables"].append(rows) + index = next_index + continue + + if line.strip(): + parsed["sections"][current_section]["paragraphs"].append(line.strip()) + index += 1 + + return parsed + + +def _iter_inventory_files() -> list[Path]: + return sorted(VPS_ROOT.rglob("services_inventory.md")) + sorted(K3S_ROOT.rglob("services_inventory.md")) + + +def _match_service(query: str, row: dict[str, str]) -> bool: + haystack = " ".join(str(value) for value in row.values()).lower() + return query.lower() in haystack + + +def _safe_doc_path(relative_path: str) -> Path: + relative = Path(relative_path) + candidate = _ensure_in_repo(REPO_ROOT / relative) + for allowed in DOC_ALLOWLIST: + allowed_resolved = allowed.resolve() + if candidate == allowed_resolved or allowed_resolved in candidate.parents: + return candidate + raise ValueError(f"path not allowed: {relative_path}") + + +@mcp.tool( + description="List scripts available in scripts/auto-organized, including whether each one is safe for read-only execution.", +) +def list_repo_scripts(name_filter: str | None = None) -> list[dict[str, Any]]: + scripts = _list_scripts() + if name_filter: + scripts = [item for item in scripts if name_filter.lower() in item.relative_path.lower()] + + return [ + { + "name": item.path.name, + "relative_path": item.relative_path, + "kind": item.kind, + "read_only": item.is_read_only, + } + for item in scripts + ] + + +@mcp.tool( + description="Run an existing repo script from scripts/auto-organized. Only read-only diagnostic scripts are executable.", +) +def run_repo_script(script_name: str, args: list[str] | None = None, timeout_seconds: int = 60) -> dict[str, Any]: + script = _resolve_script(script_name) + if not script.is_read_only: + raise ValueError( + f"script '{script.relative_path}' is not classified as read-only and cannot be executed by this tool" + ) + + args = args or [] + if script.kind == "powershell": + command = [ + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(script.path), + *args, + ] + elif script.kind == "shell": + command = ["bash", str(script.path), *args] + else: + raise ValueError(f"unsupported script type: {script.kind}") + + completed = subprocess.run( + command, + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=timeout_seconds, + check=False, + ) + return { + "script": script.relative_path, + "exit_code": completed.returncode, + "stdout": completed.stdout[-12000:], + "stderr": completed.stderr[-12000:], + } + + +@mcp.tool( + description="Parse one services_inventory.md file into structured JSON. Server examples: redbull, vim, nc1, k3s.", +) +def read_services_inventory(server: str) -> dict[str, Any]: + inventory_files = {path.parent.name.lower(): path for path in _iter_inventory_files()} + server_key = server.lower().strip() + if server_key not in inventory_files: + raise ValueError(f"inventory not found for '{server}'. Available: {', '.join(sorted(inventory_files))}") + return _parse_inventory_file(inventory_files[server_key]) + + +@mcp.tool( + description="Search all inventory files for an app, UUID, domain, server, or other service text.", +) +def find_service(query: str) -> list[dict[str, Any]]: + matches: list[dict[str, Any]] = [] + for inventory_path in _iter_inventory_files(): + parsed = _parse_inventory_file(inventory_path) + for section_name, section in parsed["sections"].items(): + for table in section["tables"]: + for row in table: + if _match_service(query, row): + matches.append( + { + "inventory": parsed["file"], + "section": section_name, + "row": row, + } + ) + return matches + + +@mcp.tool( + description="List Podman/Systemd unit files under containers/ and optionally filter by app name.", +) +def list_container_units(name_filter: str | None = None) -> list[dict[str, str]]: + results: list[dict[str, str]] = [] + for path in sorted(CONTAINERS_ROOT.iterdir()): + if not path.is_file(): + continue + if path.suffix not in {".container", ".service"}: + continue + relative = path.relative_to(REPO_ROOT).as_posix() + if name_filter and name_filter.lower() not in relative.lower(): + continue + results.append({"name": path.name, "relative_path": relative, "kind": path.suffix.lstrip(".")}) + return results + + +@mcp.tool( + description="Read a container unit file from containers/ for Podman/Systemd runtime analysis.", +) +def read_container_unit(name: str) -> dict[str, str]: + candidates = [ + path + for path in CONTAINERS_ROOT.iterdir() + if path.is_file() and path.suffix in {".container", ".service"} and (path.name == name or name.lower() in path.name.lower()) + ] + if not candidates: + raise ValueError(f"container unit not found: {name}") + if len(candidates) > 1: + names = ", ".join(path.name for path in candidates) + raise ValueError(f"multiple container units matched '{name}': {names}") + + path = candidates[0] + return { + "relative_path": path.relative_to(REPO_ROOT).as_posix(), + "content": _read_text(path, max_chars=16000), + } + + +@mcp.tool( + description="Read a repo document from README, vps, databases, k3s, or containers paths.", +) +def read_repo_document(relative_path: str, max_chars: int = 12000) -> dict[str, str]: + path = _safe_doc_path(relative_path) + return { + "relative_path": path.relative_to(REPO_ROOT).as_posix(), + "content": _read_text(path, max_chars=max_chars), + } + + +@mcp.tool( + description="Search the repo for infrastructure terms such as app names, domains, env keys, or container names.", +) +def grep_repo(query: str, glob: str | None = None) -> dict[str, Any]: + command = ["rg", "-n", query, str(REPO_ROOT)] + if glob: + command.extend(["-g", glob]) + + completed = subprocess.run( + command, + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + results = completed.stdout.splitlines() + return { + "exit_code": completed.returncode, + "matches": results[:200], + "truncated": len(results) > 200, + "stderr": completed.stderr[-4000:], + } + + +@mcp.tool( + description="Return a compact summary of the actual infracloud repo layout so agents do not assume missing folders like dev-scripts or docs/openproject.", +) +def repo_layout_summary() -> dict[str, Any]: + return { + "repo_root": str(REPO_ROOT), + "present_top_level_dirs": sorted(path.name for path in REPO_ROOT.iterdir() if path.is_dir()), + "scripts_root": SCRIPTS_ROOT.relative_to(REPO_ROOT).as_posix() if SCRIPTS_ROOT.exists() else None, + "inventory_files": [path.relative_to(REPO_ROOT).as_posix() for path in _iter_inventory_files()], + "container_units": [path.name for path in CONTAINERS_ROOT.iterdir() if path.is_file() and path.suffix in {".container", ".service"}], + "notes": [ + "The repo uses scripts/auto-organized instead of dev-scripts.", + "The repo does not currently include docs/openproject.", + "AGENT.md contains secrets and should not be used as a runtime configuration source.", + ], + } + + +if __name__ == "__main__": + mcp.run()