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>
166 lines
5.8 KiB
Python
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"))
|