Files
nearxos 6185b9b85a 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>
2026-06-14 22:11:07 +03:00

295 lines
11 KiB
Python

"""Clone MCP repos from Gitea and run them as stdio MCP subprocess sidecars."""
from __future__ import annotations
import asyncio
import json
import logging
import os
import shutil
from contextlib import AsyncExitStack
from dataclasses import dataclass, field
from app.config import settings
logger = logging.getLogger(__name__)
MCP_STATUS_KEY = "agentic:mcp:status"
@dataclass
class ServerState:
name: str
status: str = "stopped"
tools: list[dict] = field(default_factory=list)
error: str | None = None
session: object | None = None
_stack: AsyncExitStack | None = field(default=None, repr=False)
async def ensure_git_remote(repo: str, target: str) -> None:
"""Point origin at GITEA_BASE_URL — fixes repos cloned when remote was public HTTPS."""
if not os.path.isdir(os.path.join(target, ".git")):
return
url = settings.gitea_clone_url(repo)
code, out = await McpManager._run(["git", "remote", "set-url", "origin", url], cwd=target)
if code != 0:
raise RuntimeError(f"git remote set-url failed: {out[:500]}")
class McpManager:
def __init__(self) -> None:
self.servers: dict[str, ServerState] = {}
self._lock = asyncio.Lock()
self._started = False
async def startup(self) -> None:
async with self._lock:
if self._started:
return
self._started = True
os.makedirs(self.repos_dir, exist_ok=True)
for repo in settings.mcp_repo_list:
state = ServerState(name=repo)
self.servers[repo] = state
try:
await self._prepare_repo(repo, state)
await self._start_server(repo, state)
except Exception as exc: # noqa: BLE001
state.status = "error"
state.error = str(exc)
logger.warning("MCP %s failed to start: %s", repo, exc)
await self.publish_status()
async def shutdown(self) -> None:
async with self._lock:
for state in self.servers.values():
await self._stop_server(state)
@property
def repos_dir(self) -> str:
return os.path.join(settings.runtime_dir, "mcp-repos")
@property
def local_dir(self) -> str:
return os.environ.get("MCP_LOCAL_DIR", "/app/mcp-servers")
def _is_local_repo(self, repo: str) -> bool:
return os.path.isdir(os.path.join(self.local_dir, repo))
def _repo_dir(self, repo: str) -> str:
local_src = os.path.join(self.local_dir, repo)
if os.path.isdir(local_src):
dst = os.path.join(settings.runtime_dir, "mcp-local", repo)
if not os.path.isdir(dst):
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copytree(local_src, dst)
return dst
return os.path.join(self.repos_dir, repo)
async def _sync_local_repo(self, repo: str) -> None:
local_src = os.path.join(self.local_dir, repo)
if not os.path.isdir(local_src):
return
dst = os.path.join(settings.runtime_dir, "mcp-local", repo)
if os.path.isdir(dst):
shutil.rmtree(dst)
shutil.copytree(local_src, dst)
async def _ensure_git_remote(self, repo: str, target: str) -> None:
await ensure_git_remote(repo, target)
async def _prepare_repo(self, repo: str, state: ServerState) -> None:
if self._is_local_repo(repo):
await self._sync_local_repo(repo)
return
state.status = "cloning"
target = os.path.join(self.repos_dir, repo)
url = settings.gitea_clone_url(repo)
if os.path.isdir(os.path.join(target, ".git")):
await self._ensure_git_remote(repo, target)
code, out = await self._run(["git", "pull", "--rebase", "--autostash"], cwd=target)
if code != 0:
raise RuntimeError(f"git pull failed: {out[:500]}")
else:
code, out = await self._run(["git", "clone", "--depth", "1", url, target])
if code != 0:
raise RuntimeError(f"clone failed: {out[:500]}")
def _detect_start_command(self, repo: str) -> list[str]:
override = os.environ.get(f"MCP_START_{repo.upper().replace('-', '_')}")
if override:
return override.split()
target = self._repo_dir(repo)
has_pyproject = os.path.isfile(os.path.join(target, "pyproject.toml"))
for candidate in ("server.py", "main.py", "app.py", "mcp_server.py", "__main__.py"):
if os.path.isfile(os.path.join(target, candidate)):
if has_pyproject:
return ["uv", "run", "--directory", target, "python", candidate]
return ["uv", "run", "--no-project", "--directory", target, "python", candidate]
module = repo.replace("-", "_")
if os.path.isdir(os.path.join(target, "src", module)) or os.path.isdir(
os.path.join(target, module)
):
return ["uv", "run", "--directory", target, "python", "-m", module]
return ["uv", "run", "--directory", target, repo]
async def _stop_server(self, state: ServerState) -> None:
if state._stack is not None:
try:
await state._stack.aclose()
except Exception as exc: # noqa: BLE001
logger.debug("stop %s: %s", state.name, exc)
state._stack = None
state.session = None
state.tools = []
if state.status not in ("error", "cloning"):
state.status = "stopped"
def _mcp_subprocess_env(self, repo: str) -> dict[str, str]:
"""Env passed to MCP stdio subprocesses (repo-specific overrides)."""
env = {**os.environ}
if repo == "proxmox-mcp":
verify = os.environ.get("PROXMOX_VERIFY_SSL")
if verify is None:
verify = os.environ.get("DEVICE_PROXMOX_VERIFY_SSL", "false")
env["PROXMOX_VERIFY_SSL"] = verify
return env
async def _start_server(self, repo: str, state: ServerState) -> None:
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
state.status = "starting"
state.error = None
target = self._repo_dir(repo)
cmd = self._detect_start_command(repo)
params = StdioServerParameters(
command=cmd[0],
args=cmd[1:],
cwd=target,
env=self._mcp_subprocess_env(repo),
)
stack = AsyncExitStack()
read, write = await stack.enter_async_context(stdio_client(params))
session = await stack.enter_async_context(ClientSession(read, write))
await asyncio.wait_for(session.initialize(), timeout=90)
tools_resp = await session.list_tools()
state._stack = stack
state.session = session
state.tools = [{"name": t.name, "description": t.description} for t in tools_resp.tools]
state.status = "loaded"
logger.info("MCP %s loaded with %d tools", repo, len(state.tools))
async def reload_server(self, repo: str) -> None:
async with self._lock:
state = self.servers.get(repo)
if not state:
raise ValueError(f"Unknown MCP server: {repo}")
await self._stop_server(state)
await self._start_server(repo, state)
await self.publish_status()
async def upgrade_server(self, repo: str) -> None:
async with self._lock:
state = self.servers.get(repo)
if not state:
raise ValueError(f"Unknown MCP server: {repo}")
await self._stop_server(state)
await self._prepare_repo(repo, state)
await self._start_server(repo, state)
await self.publish_status()
async def reload_all(self) -> None:
async with self._lock:
for repo in settings.mcp_repo_list:
state = self.servers.get(repo)
if not state:
continue
try:
await self._stop_server(state)
await self._start_server(repo, state)
except Exception as exc: # noqa: BLE001
state.status = "error"
state.error = str(exc)
await self.publish_status()
async def process_command_queue_once(self) -> bool:
from app.mcp_manager.commands import (
MCP_COMMAND_QUEUE,
process_mcp_command,
run_develop_command_background,
store_mcp_command_result,
)
from app.services.events import get_redis
item = await get_redis().lpop(MCP_COMMAND_QUEUE)
if not item:
return False
cmd = json.loads(item)
if cmd.get("action") == "develop":
asyncio.create_task(run_develop_command_background(cmd, self))
return True
result = await process_mcp_command(cmd)
await store_mcp_command_result(cmd["request_id"], result)
return True
def find_server_for_tool(self, tool_name: str) -> str | None:
for name, state in self.servers.items():
if any(t["name"] == tool_name for t in state.tools):
return name
return None
async def call_tool(self, server: str, tool: str, args: dict) -> dict:
state = self.servers.get(server)
if not state or state.status != "loaded" or state.session is None:
raise RuntimeError(f"MCP server '{server}' is not available")
result = await state.session.call_tool(tool, args) # type: ignore[attr-defined]
content = []
for block in getattr(result, "content", []) or []:
text = getattr(block, "text", None)
if text is not None:
content.append(text)
return {
"isError": getattr(result, "isError", False),
"text": "\n".join(content),
"structured": getattr(result, "structuredContent", None),
}
def status_snapshot(self) -> list[dict]:
return [
{
"name": s.name,
"status": s.status,
"tools": s.tools,
"error": s.error,
"source": "local" if self._is_local_repo(s.name) else "git",
}
for s in self.servers.values()
]
async def publish_status(self) -> None:
from app.services.events import get_redis
await get_redis().set(MCP_STATUS_KEY, json.dumps(self.status_snapshot()))
@staticmethod
async def _run(cmd: list[str], cwd: str | None = None) -> tuple[int, str]:
proc = await asyncio.create_subprocess_exec(
*cmd,
cwd=cwd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
out, _ = await proc.communicate()
return proc.returncode or 0, out.decode(errors="replace")
_manager: McpManager | None = None
def get_manager() -> McpManager:
global _manager
if _manager is None:
_manager = McpManager()
return _manager