Files
Agentic-OS/backend/app/services/mcp_approvals.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

209 lines
6.5 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.

"""Apply or roll back MCP patches after human approval (worker-side)."""
from __future__ import annotations
import logging
from collections.abc import Awaitable, Callable
from typing import Any
from app.config import settings
from app.services import mcp_workspace
logger = logging.getLogger(__name__)
EmitFn = Callable[[int, str, str, dict | None], Awaitable[None]]
async def backup_proposal_files(server: str, edits: list[dict]) -> dict[str, str]:
"""Snapshot current workspace content before a proposed patch is applied."""
backups: dict[str, str] = {}
for edit in edits:
path = edit.get("path") or edit.get("file")
if not path:
continue
try:
backups[path] = await mcp_workspace.read_source_file(server, path)
except OSError:
backups[path] = ""
return backups
async def restore_file_backups(server: str, backups: dict[str, str]) -> list[str]:
restored: list[str] = []
for path, content in backups.items():
await mcp_workspace.write_source_file(server, path, content)
restored.append(path)
return restored
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 queue_git_push_approval(
*,
task_id: int,
server: str,
written: list[str],
commit_msg: str,
summary: str | None,
emit: EmitFn,
) -> dict[str, Any] | None:
if not settings.gitea_token or not written or task_id <= 0:
return None
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=summary,
status=ApprovalStatus.pending,
)
)
await db.commit()
await emit(
task_id,
"approval",
f"MCP patch applied — approve to push {server} to Gitea",
{"server": server, "tool_name": "mcp_git_push", "files": written},
)
return {"queued": True, "tool_name": "mcp_git_push"}
async def maybe_retry_tool_after_patch(
*,
task_id: int,
retry_context: dict | None,
manager,
emit: EmitFn,
) -> dict[str, Any] | None:
if not retry_context:
return None
device = retry_context.get("device") or "device"
server = retry_context.get("server") or ""
tool = retry_context.get("tool") or ""
args = retry_context.get("args") or {}
if not tool:
return None
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 approved 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 approved MCP patch)",
tool_event_payload(
device=device,
tool=tool,
args=args,
status="done",
ok=ok,
output=res.get("text", ""),
),
)
return {"ok": ok, "result": res}
async def execute_approved_patch(
*,
task_id: int,
tool_args: dict,
manager,
emit: EmitFn | None = None,
push: bool | None = None,
) -> dict[str, Any]:
"""Apply a previously approved MCP patch, reload the server, optionally push/retry."""
from app.services.events import publish_event
async def _emit(tid: int, kind: str, message: str, payload: dict | None = None) -> None:
if emit:
await emit(tid, kind, message, payload)
elif tid:
await publish_event(tid, kind, message, payload)
server = tool_args.get("server") or ""
edits = tool_args.get("files") or []
commit_msg = tool_args.get("commit_message") or "agentic-os: MCP patch"
summary = tool_args.get("summary")
retry_context = tool_args.get("retry_context")
if not server or not edits:
return {"ok": False, "error": "Invalid MCP patch approval payload"}
await _emit(
task_id,
"mcp_dev",
f"Applying approved MCP patch to {server}",
{"server": server, "phase": "apply", "files": [e.get("path") for e in edits]},
)
reload_result = await apply_and_reload_mcp(server, edits, 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")},
)
written = reload_result.get("files") or []
should_push = push if push is not None else settings.mcp_dev_auto_push
push_result: dict[str, Any] | None = None
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:
push_result = await queue_git_push_approval(
task_id=task_id,
server=server,
written=written,
commit_msg=commit_msg,
summary=summary,
emit=_emit,
)
retry_result = await maybe_retry_tool_after_patch(
task_id=task_id,
retry_context=retry_context,
manager=manager,
emit=_emit,
)
return {
"ok": True,
"summary": summary,
"reload": reload_result,
"push": push_result,
"retry": retry_result,
"tools": reload_result.get("tools"),
}