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