"""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.mcp_approvals import ( backup_proposal_files, execute_approved_patch, ) 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 _queue_apply_patch_approval( *, task_id: int, server: str, proposal: dict, tool: str, args: dict, device: str | None, emit: EmitFn, ) -> dict[str, Any]: from app.database import SessionLocal from app.models.enums import ApprovalStatus from app.models.task import ApprovalRequest edits = proposal.get("files") or [] backups = await backup_proposal_files(server, edits) commit_msg = proposal.get("commit_message") or f"agentic-os: fix {tool} on {server}" approval_args = { "server": server, "files": edits, "backups": backups, "commit_message": commit_msg, "summary": proposal.get("summary"), "retry_context": { "device": device, "server": server, "tool": tool, "args": args, }, } async with SessionLocal() as db: db.add( ApprovalRequest( task_id=task_id, tool_name="mcp_apply_patch", tool_args=approval_args, risk=proposal.get("summary"), status=ApprovalStatus.pending, ) ) await db.commit() await emit( task_id, "approval", f"MCP patch proposed for {server} — approve to apply and reload", { "server": server, "tool_name": "mcp_apply_patch", "files": [e.get("path") for e in edits], "summary": proposal.get("summary"), }, ) return { "ok": True, "pending_approval": True, "approval_type": "mcp_apply_patch", "summary": proposal.get("summary"), } 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, device: str | None = None, ) -> dict[str, Any]: """Full repair loop: analyze → (approval?) → 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 if settings.mcp_dev_require_approval and task_id > 0: return await _queue_apply_patch_approval( task_id=task_id, server=server, proposal=proposal, tool=tool, args=args, device=device, emit=emit, ) 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"}, ) return await execute_approved_patch( task_id=task_id, tool_args={ "server": server, "files": proposal.get("files") or [], "commit_message": proposal.get("commit_message") or f"agentic-os: fix {tool} on {server}", "summary": proposal.get("summary"), "retry_context": { "device": device, "server": server, "tool": tool, "args": args, }, }, manager=manager, emit=emit, push=push, ) 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, device=device, ) if not result.get("ok"): return None if result.get("pending_approval"): 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"], ) retry = result.get("retry") if retry and retry.get("result") is not None: return retry["result"], bool(retry.get("ok")) 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