Files
Agentic-OS/backend/app/agent/mcp_development.py
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
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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