infracloud/mcp/server.py
root 806f9be919 feat(mcp): upgrade to multi-project support
Added support for gohorsejobs, core, and saveinmed projects.
Updated tools to accept an optional 'project' parameter.
Configured Linux-compatible config.json.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-09 19:59:35 +01:00

166 lines
5.8 KiB
Python

from __future__ import annotations
import os
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from mcp.server.fastmcp import FastMCP
import psycopg
from psycopg.rows import dict_row
# Mapeamento de Projetos
PROJECTS = {
"infracloud": Path("/root/infracloud"),
"gohorsejobs": Path("/root/gohorsejobs"),
"core": Path("/root/core"),
"saveinmed": Path("/root/saveinmed"),
}
DEFAULT_PROJECT = "infracloud"
POSTGRES_DSN_ENV = "INFRA_MCP_POSTGRES_DSN"
TRANSPORT_ENV = "INFRA_MCP_TRANSPORT"
HOST_ENV = "INFRA_MCP_HOST"
PORT_ENV = "INFRA_MCP_PORT"
READ_ONLY_SCRIPT_PREFIXES = (
"check_", "fetch_", "get_", "inspect_", "verify_", "final_status", "watch_",
)
MUTATING_SCRIPT_PREFIXES = (
"approve_", "complete_", "fix_", "merge_", "retrigger_", "revert_",
)
mcp = FastMCP(
"infracloud-multi-project",
instructions=(
"Source of truth for infracloud, gohorsejobs, and core repositories. "
"Always specify the project parameter when working outside infracloud. "
"Use real repository files and inventory markdown as reference."
),
host=os.getenv(HOST_ENV, "127.0.0.1"),
port=int(os.getenv(PORT_ENV, "8000")),
)
@dataclass(frozen=True)
class ScriptInfo:
path: Path
relative_path: str
is_read_only: bool
kind: str
def _get_project_root(project: str | None = None) -> Path:
name = project or DEFAULT_PROJECT
if name not in PROJECTS:
raise ValueError(f"Project '{name}' not found. Available: {', '.join(PROJECTS.keys())}")
return PROJECTS[name]
def _ensure_in_project(path: Path, project: str | None = None) -> Path:
root = _get_project_root(project)
resolved = path.resolve()
if root not in resolved.parents and resolved != root:
raise ValueError(f"Path escapes project root: {path}")
return resolved
def _postgres_dsn() -> str | None:
return os.getenv(POSTGRES_DSN_ENV, "").strip() or None
def _get_pg_connection():
dsn = _postgres_dsn()
if not dsn:
raise ValueError(f"{POSTGRES_DSN_ENV} is not configured")
return psycopg.connect(dsn, row_factory=dict_row)
def _ensure_mcp_tables() -> None:
if not _postgres_dsn(): return
with _get_pg_connection() as conn:
with conn.cursor() as cur:
cur.execute("""
CREATE TABLE IF NOT EXISTS infra_mcp_notes (
id BIGSERIAL PRIMARY KEY,
scope TEXT NOT NULL,
project TEXT NOT NULL DEFAULT 'infracloud',
title TEXT NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
conn.commit()
@mcp.tool()
def list_repo_scripts(project: str | None = None, name_filter: str | None = None) -> list[dict[str, Any]]:
"""List scripts in scripts/auto-organized for the specified project."""
root = _get_project_root(project)
scripts_dir = root / "scripts" / "auto-organized"
if not scripts_dir.exists(): return []
results = []
for path in sorted(scripts_dir.rglob("*")):
if not path.is_file(): continue
results.append({
"name": path.name,
"relative_path": path.relative_to(root).as_posix(),
"read_only": path.name.lower().startswith(READ_ONLY_SCRIPT_PREFIXES),
"kind": "shell" if path.suffix == ".sh" else "powershell" if path.suffix == ".ps1" else "other"
})
if name_filter:
results = [s for s in results if name_filter.lower() in s["relative_path"].lower()]
return results
@mcp.tool()
def grep_project(query: str, project: str | None = None, glob: str | None = None) -> dict[str, Any]:
"""Search the specified project for infrastructure or code terms."""
root = _get_project_root(project)
command = ["rg", "-n", query, str(root)]
if glob: command.extend(["-g", glob])
completed = subprocess.run(command, capture_output=True, text=True, timeout=30)
results = completed.stdout.splitlines()
return {
"project": project or DEFAULT_PROJECT,
"matches": results[:200],
"truncated": len(results) > 200,
"stderr": completed.stderr[-4000:],
}
@mcp.tool()
def repo_layout_summary(project: str | None = None) -> dict[str, Any]:
"""Return a compact summary of the specified project layout."""
root = _get_project_root(project)
return {
"project": project or DEFAULT_PROJECT,
"root": str(root),
"dirs": sorted(path.name for path in root.iterdir() if path.is_dir() and not path.name.startswith(".")),
"important_files": [f.name for f in root.glob("*.md")]
}
@mcp.tool()
def read_project_file(relative_path: str, project: str | None = None) -> dict[str, str]:
"""Read a specific file from the selected project."""
root = _get_project_root(project)
path = _ensure_in_project(root / relative_path, project)
return {
"project": project or DEFAULT_PROJECT,
"content": path.read_text(encoding="utf-8", errors="replace")[:20000]
}
@mcp.tool()
def add_project_note(title: str, body: str, project: str | None = None, scope: str = "general") -> dict[str, Any]:
"""Store an operational note for a specific project in the database."""
_ensure_mcp_tables()
p_name = project or DEFAULT_PROJECT
with _get_pg_connection() as conn:
with conn.cursor() as cur:
cur.execute("""
INSERT INTO infra_mcp_notes (scope, project, title, body)
VALUES (%s, %s, %s, %s)
RETURNING id, project, title, body, created_at
""", (scope, p_name, title, body))
row = cur.fetchone()
conn.commit()
return row
if __name__ == "__main__":
_ensure_mcp_tables()
mcp.run(transport=os.getenv(TRANSPORT_ENV, "stdio"))