Files
Agentic-OS/backend/app/agent/mcp_development.py
nearxos 0375b20bb4 Enhance MCP development features and introduce skills management
- Added configuration options for requiring human approval before applying LLM-generated MCP patches.
- Updated Docker setup to include skills directory.
- Integrated skills management into the backend, allowing for procedural guides and skill matching.
- Refactored database initialization to apply Alembic migrations.
- Enhanced task approval process to handle MCP patch applications with optional approval.
- Introduced new schemas for skills and updated existing APIs to support skills functionality.

This commit lays the groundwork for improved agent capabilities and better management of MCP development processes.
2026-06-14 22:27:24 +03:00

329 lines
9.8 KiB
Python
Raw Permalink 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.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