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:
294
backend/app/agent/mcp_development.py
Normal file
294
backend/app/agent/mcp_development.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""Agent-driven MCP extension and bug-fix workflow."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
|
||||
from app.agent.json_utils import parse_json as _parse_json
|
||||
from app.config import settings
|
||||
from app.services import mcp_workspace
|
||||
from app.services.model_config import get_routing_config, resolve_tier_model
|
||||
from app.services.model_router import build_chat_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EmitFn = Callable[[int, str, str, dict | None], Awaitable[None]]
|
||||
|
||||
_REPAIRABLE = (
|
||||
"unknown tool",
|
||||
"traceback",
|
||||
"nameerror",
|
||||
"attributeerror",
|
||||
"typeerror",
|
||||
"not defined",
|
||||
"target' is not defined",
|
||||
"missing required",
|
||||
"unexpected keyword",
|
||||
"has no attribute",
|
||||
)
|
||||
|
||||
|
||||
def is_mcp_bug_or_gap(tool: str, error: str | None, output: str | None) -> bool:
|
||||
text = f"{error or ''} {output or ''}".lower()
|
||||
if "unknown tool" in text and tool.lower() in text:
|
||||
return True
|
||||
return any(sig in text for sig in _REPAIRABLE)
|
||||
|
||||
|
||||
async def _load_context_files(server: str) -> dict[str, str]:
|
||||
files: dict[str, str] = {}
|
||||
for rel in await mcp_workspace.list_source_files(server):
|
||||
if rel == "server.py" or rel.endswith("server.py") or rel == "pyproject.toml":
|
||||
try:
|
||||
files[rel] = await mcp_workspace.read_source_file(server, rel)
|
||||
except OSError:
|
||||
pass
|
||||
if "server.py" not in files:
|
||||
listed = await mcp_workspace.list_source_files(server, max_files=5)
|
||||
for rel in listed[:3]:
|
||||
try:
|
||||
files[rel] = await mcp_workspace.read_source_file(server, rel, max_chars=40_000)
|
||||
except OSError:
|
||||
pass
|
||||
return files
|
||||
|
||||
|
||||
async def propose_mcp_patch(
|
||||
*,
|
||||
server: str,
|
||||
tool: str,
|
||||
issue: str,
|
||||
error: str | None,
|
||||
output: str | None,
|
||||
task_title: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Use LLM to propose MCP source file edits."""
|
||||
cfg = await get_routing_config()
|
||||
tier = settings.mcp_dev_model_tier if hasattr(settings, "mcp_dev_model_tier") else "premium"
|
||||
entry = resolve_tier_model(cfg, tier) or resolve_tier_model(cfg, "economy")
|
||||
if not entry:
|
||||
return {"ok": False, "error": "No LLM available for MCP development"}
|
||||
|
||||
files = await _load_context_files(server)
|
||||
tools = mcp_workspace.list_registered_tools(server)
|
||||
|
||||
prompt = (
|
||||
"You are an expert Python MCP (Model Context Protocol) developer using FastMCP patterns. "
|
||||
"Given a failing tool call or missing capability, patch the MCP server source. "
|
||||
"Add new @mcp.tool() functions when a capability is missing; fix bugs when tools crash. "
|
||||
"Match existing code style. Keep changes minimal and safe (read-only tools unless clearly needed). "
|
||||
'Respond ONLY as JSON: {"summary":"...", "commit_message":"fix: ...", '
|
||||
'"files":[{"path":"server.py","content":"FULL file content after edit"}]}'
|
||||
)
|
||||
ctx = (
|
||||
f"MCP server: {server}\n"
|
||||
f"Registered tools: {', '.join(tools) or 'unknown'}\n"
|
||||
f"Task: {task_title}\n"
|
||||
f"Issue: {issue}\n"
|
||||
f"Tool involved: {tool}\n"
|
||||
f"Error: {error or 'none'}\n"
|
||||
f"Output: {(output or '')[:2000]}\n\n"
|
||||
f"Source files:\n"
|
||||
+ "\n\n".join(f"--- {p} ---\n{c[:25000]}" for p, c in files.items())
|
||||
)
|
||||
|
||||
model = build_chat_model(entry, temperature=0.1)
|
||||
resp = await model.ainvoke([SystemMessage(content=prompt), HumanMessage(content=ctx)])
|
||||
data = _parse_json(str(resp.content))
|
||||
if not data.get("files"):
|
||||
return {"ok": False, "error": "LLM did not return file edits", "raw": str(resp.content)[:500]}
|
||||
return {"ok": True, **data}
|
||||
|
||||
|
||||
async def apply_and_reload_mcp(
|
||||
server: str,
|
||||
edits: list[dict],
|
||||
manager,
|
||||
) -> dict[str, Any]:
|
||||
written = await mcp_workspace.apply_file_edits(server, edits)
|
||||
if not written:
|
||||
return {"ok": False, "error": "No files written"}
|
||||
await manager.reload_server(server)
|
||||
return {"ok": True, "files": written, "tools": mcp_workspace.list_registered_tools(server)}
|
||||
|
||||
|
||||
async def attempt_mcp_repair(
|
||||
*,
|
||||
task_id: int,
|
||||
server: str,
|
||||
tool: str,
|
||||
args: dict,
|
||||
issue: str,
|
||||
error: str | None,
|
||||
output: str | None,
|
||||
manager,
|
||||
emit: EmitFn,
|
||||
task_title: str = "",
|
||||
push: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Full repair loop: analyze → patch → reload → optional git push."""
|
||||
if not settings.mcp_development_enabled:
|
||||
return {"ok": False, "skipped": True, "reason": "MCP development disabled"}
|
||||
|
||||
await emit(
|
||||
task_id,
|
||||
"mcp_dev",
|
||||
f"Analyzing MCP '{server}' to fix or add capability for {tool}…",
|
||||
{"server": server, "tool": tool, "phase": "analyze"},
|
||||
)
|
||||
|
||||
proposal = await propose_mcp_patch(
|
||||
server=server,
|
||||
tool=tool,
|
||||
issue=issue,
|
||||
error=error,
|
||||
output=output,
|
||||
task_title=task_title,
|
||||
)
|
||||
if not proposal.get("ok"):
|
||||
await emit(task_id, "mcp_dev", f"MCP patch proposal failed: {proposal.get('error')}", proposal)
|
||||
return proposal
|
||||
|
||||
await emit(
|
||||
task_id,
|
||||
"mcp_dev",
|
||||
proposal.get("summary", "Applying MCP patch…"),
|
||||
{"server": server, "phase": "patch", "files": [f.get("path") for f in proposal.get("files", [])]},
|
||||
)
|
||||
|
||||
await emit(
|
||||
task_id,
|
||||
"mcp_dev",
|
||||
f"Reloading MCP '{server}' with patched source…",
|
||||
{"server": server, "phase": "reload"},
|
||||
)
|
||||
|
||||
reload_result = await apply_and_reload_mcp(server, proposal.get("files") or [], manager)
|
||||
if not reload_result.get("ok"):
|
||||
await emit(task_id, "error", reload_result.get("error", "MCP reload failed"), reload_result)
|
||||
return reload_result
|
||||
|
||||
await emit(
|
||||
task_id,
|
||||
"mcp_dev",
|
||||
f"MCP '{server}' reloaded — {len(reload_result.get('tools') or [])} tools registered",
|
||||
{"server": server, "phase": "reload_done", "tools": reload_result.get("tools")},
|
||||
)
|
||||
|
||||
should_push = push if push is not None else settings.mcp_dev_auto_push
|
||||
push_result: dict[str, Any] | None = None
|
||||
commit_msg = proposal.get("commit_message") or f"agentic-os: fix {tool} on {server}"
|
||||
written = reload_result.get("files") or []
|
||||
|
||||
if should_push and settings.gitea_token:
|
||||
push_result = await mcp_workspace.commit_and_push(server, commit_msg, written)
|
||||
await emit(
|
||||
task_id,
|
||||
"mcp_dev",
|
||||
f"Git push {'ok' if push_result.get('ok') else 'failed'}: {push_result.get('commit') or push_result.get('error')}",
|
||||
{"server": server, "phase": "push", **push_result},
|
||||
)
|
||||
elif settings.gitea_token and written and task_id > 0:
|
||||
from app.database import SessionLocal
|
||||
from app.models.enums import ApprovalStatus
|
||||
from app.models.task import ApprovalRequest
|
||||
|
||||
async with SessionLocal() as db:
|
||||
db.add(
|
||||
ApprovalRequest(
|
||||
task_id=task_id,
|
||||
tool_name="mcp_git_push",
|
||||
tool_args={
|
||||
"server": server,
|
||||
"files": written,
|
||||
"commit_message": commit_msg,
|
||||
},
|
||||
risk=proposal.get("summary"),
|
||||
status=ApprovalStatus.pending,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
await emit(
|
||||
task_id,
|
||||
"approval",
|
||||
f"MCP patch ready — approve to push {server} to Gitea",
|
||||
{"server": server, "tool_name": "mcp_git_push", "files": written},
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"summary": proposal.get("summary"),
|
||||
"reload": reload_result,
|
||||
"push": push_result,
|
||||
"tools": reload_result.get("tools"),
|
||||
}
|
||||
|
||||
|
||||
async def maybe_repair_after_tool_failure(
|
||||
*,
|
||||
task_id: int,
|
||||
device: str,
|
||||
server: str,
|
||||
tool: str,
|
||||
args: dict,
|
||||
issue: str,
|
||||
error: str | None,
|
||||
output: str | None,
|
||||
manager,
|
||||
emit: EmitFn,
|
||||
task_title: str = "",
|
||||
) -> tuple[dict, bool] | None:
|
||||
"""If failure looks MCP-related, try repair once and return retry (res, ok) or None."""
|
||||
if not settings.mcp_development_enabled:
|
||||
return None
|
||||
if not is_mcp_bug_or_gap(tool, error, output):
|
||||
return None
|
||||
|
||||
result = await attempt_mcp_repair(
|
||||
task_id=task_id,
|
||||
server=server,
|
||||
tool=tool,
|
||||
args=args,
|
||||
issue=issue,
|
||||
error=error,
|
||||
output=output,
|
||||
manager=manager,
|
||||
emit=emit,
|
||||
task_title=task_title,
|
||||
push=settings.mcp_dev_auto_push,
|
||||
)
|
||||
if not result.get("ok"):
|
||||
return None
|
||||
|
||||
if result.get("push") and not result["push"].get("ok") and result["push"].get("runtime_only"):
|
||||
await emit(
|
||||
task_id,
|
||||
"log",
|
||||
"MCP patched in runtime — no git remote; copy changes to mcp-servers or Gitea manually.",
|
||||
result["push"],
|
||||
)
|
||||
|
||||
from app.agent.mcp_helpers import tool_result_ok
|
||||
from app.agent.tool_events import tool_event_payload
|
||||
from app.mcp_manager.recovery import call_tool_resilient
|
||||
|
||||
await emit(task_id, "log", f"Retrying {tool} after MCP patch…", {"server": server, "tool": tool})
|
||||
res = await call_tool_resilient(manager, server, tool, args, task_id=task_id, emit=emit)
|
||||
ok = tool_result_ok(res)
|
||||
await emit(
|
||||
task_id,
|
||||
"tool_call",
|
||||
f"{device} › {tool} (after MCP patch)",
|
||||
tool_event_payload(
|
||||
device=device,
|
||||
tool=tool,
|
||||
args=args,
|
||||
status="done",
|
||||
ok=ok,
|
||||
output=res.get("text", ""),
|
||||
),
|
||||
)
|
||||
return res, ok
|
||||
Reference in New Issue
Block a user