diff --git a/backend/app/__main__.py b/backend/app/__main__.py index 4718a778..42bc402f 100644 --- a/backend/app/__main__.py +++ b/backend/app/__main__.py @@ -105,22 +105,6 @@ def main(): except Exception as exc: log.warning("⚠️ Startup health check error: %s", exc) - # MCP Integrity Check (fast structural only) - try: - from .api.mcp_guardrails import validate_mcp_integrity - report = validate_mcp_integrity() - if not report["ok"]: - log.warning("⚠️ MCP integrity check FAILED: %s", report["errors"]) - log.warning(" Run 'skill_mcp_self_heal' to attempt auto-repair") - else: - log.info("✅ MCP integrity check passed") - except Exception as exc: - log.warning("⚠️ MCP integrity check error: %s", exc) - - # ── Auto-Start Hivemind (port 8490) ──────────────────────── - from .mcp.hivemind_launcher import start_hivemind - start_hivemind() - # Delegate to snackbar from .core.snackbar import main as run_snackbar run_snackbar() diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py index f885a78e..6d4c072d 100644 --- a/backend/app/api/chat.py +++ b/backend/app/api/chat.py @@ -321,7 +321,7 @@ async def _execute_tool(tool_name: str, arguments: dict) -> str: return json.dumps({ "ollama": {"status": "checking"}, "backend": {"status": "online"}, - "mcp_servers": {"count": "check /api/mcp/diagnostics for details"}, + "mcp_gateway": {"mode": "external stdio client"}, }) if tool_name == "binder_create": diff --git a/backend/app/api/developer_api.py b/backend/app/api/developer_api.py index aedafab8..8d225dbd 100644 --- a/backend/app/api/developer_api.py +++ b/backend/app/api/developer_api.py @@ -1031,11 +1031,9 @@ async def handle_developer_chat(request: web.Request) -> web.Response: "• /api/developer/repos/{name}/file-preview?path=... — preview file content\n" "• /api/developer/repos/{name}/stage — stage a file (POST)\n" "• /api/developer/repos/{name}/commit — commit staged files (POST)\n\n" - "**Skills & MCP:**\n" - "• /api/skills — list all 54+ built-in skills\n" - "• /api/skills/{skill_id}/run — execute a named skill\n" - "• /api/mcp/tools — list MCP server tools\n" - "• /api/mcp/diagnostics — MCP health diagnostics\n\n" + "**Skills:**\n" + "• /api/skills — list governed built-in skills\n" + "• /api/skills/{skill_id}/run — execute a named skill\n\n" "**Health & System:**\n" "• /api/control/status — full ecosystem health (Ollama, Hivemind, providers, etc.)\n" "• /api/ollama/status — Ollama model status\n" diff --git a/backend/app/api/mcp.py b/backend/app/api/mcp.py deleted file mode 100644 index b1d3554f..00000000 --- a/backend/app/api/mcp.py +++ /dev/null @@ -1,582 +0,0 @@ -"""MCP Integration — Expose uCore skills/tools as MCP tools. - -The Model Context Protocol (MCP) lets compatible external clients -discover and call uCore skills directly. - -MCP Server spec: https://modelcontextprotocol.io - -Guardrails: Run ``validate_mcp_integrity()`` from ``app.api.mcp_guardrails`` -to check structural health. The ``skill_mcp_self_heal`` skill can auto-repair -common issues. -""" - -from __future__ import annotations - -import json -import logging -from typing import Any - -import aiohttp -from aiohttp import web - -from app.api.mcp_handlers import dispatch_tool -from app.clipboard.clipboard_buffer import ( - capture_current_clipboard, - delete_item, - get_item_by_id, - get_recent_items, -) -from app.services.gridsmith_bridge import get_gridsmith_bridge -from app.services.tasker_ops import ( - list_tasker_boards, - read_task_markdown, - write_task_markdown, -) -from app.skills.registry import get_skill -from app.skills.registry import list_skills as _list_skills - -log = logging.getLogger("ucore.api.mcp") - - -# ─── MCP Discovery ──────────────────────────────────────── - - -async def handle_mcp_discover(request: web.Request) -> web.Response: - """GET /api/mcp/tools — Return all available MCP tools. - - Exposes every registered skill plus built-in knowledge, clipboard, - tasker, gridsmith, toon, and flow-router tools. - """ - tools: list[dict[str, Any]] = [] - - # ── Skill tools ───────────────────────────────────────── - for skill_meta in _list_skills(): - tools.append( - { - "name": f"skill_{skill_meta['id']}", - "description": skill_meta.get("description", ""), - "input_schema": { - "type": "object", - "properties": { - p["name"]: { - "type": p.get("type", "string"), - "description": p.get("description", ""), - } - for p in skill_meta.get("params", []) - }, - }, - } - ) - - # ── Knowledge tools ────────────────────────────────────── - tools.append( - { - "name": "knowledge_search", - "description": "Semantic search across knowledge workspaces", - "input_schema": { - "type": "object", - "properties": { - "query": {"type": "string", "description": "Search query"}, - "workspace_id": {"type": "string", "description": "Optional workspace filter"}, - "limit": { - "type": "number", - "description": "Max results (default 10)", - "default": 10, - }, - }, - "required": ["query"], - }, - } - ) - tools.append( - { - "name": "knowledge_list_workspaces", - "description": "List all knowledge workspaces", - "input_schema": {"type": "object", "properties": {}}, - } - ) - tools.append( - { - "name": "knowledge_list_documents", - "description": "List documents in a workspace", - "input_schema": { - "type": "object", - "properties": { - "workspace_id": { - "type": "string", - "description": "Workspace ID (omit for all)", - }, - }, - }, - } - ) - - # ── Clipboard tools ────────────────────────────────────── - tools.append( - { - "name": "clipboard_capture", - "description": "Capture current clipboard content", - "input_schema": { - "type": "object", - "properties": { - "source": { - "type": "string", - "description": "Capture source", - "default": "user_copy", - }, - "metadata": {"type": "object", "description": "Optional metadata"}, - }, - }, - } - ) - tools.append( - { - "name": "clipboard_get", - "description": "Get clipboard item(s)", - "input_schema": { - "type": "object", - "properties": { - "item_id": { - "type": "string", - "description": "Specific item ID (omit for recent)", - }, - "limit": { - "type": "number", - "description": "Max items (default 50)", - "default": 50, - }, - "include_pinned": { - "type": "boolean", - "description": "Include pinned items", - "default": True, - }, - }, - }, - } - ) - tools.append( - { - "name": "clipboard_delete", - "description": "Delete a clipboard item", - "input_schema": { - "type": "object", - "properties": { - "item_id": {"type": "string", "description": "Item to delete"}, - }, - "required": ["item_id"], - }, - } - ) - - # ── Tasker tools ───────────────────────────────────────── - tools.append( - { - "name": "tasker_list_boards", - "description": "List all tasker boards", - "input_schema": { - "type": "object", - "properties": { - "tasker_dir": {"type": "string", "description": "Custom tasker directory"}, - }, - }, - } - ) - tools.append( - { - "name": "tasker_read_task", - "description": "Read a task from a board", - "input_schema": { - "type": "object", - "properties": { - "board": {"type": "string", "description": "Board name"}, - "task": {"type": "string", "description": "Task ID"}, - "tasker_dir": {"type": "string", "description": "Custom tasker directory"}, - }, - "required": ["board", "task"], - }, - } - ) - tools.append( - { - "name": "tasker_write_task", - "description": "Create or update a task", - "input_schema": { - "type": "object", - "properties": { - "title": {"type": "string", "description": "Task title"}, - "board": { - "type": "string", - "description": "Board name (default: inbox)", - "default": "inbox", - }, - "status": { - "type": "string", - "description": "Task status (default: todo)", - "default": "todo", - }, - "body": {"type": "string", "description": "Task body/markdown"}, - "source": {"type": "string", "description": "Creation source"}, - "source_id": {"type": "string", "description": "Source ID"}, - "metadata": {"type": "object", "description": "Custom metadata"}, - "task": {"type": "string", "description": "Task ID (for updates)"}, - "tasker_dir": {"type": "string", "description": "Custom tasker directory"}, - }, - "required": ["title"], - }, - } - ) - tools.append( - { - "name": "tasker_sync_export", - "description": "Sync and export tasker data", - "input_schema": { - "type": "object", - "properties": { - "tasker_dir": {"type": "string", "description": "Custom tasker directory"}, - }, - }, - } - ) - - # ── Gridsmith tools ────────────────────────────────────── - tools.append( - { - "name": "gridsmith_tools_list", - "description": "List available gridsmith tools", - "input_schema": {"type": "object", "properties": {}}, - } - ) - tools.append( - { - "name": "gridsmith_create_grid", - "description": "Create a new grid", - "input_schema": { - "type": "object", - "properties": { - "cols": { - "type": "number", - "description": "Columns (default 80)", - "default": 80, - }, - "rows": {"type": "number", "description": "Rows (default 24)", "default": 24}, - }, - }, - } - ) - tools.append( - { - "name": "gridsmith_latlon_to_ucode", - "description": "Convert lat/lon to uCode coordinate", - "input_schema": { - "type": "object", - "properties": { - "lat": {"type": "number", "description": "Latitude"}, - "lon": {"type": "number", "description": "Longitude"}, - "level": { - "type": "number", - "description": "Precision level (default 340)", - "default": 340, - }, - }, - "required": ["lat", "lon"], - }, - } - ) - tools.append( - { - "name": "gridsmith_ucode_to_latlon", - "description": "Convert uCode coordinate to lat/lon", - "input_schema": { - "type": "object", - "properties": { - "coord": {"type": "string", "description": "uCode coordinate"}, - }, - "required": ["coord"], - }, - } - ) - tools.append( - { - "name": "gridsmith_import_basic_program", - "description": "Import a basic program into a world", - "input_schema": { - "type": "object", - "properties": { - "program": {"type": "string", "description": "Program code"}, - "world_name": {"type": "string", "description": "Target world name"}, - }, - "required": ["program", "world_name"], - }, - } - ) - - # ── Toon tools ─────────────────────────────────────────── - tools.append( - { - "name": "toon_encode", - "description": "Encode data to toon format", - "input_schema": { - "type": "object", - "properties": { - "data": {"type": "string", "description": "Data to encode"}, - }, - "required": ["data"], - }, - } - ) - tools.append( - { - "name": "toon_stats", - "description": "Get toon encoding statistics", - "input_schema": {"type": "object", "properties": {}}, - } - ) - tools.append( - { - "name": "toon_clear", - "description": "Clear toon state", - "input_schema": {"type": "object", "properties": {}}, - } - ) - - # ── Flow Router tools ──────────────────────────────────── - tools.append( - { - "name": "flow_router_route", - "description": "Route a flow task", - "input_schema": { - "type": "object", - "properties": { - "task": {"type": "string", "description": "Task to route"}, - }, - "required": ["task"], - }, - } - ) - tools.append( - { - "name": "flow_router_analytics", - "description": "Get flow router analytics", - "input_schema": {"type": "object", "properties": {}}, - } - ) - tools.append( - { - "name": "flow_router_history", - "description": "Get flow routing history", - "input_schema": { - "type": "object", - "properties": { - "limit": { - "type": "number", - "description": "Max entries (default 100)", - "default": 100, - }, - }, - }, - } - ) - - return web.json_response( - { - "jsonrpc": "2.0", - "result": { - "tools": tools, - "protocolVersion": "2025-03-26", - "serverInfo": { - "name": "uCore MCP", - "version": "4.0.0", - }, - }, - "id": None, - } - ) - - -async def handle_mcp_call(request: web.Request) -> web.Response: - """POST /api/mcp/call — Execute a tool via MCP. - - Accepts multiple payload shapes: - - Standard MCP: { "name": "tool_name", "arguments": {...} } - - Compatible clients: { "tool": "tool_name", "params": {...} } or { "tool": "tool_name", "input": {...} } - - Delegates all tool execution to the modular dispatcher in mcp_handlers.py. - """ - try: - body = await request.json() - except Exception: - return web.json_response( - { - "jsonrpc": "2.0", - "error": {"code": -32700, "message": "Parse error"}, - "id": None, - }, - status=400, - ) - - return await dispatch_tool(body) - - -async def handle_mcp_diagnostics(request: web.Request) -> web.Response: - """GET /api/mcp/diagnostics — Return MCP layer diagnostics. - - Implements the spec from docs/mcp-diagnostics.md: - - current tool registry snapshot (names, ids, and schemas) - - last N tool calls attempted (from spool) - - health status and a quick router health check - - a short recommended remediation path - """ - from app.api.mcp_guardrails import validate_mcp_integrity - from app.skills.registry import list_skills as _list_skills - - # ── 1. Integrity check ────────────────────────────────── - try: - integrity_report = validate_mcp_integrity() - except Exception as exc: - log.error("MCP integrity check failed: %s", exc) - integrity_report = { - "ok": False, - "errors": [f"Integrity check crashed: {exc}"], - "checks": [], - } - - # ── 2. Tool registry snapshot ─────────────────────────── - from app.api.mcp_handlers import TOOL_HANDLERS - - tool_snapshot = [] - for name, handler in sorted(TOOL_HANDLERS.items()): - tool_snapshot.append( - { - "name": name, - "handler": getattr(handler, "__name__", str(handler)), - "module": getattr(handler, "__module__", "unknown"), - } - ) - - # Add dynamic skill tools - skill_tools = [] - for skill_meta in _list_skills(): - skill_tools.append( - { - "name": f"skill_{skill_meta['id']}", - "skill_id": skill_meta["id"], - "category": skill_meta.get("category", ""), - } - ) - - # ── 3. Recent tool calls from spool ───────────────────── - recent_calls: list[dict[str, Any]] = [] - try: - from app.services.spool_reader import read_spool - - spool_entries = read_spool(max_entries=20, search="mcp") - for entry in spool_entries: - recent_calls.append( - { - "timestamp": entry.timestamp, - "level": entry.level, - "module": entry.module, - "message": entry.message[:200], - "source": entry.source, - } - ) - except Exception: - # Spool reader may not be available - pass - - # ── 4. Backend runtime probes (MCP/Ollama/Hivemind) ───── - backend_health = { - "mcp_tools": await _probe_http_json("http://127.0.0.1:8484/api/mcp/tools"), - "ollama": await _probe_http_json("http://127.0.0.1:11434/api/tags"), - "hivemind": await _probe_http_json("http://127.0.0.1:8490/api/hivemind/llm/health"), - } - - # ── 5. Recommended remediation path ───────────────────── - remediation: list[str] = [] - if not integrity_report.get("ok", False): - for check in integrity_report.get("checks", []): - if check.get("ok"): - continue - check_name = check.get("check", "unknown") - if check_name == "syntax": - remediation.append( - "Fix syntax errors in mcp.py/mcp_handlers.py manually — " - 'run: python -c "from app.api.mcp_guardrails import validate_mcp_integrity; validate_mcp_integrity()"' - ) - elif check_name == "exports": - remediation.append( - "Restore missing MCP exports (handle_mcp_discover, handle_mcp_call) — " - "check git history for last known good version" - ) - elif check_name == "handler_signatures": - remediation.append( - "Run skill_mcp_self_heal with dry_run=false to auto-normalize handler signatures" - ) - elif check_name == "tool_registry": - remediation.append( - "Run skill_mcp_self_heal with dry_run=false to add missing tools to registry" - ) - elif check_name == "frontend_port_consistency": - remediation.append( - "Run skill_mcp_self_heal with dry_run=false to auto-fix stale port references" - ) - elif check_name == "dispatch_tool": - remediation.append("Restore dispatch_tool() in mcp_handlers.py — check git history") - - if not remediation: - remediation.append("No issues detected — MCP layer is healthy") - - if backend_health["ollama"]["ok"] is False: - remediation.append("Ollama health check failed — verify ollama daemon on :11434") - if backend_health["hivemind"]["ok"] is False: - remediation.append("Hivemind health check failed — start backend/mcp/start_hivemind.sh") - - return web.json_response( - { - "status": "ok" if integrity_report.get("ok") else "degraded", - "timestamp": _utc_now_iso(), - "integrity": integrity_report, - "backend_health": backend_health, - "tool_registry": { - "registered_tools": len(TOOL_HANDLERS), - "tools": tool_snapshot, - "skill_tools": skill_tools, - "skill_tool_count": len(skill_tools), - }, - "recent_calls": recent_calls, - "remediation": remediation, - } - ) - - -def _utc_now_iso() -> str: - from datetime import UTC, datetime - - return datetime.now(UTC).isoformat() - - -async def _probe_http_json(url: str, timeout_seconds: float = 2.0) -> dict[str, Any]: - """Best-effort HTTP JSON health probe for diagnostics payloads.""" - try: - timeout = aiohttp.ClientTimeout(total=timeout_seconds) - async with aiohttp.ClientSession(timeout=timeout) as session: - async with session.get(url) as resp: - text = await resp.text() - payload: Any - try: - payload = json.loads(text) if text else {} - except Exception: - payload = text[:500] - return { - "ok": 200 <= resp.status < 300, - "status": resp.status, - "url": url, - "payload": payload, - } - except Exception as exc: - return { - "ok": False, - "status": None, - "url": url, - "error": str(exc), - } diff --git a/backend/app/api/mcp_guardrails.py b/backend/app/api/mcp_guardrails.py deleted file mode 100644 index 1d53b090..00000000 --- a/backend/app/api/mcp_guardrails.py +++ /dev/null @@ -1,259 +0,0 @@ -#!/usr/bin/env python3 -"""MCP Integrity Guardrails — Structural validation for the MCP layer. - -This module provides runtime checks that catch the class of corruption -that broke mcp.py (garbled code from bad merges, missing functions, -orphaned dead code, mismatched handler signatures). - -Usage: - from app.api.mcp_guardrails import validate_mcp_integrity - - # At startup or in tests - report = validate_mcp_integrity() - if report["errors"]: - log.error("MCP integrity check failed: %s", report["errors"]) -""" -from __future__ import annotations - -import ast -import inspect -import logging -from pathlib import Path -from typing import Any - -log = logging.getLogger("ucore.api.mcp_guardrails") - -# ─── Expected MCP API surface ────────────────────────────────── - -REQUIRED_MCP_EXPORTS = { - "handle_mcp_discover", - "handle_mcp_call", -} - -REQUIRED_HANDLER_SIGNATURES = { - # All handlers must accept (arguments: dict, request_id: Any) - "handle_skill_tool": ["tool_name", "arguments", "request_id"], - "handle_knowledge_search": ["arguments", "request_id"], - "handle_knowledge_list_workspaces": ["arguments", "request_id"], - "handle_knowledge_list_documents": ["arguments", "request_id"], - "handle_clipboard_capture": ["arguments", "request_id"], - "handle_clipboard_get": ["arguments", "request_id"], - "handle_clipboard_delete": ["arguments", "request_id"], - "handle_tasker_list_boards": ["arguments", "request_id"], - "handle_tasker_read_task": ["arguments", "request_id"], - "handle_tasker_write_task": ["arguments", "request_id"], - "handle_tasker_sync_export": ["arguments", "request_id"], - "handle_flow_router_route": ["arguments", "request_id"], - "handle_flow_router_analytics": ["arguments", "request_id"], - "handle_flow_router_history": ["arguments", "request_id"], - "handle_gridsmith_tools_list": ["arguments", "request_id"], - "handle_gridsmith_create_grid": ["arguments", "request_id"], - "handle_gridsmith_latlon_to_ucode": ["arguments", "request_id"], - "handle_gridsmith_ucode_to_latlon": ["arguments", "request_id"], - "handle_gridsmith_import_basic_program": ["arguments", "request_id"], - "handle_toon_encode": ["arguments", "request_id"], - "handle_toon_stats": ["arguments", "request_id"], - "handle_toon_clear": ["arguments", "request_id"], -} - -# Domain modules that make up the mcp_handlers package -HANDLER_DOMAIN_MODULES = [ - "knowledge", - "clipboard", - "tasker", - "gridsmith", - "flow_router", - "toon", - "skill", -] - -REQUIRED_TOOL_NAMES = { - "knowledge_search", - "knowledge_list_workspaces", - "knowledge_list_documents", - "clipboard_capture", - "clipboard_get", - "clipboard_delete", - "tasker_list_boards", - "tasker_read_task", - "tasker_write_task", - "tasker_sync_export", - "flow_router_route", - "flow_router_analytics", - "flow_router_history", - "gridsmith_tools_list", - "gridsmith_create_grid", - "gridsmith_latlon_to_ucode", - "gridsmith_ucode_to_latlon", - "gridsmith_import_basic_program", - "toon_encode", - "toon_stats", - "toon_clear", -} - - -# ─── Validation functions ────────────────────────────────────── - -def validate_mcp_syntax() -> dict[str, Any]: - """Check that mcp.py, mcp_guardrails.py, and all mcp_handlers/ domain - modules parse as valid Python.""" - errors: list[str] = [] - api_dir = Path(__file__).parent - - # Top-level MCP source files - for name in ("mcp.py", "mcp_guardrails.py"): - path = api_dir / name - try: - ast.parse(path.read_text()) - except SyntaxError as e: - errors.append(f"{name}:{e.lineno}: {e.msg}") - except FileNotFoundError: - errors.append(f"{name}: file not found") - - # mcp_handlers package — walk all *.py files inside it - handlers_dir = api_dir / "mcp_handlers" - if not handlers_dir.is_dir(): - errors.append("mcp_handlers/: directory not found") - else: - for py_file in sorted(handlers_dir.rglob("*.py")): - rel = py_file.relative_to(api_dir) - try: - ast.parse(py_file.read_text()) - except SyntaxError as e: - errors.append(f"{rel}:{e.lineno}: {e.msg}") - - return {"check": "syntax", "errors": errors, "ok": len(errors) == 0} - - -def validate_mcp_exports() -> dict[str, Any]: - """Check that mcp.py exports the required functions.""" - import app.api.mcp as mcp_mod - - missing = REQUIRED_MCP_EXPORTS - set(dir(mcp_mod)) - return { - "check": "exports", - "missing": sorted(missing), - "ok": len(missing) == 0, - } - - -def validate_handler_signatures() -> dict[str, Any]: - """Check that all MCP handlers have the expected (arguments, request_id) signature.""" - import app.api.mcp_handlers as handlers_mod - - errors: list[str] = [] - for name, expected_params in REQUIRED_HANDLER_SIGNATURES.items(): - fn = getattr(handlers_mod, name, None) - if fn is None: - errors.append(f"Handler '{name}' not found in mcp_handlers") - continue - sig = inspect.signature(fn) - params = list(sig.parameters.keys()) - if params != expected_params: - errors.append( - f"Handler '{name}' signature is {params}, expected {expected_params}" - ) - return {"check": "handler_signatures", "errors": errors, "ok": len(errors) == 0} - - -def validate_tool_registry() -> dict[str, Any]: - """Check that TOOL_HANDLERS in mcp_handlers.py covers all required tools.""" - import app.api.mcp_handlers as handlers_mod - - registry = getattr(handlers_mod, "TOOL_HANDLERS", {}) - registered = set(registry.keys()) - missing = REQUIRED_TOOL_NAMES - registered - extra = registered - REQUIRED_TOOL_NAMES - {"skill_*"} - return { - "check": "tool_registry", - "missing": sorted(missing), - "extra": sorted(extra), - "registered_count": len(registered), - "ok": len(missing) == 0, - } - - -def validate_dispatch_tool() -> dict[str, Any]: - """Check that dispatch_tool exists and is callable.""" - import app.api.mcp_handlers as handlers_mod - - fn = getattr(handlers_mod, "dispatch_tool", None) - if fn is None: - return {"check": "dispatch_tool", "ok": False, "error": "dispatch_tool not found"} - if not callable(fn): - return {"check": "dispatch_tool", "ok": False, "error": "dispatch_tool not callable"} - sig = inspect.signature(fn) - params = list(sig.parameters.keys()) - if "body" not in params: - return {"check": "dispatch_tool", "ok": False, "error": f"dispatch_tool params: {params}"} - return {"check": "dispatch_tool", "ok": True} - - -def validate_frontend_port_consistency() -> dict[str, Any]: - """Check that all backend port references for the frontend use the correct port. - - The Vue frontend runs on port 5175. Stale references to 5173 (React) or - 5174 (old Vue) will cause the menu to fail to connect. - """ - import re - correct_port = "5175" - stale_ports = {"5173", "5174"} - patterns = [ - r"http://localhost:(\d+)", - r"port[=:]\s*(\d+)", - r"create_connection\(\(\"127\.0\.0\.1\",\s*(\d+)\)", - ] - errors: list[str] = [] - - # Check all Python files in the menu package (skip archived/) - menu_dir = Path(__file__).parent.parent / "menu" - for py_file in menu_dir.rglob("*.py"): - if "archived" in py_file.parts: - continue - content = py_file.read_text() - for pattern in patterns: - for match in re.finditer(pattern, content): - port = match.group(1) - if port in stale_ports: - errors.append( - f"{py_file.name}:{port} — stale port, should be {correct_port}" - ) - - return { - "check": "frontend_port_consistency", - "errors": errors, - "ok": len(errors) == 0, - } - - -def validate_mcp_integrity() -> dict[str, Any]: - """Run all MCP integrity checks and return a combined report. - - Returns: - dict with 'checks' list, 'errors' list, and 'ok' boolean. - """ - checks = [ - validate_mcp_syntax(), - validate_mcp_exports(), - validate_handler_signatures(), - validate_tool_registry(), - validate_dispatch_tool(), - validate_frontend_port_consistency(), - ] - all_errors = [] - for c in checks: - if not c.get("ok"): - all_errors.extend(c.get("errors", []) or c.get("missing", []) or [c.get("error", "unknown")]) - - report = { - "checks": checks, - "errors": all_errors, - "ok": len(all_errors) == 0, - } - - if report["ok"]: - log.info("MCP integrity check passed (%d checks)", len(checks)) - else: - log.error("MCP integrity check FAILED: %s", all_errors) - - return report diff --git a/backend/app/api/mcp_handlers/__init__.py b/backend/app/api/mcp_handlers/__init__.py deleted file mode 100644 index 9c9bdc4a..00000000 --- a/backend/app/api/mcp_handlers/__init__.py +++ /dev/null @@ -1,117 +0,0 @@ -"""MCP Tool Handlers — Modular domain submodules. - -This package splits the monolithic mcp_handlers.py into domain-specific -modules for better maintainability. Each module exports its handler functions -and is registered in the TOOL_HANDLERS registry below. - -Usage: - from app.api.mcp_handlers import dispatch_tool, TOOL_HANDLERS -""" - -from __future__ import annotations - -import logging -from typing import Any, Dict - -from aiohttp import web - -from app.api.mcp_handlers.clipboard import ( - handle_clipboard_capture, - handle_clipboard_delete, - handle_clipboard_get, -) -from app.api.mcp_handlers.flow_router import ( - handle_flow_router_analytics, - handle_flow_router_history, - handle_flow_router_route, -) -from app.api.mcp_handlers.gridsmith import ( - handle_gridsmith_create_grid, - handle_gridsmith_import_basic_program, - handle_gridsmith_latlon_to_ucode, - handle_gridsmith_tools_list, - handle_gridsmith_ucode_to_latlon, -) -from app.api.mcp_handlers.knowledge import ( - handle_knowledge_list_documents, - handle_knowledge_list_workspaces, - handle_knowledge_search, -) -from app.api.mcp_handlers.skill import ( - handle_skill_tool, -) -from app.api.mcp_handlers.tasker import ( - handle_tasker_list_boards, - handle_tasker_read_task, - handle_tasker_sync_export, - handle_tasker_write_task, -) -from app.api.mcp_handlers.toon import ( - handle_toon_clear, - handle_toon_encode, - handle_toon_stats, -) - -log = logging.getLogger("ucore.api.mcp_handlers") - - -# ─── Tool Handler Registry ───────────────────────────────────── - -TOOL_HANDLERS: dict[str, Any] = { - "knowledge_search": handle_knowledge_search, - "knowledge_list_workspaces": handle_knowledge_list_workspaces, - "knowledge_list_documents": handle_knowledge_list_documents, - "clipboard_capture": handle_clipboard_capture, - "clipboard_get": handle_clipboard_get, - "clipboard_delete": handle_clipboard_delete, - "tasker_list_boards": handle_tasker_list_boards, - "tasker_read_task": handle_tasker_read_task, - "tasker_write_task": handle_tasker_write_task, - "tasker_sync_export": handle_tasker_sync_export, - "flow_router_route": handle_flow_router_route, - "flow_router_analytics": handle_flow_router_analytics, - "flow_router_history": handle_flow_router_history, - "gridsmith_tools_list": handle_gridsmith_tools_list, - "gridsmith_create_grid": handle_gridsmith_create_grid, - "gridsmith_latlon_to_ucode": handle_gridsmith_latlon_to_ucode, - "gridsmith_ucode_to_latlon": handle_gridsmith_ucode_to_latlon, - "gridsmith_import_basic_program": handle_gridsmith_import_basic_program, - "toon_encode": handle_toon_encode, - "toon_stats": handle_toon_stats, - "toon_clear": handle_toon_clear, -} - - -async def dispatch_tool(body: Dict[str, Any]) -> web.Response: - """Dispatch an MCP request to the appropriate handler. - - Accepts multiple payload shapes: - - Standard MCP: { "name": "tool_name", "arguments": {...} } - - Compatible clients: { "tool": "tool_name", "params": {...} } - - Alternate: { "tool_name": "tool_name", "input": {...} } - - Delegates to individual handler functions by domain module. - """ - tool_name = body.get("name") or body.get("tool") or body.get("tool_name") or "" - arguments = body.get("arguments") or body.get("params") or body.get("input") or {} - request_id = body.get("id") - - log.info( - "[MCP call] tool=%r args_keys=%s", tool_name, list(arguments.keys()) if arguments else [] - ) - - if tool_name.startswith("skill_"): - return await handle_skill_tool(tool_name, arguments, request_id) - - handler = TOOL_HANDLERS.get(tool_name) - if handler: - return await handler(arguments, request_id) - - return web.json_response( - { - "jsonrpc": "2.0", - "error": {"code": -32601, "message": f"Tool '{tool_name}' not found"}, - "id": request_id, - }, - status=404, - ) diff --git a/backend/app/api/mcp_handlers/clipboard.py b/backend/app/api/mcp_handlers/clipboard.py deleted file mode 100644 index a056df3f..00000000 --- a/backend/app/api/mcp_handlers/clipboard.py +++ /dev/null @@ -1,88 +0,0 @@ -"""MCP handlers: Clipboard domain (capture, get, delete).""" -from __future__ import annotations - -import json -import logging -from typing import Any - -from aiohttp import web - -log = logging.getLogger("ucore.api.mcp_handlers.clipboard") - - -async def handle_clipboard_capture(arguments: dict, request_id: Any) -> web.Response: - """Handle clipboard_capture tool.""" - from app.api.mcp import capture_current_clipboard - - source = arguments.get("source", "user_copy") - metadata = arguments.get("metadata") or {} - try: - item = capture_current_clipboard(source=source, metadata=metadata) - except Exception as exc: - return web.json_response({ - "jsonrpc": "2.0", - "error": {"code": -32000, "message": str(exc)}, - "id": request_id, - }, status=400) - return web.json_response({ - "jsonrpc": "2.0", - "result": { - "content": [{"type": "text", "text": json.dumps(item, indent=2)}], - }, - "id": request_id, - }) - - -async def handle_clipboard_get(arguments: dict, request_id: Any) -> web.Response: - """Handle clipboard_get tool.""" - from app.api.mcp import get_item_by_id, get_recent_items - - item_id = arguments.get("item_id") - if item_id: - found_item = get_item_by_id(str(item_id)) - if not found_item: - return web.json_response({ - "jsonrpc": "2.0", - "error": {"code": -32602, "message": f"Item {item_id} not found"}, - "id": request_id, - }, status=404) - return web.json_response({ - "jsonrpc": "2.0", - "result": { - "content": [{"type": "text", "text": json.dumps(found_item, indent=2)}], - }, - "id": request_id, - }) - - limit = int(arguments.get("limit", 20)) - include_pinned = bool(arguments.get("include_pinned", True)) - items = get_recent_items(limit=limit, include_pinned=include_pinned) - return web.json_response({ - "jsonrpc": "2.0", - "result": { - "content": [{"type": "text", "text": json.dumps({"items": items, "count": len(items)}, indent=2)}], - }, - "id": request_id, - }) - - -async def handle_clipboard_delete(arguments: dict, request_id: Any) -> web.Response: - """Handle clipboard_delete tool.""" - from app.api.mcp import delete_item - - item_id = arguments.get("item_id") - if not item_id: - return web.json_response({ - "jsonrpc": "2.0", - "error": {"code": -32602, "message": "item_id is required"}, - "id": request_id, - }, status=400) - - success = delete_item(str(item_id)) - return web.json_response({ - "jsonrpc": "2.0", - "result": { - "content": [{"type": "text", "text": json.dumps({"status": "deleted" if success else "not_found", "id": item_id})}], - }, - "id": request_id, - }) diff --git a/backend/app/api/mcp_handlers/flow_router.py b/backend/app/api/mcp_handlers/flow_router.py deleted file mode 100644 index 093fea69..00000000 --- a/backend/app/api/mcp_handlers/flow_router.py +++ /dev/null @@ -1,62 +0,0 @@ -"""MCP handlers: Flow Router domain (route, analytics, history).""" -from __future__ import annotations - -import json -import logging -from typing import Any - -from aiohttp import web - -log = logging.getLogger("ucore.api.mcp_handlers.flow_router") - - -async def handle_flow_router_route(arguments: dict, request_id: Any) -> web.Response: - """Handle flow_router_route tool.""" - from app.api.flow_router import handle_flow_router_route as _route_handler - from app.skills.shared_utils import create_mock_request - - mock_request = create_mock_request(arguments) - response = await _route_handler(mock_request) - response_data = await response.json() - return web.json_response({ - "jsonrpc": "2.0", - "result": { - "content": [{"type": "text", "text": json.dumps(response_data, indent=2)}], - }, - "id": request_id, - }) - - -async def handle_flow_router_analytics(arguments: dict, request_id: Any) -> web.Response: - """Handle flow_router_analytics tool.""" - from app.api.flow_router.api import get_flow_router - - router = get_flow_router() - analytics = router.get_analytics() - return web.json_response({ - "jsonrpc": "2.0", - "result": { - "content": [{"type": "text", "text": json.dumps(analytics, indent=2)}], - }, - "id": request_id, - }) - - -async def handle_flow_router_history(arguments: dict, request_id: Any) -> web.Response: - """Handle flow_router_history tool.""" - from app.api.flow_router.api import get_flow_router - - try: - limit = int(arguments.get("limit", 100)) - except (TypeError, ValueError): - limit = 100 - - router = get_flow_router() - history = router.get_routing_history(limit=limit) - return web.json_response({ - "jsonrpc": "2.0", - "result": { - "content": [{"type": "text", "text": json.dumps(history, indent=2)}], - }, - "id": request_id, - }) diff --git a/backend/app/api/mcp_handlers/gridsmith.py b/backend/app/api/mcp_handlers/gridsmith.py deleted file mode 100644 index 30793bef..00000000 --- a/backend/app/api/mcp_handlers/gridsmith.py +++ /dev/null @@ -1,101 +0,0 @@ -"""MCP handlers: Gridsmith domain (grids, coordinates, programs).""" -from __future__ import annotations - -import json -import logging -from typing import Any - -from aiohttp import web - -log = logging.getLogger("ucore.api.mcp_handlers.gridsmith") - - -async def handle_gridsmith_tools_list(arguments: dict, request_id: Any) -> web.Response: - """Handle gridsmith_tools_list tool.""" - from app.api.mcp import get_gridsmith_bridge - - bridge = get_gridsmith_bridge() - payload = bridge.run("tools", "list") - return web.json_response({ - "jsonrpc": "2.0", - "result": { - "content": [{"type": "text", "text": json.dumps(payload, indent=2)}], - }, - "id": request_id, - }) - - -async def handle_gridsmith_create_grid(arguments: dict, request_id: Any) -> web.Response: - """Handle gridsmith_create_grid tool.""" - from app.api.mcp import get_gridsmith_bridge - - bridge = get_gridsmith_bridge() - payload = bridge.run( - "grid", "create", - "--cols", str(int(arguments.get("cols", 80))), - "--rows", str(int(arguments.get("rows", 24))), - ) - return web.json_response({ - "jsonrpc": "2.0", - "result": { - "content": [{"type": "text", "text": json.dumps(payload, indent=2)}], - }, - "id": request_id, - }) - - -async def handle_gridsmith_latlon_to_ucode(arguments: dict, request_id: Any) -> web.Response: - """Handle gridsmith_latlon_to_ucode tool.""" - from app.api.mcp import get_gridsmith_bridge - - bridge = get_gridsmith_bridge() - payload = bridge.run( - "location", "latlon-to-ucode", - "--lat", str(arguments.get("lat")), - "--lon", str(arguments.get("lon")), - "--level", str(int(arguments.get("level", 340))), - ) - return web.json_response({ - "jsonrpc": "2.0", - "result": { - "content": [{"type": "text", "text": json.dumps(payload, indent=2)}], - }, - "id": request_id, - }) - - -async def handle_gridsmith_ucode_to_latlon(arguments: dict, request_id: Any) -> web.Response: - """Handle gridsmith_ucode_to_latlon tool.""" - from app.api.mcp import get_gridsmith_bridge - - bridge = get_gridsmith_bridge() - payload = bridge.run( - "location", "ucode-to-latlon", - "--coord", str(arguments.get("coord", "")), - ) - return web.json_response({ - "jsonrpc": "2.0", - "result": { - "content": [{"type": "text", "text": json.dumps(payload, indent=2)}], - }, - "id": request_id, - }) - - -async def handle_gridsmith_import_basic_program(arguments: dict, request_id: Any) -> web.Response: - """Handle gridsmith_import_basic_program tool.""" - from app.api.mcp import get_gridsmith_bridge - - bridge = get_gridsmith_bridge() - payload = bridge.run( - "world", "import-basic", - "--program", str(arguments.get("program", "")), - "--world-name", str(arguments.get("world_name", "")), - ) - return web.json_response({ - "jsonrpc": "2.0", - "result": { - "content": [{"type": "text", "text": json.dumps(payload, indent=2)}], - }, - "id": request_id, - }) diff --git a/backend/app/api/mcp_handlers/knowledge.py b/backend/app/api/mcp_handlers/knowledge.py deleted file mode 100644 index d52b27b0..00000000 --- a/backend/app/api/mcp_handlers/knowledge.py +++ /dev/null @@ -1,71 +0,0 @@ -"""MCP handlers: Knowledge domain (search, workspaces, documents).""" -from __future__ import annotations - -import json -import logging -from typing import Any - -from aiohttp import web - -log = logging.getLogger("ucore.api.mcp_handlers.knowledge") - - -async def handle_knowledge_search(arguments: dict, request_id: Any) -> web.Response: - """Handle knowledge_search tool.""" - from app.knowledge.vault import semantic_search - - query = str(arguments.get("query", "")).strip() - if not query: - return web.json_response({ - "jsonrpc": "2.0", - "error": {"code": -32602, "message": "query is required"}, - "id": request_id, - }, status=400) - - try: - limit = int(arguments.get("limit", 10)) - except (TypeError, ValueError): - return web.json_response({ - "jsonrpc": "2.0", - "error": {"code": -32602, "message": "limit must be an integer"}, - "id": request_id, - }, status=400) - - workspace_id = arguments.get("workspace_id") - results = semantic_search(query, workspace_id, max(1, limit)) - return web.json_response({ - "jsonrpc": "2.0", - "result": { - "content": [{"type": "text", "text": json.dumps(results, indent=2)}], - }, - "id": request_id, - }) - - -async def handle_knowledge_list_workspaces(arguments: dict, request_id: Any) -> web.Response: - """Handle knowledge_list_workspaces tool.""" - from app.knowledge.vault import list_workspaces - - ws = list_workspaces() - return web.json_response({ - "jsonrpc": "2.0", - "result": { - "content": [{"type": "text", "text": json.dumps(ws, indent=2)}], - }, - "id": request_id, - }) - - -async def handle_knowledge_list_documents(arguments: dict, request_id: Any) -> web.Response: - """Handle knowledge_list_documents tool.""" - from app.knowledge.vault import list_documents - - workspace_id = arguments.get("workspace_id") - docs = list_documents(str(workspace_id) if workspace_id else None) - return web.json_response({ - "jsonrpc": "2.0", - "result": { - "content": [{"type": "text", "text": json.dumps(docs, indent=2)}], - }, - "id": request_id, - }) diff --git a/backend/app/api/mcp_handlers/skill.py b/backend/app/api/mcp_handlers/skill.py deleted file mode 100644 index 714f147e..00000000 --- a/backend/app/api/mcp_handlers/skill.py +++ /dev/null @@ -1,33 +0,0 @@ -"""MCP handlers: Dynamic skill dispatch (skill_xxx tools).""" -from __future__ import annotations - -import json -import logging -from typing import Any - -from aiohttp import web - -log = logging.getLogger("ucore.api.mcp_handlers.skill") - - -async def handle_skill_tool(tool_name: str, arguments: dict, request_id: Any) -> web.Response: - """Handle skill_xxx tool calls by dispatching to the skills registry.""" - from app.skills.registry import get_skill - - skill_id = tool_name[6:] # Remove "skill_" prefix - skill = get_skill(skill_id) - if not skill: - return web.json_response({ - "jsonrpc": "2.0", - "error": {"code": -32601, "message": f"Tool '{tool_name}' not found"}, - "id": request_id, - }, status=404) - - result = await skill.run(**arguments) - return web.json_response({ - "jsonrpc": "2.0", - "result": { - "content": [{"type": "text", "text": json.dumps(result, indent=2)}], - }, - "id": request_id, - }) diff --git a/backend/app/api/mcp_handlers/tasker.py b/backend/app/api/mcp_handlers/tasker.py deleted file mode 100644 index 6ac2ff69..00000000 --- a/backend/app/api/mcp_handlers/tasker.py +++ /dev/null @@ -1,119 +0,0 @@ -"""MCP handlers: Tasker domain (boards, tasks, sync).""" -from __future__ import annotations - -import json -import logging -from typing import Any - -from aiohttp import web - -log = logging.getLogger("ucore.api.mcp_handlers.tasker") - - -async def handle_tasker_list_boards(arguments: dict, request_id: Any) -> web.Response: - """Handle tasker_list_boards tool.""" - from app.api.mcp import list_tasker_boards - - boards = list_tasker_boards(arguments.get("tasker_dir")) - return web.json_response({ - "jsonrpc": "2.0", - "result": { - "content": [{"type": "text", "text": json.dumps(boards, indent=2)}], - }, - "id": request_id, - }) - - -async def handle_tasker_read_task(arguments: dict, request_id: Any) -> web.Response: - """Handle tasker_read_task tool.""" - from app.api.mcp import read_task_markdown - - board = arguments.get("board") - task = arguments.get("task") - if not board or not task: - return web.json_response({ - "jsonrpc": "2.0", - "error": {"code": -32602, "message": "board and task are required"}, - "id": request_id, - }, status=400) - - result = read_task_markdown( - board=board, task=task, tasker_dir=arguments.get("tasker_dir") - ) - return web.json_response({ - "jsonrpc": "2.0", - "result": { - "content": [{"type": "text", "text": json.dumps(result, indent=2)}], - }, - "id": request_id, - }) - - -async def handle_tasker_write_task(arguments: dict, request_id: Any) -> web.Response: - """Handle tasker_write_task tool.""" - from app.api.mcp import write_task_markdown - - title = str(arguments.get("title", "")) - if not title: - return web.json_response({ - "jsonrpc": "2.0", - "error": {"code": -32602, "message": "title is required"}, - "id": request_id, - }, status=400) - - metadata = arguments.get("metadata") - if metadata is not None and not isinstance(metadata, dict): - return web.json_response({ - "jsonrpc": "2.0", - "error": {"code": -32602, "message": "metadata must be an object"}, - "id": request_id, - }, status=400) - - try: - result = write_task_markdown( - title=title, - board=str(arguments.get("board", "inbox")), - status=str(arguments.get("status", "todo")), - body=str(arguments.get("body", "")), - source=str(arguments.get("source", "manual")), - source_id=str(arguments["source_id"]) if "source_id" in arguments else None, - metadata=metadata, - task=str(arguments["task"]) if "task" in arguments else None, - tasker_dir=arguments.get("tasker_dir"), - ) - except ValueError as exc: - return web.json_response({ - "jsonrpc": "2.0", - "error": {"code": -32602, "message": str(exc)}, - "id": request_id, - }, status=400) - - return web.json_response({ - "jsonrpc": "2.0", - "result": { - "content": [{"type": "text", "text": json.dumps(result, indent=2)}], - }, - "id": request_id, - }) - - -async def handle_tasker_sync_export(arguments: dict, request_id: Any) -> web.Response: - """Handle tasker_sync_export tool.""" - from app.api.mcp import get_skill - - skill = get_skill("tasker_sync") - if not skill: - return web.json_response({ - "jsonrpc": "2.0", - "error": {"code": -32601, "message": "Tool 'tasker_sync_export' not available"}, - "id": request_id, - }, status=404) - - result = await skill.run(**arguments) - return web.json_response({ - "jsonrpc": "2.0", - "result": { - "content": [{"type": "text", "text": json.dumps(result, indent=2)}], - }, - "id": request_id, - }) diff --git a/backend/app/api/mcp_handlers/toon.py b/backend/app/api/mcp_handlers/toon.py deleted file mode 100644 index 94087081..00000000 --- a/backend/app/api/mcp_handlers/toon.py +++ /dev/null @@ -1,61 +0,0 @@ -"""MCP handlers: Toon domain (encode, stats, clear).""" -from __future__ import annotations - -import json -import logging -from typing import Any - -from aiohttp import web - -log = logging.getLogger("ucore.api.mcp_handlers.toon") - - -async def handle_toon_encode(arguments: dict, request_id: Any) -> web.Response: - """Handle toon_encode tool.""" - from app.api.toon import handle_toon_encode as _encode_handler - from app.skills.shared_utils import create_mock_request - - mock_request = create_mock_request(arguments) - response = await _encode_handler(mock_request) - response_data = await response.json() - return web.json_response({ - "jsonrpc": "2.0", - "result": { - "content": [{"type": "text", "text": json.dumps(response_data, indent=2)}], - }, - "id": request_id, - }) - - -async def handle_toon_stats(arguments: dict, request_id: Any) -> web.Response: - """Handle toon_stats tool.""" - from app.api.toon import handle_toon_stats as _stats_handler - from app.skills.shared_utils import create_mock_request - - mock_request = create_mock_request({}) - response = await _stats_handler(mock_request) - response_data = await response.json() - return web.json_response({ - "jsonrpc": "2.0", - "result": { - "content": [{"type": "text", "text": json.dumps(response_data, indent=2)}], - }, - "id": request_id, - }) - - -async def handle_toon_clear(arguments: dict, request_id: Any) -> web.Response: - """Handle toon_clear tool.""" - from app.api.toon import handle_toon_clear as _clear_handler - from app.skills.shared_utils import create_mock_request - - mock_request = create_mock_request({}) - response = await _clear_handler(mock_request) - response_data = await response.json() - return web.json_response({ - "jsonrpc": "2.0", - "result": { - "content": [{"type": "text", "text": json.dumps(response_data, indent=2)}], - }, - "id": request_id, - }) diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 19c0fe95..766b127d 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -91,11 +91,6 @@ def register_routes(app: web.Application) -> None: handle_ollama_performance, handle_ollama_status, ) - from .mcp import ( - handle_mcp_call, - handle_mcp_diagnostics, - handle_mcp_discover, - ) from .metadata import ( maintenance_status_handler, system_info_handler, @@ -143,11 +138,6 @@ def register_routes(app: web.Application) -> None: registry.register_routes(app) log.debug("Extension registry routes wired") - # MCP Integration - app.router.add_get("/api/mcp/tools", handle_mcp_discover) - app.router.add_post("/api/mcp/call", handle_mcp_call) - app.router.add_get("/api/mcp/diagnostics", handle_mcp_diagnostics) - # TOON Context Optimization app.router.add_post("/api/toon/encode", handle_toon_encode) app.router.add_get("/api/toon/stats", handle_toon_stats) diff --git a/backend/app/api/services_api.py b/backend/app/api/services_api.py index a0adc9b7..5982ea3f 100644 --- a/backend/app/api/services_api.py +++ b/backend/app/api/services_api.py @@ -1,8 +1,8 @@ -"""Unified Services API — server services + host tools + MCP servers. +"""Unified Services API — server services and host tools. -Consolidated model (2026-08-08): Tools + MCP == Services. This endpoint -presents one merged, typed list so the UI renders infra processes, installed -host runtimes, and MCP servers side by side. +This endpoint presents one typed list so the UI renders infrastructure +processes and installed host runtimes side by side. The local stdio MCP gateway +is launched by external clients and is not an internal service daemon. GET /api/services — merged services list with summary. """ @@ -17,7 +17,7 @@ async def handle_list_services(request: web.Request) -> web.Response: - """GET /api/services — merged services + tools + MCP servers.""" + """GET /api/services — merged services and host tools.""" store = request.app.get("_server_store") services: list[dict[str, Any]] = [] # 1. Server services — probed infra processes @@ -60,29 +60,6 @@ async def handle_list_services(request: web.Request) -> web.Response: except Exception as exc: log.warning("Host tools gather failed: %s", exc) - # 3. MCP servers - try: - from app.services.control_service import get_mcp_servers - mcp_list = await get_mcp_servers() - if not mcp_list: - mcp_list = await _probe_known_mcp() - for mcp in mcp_list: - services.append({ - "id": mcp.get("name", ""), - "name": mcp.get("name", ""), - "kind": "mcp", - "description": "MCP server", - "status": "up" if mcp.get("online") else "down", - "port": 0, - "type": "mcp", - "meta": { - "endpoint": mcp.get("endpoint", ""), - "tools": mcp.get("tools", 0), - }, - }) - except Exception as exc: - log.warning("MCP servers gather failed: %s", exc) - statuses = [s["status"] for s in services] kinds: dict[str, int] = {} for s in services: @@ -105,28 +82,3 @@ def register_services_routes(app: web.Application) -> None: """Register unified Services API routes.""" app.router.add_get("/api/services", handle_list_services) log.debug("Services API routes registered") - - -async def _probe_known_mcp() -> list[dict]: - """Fallback MCP server probe when the tools registry is empty.""" - import asyncio - - from aiohttp import ClientSession, ClientTimeout - - known = [ - {"name": "snackbar", "url": "http://localhost:8484/health", "endpoint": "localhost:8484"}, - {"name": "hivemind", "url": "http://localhost:8490/health", "endpoint": "localhost:8490"}, - {"name": "vault-mcp", "url": "http://localhost:8765/health", "endpoint": "localhost:8765"}, - {"name": "gridsmith", "url": "http://localhost:8888/health", "endpoint": "localhost:8888"}, - ] - - async def _check(s: dict) -> dict: - try: - async with ClientSession(timeout=ClientTimeout(total=2)) as session: - async with session.get(s["url"]) as resp: - online = resp.status < 400 - except Exception: - online = False - return {"name": s["name"], "online": online, "endpoint": s["endpoint"], "tools": 0} - - return list(await asyncio.gather(*[_check(s) for s in known])) diff --git a/backend/app/core/snackbar.py b/backend/app/core/snackbar.py index 47926736..3ecc9bdd 100644 --- a/backend/app/core/snackbar.py +++ b/backend/app/core/snackbar.py @@ -243,76 +243,6 @@ async def shutdown_handler(request: web.Request) -> web.Response: return web.json_response({"status": "shutting down"}) -# ─── MCP Bridge handlers ────────────────────────────────────────── - - -async def mcp_query_handler(request: web.Request) -> web.Response: - """POST /api/mcp/query — proxy a query to MCP bridge""" - try: - data = await request.json() - except Exception: - return web.json_response({"error": "Invalid JSON body"}, status=400) - - try: - from app.services.mcp import get_bridge - bridge = get_bridge() - peer = data.get("peer", "forge") - tool = data.get("tool", "") - params = data.get("params", {}) - result = await bridge.call_tool(peer, tool, params) - return web.json_response({"result": result}) - except ImportError as e: - return web.json_response({"error": f"MCP bridge not available: {e}"}, status=501) - except Exception as e: - return web.json_response({"error": str(e)}, status=500) - - -async def mcp_status_handler(request: web.Request) -> web.Response: - """GET /api/mcp/status — get MCP mesh status""" - try: - from app.services.mcp import get_bridge - bridge = get_bridge() - status = await bridge.get_mesh_status() - return web.json_response(status) - except ImportError: - return web.json_response({"status": "unavailable", "message": "MCP bridge not loaded"}) - - -async def mcp_providers_handler(request: web.Request) -> web.Response: - """GET /api/mcp/providers — list AI providers""" - try: - from app.services.provider_router import get_router - router = get_router() - return web.json_response({ - "providers": router.list_providers(), - "models": router.list_models(), - }) - except ImportError as e: - return web.json_response({"error": f"Provider router not available: {e}"}, status=501) - - -async def mcp_chat_handler(request: web.Request) -> web.Response: - """POST /api/mcp/chat — chat with AI provider""" - try: - data = await request.json() - except Exception: - return web.json_response({"error": "Invalid JSON body"}, status=400) - messages = data.get("messages", []) - provider = data.get("provider") - model = data.get("model") - if not messages: - return web.json_response({"error": "messages are required"}, status=400) - try: - from app.services.provider_router import get_router - router = get_router() - result = await router.chat(messages, provider=provider, model=model) - return web.json_response(result) - except ImportError as e: - return web.json_response({"error": f"Provider router not available: {e}"}, status=501) - except Exception as e: - return web.json_response({"error": str(e)}, status=500) - - async def migrate_admin_handler(request: web.Request) -> web.Response: """GET /api/admin/migrate — run database migration""" try: @@ -548,7 +478,7 @@ def create_app() -> web.Application: log.warning("Budget manager unavailable: %s", e) # ── Load all modular route handlers ──────────────────────── - # Covers: health, version, info, shutdown, mcp/*, admin/migrate, + # Covers: health, version, info, shutdown, admin/migrate, # popcorn, health/status, health/logs, diagnostics, skills _load_modules(app) @@ -629,4 +559,3 @@ def main(): if __name__ == "__main__": main() - diff --git a/backend/app/services/control_service.py b/backend/app/services/control_service.py index 35a23657..783cb620 100644 --- a/backend/app/services/control_service.py +++ b/backend/app/services/control_service.py @@ -430,43 +430,6 @@ async def get_tasker_overview() -> dict: return {"total": len(task_files), "done": done, "next": ""} -# --------------------------------------------------------------------------- -# MCP servers -# --------------------------------------------------------------------------- - -async def get_mcp_servers() -> list[dict]: - """Get MCP server status.""" - data = await _http_get("http://localhost:8484/api/mcp/tools", timeout=1.0) - if data: - tools = data if isinstance(data, list) else data.get("tools", []) - # Group by server - servers: dict[str, dict] = {} - for t in tools: - srv = t.get("server", "unknown") - if srv not in servers: - servers[srv] = {"name": srv, "online": True, "tools": 0} - servers[srv]["tools"] += 1 - return list(servers.values()) - - # Fallback: hardcoded known servers with health checks - known = [ - {"name": "snackbar", "url": "http://localhost:8484/health", "endpoint": "localhost:8484"}, - {"name": "hivemind", "url": "http://localhost:8490/health", "endpoint": "localhost:8490"}, - {"name": "vault", "url": "http://localhost:8765/health", "endpoint": "localhost:8765"}, - {"name": "gridsmith", "url": "http://localhost:8888/health", "endpoint": "localhost:8888"}, - ] - results = [] - for s in known: - health = await _http_get(s["url"], timeout=1.5) - results.append({ - "name": s["name"], - "online": health is not None, - "endpoint": s["endpoint"], - "tools": 0, - }) - return results - - # --------------------------------------------------------------------------- # Slates # --------------------------------------------------------------------------- @@ -549,14 +512,13 @@ async def get_active_alerts() -> list[dict]: async def get_control_status() -> dict: """Aggregate all ecosystem status into one payload.""" # Run all checks concurrently - statuses, feed, agents, cost, mission, tasker, mcp, slates, alerts = await asyncio.gather( + statuses, feed, agents, cost, mission, tasker, slates, alerts = await asyncio.gather( _gather_statuses(), _gather_feed(), _gather_agents(), get_cost_summary(), get_active_mission(), get_tasker_overview(), - get_mcp_servers(), get_slate_list(), get_active_alerts(), ) @@ -568,7 +530,6 @@ async def get_control_status() -> dict: "cost": cost, "mission": mission, "tasker": tasker, - "mcp": mcp, "slates": slates, "alerts": alerts, "updated_at": datetime.now(timezone.utc).isoformat(), diff --git a/backend/app/services/mcp/__init__.py b/backend/app/services/mcp/__init__.py deleted file mode 100644 index 741fb2b2..00000000 --- a/backend/app/services/mcp/__init__.py +++ /dev/null @@ -1,382 +0,0 @@ -"""MCP Bridge — Cross-Machine Tool & Skill Execution (uCore integrated). - -Provides remote tool calling, peer management, and AI provider routing -using uCore's logging and settings infrastructure. - -Usage: - from app.services.mcp import get_bridge, MCPBridge - bridge = get_bridge() - result = await bridge.call_tool("forge", "copernicus.search", {"query": "test"}) -""" - -from __future__ import annotations - -import json -import logging -import os -import time -import uuid -from pathlib import Path -from typing import Optional -from urllib.parse import urljoin - -try: - from aiohttp import ClientSession, ClientTimeout -except ImportError: - ClientSession = None - ClientTimeout = None - -from app.core.logging import log as ucore_log - -# Use uCore's logger -log = ucore_log.getChild("mcp_bridge") - -# ─── Default Peers ───────────────────────────────────────────────── - -DEFAULT_PEERS: dict[str, dict] = { - "wizard": { - "id": "wizard", - "name": "Mac Studio (local)", - "endpoint": "http://localhost:8484", - "transport": "http", - "status": "unknown", - }, - "forge": { - "id": "forge", - "name": "Linux Mint (NAS)", - "endpoint": "http://192.168.1.100:8484", - "transport": "snackmachine", - "status": "unknown", - "snackmachine": { - "commands_path": str(Path.home() / ".local/share/snackmachine/commands.jsonl"), - "replies_path": str(Path.home() / ".local/share/snackmachine/replies.jsonl"), - "poll_interval": 0.5, - "poll_timeout": 60, - }, - }, - "ollama": { - "id": "ollama", - "name": "Local Ollama", - "endpoint": "http://localhost:11434", - "transport": "http", - "status": "unknown", - }, -} - - -# ─── Snackmachine Transport ──────────────────────────────────────── - - -class SnackmachineTransport: - """File-based transport for communicating with Snackmachine on Linux.""" - - def __init__(self, config: dict): - self.commands_path = Path( - config.get("commands_path", "~/.local/share/snackmachine/commands.jsonl"), - ).expanduser() - self.replies_path = Path( - config.get("replies_path", "~/.local/share/snackmachine/replies.jsonl"), - ).expanduser() - self.poll_interval = config.get("poll_interval", 0.5) - self.poll_timeout = config.get("poll_timeout", 60) - self.commands_path.parent.mkdir(parents=True, exist_ok=True) - - def write_command(self, cmd: dict) -> str: - correlation_id = str(uuid.uuid4()) - cmd["correlation_id"] = correlation_id - cmd["timestamp"] = time.time() - with open(self.commands_path, "a") as f: - f.write(json.dumps(cmd) + "\n") - log.debug("Wrote command %s to %s", correlation_id, self.commands_path) - return correlation_id - - def poll_for_reply(self, correlation_id: str, timeout: float | None = None) -> dict | None: - timeout = timeout or self.poll_timeout - deadline = time.time() + timeout - last_size = 0 - if self.replies_path.exists(): - last_size = self.replies_path.stat().st_size - while time.time() < deadline: - if self.replies_path.exists(): - current_size = self.replies_path.stat().st_size - if current_size > last_size: - with open(self.replies_path) as f: - f.seek(last_size) - for line in f: - line = line.strip() - if not line: - continue - try: - entry = json.loads(line) - if entry.get("correlation_id") == correlation_id: - return entry - except json.JSONDecodeError: - continue - last_size = current_size - time.sleep(self.poll_interval) - return None - - def check_health(self) -> bool: - return self.replies_path.exists() - - def list_available(self) -> dict: - spices, skills = [], [] - if self.replies_path.exists(): - with open(self.replies_path) as f: - for line in f: - line = line.strip() - if not line: - continue - try: - entry = json.loads(line) - if entry.get("type") == "list_spices": - spices = entry.get("spices", []) - elif entry.get("type") == "list_skills": - skills = entry.get("skills", []) - except json.JSONDecodeError: - continue - return {"spices": spices, "skills": skills} - - -# ─── MCP Bridge ──────────────────────────────────────────────────── - - -class MCPBridge: - """Cross-machine MCP tool and skill execution bridge.""" - - def __init__(self, peers: dict | None = None): - self.peers = peers or DEFAULT_PEERS.copy() - self._session: ClientSession | None = None - self._timeout = ClientTimeout(total=30) if ClientTimeout else None - self._snackmachine_transports: dict[str, SnackmachineTransport] = {} - - async def _get_session(self) -> ClientSession: - if self._session is None or self._session.closed: - if ClientSession is None: - raise RuntimeError("aiohttp is required for MCP bridge") - self._session = ClientSession() - return self._session - - async def close(self): - if self._session and not self._session.closed: - await self._session.close() - - def _get_snackmachine_transport(self, peer_id: str) -> SnackmachineTransport | None: - if peer_id not in self._snackmachine_transports: - peer = self.peers.get(peer_id) - if not peer or "snackmachine" not in peer: - return None - self._snackmachine_transports[peer_id] = SnackmachineTransport(peer["snackmachine"]) - return self._snackmachine_transports[peer_id] - - # ── Peer Management ────────────────────────────────────────── - - def get_peers(self) -> dict: - return self.peers - - def get_peer(self, peer_id: str) -> dict | None: - return self.peers.get(peer_id) - - def set_peer_status(self, peer_id: str, status: str): - if peer_id in self.peers: - self.peers[peer_id]["status"] = status - - async def check_peer_health(self, peer_id: str) -> dict: - peer = self.peers.get(peer_id) - if not peer: - return {"peer": peer_id, "status": "unknown", "error": "Unknown peer"} - transport = peer.get("transport", "http") - - if transport == "snackmachine": - sm = self._get_snackmachine_transport(peer_id) - if sm and sm.check_health(): - self.peers[peer_id]["status"] = "online" - return {"peer": peer_id, "status": "online", "transport": "snackmachine", "latency_ms": 0} - self.peers[peer_id]["status"] = "offline" - return {"peer": peer_id, "status": "offline", "transport": "snackmachine", "error": "Snackmachine replies file not found"} - - endpoint = peer["endpoint"] - try: - session = await self._get_session() - start = time.time() - async with session.get( - urljoin(endpoint + "/", "v1/health"), - timeout=self._timeout, - ) as resp: - latency_ms = int((time.time() - start) * 1000) - if resp.status == 200: - data = await resp.json() - self.peers[peer_id]["status"] = "online" - return {"peer": peer_id, "status": "online", "latency_ms": latency_ms, "version": data.get("version", "unknown")} - self.peers[peer_id]["status"] = "offline" - return {"peer": peer_id, "status": "offline", "error": f"HTTP {resp.status}"} - except Exception as e: - self.peers[peer_id]["status"] = "offline" - return {"peer": peer_id, "status": "offline", "error": str(e)} - - async def check_all_peers(self) -> list[dict]: - results = [] - for peer_id in self.peers: - result = await self.check_peer_health(peer_id) - results.append(result) - return results - - # ── MCP Tool Bridge ───────────────────────────────────────── - - async def call_tool(self, peer_id: str, tool: str, params: dict | None = None) -> dict: - peer = self.peers.get(peer_id) - if not peer: - return {"success": False, "error": f"Unknown peer: {peer_id}"} - transport = peer.get("transport", "http") - if transport == "snackmachine": - return await self._call_tool_snackmachine(peer_id, tool, params) - return await self._call_tool_http(peer_id, tool, params) - - async def _call_tool_snackmachine(self, peer_id: str, tool: str, params: dict | None = None) -> dict: - sm = self._get_snackmachine_transport(peer_id) - if not sm: - return {"success": False, "error": f"No Snackmachine transport for peer: {peer_id}"} - if not sm.check_health(): - self.peers[peer_id]["status"] = "offline" - return {"success": False, "error": f"Peer {peer_id} is offline (replies file not found)"} - self.peers[peer_id]["status"] = "online" - start = time.time() - cmd = { - "type": "run_spice" if tool.startswith("spice:") else "run_skill", - "name": tool.replace("spice:", "").replace("skill:", ""), - "params": params or {}, - } - correlation_id = sm.write_command(cmd) - reply = sm.poll_for_reply(correlation_id) - latency_ms = int((time.time() - start) * 1000) - if reply is None: - return {"success": False, "error": "Timeout waiting for Snackmachine reply", "peer": peer_id, "latency_ms": latency_ms} - return {"success": reply.get("status") == "success", "result": {"output": reply.get("output", ""), "error": reply.get("error", ""), "status": reply.get("status", "unknown")}, "peer": peer_id, "latency_ms": latency_ms} - - async def _call_tool_http(self, peer_id: str, tool: str, params: dict | None = None) -> dict: - peer = self.peers.get(peer_id) - if not peer: - return {"success": False, "error": f"Unknown peer: {peer_id}"} - if peer["status"] == "offline": - health = await self.check_peer_health(peer_id) - if health["status"] != "online": - return {"success": False, "error": f"Peer {peer_id} is offline"} - endpoint = peer["endpoint"] - payload = {"tool": tool, "params": params or {}} - try: - session = await self._get_session() - start = time.time() - async with session.post(urljoin(endpoint + "/", "v1/mcp/call"), json=payload, timeout=self._timeout) as resp: - latency_ms = int((time.time() - start) * 1000) - if resp.status == 200: - result = await resp.json() - return {"success": True, "result": result.get("result", result), "peer": peer_id, "latency_ms": latency_ms} - error_text = await resp.text() - return {"success": False, "error": f"Remote error (HTTP {resp.status}): {error_text}", "peer": peer_id, "latency_ms": latency_ms} - except Exception as e: - self.peers[peer_id]["status"] = "offline" - return {"success": False, "error": str(e), "peer": peer_id} - - async def list_remote_tools(self, peer_id: str) -> dict: - peer = self.peers.get(peer_id) - if not peer: - return {"success": False, "error": f"Unknown peer: {peer_id}"} - transport = peer.get("transport", "http") - if transport == "snackmachine": - sm = self._get_snackmachine_transport(peer_id) - if not sm: - return {"success": False, "error": f"No Snackmachine transport for peer: {peer_id}"} - available = sm.list_available() - tools = [] - for spice in available.get("spices", []): - tools.append({"name": f"spice:{spice}", "description": f"Snackmachine spice: {spice}", "server": peer_id}) - for skill in available.get("skills", []): - tools.append({"name": f"skill:{skill}", "description": f"Snackmachine skill: {skill}", "server": peer_id}) - return {"success": True, "peer": peer_id, "tools": tools} - endpoint = peer["endpoint"] - try: - session = await self._get_session() - async with session.get(urljoin(endpoint + "/", "v1/mcp/tools"), timeout=self._timeout) as resp: - if resp.status == 200: - tools = await resp.json() - return {"success": True, "peer": peer_id, "tools": tools.get("tools", tools)} - return {"success": False, "error": f"HTTP {resp.status}", "peer": peer_id} - except Exception as e: - return {"success": False, "error": str(e), "peer": peer_id} - - async def get_mesh_tools(self) -> dict: - local_tools, remote_tools = [], [] - for peer_id, peer in self.peers.items(): - if peer["status"] != "online": - continue - result = await self.list_remote_tools(peer_id) - if result.get("success"): - tools = result.get("tools", []) - for tool in tools: - entry = {"name": tool.get("name", tool) if isinstance(tool, str) else tool, "server": peer_id, "enabled": True} - if peer_id in ("wizard", "localhost"): - local_tools.append(entry) - else: - remote_tools.append(entry) - return {"local": local_tools, "remote": remote_tools, "total": len(local_tools) + len(remote_tools)} - - async def run_skill(self, peer_id: str, skill_id: str, params: dict | None = None) -> dict: - peer = self.peers.get(peer_id) - if not peer: - return {"success": False, "error": f"Unknown peer: {peer_id}"} - transport = peer.get("transport", "http") - if transport == "snackmachine": - sm = self._get_snackmachine_transport(peer_id) - if not sm: - return {"success": False, "error": f"No Snackmachine transport for peer: {peer_id}"} - if not sm.check_health(): - self.peers[peer_id]["status"] = "offline" - return {"success": False, "error": f"Peer {peer_id} is offline"} - self.peers[peer_id]["status"] = "online" - start = time.time() - cmd = {"type": "run_skill", "name": skill_id, "params": params or {}} - correlation_id = sm.write_command(cmd) - reply = sm.poll_for_reply(correlation_id) - duration_ms = int((time.time() - start) * 1000) - if reply is None: - return {"success": False, "error": "Timeout waiting for Snackmachine reply", "peer": peer_id, "duration_ms": duration_ms} - return {"success": reply.get("status") == "success", "output": reply.get("output", ""), "error": reply.get("error", ""), "peer": peer_id, "duration_ms": duration_ms} - if peer["status"] == "offline": - health = await self.check_peer_health(peer_id) - if health["status"] != "online": - return {"success": False, "error": f"Peer {peer_id} is offline"} - endpoint = peer["endpoint"] - payload = {"params": params or {}} - try: - session = await self._get_session() - start = time.time() - async with session.post(urljoin(endpoint + "/", f"v1/skills/{skill_id}/run"), json=payload, timeout=self._timeout) as resp: - duration_ms = int((time.time() - start) * 1000) - if resp.status == 200: - result = await resp.json() - return {"success": True, "output": result.get("output", result), "peer": peer_id, "duration_ms": duration_ms} - error_text = await resp.text() - return {"success": False, "error": f"Remote error (HTTP {resp.status}): {error_text}", "peer": peer_id, "duration_ms": duration_ms} - except Exception as e: - self.peers[peer_id]["status"] = "offline" - return {"success": False, "error": str(e), "peer": peer_id} - - async def get_mesh_status(self) -> dict: - peer_health = await self.check_all_peers() - mesh_tools = await self.get_mesh_tools() - online_count = sum(1 for p in peer_health if p["status"] == "online") - offline_count = sum(1 for p in peer_health if p["status"] == "offline") - return {"peers": peer_health, "tools": mesh_tools, "summary": {"total_peers": len(peer_health), "online": online_count, "offline": offline_count, "total_tools": mesh_tools["total"]}} - - -# ─── Singleton ───────────────────────────────────────────────────── - -_bridge_instance: MCPBridge | None = None - - -def get_bridge() -> MCPBridge: - """Get or create the singleton MCPBridge instance.""" - global _bridge_instance - if _bridge_instance is None: - _bridge_instance = MCPBridge() - return _bridge_instance diff --git a/backend/app/services/mcp/github_client.py b/backend/app/services/mcp/github_client.py deleted file mode 100644 index 669bc107..00000000 --- a/backend/app/services/mcp/github_client.py +++ /dev/null @@ -1,259 +0,0 @@ -"""GitHub API Client — wrapper for GitHub operations""" -from __future__ import annotations - -import json -import logging -import os -import subprocess - -try: - from github import Github, GithubException - HAS_PYGITHUB = True -except ImportError: - HAS_PYGITHUB = False - -log = logging.getLogger("ucore.github") - - -class GitHubClient: - """GitHub API client for uCore MCP tools.""" - - def __init__(self, token: str | None = None, org: str = "uDosGo"): - """Initialize GitHub client. - - Args: - token: GitHub Personal Access Token (defaults to env GITHUB_TOKEN) - org: GitHub organization name - - """ - self.token = token or os.getenv("GITHUB_TOKEN", "") - self.org = org - self._gh = None - self._org_obj = None - - if HAS_PYGITHUB and self.token: - try: - from github import Auth - auth = Auth.Token(self.token) - self._gh = Github(auth=auth) - self._org_obj = self._gh.get_organization(self.org) - log.info(f"GitHub client initialized for org: {self.org}") - except GithubException as e: - log.error(f"Failed to initialize GitHub client: {e}") - - def is_authenticated(self) -> bool: - """Check if GitHub authentication is valid.""" - if not self._gh: - return False - try: - self._gh.get_user().login - return True - except Exception: - return False - - def run_gh_cli(self, args: list[str]) -> dict: - """Execute GitHub CLI command. - - Args: - args: Command arguments (e.g., ['repo', 'list']) - - Returns: - dict with success, stdout, stderr - - """ - try: - result = subprocess.run( - ["gh"] + args, - capture_output=True, - text=True, - timeout=30, - env={**os.environ, "GITHUB_TOKEN": self.token}, - ) - return { - "success": result.returncode == 0, - "stdout": result.stdout.strip(), - "stderr": result.stderr.strip(), - "returncode": result.returncode, - } - except subprocess.TimeoutExpired: - return {"success": False, "error": "Command timeout"} - except Exception as e: - return {"success": False, "error": str(e)} - - def list_repos(self) -> list[dict]: - """List all repositories in the organization.""" - if self._org_obj: - try: - repos = [] - for repo in self._org_obj.get_repos(): - repos.append({ - "name": repo.name, - "full_name": repo.full_name, - "private": repo.private, - "default_branch": repo.default_branch, - "html_url": repo.html_url, - }) - return repos - except Exception as e: - log.error(f"Failed to list repos: {e}") - - # Fallback to gh CLI - result = self.run_gh_cli(["repo", "list", self.org, "--json", "name,url"]) - if result["success"]: - try: - return json.loads(result["stdout"]) - except json.JSONDecodeError: - return [] - return [] - - def get_repo(self, repo_name: str): - """Get repository object.""" - if self._org_obj: - try: - return self._org_obj.get_repo(repo_name) - except Exception as e: - log.error(f"Failed to get repo {repo_name}: {e}") - return None - - def create_release(self, repo_name: str, tag: str, name: str, - body: str, draft: bool = False) -> dict: - """Create a GitHub release. - - Args: - repo_name: Repository name - tag: Git tag for release - name: Release title - body: Release description/changelog - draft: Whether to create as draft - - Returns: - dict with success, release_url - - """ - repo = self.get_repo(repo_name) - if repo: - try: - release = repo.create_git_release( - tag=tag, - name=name, - message=body, - draft=draft, - ) - return { - "success": True, - "release_url": release.html_url, - "tag": tag, - } - except Exception as e: - return {"success": False, "error": str(e)} - - # Fallback to gh CLI - args = [ - "release", "create", tag, - "--repo", f"{self.org}/{repo_name}", - "--title", name, - "--notes", body, - ] - if draft: - args.append("--draft") - - result = self.run_gh_cli(args) - if result["success"]: - return { - "success": True, - "tag": tag, - "output": result["stdout"], - } - return {"success": False, "error": result.get("stderr", "Unknown error")} - - def create_pr(self, repo_name: str, title: str, body: str, - head: str, base: str = "main") -> dict: - """Create a pull request. - - Args: - repo_name: Repository name - title: PR title - body: PR description - head: Branch to merge from - base: Branch to merge into - - Returns: - dict with success, pr_url, pr_number - - """ - repo = self.get_repo(repo_name) - if repo: - try: - pr = repo.create_pull( - title=title, - body=body, - head=head, - base=base, - ) - return { - "success": True, - "pr_url": pr.html_url, - "pr_number": pr.number, - } - except Exception as e: - return {"success": False, "error": str(e)} - - # Fallback to gh CLI - result = self.run_gh_cli([ - "pr", "create", - "--repo", f"{self.org}/{repo_name}", - "--title", title, - "--body", body, - "--head", head, - "--base", base, - ]) - - if result["success"]: - return { - "success": True, - "output": result["stdout"], - } - return {"success": False, "error": result.get("stderr", "Unknown error")} - - def list_workflow_runs(self, repo_name: str, limit: int = 10) -> list[dict]: - """List recent workflow runs.""" - result = self.run_gh_cli([ - "run", "list", - "--repo", f"{self.org}/{repo_name}", - "--limit", str(limit), - "--json", "databaseId,status,conclusion,name,headBranch,createdAt", - ]) - - if result["success"]: - try: - return json.loads(result["stdout"]) - except json.JSONDecodeError: - return [] - return [] - - def list_issues(self, repo_name: str, state: str = "open", - labels: list[str] | None = None) -> list[dict]: - """List issues in repository.""" - args = [ - "issue", "list", - "--repo", f"{self.org}/{repo_name}", - "--state", state, - "--json", "number,title,labels,createdAt,updatedAt,author", - ] - - if labels: - for label in labels: - args.extend(["--label", label]) - - result = self.run_gh_cli(args) - if result["success"]: - try: - return json.loads(result["stdout"]) - except json.JSONDecodeError: - return [] - return [] - - -def get_github_client(token: str | None = None, org: str = "uDosGo") -> GitHubClient: - """Get or create singleton GitHub client.""" - return GitHubClient(token=token, org=org) diff --git a/backend/app/services/mcp/github_tools.py b/backend/app/services/mcp/github_tools.py deleted file mode 100644 index 88746374..00000000 --- a/backend/app/services/mcp/github_tools.py +++ /dev/null @@ -1,463 +0,0 @@ -"""GitHub MCP Tools — autonomous GitHub workflow automation""" -from __future__ import annotations - -import json -import logging -import subprocess -from datetime import datetime -from pathlib import Path - -from .github_client import get_github_client - -log = logging.getLogger("ucore.github_tools") - - -class GitHubTools: - """MCP tools for GitHub automation.""" - - def __init__(self, token: str | None = None, org: str = "uDosGo"): - """Initialize GitHub tools with authentication.""" - self.client = get_github_client(token=token, org=org) - self.org = org - - def publish_release(self, repo_name: str, version: str | None = None, - draft: bool = False, auto_tag: bool = True) -> dict: - """Publish a GitHub release with auto-generated changelog. - - Args: - repo_name: Repository name - version: Version tag (auto-detected if None) - draft: Create as draft release - auto_tag: Auto-generate tag from version file - - Returns: - dict with success, release_url, tag - - """ - log.info(f"Publishing release for {repo_name}") - - # Auto-detect version from pyproject.toml or package.json - if not version and auto_tag: - version = self._detect_version(repo_name) - if not version: - return { - "success": False, - "error": "Could not detect version", - } - - tag = f"v{version}" if version else "v0.0.1" - - # Generate changelog from recent commits - changelog = self._generate_changelog(repo_name) - - # Create release - result = self.client.create_release( - repo_name=repo_name, - tag=tag, - name=f"Release {tag}", - body=changelog, - draft=draft, - ) - - log.info(f"Release result: {result}") - return result - - def sync_repos(self, local_dir: str | None = None) -> dict: - """Sync all org repositories and report status. - - Args: - local_dir: Local directory to clone/sync repos - - Returns: - dict with synced repos, status summary - - """ - log.info(f"Syncing repos for org: {self.org}") - - repos = self.client.list_repos() - if not repos: - return {"success": False, "error": "No repos found"} - - base_dir = Path(local_dir or f"~/Code/{self.org}").expanduser() - base_dir.mkdir(parents=True, exist_ok=True) - - results = [] - for repo in repos: - repo_name = repo["name"] - repo_path = base_dir / repo_name - - if repo_path.exists(): - # Pull latest - status = self._git_pull(repo_path) - else: - # Clone repo - status = self._git_clone(repo["html_url"], repo_path) - - results.append({ - "repo": repo_name, - "path": str(repo_path), - "status": status, - }) - - return { - "success": True, - "repos": results, - "total": len(results), - "synced": sum(1 for r in results if r["status"] == "ok"), - } - - def create_pr(self, repo_name: str, title: str | None = None, - body: str | None = None, base: str = "main") -> dict: - """Create PR from current branch with auto-generated content. - - Args: - repo_name: Repository name - title: PR title (auto-generated from commits if None) - body: PR description (auto-generated if None) - base: Base branch to merge into - - Returns: - dict with success, pr_url, pr_number - - """ - log.info(f"Creating PR for {repo_name}") - - # Get current branch - head = self._get_current_branch(repo_name) - if not head or head == base: - return { - "success": False, - "error": f"Invalid branch (current: {head}, base: {base})", - } - - # Auto-generate title from commits - if not title: - title = self._generate_pr_title(repo_name, base, head) - - # Auto-generate body from commits - if not body: - body = self._generate_pr_body(repo_name, base, head) - - result = self.client.create_pr( - repo_name=repo_name, - title=title, - body=body, - head=head, - base=base, - ) - - log.info(f"PR creation result: {result}") - return result - - def heal_issues(self, repo_name: str, auto_label: bool = True, - auto_close_stale: bool = False, - stale_days: int = 30) -> dict: - """Triage and heal issues automatically. - - Args: - repo_name: Repository name - auto_label: Auto-label issues by type - auto_close_stale: Close stale issues - stale_days: Days before issue considered stale - - Returns: - dict with processed issues, actions taken - - """ - log.info(f"Healing issues for {repo_name}") - - issues = self.client.list_issues(repo_name, state="open") - if not issues: - return {"success": True, "message": "No open issues"} - - actions = [] - for issue in issues: - issue_num = issue["number"] - issue_title = issue["title"] - - # Auto-label by keywords - if auto_label: - labels = self._detect_issue_labels(issue_title) - if labels: - self._add_labels(repo_name, issue_num, labels) - actions.append({ - "issue": issue_num, - "action": "labeled", - "labels": labels, - }) - - # Check staleness - if auto_close_stale: - updated = datetime.fromisoformat( - issue["updatedAt"].replace("Z", "+00:00"), - ) - age = (datetime.now(updated.tzinfo) - updated).days - - if age > stale_days: - self._close_issue(repo_name, issue_num, - "Closing stale issue") - actions.append({ - "issue": issue_num, - "action": "closed", - "reason": f"stale ({age} days)", - }) - - return { - "success": True, - "issues_processed": len(issues), - "actions_taken": len(actions), - "actions": actions, - } - - def actions_status(self, repo_name: str | None = None, - auto_retry_failed: bool = False) -> dict: - """Check GitHub Actions workflow status. - - Args: - repo_name: Repository name (None for all repos) - auto_retry_failed: Auto-retry failed workflow runs - - Returns: - dict with workflow runs, failures, retries - - """ - log.info(f"Checking Actions status for {repo_name or 'all repos'}") - - if repo_name: - repos = [{"name": repo_name}] - else: - repos = self.client.list_repos() - - all_runs = [] - failures = [] - retries = [] - - for repo in repos: - name = repo["name"] - runs = self.client.list_workflow_runs(name, limit=5) - - for run in runs: - run["repo"] = name - all_runs.append(run) - - if run["conclusion"] == "failure": - failures.append(run) - - if auto_retry_failed: - retry_result = self._retry_workflow( - name, run["databaseId"], - ) - if retry_result["success"]: - retries.append(run) - - return { - "success": True, - "total_runs": len(all_runs), - "failures": len(failures), - "retries": len(retries) if auto_retry_failed else 0, - "failed_runs": failures[:10], # Limit output - "retried_runs": retries, - } - - def approve_pr(self, repo_name: str, pr_number: int, - auto_merge: bool = False, - run_tests: bool = True) -> dict: - """Review and approve PR with automated checks. - - Args: - repo_name: Repository name - pr_number: PR number - auto_merge: Auto-merge if checks pass - run_tests: Run local tests before approval - - Returns: - dict with success, approval status, merge status - - """ - log.info(f"Reviewing PR #{pr_number} in {repo_name}") - - # Get PR details - result = self.client.run_gh_cli([ - "pr", "view", str(pr_number), - "--repo", f"{self.org}/{repo_name}", - "--json", "title,state,mergeable", - ]) - - if not result["success"]: - return {"success": False, "error": "Could not fetch PR"} - - pr_data = json.loads(result["stdout"]) - - checks = { - "pr_open": pr_data["state"] == "OPEN", - "mergeable": pr_data.get("mergeable") == "MERGEABLE", - "tests_pass": True, - } - - # Run local tests if requested - if run_tests: - test_result = self._run_local_tests(repo_name) - checks["tests_pass"] = test_result["success"] - - all_pass = all(checks.values()) - - # Approve PR - if all_pass: - approve_result = self.client.run_gh_cli([ - "pr", "review", str(pr_number), - "--repo", f"{self.org}/{repo_name}", - "--approve", - "--body", "✅ Auto-approved: All checks passed", - ]) - - # Auto-merge if requested - if auto_merge and approve_result["success"]: - merge_result = self.client.run_gh_cli([ - "pr", "merge", str(pr_number), - "--repo", f"{self.org}/{repo_name}", - "--auto", - "--squash", - ]) - return { - "success": True, - "approved": True, - "merged": merge_result["success"], - "checks": checks, - } - - return { - "success": True, - "approved": True, - "checks": checks, - } - - return { - "success": False, - "approved": False, - "reason": "Checks failed", - "checks": checks, - } - - # ── Helper Methods ────────────────────────────────────────────── - - def _detect_version(self, repo_name: str) -> str | None: - """Detect version from pyproject.toml or package.json.""" - # This would need actual file access - simplified for now - return "0.1.0" - - def _generate_changelog(self, repo_name: str) -> str: - """Generate changelog from git commits.""" - result = self.client.run_gh_cli([ - "api", - f"repos/{self.org}/{repo_name}/commits", - "--jq", ".[].commit.message", - ]) - if result["success"]: - commits = result["stdout"].split("\n")[:10] - return "## Changes\n\n" + "\n".join( - f"- {c}" for c in commits if c - ) - return "Release notes" - - def _git_pull(self, repo_path: Path) -> str: - """Pull latest changes.""" - try: - subprocess.run( - ["git", "pull"], - cwd=repo_path, - capture_output=True, - timeout=30, - ) - return "ok" - except Exception: - return "failed" - - def _git_clone(self, url: str, path: Path) -> str: - """Clone repository.""" - try: - subprocess.run( - ["git", "clone", url, str(path)], - capture_output=True, - timeout=60, - ) - return "ok" - except Exception: - return "failed" - - def _get_current_branch(self, repo_name: str) -> str | None: - """Get current git branch.""" - try: - result = subprocess.run( - ["git", "branch", "--show-current"], - capture_output=True, - text=True, - timeout=5, - ) - return result.stdout.strip() if result.returncode == 0 else None - except Exception: - return None - - def _generate_pr_title(self, repo: str, base: str, head: str) -> str: - """Generate PR title from commits.""" - return f"Merge {head} into {base}" - - def _generate_pr_body(self, repo: str, base: str, head: str) -> str: - """Generate PR body from commits.""" - return f"Changes from {head}\n\n## What changed\n\nTBD" - - def _detect_issue_labels(self, title: str) -> list[str]: - """Detect labels from issue title.""" - title_lower = title.lower() - labels = [] - if "bug" in title_lower or "fix" in title_lower: - labels.append("bug") - if "feat" in title_lower or "feature" in title_lower: - labels.append("enhancement") - if "doc" in title_lower: - labels.append("documentation") - return labels - - def _add_labels(self, repo: str, issue_num: int, - labels: list[str]) -> bool: - """Add labels to issue.""" - result = self.client.run_gh_cli([ - "issue", "edit", str(issue_num), - "--repo", f"{self.org}/{repo}", - "--add-label", ",".join(labels), - ]) - return result["success"] - - def _close_issue(self, repo: str, issue_num: int, reason: str) -> bool: - """Close an issue.""" - result = self.client.run_gh_cli([ - "issue", "close", str(issue_num), - "--repo", f"{self.org}/{repo}", - "--comment", reason, - ]) - return result["success"] - - def _retry_workflow(self, repo: str, run_id: int) -> dict: - """Retry a failed workflow run.""" - result = self.client.run_gh_cli([ - "run", "rerun", str(run_id), - "--repo", f"{self.org}/{repo}", - ]) - return {"success": result["success"]} - - def _run_local_tests(self, repo_name: str) -> dict: - """Run local tests for repo.""" - # Simplified - would need actual test runner - return {"success": True} - - -# ── MCP Tool Exports ──────────────────────────────────────────── - -_tools_instance: GitHubTools | None = None - - -def get_github_tools(token: str | None = None) -> GitHubTools: - """Get or create GitHub tools singleton.""" - global _tools_instance - if _tools_instance is None: - _tools_instance = GitHubTools(token=token) - return _tools_instance diff --git a/backend/app/services/system_health.py b/backend/app/services/system_health.py index 8d7884af..7ab17d3f 100644 --- a/backend/app/services/system_health.py +++ b/backend/app/services/system_health.py @@ -2,7 +2,6 @@ Provides a single entrypoint for full-system health checks, aggregating: - HTTP server liveness -- MCP structural integrity (guardrails) - Skill registry load status - Plate/template verification - Database connectivity @@ -48,27 +47,6 @@ async def _check_http_liveness() -> HealthComponent: ) -async def _check_mcp_integrity() -> HealthComponent: - t0 = time.perf_counter() - try: - from app.api.mcp_guardrails import validate_mcp_integrity - report = validate_mcp_integrity() - return HealthComponent( - name="mcp_integrity", - ok=report["ok"], - message="MCP layer healthy" if report["ok"] else f"MCP integrity failure: {report['errors']}", - detail={"checks": len(report.get("checks", [])), "errors": report.get("errors", [])}, - latency_ms=(time.perf_counter() - t0) * 1000, - ) - except Exception as e: - return HealthComponent( - name="mcp_integrity", - ok=False, - message=f"MCP integrity check error: {e}", - latency_ms=(time.perf_counter() - t0) * 1000, - ) - - async def _check_skill_registry() -> HealthComponent: t0 = time.perf_counter() try: @@ -214,7 +192,6 @@ async def get_full_health() -> dict[str, Any]: """ checks = await asyncio.gather( _check_http_liveness(), - _check_mcp_integrity(), _check_skill_registry(), _check_plate_health(), _check_database(), @@ -266,24 +243,14 @@ async def run_self_repair() -> dict[str, Any]: """Attempt automatic repair of known issues. Triggers: - 1. MCP self-heal skill (structural repair) - 2. Skill registry reload - 3. Plate verification (identify corrupted plates) + 1. Skill registry reload + 2. Plate verification (identify corrupted plates) Returns repair report. """ repairs: list[dict[str, Any]] = [] - # 1. MCP self-heal - try: - from app.skills.builtin.skill_mcp_self_heal import MCPSelfHealSkill - skill = MCPSelfHealSkill() - result = await skill.run(dry_run=False) - repairs.append({"component": "mcp_layer", "success": result.get("success", False), "message": result.get("message", "")}) - except Exception as e: - repairs.append({"component": "mcp_layer", "success": False, "message": str(e)}) - - # 2. Skill registry reload + # 1. Skill registry reload try: from app.skills.registry import reload_registry reload_result = reload_registry() @@ -291,7 +258,7 @@ async def run_self_repair() -> dict[str, Any]: except Exception as e: repairs.append({"component": "skill_registry", "success": False, "message": str(e)}) - # 3. Plate verification + # 2. Plate verification try: from backend.plate_refresh.verification import verify_all_plates plate_result = verify_all_plates() @@ -299,7 +266,7 @@ async def run_self_repair() -> dict[str, Any]: except Exception as e: repairs.append({"component": "plate_health", "success": False, "message": str(e)}) - # 4. Run health check post-repair + # 3. Run health check post-repair health = await get_full_health() return { diff --git a/backend/app/skills/builtin/skill_mcp_self_heal.py b/backend/app/skills/builtin/skill_mcp_self_heal.py deleted file mode 100644 index 2385fc11..00000000 --- a/backend/app/skills/builtin/skill_mcp_self_heal.py +++ /dev/null @@ -1,252 +0,0 @@ -#!/usr/bin/env python3 -"""Skill: MCP Self-Heal — Diagnose and repair MCP layer issues. - -This skill runs the MCP guardrails checks and attempts automatic repair -for known failure patterns: - -1. **Syntax errors** in mcp.py / mcp_handlers.py → cannot auto-repair, log alert -2. **Missing exports** (handle_mcp_discover, handle_mcp_call) → cannot auto-repair -3. **Handler signature mismatch** → normalize to (arguments, request_id) -4. **Missing tool in registry** → add to TOOL_HANDLERS -5. **Orphaned dead code** → truncate file after last function -6. **Import errors** → switch to lazy imports - -The skill is registered as `mcp_self_heal` and can be called via MCP: - POST /api/mcp/call {"name": "skill_mcp_self_heal", "arguments": {}} -""" -from __future__ import annotations - -import ast -import logging -import re -from pathlib import Path -from typing import Any - -from app.skills.base import BaseSkill, SkillMeta, SkillParam - -log = logging.getLogger("ucore.skills.mcp_self_heal") - - -class MCPSelfHealSkill(BaseSkill): - meta = SkillMeta( - id="mcp_self_heal", - name="MCP Self-Heal", - description="Diagnose and repair MCP layer integrity issues", - category="maintenance", - params=[ - SkillParam( - name="dry_run", - type="boolean", - description="Only diagnose, don't repair", - default=True, - ), - ], - timeout=30, - ) - - async def run(self, **kwargs) -> dict: - dry_run = kwargs.get("dry_run", True) - from app.api.mcp_guardrails import validate_mcp_integrity - - report = validate_mcp_integrity() - - if report["ok"]: - return { - "success": True, - "message": "MCP integrity check passed — no repairs needed", - "checks": len(report["checks"]), - } - - repairs: list[dict[str, Any]] = [] - - for check in report["checks"]: - if check["ok"]: - continue - - if check["check"] == "syntax": - repairs.append({ - "issue": "syntax_error", - "files": check.get("errors", []), - "action": "manual_fix_required", - "message": "Syntax errors cannot be auto-repaired. Fix manually.", - }) - - elif check["check"] == "handler_signatures": - if not dry_run: - fixed = self._fix_handler_signatures(check.get("errors", [])) - repairs.append({ - "issue": "handler_signature_mismatch", - "action": "normalized_signatures", - "fixed": fixed, - }) - else: - repairs.append({ - "issue": "handler_signature_mismatch", - "action": "would_normalize_signatures", - "errors": check.get("errors", []), - }) - - elif check["check"] == "tool_registry": - if not dry_run: - fixed = self._fix_tool_registry(check.get("missing", [])) - repairs.append({ - "issue": "missing_tools_in_registry", - "action": "added_to_registry", - "fixed": fixed, - }) - else: - repairs.append({ - "issue": "missing_tools_in_registry", - "action": "would_add_to_registry", - "missing": check.get("missing", []), - }) - - elif check["check"] == "exports": - repairs.append({ - "issue": "missing_exports", - "action": "manual_fix_required", - "missing": check.get("missing", []), - "message": "Missing exports require manual code changes.", - }) - - elif check["check"] == "frontend_port_consistency": - if not dry_run: - fixed = self._fix_frontend_ports(check.get("errors", [])) - repairs.append({ - "issue": "stale_frontend_port_reference", - "action": "corrected_ports", - "fixed": fixed, - }) - else: - repairs.append({ - "issue": "stale_frontend_port_reference", - "action": "would_correct_ports", - "errors": check.get("errors", []), - }) - - return { - "success": True, - "dry_run": dry_run, - "repairs": repairs, - "original_errors": report["errors"], - } - - # ─── Auto-repair helpers ────────────────────────────────── - - @staticmethod - def _fix_handler_signatures(errors: list[str]) -> list[str]: - """Attempt to normalize handler signatures to (arguments, request_id). - - Checks all domain modules in the mcp_handlers/ package. - """ - handlers_dir = Path(__file__).parent.parent / "api" / "mcp_handlers" - if not handlers_dir.exists(): - return ["mcp_handlers/ directory not found"] - - fixed: list[str] = [] - for py_file in sorted(handlers_dir.glob("*.py")): - if py_file.name == "__init__.py": - continue - content = py_file.read_text() - original = content - - # Pattern: handlers that only take (request_id) need (arguments, request_id) - pattern = re.compile( - r"async def (handle_\w+)\(request_id: Any\)" - ) - for match in pattern.finditer(content): - name = match.group(1) - content = content.replace( - match.group(0), - f"async def {name}(arguments: dict, request_id: Any)", - ) - fixed.append(name) - - if content != original: - py_file.write_text(content) - log.info("Fixed handler signatures in %s: %s", py_file.name, fixed) - - return fixed - - @staticmethod - def _fix_tool_registry(missing: list[str]) -> list[str]: - - """Add missing tools to the TOOL_HANDLERS registry. - - This requires that a handler function with the matching name exists. - """ - import app.api.mcp_handlers as handlers_mod - - added: list[str] = [] - registry = getattr(handlers_mod, "TOOL_HANDLERS", {}) - - for tool_name in missing: - handler_name = f"handle_{tool_name}" - handler = getattr(handlers_mod, handler_name, None) - if handler and callable(handler): - registry[tool_name] = handler - added.append(tool_name) - log.info("Added '%s' → %s to TOOL_HANDLERS", tool_name, handler_name) - - return added - - @staticmethod - def _fix_frontend_ports(errors: list[str]) -> list[str]: - """Replace stale frontend port references (5173, 5174) with 5175. - - Scans all Python files in the menu package and replaces stale ports. - """ - menu_dir = Path(__file__).parent.parent.parent / "menu" - correct_port = "5175" - stale_ports = {"5173", "5174"} - fixed: list[str] = [] - - for py_file in menu_dir.rglob("*.py"): - if "archived" in py_file.parts: - continue - content = py_file.read_text() - original = content - for stale in stale_ports: - content = content.replace(f":{stale}", f":{correct_port}") - if content != original: - py_file.write_text(content) - fixed.append(py_file.name) - log.info("Fixed ports in %s", py_file.name) - - return fixed - - @staticmethod - def detect_orphaned_code(filepath: Path) -> dict[str, Any]: - """Detect orphaned code after the last top-level function definition. - - This catches the pattern where a bad merge leaves dead code - dangling after the last function in a module. - """ - if not filepath.exists(): - return {"error": f"{filepath} not found"} - - source = filepath.read_text() - tree = ast.parse(source) - - if not tree.body: - return {"orphaned": False, "message": "Empty file"} - - # Find the last top-level function/class definition - last_def_end = 0 - for node in ast.iter_child_nodes(tree): - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): - last_def_end = max(last_def_end, node.end_lineno or 0) - - total_lines = source.count("\n") + 1 - orphaned_lines = total_lines - last_def_end - - if orphaned_lines > 5: # Allow a few blank lines at end - return { - "orphaned": True, - "last_function_line": last_def_end, - "total_lines": total_lines, - "orphaned_line_count": orphaned_lines, - "message": f"~{orphaned_lines} lines of potential orphaned code after line {last_def_end}", - } - - return {"orphaned": False, "total_lines": total_lines} diff --git a/backend/app/skills/catalogue.json b/backend/app/skills/catalogue.json index 3f56709c..66d7afbd 100644 --- a/backend/app/skills/catalogue.json +++ b/backend/app/skills/catalogue.json @@ -10,7 +10,6 @@ {"module": "episodic_log.py", "owner": "uCore", "lifecycle": "review", "risk": "write", "lane": "memory", "allowed_roots": ["UDOS_HOME"]}, {"module": "git_maintenance.py", "owner": "uCore", "lifecycle": "active", "risk": "write", "lane": "developer", "allowed_roots": ["Code"]}, {"module": "lint_fix.py", "owner": "uCore", "lifecycle": "active", "risk": "write", "lane": "developer", "allowed_roots": ["Code"]}, - {"module": "skill_mcp_self_heal.py", "owner": "uCore", "lifecycle": "merge", "risk": "write", "lane": "recovery", "allowed_roots": ["Code", "UDOS_HOME"]}, {"module": "skill_nuggets_and_spool.py", "owner": "uCore", "lifecycle": "split", "risk": "destructive", "lane": "recovery", "allowed_roots": ["UDOS_HOME"]}, {"module": "skill_self_heal.py", "owner": "uCore", "lifecycle": "merge", "risk": "destructive", "lane": "recovery", "allowed_roots": ["Code", "UDOS_HOME"]}, {"module": "skill_vault_discovery.py", "owner": "uKnowledge", "lifecycle": "move", "risk": "read", "lane": "knowledge", "allowed_roots": ["Vault", "Shared", "Public"]}, diff --git a/backend/app/snackbar/modules/mcp_bridge.py b/backend/app/snackbar/modules/mcp_bridge.py deleted file mode 100644 index 83dcbd65..00000000 --- a/backend/app/snackbar/modules/mcp_bridge.py +++ /dev/null @@ -1,102 +0,0 @@ -"""MCP bridge routes — proxy queries and AI chat to MCP services.""" -from __future__ import annotations - -from aiohttp import web - - -async def mcp_query_handler(request: web.Request) -> web.Response: - """POST /api/mcp/query — proxy a query to MCP bridge.""" - try: - data = await request.json() - except Exception: - return web.json_response( - {"error": "Invalid JSON body"}, status=400, - ) - - try: - from app.services.mcp import get_bridge - - bridge = get_bridge() - peer = data.get("peer", "forge") - tool = data.get("tool", "") - params = data.get("params", {}) - result = await bridge.call_tool(peer, tool, params) - return web.json_response({"result": result}) - except ImportError as e: - return web.json_response( - {"error": f"MCP bridge not available: {e}"}, - status=501, - ) - except Exception as e: - return web.json_response({"error": str(e)}, status=500) - - -async def mcp_status_handler(request: web.Request) -> web.Response: - """GET /api/mcp/status — get MCP mesh status.""" - try: - from app.services.mcp import get_bridge - - bridge = get_bridge() - status = await bridge.get_mesh_status() - return web.json_response(status) - except ImportError: - return web.json_response( - {"status": "unavailable", - "message": "MCP bridge not loaded"}, - ) - - -async def mcp_providers_handler(request: web.Request) -> web.Response: - """GET /api/mcp/providers — list AI providers.""" - try: - from app.services.provider_router import get_router - - router = get_router() - return web.json_response({ - "providers": router.list_providers(), - "models": router.list_models(), - }) - except ImportError as e: - return web.json_response( - {"error": f"Provider router not available: {e}"}, - status=501, - ) - - -async def mcp_chat_handler(request: web.Request) -> web.Response: - """POST /api/mcp/chat — chat with AI provider.""" - try: - data = await request.json() - except Exception: - return web.json_response( - {"error": "Invalid JSON body"}, status=400, - ) - messages = data.get("messages", []) - provider = data.get("provider") - model = data.get("model") - if not messages: - return web.json_response( - {"error": "messages are required"}, status=400, - ) - try: - from app.services.provider_router import get_router - - router = get_router() - result = await router.chat( - messages, provider=provider, model=model, - ) - return web.json_response(result) - except ImportError as e: - return web.json_response( - {"error": f"Provider router not available: {e}"}, - status=501, - ) - except Exception as e: - return web.json_response({"error": str(e)}, status=500) - - -def register(app: web.Application) -> None: - app.router.add_post("/api/mcp/query", mcp_query_handler) - app.router.add_get("/api/mcp/status", mcp_status_handler) - app.router.add_get("/api/mcp/providers", mcp_providers_handler) - app.router.add_post("/api/mcp/chat", mcp_chat_handler) diff --git a/backend/mcp/README.md b/backend/mcp/README.md deleted file mode 100644 index 3a9500e0..00000000 --- a/backend/mcp/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# uCore MCP - -Canonical implementation uses one self-hosted MCP stdio gateway: - -- Server id: `udos-mcp` -- Source: `backend/app/mcp/udos_mcp/` -- Command: `node backend/app/mcp/udos_mcp/build/index.js` -- Backend target: `UCORE_URL=http://127.0.0.1:8484` - -Client-specific MCP configuration is external. uCore does not depend on an -editor-owned configuration directory. The old multi-manifest layout is retired. - -## Diagnostics - -```bash -cd backend/app/mcp/udos_mcp && npm test -``` - -This compiles the gateway and exercises it through the official MCP client. -The remaining scripts in this directory are legacy migration targets and are -not MCP servers in the canonical architecture. diff --git a/backend/mcp/__init__.py b/backend/mcp/__init__.py deleted file mode 100644 index aeba9314..00000000 --- a/backend/mcp/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""uCore MCP diagnostics package.""" diff --git a/backend/mcp/archived/mcp-firewatch-manifest.json b/backend/mcp/archived/mcp-firewatch-manifest.json deleted file mode 100644 index 3d6ec231..00000000 --- a/backend/mcp/archived/mcp-firewatch-manifest.json +++ /dev/null @@ -1 +0,0 @@ -{"name": "mcp-firewatch", "version": "1.0.0", "description": "Firefox browser automation MCP server: navigate pages, extract content, click elements, fill forms, take screenshots, and evaluate JavaScript", "tools": ["firewatch_navigate", "firewatch_click", "firewatch_fill", "firewatch_screenshot", "firewatch_extract", "firewatch_evaluate"], "transport": "stdio", "health_check": "/health", "protocolVersion": "0.1.0", "serverInfo": {"name": "Firewatch MCP", "version": "1.5.0"}} \ No newline at end of file diff --git a/backend/mcp/archived/mcp-playwright-manifest.json b/backend/mcp/archived/mcp-playwright-manifest.json deleted file mode 100644 index ebfdf148..00000000 --- a/backend/mcp/archived/mcp-playwright-manifest.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "mcp-playwright", - "version": "1.0.0", - "description": "Playwright MCP server for browser automation: navigation, input, network, cookies, storage, snapshot, tracing", - "tools": [ - "playwright_navigate", - "playwright_click", - "playwright_fill", - "playwright_screenshot", - "playwright_extract", - "playwright_evaluate", - "playwright_wait", - "playwright_close" - ], - "transport": "stdio", - "health_check": "/health", - "protocolVersion": "0.1.0", - "serverInfo": { - "name": "Playwright MCP", - "version": "0.0.76" - } -} diff --git a/backend/mcp/archived/mcp-scheduler-manifest.json b/backend/mcp/archived/mcp-scheduler-manifest.json deleted file mode 100644 index 935dd9d3..00000000 --- a/backend/mcp/archived/mcp-scheduler-manifest.json +++ /dev/null @@ -1 +0,0 @@ -{"name": "mcp-scheduler", "version": "1.0.0", "description": "AI-native research feed engine with CLI, MCP, scheduler hooks, and an English dashboard", "tools": ["scheduler_add", "scheduler_remove", "scheduler_list", "scheduler_run", "scheduler_status"], "transport": "stdio", "health_check": "/health", "protocolVersion": "0.1.0", "serverInfo": {"name": "Feed Sage MCP", "version": "0.1.6"}} \ No newline at end of file diff --git a/backend/mcp/archived/mcp-secrets-manifest.json b/backend/mcp/archived/mcp-secrets-manifest.json deleted file mode 100644 index aeddbb76..00000000 --- a/backend/mcp/archived/mcp-secrets-manifest.json +++ /dev/null @@ -1 +0,0 @@ -{"name": "mcp-secrets", "version": "1.0.0", "description": "MCP Secrets & Token Broker: A security layer for managing short-lived credentials and tokens", "tools": ["secrets_get", "secrets_set", "secrets_list", "secrets_delete", "secrets_rotate"], "transport": "stdio", "health_check": "/health", "protocolVersion": "0.1.0", "serverInfo": {"name": "Secrets Broker MCP", "version": "1.0.4"}} \ No newline at end of file diff --git a/backend/mcp/archived/mcp-serena-manifest.json b/backend/mcp/archived/mcp-serena-manifest.json deleted file mode 100644 index eae356b1..00000000 --- a/backend/mcp/archived/mcp-serena-manifest.json +++ /dev/null @@ -1 +0,0 @@ -{"name": "mcp-serena", "version": "1.0.0", "description": "Code analysis MCP server with token reduction: detect hallucinated symbols, typos in imports, and invalid API routes via AST-based fuzzy matching", "tools": ["serena_analyze", "serena_check_imports", "serena_check_symbols", "serena_check_api", "serena_suggest_fixes"], "transport": "stdio", "health_check": "/health", "protocolVersion": "0.1.0", "serverInfo": {"name": "Serena Slim MCP", "version": "0.0.1-slim.1.10"}} \ No newline at end of file diff --git a/backend/mcp/archived/start_playwright.sh b/backend/mcp/archived/start_playwright.sh deleted file mode 100755 index 443bd653..00000000 --- a/backend/mcp/archived/start_playwright.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash -# Startup script for Playwright MCP server - -MCP_SERVER="${MCP_PLAYWRIGHT_BIN:-$HOME/.nvm/versions/node/v20.20.2/bin/playwright-mcp}" -MANIFEST="${UCORE_ROOT:-$HOME/Code/uCore}/backend/mcp/mcp-playwright-manifest.json" - -if [ ! -f "$MCP_SERVER" ]; then - echo "Error: Playwright MCP server not found at $MCP_SERVER" - exit 1 -fi - -if [ ! -f "$MANIFEST" ]; then - echo "Error: Manifest not found at $MANIFEST" - exit 1 -fi - -echo "Starting Playwright MCP server..." -exec "$MCP_SERVER" --config "$MANIFEST" diff --git a/backend/mcp/dry_run_example.md b/backend/mcp/dry_run_example.md deleted file mode 100644 index adf1c344..00000000 --- a/backend/mcp/dry_run_example.md +++ /dev/null @@ -1,10 +0,0 @@ -# MCP Destroy Dry Run Example - -This document demonstrates how a dry-run of the destroy workflow would appear. - -``` -action: destroy -scope: dev -mode: dry_run -notes: no changes will be written -``` \ No newline at end of file diff --git a/backend/mcp/start_firewatch.sh b/backend/mcp/start_firewatch.sh deleted file mode 100755 index 3a2f01f9..00000000 --- a/backend/mcp/start_firewatch.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash -# Startup script for Firewatch MCP server - -MCP_SERVER="${MCP_FIREWATCH_BIN:-$HOME/Library/pnpm/global/5/bin/firewatch-mcp}" -MANIFEST="${UCORE_ROOT:-$HOME/Code/uCore}/backend/mcp/mcp-firewatch-manifest.json" - -if [ ! -f "$MCP_SERVER" ]; then - echo "Error: Firewatch MCP server not found at $MCP_SERVER" - exit 1 -fi - -if [ ! -f "$MANIFEST" ]; then - echo "Error: Manifest not found at $MANIFEST" - exit 1 -fi - -echo "Starting Firewatch MCP server..." -exec "$MCP_SERVER" --manifest "$MANIFEST" diff --git a/backend/mcp/start_hivemind.sh b/backend/mcp/start_hivemind.sh deleted file mode 100755 index b011d6ed..00000000 --- a/backend/mcp/start_hivemind.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash -# Startup script for Hivemind MCP server (multi-agent orchestration) -# Port: 8490 - -UCORE_ROOT="${UCORE_ROOT:-$HOME/Code/uCore}" -AGENTS_CONFIG="${UCORE_ROOT}/backend/config/agents.yaml" -HOST="127.0.0.1" -PORT=8490 - -echo "Starting Hivemind MCP server on ${HOST}:${PORT}..." -echo "Agents config: ${AGENTS_CONFIG}" - -cd "$UCORE_ROOT/backend" || exit 1 - -exec python3 -m app.mcp.hivemind_server \ - --host "$HOST" \ - --port "$PORT" \ - --agents-config "$AGENTS_CONFIG" diff --git a/backend/mcp/start_scheduler.sh b/backend/mcp/start_scheduler.sh deleted file mode 100755 index c876777c..00000000 --- a/backend/mcp/start_scheduler.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash -# Startup script for Feed Sage MCP server - -MCP_SERVER="${MCP_SCHEDULER_BIN:-$HOME/Library/pnpm/global/5/bin/feed-sage}" -MANIFEST="${UCORE_ROOT:-$HOME/Code/uCore}/backend/mcp/mcp-scheduler-manifest.json" - -if [ ! -f "$MCP_SERVER" ]; then - echo "Error: Feed Sage MCP server not found at $MCP_SERVER" - exit 1 -fi - -if [ ! -f "$MANIFEST" ]; then - echo "Error: Manifest not found at $MANIFEST" - exit 1 -fi - -echo "Starting Feed Sage MCP server..." -exec "$MCP_SERVER" --config "$MANIFEST" diff --git a/backend/mcp/start_secrets.sh b/backend/mcp/start_secrets.sh deleted file mode 100755 index fc26b5ae..00000000 --- a/backend/mcp/start_secrets.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash -# Startup script for Secrets Broker MCP server - -MCP_SERVER="${MCP_SECRETS_BIN:-$HOME/Library/pnpm/global/5/bin/mcp-credentials-broker}" -MANIFEST="${UCORE_ROOT:-$HOME/Code/uCore}/backend/mcp/mcp-secrets-manifest.json" - -if [ ! -f "$MCP_SERVER" ]; then - echo "Error: Secrets Broker MCP server not found at $MCP_SERVER" - exit 1 -fi - -if [ ! -f "$MANIFEST" ]; then - echo "Error: Manifest not found at $MANIFEST" - exit 1 -fi - -echo "Starting Secrets Broker MCP server..." -exec "$MCP_SERVER" --config "$MANIFEST" diff --git a/backend/mcp/start_serena.sh b/backend/mcp/start_serena.sh deleted file mode 100755 index ba812921..00000000 --- a/backend/mcp/start_serena.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash -# Startup script for Serena Slim MCP server - -MCP_SERVER="${MCP_SERENA_BIN:-$HOME/Library/pnpm/global/5/bin/serena-slim}" -MANIFEST="${UCORE_ROOT:-$HOME/Code/uCore}/backend/mcp/mcp-serena-manifest.json" - -if [ ! -f "$MCP_SERVER" ]; then - echo "Error: Serena Slim MCP server not found at $MCP_SERVER" - exit 1 -fi - -if [ ! -f "$MANIFEST" ]; then - echo "Error: Manifest not found at $MANIFEST" - exit 1 -fi - -echo "Starting Serena Slim MCP server..." -exec "$MCP_SERVER" --config "$MANIFEST" diff --git a/backend/snackmachine/cli.py b/backend/snackmachine/cli.py index 31145d99..04197f9d 100644 --- a/backend/snackmachine/cli.py +++ b/backend/snackmachine/cli.py @@ -3,7 +3,6 @@ from __future__ import annotations import argparse -import json import os from pathlib import Path @@ -39,7 +38,7 @@ def cmd_init(force: bool = False) -> None: """Seed the canonical uDos runtime home with SnackMachine config.""" data_dir = Path(os.environ.get("SNACKMACHINE_DATA_DIR", settings.udos_home)) dirs = [ - data_dir / "config" / "mcp-manifests", + data_dir / "config", data_dir / "indices", data_dir / "knowledge", data_dir / "logs", @@ -48,13 +47,6 @@ def cmd_init(force: bool = False) -> None: for d in dirs: d.mkdir(parents=True, exist_ok=True) - manifest_path = data_dir / "config" / "mcp-manifests" / "knowledge.json" - if manifest_path.exists() and not force: - print(f"✓ {manifest_path} already exists (use --force to overwrite)") - else: - manifest_path.write_text(MCP_KNOWLEDGE_MANIFEST) - print(f"✓ wrote {manifest_path}") - spool_example = data_dir / "config" / "snackmachine.yaml" if spool_example.exists() and not force: print(f"✓ {spool_example} already exists") @@ -67,29 +59,6 @@ def cmd_init(force: bool = False) -> None: print(f" Drop snacks into {data_dir / 'snacks'} to auto-discover them.") -MCP_KNOWLEDGE_MANIFEST = json.dumps( - { - "name": "mcp-knowledge-conduit", - "version": "1.0.0", - "description": "Knowledge conduit: vault search, AI-ranked retrieval, doclang", - "tools": [ - "knowledge_search", - "knowledge_ask", - "knowledge_list_sources", - "knowledge_extract_links", - "knowledge_summarize", - "knowledge_publish", - "knowledge_query_memory", - ], - "transport": "http", - "health_check": "/api/mcp/status", - "protocolVersion": "0.1.0", - "serverInfo": {"name": "SnackMachine Knowledge Conduit", "version": "1.0.0"}, - }, - indent=2, -) - - SNACKMACHINE_CONFIG = """\ # SnackMachine config # Drop .py snacks into $UDOS_HOME/snacks — they auto-discover diff --git a/backend/tests/test_api_budget_enforcement.py b/backend/tests/test_api_budget_enforcement.py index 41f629e5..61ef46f7 100644 --- a/backend/tests/test_api_budget_enforcement.py +++ b/backend/tests/test_api_budget_enforcement.py @@ -16,7 +16,7 @@ class _Policy: class _FakeBudgetManager: def __init__(self, *, allowed: bool, reason: str | None = None): - self.policy = _Policy(guarded_endpoints=["/api/chat", "/api/mcp/chat"]) + self.policy = _Policy(guarded_endpoints=["/api/chat", "/api/developer/chat"]) self.allowed = allowed self.reason = reason self.calls: list[dict] = [] @@ -73,7 +73,7 @@ async def _chat_handler(_request: web.Request) -> web.Response: return response app.router.add_post("/api/chat", _chat_handler) - app.router.add_post("/api/mcp/chat", _chat_handler) + app.router.add_post("/api/developer/chat", _chat_handler) async def _health_handler(_request: web.Request) -> web.Response: return web.json_response({"status": "ok"}) @@ -116,7 +116,7 @@ async def test_budget_extracts_provider_and_model_from_nested_payload( self.fake_budget.allowed = True resp = await self.client.post( - "/api/mcp/chat", + "/api/developer/chat", json={ "messages": [{"role": "user", "content": "hi"}], "vendor": "anthropic", diff --git a/backend/tests/test_api_mcp.py b/backend/tests/test_api_mcp.py deleted file mode 100644 index 0f0e3e26..00000000 --- a/backend/tests/test_api_mcp.py +++ /dev/null @@ -1,396 +0,0 @@ -"""Integration tests for MCP Protocol API endpoints.""" -from __future__ import annotations - -from typing import Any, cast - -from aiohttp import web -from aiohttp.test_utils import AioHTTPTestCase - -import app.api.mcp as mcp_api -from app.api.mcp import handle_mcp_call, handle_mcp_discover - - -class MCPAPITest(AioHTTPTestCase): - async def get_application(self): - app = web.Application() - app.router.add_get("/api/mcp/tools", handle_mcp_discover) - app.router.add_post("/api/mcp/call", handle_mcp_call) - return app - - async def test_mcp_discover_returns_tools(self): - resp = await self.client.get("/api/mcp/tools") - assert resp.status == 200 - data = await resp.json() - assert "result" in data - tools = data["result"]["tools"] - assert len(tools) >= 18 # 15 skills + 3 knowledge tools - assert all("name" in t for t in tools) - assert all("description" in t for t in tools) - assert all("input_schema" in t for t in tools) - - async def test_mcp_discover_has_skill_tools(self): - resp = await self.client.get("/api/mcp/tools") - data = await resp.json() - tools = data["result"]["tools"] - skill_tools = [t for t in tools if t["name"].startswith("skill_")] - assert len(skill_tools) >= 14 - - async def test_mcp_discover_has_knowledge_tools(self): - resp = await self.client.get("/api/mcp/tools") - data = await resp.json() - tools = data["result"]["tools"] - knowledge_tools = [ - t for t in tools if t["name"].startswith("knowledge_") - ] - assert len(knowledge_tools) == 3 - - async def test_mcp_discover_has_clipboard_tools(self): - resp = await self.client.get("/api/mcp/tools") - data = await resp.json() - tools = data["result"]["tools"] - names = {tool["name"] for tool in tools} - assert "clipboard_capture" in names - assert "clipboard_get" in names - assert "clipboard_delete" in names - - async def test_mcp_discover_has_tasker_tools(self): - resp = await self.client.get("/api/mcp/tools") - data = await resp.json() - tools = data["result"]["tools"] - names = {tool["name"] for tool in tools} - assert "tasker_list_boards" in names - assert "tasker_read_task" in names - assert "tasker_write_task" in names - assert "tasker_sync_export" in names - - async def test_mcp_discover_has_gridsmith_tools(self): - resp = await self.client.get("/api/mcp/tools") - data = await resp.json() - tools = data["result"]["tools"] - names = {tool["name"] for tool in tools} - assert "gridsmith_tools_list" in names - assert "gridsmith_create_grid" in names - assert "gridsmith_import_basic_program" in names - - async def test_mcp_call_unknown_tool(self): - resp = await self.client.post("/api/mcp/call", json={ - "name": "nonexistent_tool", - "arguments": {}, - "id": 1, - }) - assert resp.status == 404 - data = await resp.json() - assert "error" in data - - async def test_mcp_call_invalid_json(self): - resp = await self.client.post( - "/api/mcp/call", - data=b"not json", - headers={"Content-Type": "application/json"}, - ) - assert resp.status == 400 - - async def test_mcp_call_skill_hello_world(self): - resp = await self.client.post("/api/mcp/call", json={ - "name": "skill_hello-world", - "arguments": {}, - "id": 1, - }) - assert resp.status == 200 - data = await resp.json() - assert "result" in data - assert "content" in data["result"] - - async def test_mcp_discover_protocol_version(self): - resp = await self.client.get("/api/mcp/tools") - data = await resp.json() - assert data["result"]["protocolVersion"] == "2025-03-26" - assert data["result"]["serverInfo"]["name"] == "uCore MCP" - - async def test_mcp_call_clipboard_capture(self): - def fake_capture_current_clipboard(source: str, metadata: dict): - assert source == "user_copy" - assert metadata == {"origin": "mcp"} - return {"id": "clip_abc", "content": "hello"} - - old_capture = mcp_api.capture_current_clipboard - mcp_api.capture_current_clipboard = fake_capture_current_clipboard - try: - resp = await self.client.post("/api/mcp/call", json={ - "name": "clipboard_capture", - "arguments": { - "source": "user_copy", - "metadata": {"origin": "mcp"}, - }, - "id": 12, - }) - assert resp.status == 200 - data = await resp.json() - assert "result" in data - assert "clip_abc" in data["result"]["content"][0]["text"] - finally: - mcp_api.capture_current_clipboard = old_capture - - async def test_mcp_call_clipboard_get_recent(self): - def fake_get_recent_items(limit: int, include_pinned: bool): - assert limit == 2 - assert include_pinned is True - return [{"id": "clip_1"}, {"id": "clip_2"}] - - old_get_recent = mcp_api.get_recent_items - mcp_api.get_recent_items = fake_get_recent_items - try: - resp = await self.client.post("/api/mcp/call", json={ - "name": "clipboard_get", - "arguments": {"limit": 2, "include_pinned": True}, - "id": 13, - }) - assert resp.status == 200 - data = await resp.json() - text = data["result"]["content"][0]["text"] - assert '"count": 2' in text - assert "clip_1" in text - finally: - mcp_api.get_recent_items = old_get_recent - - async def test_mcp_call_clipboard_delete(self): - def fake_delete_item(item_id: str): - assert item_id == "clip_del" - return True - - old_delete = mcp_api.delete_item - mcp_api.delete_item = fake_delete_item - try: - resp = await self.client.post("/api/mcp/call", json={ - "name": "clipboard_delete", - "arguments": {"item_id": "clip_del"}, - "id": 14, - }) - assert resp.status == 200 - data = await resp.json() - assert "deleted" in data["result"]["content"][0]["text"] - finally: - mcp_api.delete_item = old_delete - - async def test_mcp_call_knowledge_list_workspaces(self): - from app.knowledge import vault - - def fake_list_workspaces(): - return [{"id": "ws_1", "name": "Workspace 1"}] - - old_list_workspaces = vault.list_workspaces - vault.list_workspaces = fake_list_workspaces - try: - resp = await self.client.post("/api/mcp/call", json={ - "name": "knowledge_list_workspaces", - "arguments": {}, - "id": 15, - }) - assert resp.status == 200 - data = await resp.json() - text = data["result"]["content"][0]["text"] - assert "ws_1" in text - finally: - vault.list_workspaces = old_list_workspaces - - async def test_mcp_call_knowledge_list_documents(self): - from app.knowledge import vault - - def fake_list_documents(workspace_id: str | None = None): - assert workspace_id == "ws_abc" - return [{"id": "doc_1", "title": "Doc One"}] - - old_list_documents = vault.list_documents - vault.list_documents = fake_list_documents - try: - resp = await self.client.post("/api/mcp/call", json={ - "name": "knowledge_list_documents", - "arguments": {"workspace_id": "ws_abc"}, - "id": 16, - }) - assert resp.status == 200 - data = await resp.json() - text = data["result"]["content"][0]["text"] - assert "doc_1" in text - finally: - vault.list_documents = old_list_documents - - async def test_mcp_call_knowledge_search(self): - from app.knowledge import vault - - def fake_semantic_search( - query: str, - workspace_id: str | None, - limit: int, - ): - assert query == "vector search" - assert workspace_id == "ws_abc" - assert limit == 5 - return [{"id": "row_1", "content": "match"}] - - old_semantic_search = vault.semantic_search - vault.semantic_search = fake_semantic_search - try: - resp = await self.client.post("/api/mcp/call", json={ - "name": "knowledge_search", - "arguments": { - "query": "vector search", - "workspace_id": "ws_abc", - "limit": 5, - }, - "id": 17, - }) - assert resp.status == 200 - data = await resp.json() - text = data["result"]["content"][0]["text"] - assert "row_1" in text - finally: - vault.semantic_search = old_semantic_search - - async def test_mcp_call_knowledge_search_missing_query(self): - resp = await self.client.post("/api/mcp/call", json={ - "name": "knowledge_search", - "arguments": {}, - "id": 18, - }) - assert resp.status == 400 - data = await resp.json() - assert data["error"]["code"] == -32602 - - async def test_mcp_call_knowledge_search_invalid_limit(self): - resp = await self.client.post("/api/mcp/call", json={ - "name": "knowledge_search", - "arguments": {"query": "abc", "limit": "bad"}, - "id": 19, - }) - assert resp.status == 400 - data = await resp.json() - assert data["error"]["code"] == -32602 - - async def test_mcp_call_tasker_list_boards(self): - def fake_list_tasker_boards(tasker_dir=None): - assert tasker_dir == "/tmp/tasker" - return {"boards": [{"name": "inbox", "count": 2}], "count": 1} - - old_list_tasker_boards = mcp_api.list_tasker_boards - mcp_api.list_tasker_boards = fake_list_tasker_boards - try: - resp = await self.client.post("/api/mcp/call", json={ - "name": "tasker_list_boards", - "arguments": {"tasker_dir": "/tmp/tasker"}, - "id": 20, - }) - assert resp.status == 200 - data = await resp.json() - assert "inbox" in data["result"]["content"][0]["text"] - finally: - mcp_api.list_tasker_boards = old_list_tasker_boards - - async def test_mcp_call_tasker_read_task(self): - def fake_read_task_markdown(*, board: str, task: str, tasker_dir=None): - assert board == "inbox" - assert task == "todo-card.md" - assert tasker_dir == "/tmp/tasker" - return {"task": task, "content": "# Card\n"} - - old_read_task_markdown = mcp_api.read_task_markdown - mcp_api.read_task_markdown = fake_read_task_markdown - try: - resp = await self.client.post("/api/mcp/call", json={ - "name": "tasker_read_task", - "arguments": { - "board": "inbox", - "task": "todo-card.md", - "tasker_dir": "/tmp/tasker", - }, - "id": 21, - }) - assert resp.status == 200 - data = await resp.json() - assert "todo-card.md" in data["result"]["content"][0]["text"] - finally: - mcp_api.read_task_markdown = old_read_task_markdown - - async def test_mcp_call_tasker_write_task(self): - def fake_write_task_markdown( - *, - title: str, - board: str, - status: str, - body: str, - source: str, - source_id, - metadata, - task, - tasker_dir, - ): - assert title == "Write MCP card" - assert board == "doing" - assert status == "todo" - assert body == "Body" - assert source == "manual" - assert source_id == "abc" - assert metadata == {"priority": "P1"} - assert task is None - assert tasker_dir == "/tmp/tasker" - return {"task": "todo-write-mcp-card-abc.md", "written": True} - - old_write_task_markdown = mcp_api.write_task_markdown - mcp_api.write_task_markdown = fake_write_task_markdown - try: - resp = await self.client.post("/api/mcp/call", json={ - "name": "tasker_write_task", - "arguments": { - "title": "Write MCP card", - "board": "doing", - "status": "todo", - "body": "Body", - "source": "manual", - "source_id": "abc", - "metadata": {"priority": "P1"}, - "tasker_dir": "/tmp/tasker", - }, - "id": 22, - }) - assert resp.status == 200 - data = await resp.json() - assert "written" in data["result"]["content"][0]["text"] - finally: - mcp_api.write_task_markdown = old_write_task_markdown - - async def test_mcp_call_tasker_write_task_invalid_metadata(self): - resp = await self.client.post("/api/mcp/call", json={ - "name": "tasker_write_task", - "arguments": {"title": "Bad", "metadata": "wrong"}, - "id": 23, - }) - assert resp.status == 400 - data = await resp.json() - assert data["error"]["code"] == -32602 - - async def test_mcp_call_tasker_sync_export(self): - class FakeSkill: - async def run(self, **kwargs): - assert kwargs["db"] == "database" - assert kwargs["board"] == "inbox" - return {"success": True, "count": 1} - - def fake_get_skill(skill_id: str): - if skill_id == "tasker_sync": - return cast("Any", FakeSkill()) - return None - - old_get_skill = mcp_api.get_skill - mcp_api.get_skill = fake_get_skill - try: - resp = await self.client.post("/api/mcp/call", json={ - "name": "tasker_sync_export", - "arguments": {"db": "database", "board": "inbox"}, - "id": 24, - }) - assert resp.status == 200 - data = await resp.json() - text = data["result"]["content"][0]["text"].lower() - assert '"success": true' in text - finally: - mcp_api.get_skill = old_get_skill diff --git a/backend/tests/test_mcp_github_client.py b/backend/tests/test_mcp_github_client.py deleted file mode 100644 index 6427c779..00000000 --- a/backend/tests/test_mcp_github_client.py +++ /dev/null @@ -1,178 +0,0 @@ -"""Unit tests for the GitHub API client — No network calls required.""" -from __future__ import annotations - -import json -import subprocess -from unittest.mock import MagicMock, patch - -import pytest - -from app.services.mcp.github_client import GitHubClient - - -@pytest.fixture -def client(monkeypatch): - """GitHubClient with no token (no network calls).""" - monkeypatch.delenv("GITHUB_TOKEN", raising=False) - return GitHubClient(token="", org="test-org") - - -def test_init_no_token(client): - """Client initializes without error even without a token.""" - assert client.token == "" - assert client.org == "test-org" - assert client._gh is None - - -def test_is_authenticated_no_token(client): - """Without token, auth check returns False.""" - assert client.is_authenticated() is False - - -def test_list_repos_fallback_to_gh_cli(client): - """When pygithub unavailable, falls back to gh CLI.""" - with patch.object(client, "run_gh_cli") as mock_run: - mock_run.return_value = {"success": True, "stdout": json.dumps([ - {"name": "repo1", "url": "https://github.com/test-org/repo1"}, - {"name": "repo2", "url": "https://github.com/test-org/repo2"}, - ])} - repos = client.list_repos() - assert len(repos) == 2 - assert repos[0]["name"] == "repo1" - - -def test_list_repos_gh_cli_fails(client): - """When gh CLI fails, returns empty list.""" - with patch.object(client, "run_gh_cli") as mock_run: - mock_run.return_value = {"success": False, "stderr": "not authenticated"} - repos = client.list_repos() - assert repos == [] - - -def test_run_gh_cli_success(client): - """run_gh_cli returns parsed result.""" - with patch("subprocess.run") as mock_run: - mock_result = MagicMock() - mock_result.returncode = 0 - mock_result.stdout = "hello" - mock_result.stderr = "" - mock_run.return_value = mock_result - - result = client.run_gh_cli(["version"]) - assert result["success"] is True - assert result["stdout"] == "hello" - - -def test_run_gh_cli_failure(client): - """run_gh_cli handles non-zero return code.""" - with patch("subprocess.run") as mock_run: - mock_result = MagicMock() - mock_result.returncode = 1 - mock_result.stdout = "" - mock_result.stderr = "error: not logged in" - mock_run.return_value = mock_result - - result = client.run_gh_cli(["pr", "list"]) - assert result["success"] is False - assert "error" in result.get("stderr", "") - - -def test_run_gh_cli_timeout(client): - """run_gh_cli handles timeout gracefully.""" - with patch("subprocess.run") as mock_run: - mock_run.side_effect = subprocess.TimeoutExpired(cmd="gh", timeout=30) - result = client.run_gh_cli(["api", "slow-endpoint"]) - assert result["success"] is False - assert "timeout" in result.get("error", "").lower() - - -def test_run_gh_cli_exception(client): - """run_gh_cli handles unexpected exceptions.""" - with patch("subprocess.run") as mock_run: - mock_run.side_effect = FileNotFoundError("gh not found") - result = client.run_gh_cli(["repo", "list"]) - assert result["success"] is False - - -def test_create_release_fallback(client): - """create_release uses gh CLI when no pygithub.""" - with patch.object(client, "run_gh_cli") as mock_run: - mock_run.return_value = {"success": True, "stdout": "https://github.com/test-org/repo/releases/tag/v1.0"} - result = client.create_release("repo", "v1.0", "Release v1.0", body="Changelog notes", draft=False) - assert result["success"] is True - - -def test_create_release_fallback_fail(client): - """create_release returns error when gh CLI fails.""" - with patch.object(client, "run_gh_cli") as mock_run: - mock_run.return_value = {"success": False, "stderr": "tag already exists"} - result = client.create_release("repo", "v1.0", "Release v1.0", body="notes") - assert result["success"] is False - - -def test_create_pr_fallback(client): - """create_pr uses gh CLI when no pygithub.""" - with patch.object(client, "run_gh_cli") as mock_run: - mock_run.return_value = {"success": True, "stdout": "https://github.com/test-org/repo/pull/42"} - result = client.create_pr("repo", title="My PR", body="Description", head="feature", base="main") - assert result["success"] is True - - -def test_create_pr_fallback_fail(client): - """create_pr returns error when gh CLI fails.""" - with patch.object(client, "run_gh_cli") as mock_run: - mock_run.return_value = {"success": False, "stderr": "no commits"} - result = client.create_pr("repo", title="My PR", body="Desc", head="feature") - assert result["success"] is False - - -def test_list_workflow_runs(client): - """list_workflow_runs parses gh CLI JSON output.""" - with patch.object(client, "run_gh_cli") as mock_run: - mock_run.return_value = {"success": True, "stdout": json.dumps([ - {"databaseId": 123, "status": "completed", "conclusion": "success", "name": "CI", "headBranch": "main"}, - ])} - runs = client.list_workflow_runs("repo") - assert len(runs) == 1 - assert runs[0]["databaseId"] == 123 - - -def test_list_workflow_runs_empty(client): - """list_workflow_runs returns empty list on failure.""" - with patch.object(client, "run_gh_cli") as mock_run: - mock_run.return_value = {"success": False, "stderr": "error"} - runs = client.list_workflow_runs("repo") - assert runs == [] - - -def test_list_issues(client): - """list_issues parses gh CLI JSON output.""" - with patch.object(client, "run_gh_cli") as mock_run: - mock_run.return_value = {"success": True, "stdout": json.dumps([ - {"number": 1, "title": "Bug: crash on start", "labels": [], "author": {"login": "user"}}, - ])} - issues = client.list_issues("repo") - assert len(issues) == 1 - assert issues[0]["number"] == 1 - - -def test_list_issues_with_labels(client): - """list_issues passes label filters to gh CLI.""" - with patch.object(client, "run_gh_cli") as mock_run: - mock_run.return_value = {"success": True, "stdout": "[]"} - client.list_issues("repo", labels=["bug"]) - # Verify the label argument was passed - labels_flag_index = None - for i, arg in enumerate(mock_run.call_args[0][0]): - if arg == "--label": - labels_flag_index = i - break - assert labels_flag_index is not None - - -def test_list_issues_fail(client): - """list_issues returns empty list on failure.""" - with patch.object(client, "run_gh_cli") as mock_run: - mock_run.return_value = {"success": False, "stderr": "not found"} - issues = client.list_issues("repo") - assert issues == [] diff --git a/backend/tests/test_mcp_github_tools.py b/backend/tests/test_mcp_github_tools.py deleted file mode 100644 index ce997db0..00000000 --- a/backend/tests/test_mcp_github_tools.py +++ /dev/null @@ -1,282 +0,0 @@ -"""Unit tests for GitHub MCP Tools (autonomous workflow automation).""" -from __future__ import annotations - -from datetime import UTC -from unittest.mock import MagicMock, patch - -import pytest - -from app.services.mcp.github_client import GitHubClient -from app.services.mcp.github_tools import GitHubTools - - -@pytest.fixture -def tools(monkeypatch): - """GitHubTools with mocked client.""" - monkeypatch.delenv("GITHUB_TOKEN", raising=False) - ght = GitHubTools(token="fake-token", org="test-org") - ght.client = MagicMock(spec=GitHubClient) - return ght - - -def test_publish_release_no_token(tools): - """Publish release without PyGithub falls back to gh CLI.""" - tools.client.create_release.return_value = { - "success": True, - "release_url": "https://github.com/test-org/repo/releases/tag/v0.1.0", - "tag": "v0.1.0", - } - result = tools.publish_release("repo", version="0.1.0") - assert result["success"] is True - assert result["tag"] == "v0.1.0" - - -def test_publish_release_draft(tools): - """Publish draft release.""" - tools.client.create_release.return_value = { - "success": True, - "release_url": "https://github.com/test-org/repo/releases/tag/v0.2.0", - "tag": "v0.2.0", - } - result = tools.publish_release("repo", version="0.2.0", draft=True) - assert result["success"] is True - - -def test_publish_release_no_version(tools): - """Auto-detect version when none provided.""" - tools.client.create_release.return_value = { - "success": True, - "release_url": "https://github.com/test-org/repo/releases/tag/v0.1.0", - "tag": "v0.1.0", - } - result = tools.publish_release("repo", auto_tag=True) - assert result["success"] is True - assert "v0.1" in result["tag"] - - -def test_sync_repos_empty(tools): - """Sync repos when no repos exist.""" - tools.client.list_repos.return_value = [] - result = tools.sync_repos() - assert result["success"] is False - assert "No repos found" in result.get("error", "") - - -def test_sync_repos_success(tools, tmp_path): - """Sync repos clones missing and pulls existing.""" - tools.client.list_repos.return_value = [ - {"name": "repo1", "html_url": "https://github.com/test-org/repo1"}, - ] - # Simulate repo doesn't exist locally → will try clone - with patch("app.services.mcp.github_tools.Path.exists") as mock_exists: - mock_exists.return_value = False - with patch.object(tools, "_git_clone") as mock_clone: - mock_clone.return_value = "ok" - result = tools.sync_repos(local_dir=str(tmp_path)) - assert result["success"] is True - assert result["total"] == 1 - - -def test_sync_repos_pull_existing(tools, tmp_path): - """Sync repos pulls existing repos.""" - tools.client.list_repos.return_value = [ - {"name": "repo1", "html_url": "https://github.com/test-org/repo1"}, - ] - with patch("app.services.mcp.github_tools.Path.exists") as mock_exists: - mock_exists.return_value = True - with patch.object(tools, "_git_pull") as mock_pull: - mock_pull.return_value = "ok" - result = tools.sync_repos(local_dir=str(tmp_path)) - assert result["success"] is True - assert result["total"] == 1 - - -def test_create_pr_no_branch(tools): - """create_pr fails when no current branch.""" - with patch.object(tools, "_get_current_branch") as mock_branch: - mock_branch.return_value = None - result = tools.create_pr("repo") - assert result["success"] is False - - -def test_create_pr_on_main_branch(tools): - """create_pr fails when on base branch.""" - with patch.object(tools, "_get_current_branch") as mock_branch: - mock_branch.return_value = "main" - result = tools.create_pr("repo", base="main") - assert result["success"] is False - - -def test_create_pr_success(tools): - """create_pr succeeds with valid branch.""" - with patch.object(tools, "_get_current_branch") as mock_branch: - mock_branch.return_value = "feature-xyz" - tools.client.create_pr.return_value = { - "success": True, - "pr_url": "https://github.com/test-org/repo/pull/42", - "pr_number": 42, - } - result = tools.create_pr("repo") - assert result["success"] is True - assert result["pr_number"] == 42 - - -def test_create_pr_auto_title(tools): - """Auto-generated title and body.""" - with patch.object(tools, "_get_current_branch") as mock_branch: - mock_branch.return_value = "fix-crash-bug" - tools.client.create_pr.return_value = { - "success": True, - "pr_url": "https://github.com/test-org/repo/pull/43", - "pr_number": 43, - } - result = tools.create_pr("repo") - assert result["success"] is True - - -def test_heal_issues_no_issues(tools): - """heal_issues with no open issues.""" - tools.client.list_issues.return_value = [] - result = tools.heal_issues("repo") - assert result["success"] is True - assert "No open issues" in result["message"] - - -def test_heal_issues_auto_labels(tools): - """Auto-label issues by title keywords.""" - tools.client.list_issues.return_value = [ - {"number": 1, "title": "Bug: crash on startup", "labels": []}, - {"number": 2, "title": "Feature: add dark mode", "labels": []}, - ] - with patch.object(tools, "_add_labels") as mock_add: - mock_add.return_value = True - result = tools.heal_issues("repo", auto_label=True) - assert result["success"] is True - assert result["issues_processed"] == 2 - assert result["actions_taken"] >= 2 - - -def test_heal_issues_stale_closing(tools): - """Auto-close stale issues.""" - from datetime import datetime, timedelta - stale_date = (datetime.now(UTC) - timedelta(days=60)).isoformat() - tools.client.list_issues.return_value = [ - {"number": 5, "title": "Old issue", "labels": [], "updatedAt": stale_date}, - ] - with patch.object(tools, "_add_labels") as mock_add: - mock_add.return_value = True - with patch.object(tools, "_close_issue") as mock_close: - mock_close.return_value = True - result = tools.heal_issues("repo", auto_label=True, auto_close_stale=True, stale_days=30) - assert result["success"] is True - assert mock_close.called - - -def test_actions_status_no_runs(tools): - """Actions status with no workflows.""" - tools.client.list_repos.return_value = [{"name": "repo1"}] - tools.client.list_workflow_runs.return_value = [] - result = tools.actions_status() - assert result["success"] is True - assert result["total_runs"] == 0 - - -def test_actions_status_with_failures(tools): - """Actions status reports failures.""" - tools.client.list_repos.return_value = [{"name": "repo1"}] - tools.client.list_workflow_runs.return_value = [ - {"databaseId": 1, "status": "completed", "conclusion": "success", "name": "CI", "headBranch": "main"}, - {"databaseId": 2, "status": "completed", "conclusion": "failure", "name": "Lint", "headBranch": "main"}, - ] - result = tools.actions_status() - assert result["total_runs"] == 2 - assert result["failures"] == 1 - - -def test_actions_status_auto_retry(tools): - """Auto-retry failed workflows.""" - tools.client.list_repos.return_value = [{"name": "repo1"}] - tools.client.list_workflow_runs.return_value = [ - {"databaseId": 2, "status": "completed", "conclusion": "failure", "name": "Lint", "headBranch": "main"}, - ] - with patch.object(tools, "_retry_workflow") as mock_retry: - mock_retry.return_value = {"success": True} - result = tools.actions_status(auto_retry_failed=True) - assert result["retries"] == 1 - - -def test_approve_pr_mergeable(tools): - """Approve a PR with all checks passing.""" - tools.client.run_gh_cli.return_value = { - "success": True, - "stdout": '{"title": "Fix", "state": "OPEN", "mergeable": "MERGEABLE"}', - } - with patch.object(tools, "_run_local_tests") as mock_tests: - mock_tests.return_value = {"success": True} - result = tools.approve_pr("repo", pr_number=1, auto_merge=False, run_tests=True) - assert result["success"] is True - assert result["approved"] is True - - -def test_approve_pr_not_mergeable(tools): - """Approve fails when PR is not mergeable.""" - tools.client.run_gh_cli.return_value = { - "success": True, - "stdout": '{"title": "Fix", "state": "OPEN", "mergeable": "NOT_MERGEABLE"}', - } - result = tools.approve_pr("repo", pr_number=1, run_tests=False) - assert result["success"] is False - assert result["approved"] is False - - -def test_approve_pr_tests_fail(tools): - """Approve fails when local tests fail.""" - tools.client.run_gh_cli.return_value = { - "success": True, - "stdout": '{"title": "Fix", "state": "OPEN", "mergeable": "MERGEABLE"}', - } - with patch.object(tools, "_run_local_tests") as mock_tests: - mock_tests.return_value = {"success": False} - result = tools.approve_pr("repo", pr_number=1, run_tests=True) - assert result["success"] is False - - -def test_approve_pr_auto_merge(tools): - """Approve + auto-merge when all checks pass.""" - tools.client.run_gh_cli.return_value = { - "success": True, - "stdout": '{"title": "Fix", "state": "OPEN", "mergeable": "MERGEABLE"}', - } - with patch.object(tools, "_run_local_tests") as mock_tests: - mock_tests.return_value = {"success": True} - result = tools.approve_pr("repo", pr_number=1, auto_merge=True, run_tests=False) - assert result["success"] is True - assert result["approved"] is True - - -def test_get_github_tools_singleton(): - """get_github_tools returns same instance.""" - from app.services.mcp.github_tools import get_github_tools - t1 = get_github_tools() - t2 = get_github_tools() - assert t1 is t2 - - -def test_detect_issue_labels_bug(): - tools = GitHubTools(token="", org="test") - assert "bug" in tools._detect_issue_labels("Bug: crash on startup") - - -def test_detect_issue_labels_feature(): - tools = GitHubTools(token="", org="test") - assert "enhancement" in tools._detect_issue_labels("Feature: add dark mode") - - -def test_detect_issue_labels_doc(): - tools = GitHubTools(token="", org="test") - assert "documentation" in tools._detect_issue_labels("Update docs for API") - - -def test_detect_issue_labels_empty(): - tools = GitHubTools(token="", org="test") - assert tools._detect_issue_labels("Refactor internal logic") == [] diff --git a/backend/tests/test_mcp_guardrails.py b/backend/tests/test_mcp_guardrails.py deleted file mode 100644 index b08f6b94..00000000 --- a/backend/tests/test_mcp_guardrails.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Tests for MCP integrity guardrails and self-heal skill.""" -from __future__ import annotations - -import pytest - -from app.api.mcp_guardrails import ( - REQUIRED_MCP_EXPORTS, - REQUIRED_TOOL_NAMES, - validate_dispatch_tool, - validate_handler_signatures, - validate_mcp_exports, - validate_mcp_integrity, - validate_mcp_syntax, - validate_tool_registry, -) - - -class TestMCPGuardrails: - """Validate that the MCP layer is structurally sound.""" - - def test_syntax_check_passes(self): - report = validate_mcp_syntax() - assert report["ok"], f"Syntax errors: {report['errors']}" - - def test_exports_check_passes(self): - report = validate_mcp_exports() - assert report["ok"], f"Missing exports: {report['missing']}" - - def test_handler_signatures_check_passes(self): - report = validate_handler_signatures() - assert report["ok"], f"Signature errors: {report['errors']}" - - def test_tool_registry_check_passes(self): - report = validate_tool_registry() - assert report["ok"], f"Missing tools: {report['missing']}" - - def test_dispatch_tool_check_passes(self): - report = validate_dispatch_tool() - assert report["ok"], f"dispatch_tool error: {report.get('error')}" - - def test_full_integrity_check_passes(self): - report = validate_mcp_integrity() - assert report["ok"], f"Integrity errors: {report['errors']}" - - def test_required_exports_are_complete(self): - """Ensure the expected exports list covers the critical functions.""" - assert "handle_mcp_discover" in REQUIRED_MCP_EXPORTS - assert "handle_mcp_call" in REQUIRED_MCP_EXPORTS - - def test_required_tools_are_complete(self): - """Ensure the expected tools list covers all built-in tools.""" - assert "knowledge_search" in REQUIRED_TOOL_NAMES - assert "clipboard_capture" in REQUIRED_TOOL_NAMES - assert "tasker_list_boards" in REQUIRED_TOOL_NAMES - assert "gridsmith_create_grid" in REQUIRED_TOOL_NAMES - assert "toon_encode" in REQUIRED_TOOL_NAMES - assert "flow_router_route" in REQUIRED_TOOL_NAMES - - -class TestMCPSelfHealSkill: - """Test the MCP self-heal skill.""" - - def test_self_heal_dry_run(self): - import asyncio - - from app.skills.builtin.skill_mcp_self_heal import MCPSelfHealSkill - - skill = MCPSelfHealSkill() - result = asyncio.run(skill.run(dry_run=True)) - assert result["success"] is True - - def test_self_heal_detects_healthy_state(self): - import asyncio - - from app.skills.builtin.skill_mcp_self_heal import MCPSelfHealSkill - - skill = MCPSelfHealSkill() - result = asyncio.run(skill.run(dry_run=True)) - # When MCP is healthy, no repairs needed - if not result.get("repairs"): - assert "no repairs needed" in result.get("message", "").lower() or result["success"] diff --git a/docs/MCP_ARCHITECTURE_AUDIT_2026-08-19.md b/docs/MCP_ARCHITECTURE_AUDIT_2026-08-19.md index a2bede3d..290e9a69 100644 --- a/docs/MCP_ARCHITECTURE_AUDIT_2026-08-19.md +++ b/docs/MCP_ARCHITECTURE_AUDIT_2026-08-19.md @@ -37,11 +37,11 @@ tool discovery through an MCP SDK. | Path | Finding | Disposition | |---|---|---| -| `backend/app/api/mcp.py` and `mcp_handlers/` | Bespoke GET/POST REST facade that resembles JSON-RPC but has no MCP initialization, session, or transport lifecycle | Remove after the gateway uses owned APIs directly | -| `backend/app/services/mcp/` | Custom peer mesh with hard-coded machines, `/v1/health`, and legacy Snackmachine JSONL transport | Remove | -| `backend/app/snackbar/modules/mcp_bridge.py` | Second API facade mixing peer calls, providers, and chat under `/api/mcp/*` | Remove | -| `backend/mcp/mcp_diagnostics.py` | Static file-existence check with tool names already removed from the bridge | Replace with a real gateway protocol smoke test | -| `skill_mcp_self_heal.py` and MCP guardrails | Validate and repair the bespoke Python facade, not an MCP transport | Remove with that facade | +| Python REST facade and handler registry | Removed in PR #11 after the gateway switched to owned APIs | +| Custom peer/GitHub mesh | Removed in PR #11; external GitHub MCP connects directly to its host | +| Duplicate Snackbar MCP facade and panel | Removed in PR #11 | +| Static Python diagnostics | Replaced in PR #10 by an official-client protocol test | +| MCP self-heal and guardrails | Removed in PR #11 with the facade they mutated | | HiveMind/feed/TOON naming | Independent HTTP services described as MCP without a compliant MCP transport | Rename as services or expose selected operations through the gateway | | uCode `config/mcp_config.json` | Registers the uCore web app, Ollama, HiveMind, and feed processes as MCP servers although they do not speak MCP over stdio | Remove | | uCode GridSmith MCP server | Hand-rolled 2024-11-05 HTTP JSON-RPC; POST-only, permissive CORS, no standard Streamable HTTP lifecycle/auth | Replace with a domain adapter registered in `udos-mcp` | @@ -160,9 +160,9 @@ Vendor intake rules are tightened for this work: adapters that have implemented canonical routes, including input schemas and timeouts. Defer single-task and single-document reads until their owning repositories provide real GET contracts. -3. Remove Python `api/mcp.py`, `mcp_handlers`, MCP guardrails/self-heal, the - custom peer mesh, duplicate Snackbar routes, stale diagnostics, launchers, - manifests, and UI claims that enumerate non-MCP services. +3. Remove Python facade/handlers, guardrails/self-heal, custom peer mesh, + duplicate Snackbar routes, stale diagnostics/launchers/manifests, and UI + claims that enumerate non-MCP services. Completed in PR #11. 4. Remove uCode's invalid MCP configuration; migrate selected GridSmith reads behind the uCode adapter and rename Mini Control Protocol symbols/docs. 5. Publish one client configuration example for the stdio gateway and prove it diff --git a/docs/PLATES_SYSTEM_SPEC.md b/docs/PLATES_SYSTEM_SPEC.md index 58e23d40..48d03097 100644 --- a/docs/PLATES_SYSTEM_SPEC.md +++ b/docs/PLATES_SYSTEM_SPEC.md @@ -65,74 +65,12 @@ plates/ │ ├── skill_plate.py # Cookiecutter template: skill scaffold │ └── ... ├── snacks/ # Snack plates -├── mcp/ # MCP tool plates ├── hivemind/ # Hivemind workflow plates ├── secrets/ # Variable/secret plates └── css/ # CSS/USX theme plates ``` -## 5. OpenAPI/Swagger MCP Registry - -The MCP tool registry (`TOOL_HANDLERS`) auto-generates an **OpenAPI 3.1 spec** at `/api/mcp/openapi.json`: - -```yaml -openapi: "3.1.0" -info: - title: "uCore MCP API" - version: "1.0.0" - description: "Auto-generated from TOOL_HANDLERS registry" -servers: - - url: "http://localhost:8484" - description: "uCore Backend" -paths: - /api/mcp/discover: - get: - summary: "List all available MCP tools" - operationId: "handle_mcp_discover" - responses: - "200": - description: "Tool list" - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/ToolDefinition" - /api/mcp/call: - post: - summary: "Call an MCP tool" - operationId: "handle_mcp_call" - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/MCPCallRequest" - responses: - "200": - description: "Tool response" -components: - schemas: - ToolDefinition: - type: object - properties: - name: { type: string } - description: { type: string } - input_schema: { type: object } - MCPCallRequest: - type: object - properties: - name: { type: string } - arguments: { type: object } -``` - -**Benefits:** -- Swagger UI at `/api/mcp/docs` for interactive testing -- Auto-generated client SDKs for any language -- MCP-compatible discovery endpoint -- Versioned API surface for drift detection - -## 6. Cookiecutter for Skill Plates +## 5. Cookiecutter for Skill Plates Skill scaffolding uses Cookiecutter: @@ -160,7 +98,7 @@ skill_plate/ └── post_gen_project.py # Register in registry, run tests ``` -## 7. Pydantic BaseModel for Plate Validation +## 6. Pydantic BaseModel for Plate Validation Extend the existing `BaseSkill`/`SkillMeta` pattern for all plates: @@ -191,7 +129,7 @@ class DestroyRebuildConfig(BaseModel): backup_before_destroy: bool = True ``` -## 8. Plate Refresh Engine +## 7. Plate Refresh Engine Located at `backend/plate_refresh/`, the engine: @@ -213,7 +151,7 @@ if report["drift_detected"]: salvage_and_rebuild(report) ``` -## 9. DESTROY/REBUILD Protocol +## 8. DESTROY/REBUILD Protocol When corruption or hallucination is detected: @@ -344,7 +282,7 @@ plate: 5. Never delete a plate — archive with version suffix ``` -## 10. Promotion Workflow +## 9. Promotion Workflow Users can promote working modifications to plates: @@ -359,7 +297,7 @@ ucore plate promote --from backend/app/skills/builtin/my_skill.py \ --version 1.1.0 ``` -## 11. Drift Detection +## 10. Drift Detection The capability catalogue and registry discovery test compare enabled modules against the governed builtin set: @@ -373,24 +311,21 @@ python -m pytest -q backend/tests/test_skill_registry_discovery_policy.py # - orphaned: instances with no plate ``` -## 12. Integration Points +## 11. Integration Points | System | Plate Role | |--------|-----------| | `backend/app/skills/registry.py` | Auto-discovers skills; plates define canonical set | | `backend/app/skills/state.py` | Persists skill state; plates define default state shape | -| `backend/app/api/mcp_guardrails.py` | Validates MCP layer against expected plate | | `backend/plate_refresh/refresh.py` | Renders plates to output | | `plates/destroy/` | DESTROY/REBUILD recovery plates | -| `backend/app/api/mcp.py` | Auto-generates OpenAPI spec from TOOL_HANDLERS | | `.clinerules` | Agent instructions for plate workflow | -## 13. Future Work +## 12. Future Work - [ ] Plate version migration (auto-upgrade on version mismatch) - [ ] Community plate repository (shareable via git submodule) - [ ] Plate diff viewer (visual comparison of drift) - [ ] Auto-promote on successful test pass -- [ ] OpenAPI/Swagger UI at `/api/mcp/docs` - [ ] Cookiecutter plate for `gh:ucore/skill-plate` - [ ] Pydantic `PlateMeta` → `BaseSkill` auto-generation diff --git a/docs/README.md b/docs/README.md index 69d7f6f1..c3d15348 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,8 +6,8 @@ | Doc | Purpose | | ---------------------------------------------------------------------------------------- | --------------------------------- | -| [MCP_SETUP.md](MCP_SETUP.md) | Install and configure MCP servers | -| [USER_SETUP_VAULT_MCP_WORKSPACES.md](USER_SETUP_VAULT_MCP_WORKSPACES.md) | Vault and MCP workspace setup | +| [MCP_SETUP.md](MCP_SETUP.md) | Build and configure `udos-mcp` | +| [USER_SETUP_VAULT_MCP_WORKSPACES.md](USER_SETUP_VAULT_MCP_WORKSPACES.md) | Vault and workspace setup | ## Active System Specs diff --git a/docs/USER_SETUP_VAULT_MCP_WORKSPACES.md b/docs/USER_SETUP_VAULT_MCP_WORKSPACES.md index 336884fc..902b4a6e 100644 --- a/docs/USER_SETUP_VAULT_MCP_WORKSPACES.md +++ b/docs/USER_SETUP_VAULT_MCP_WORKSPACES.md @@ -36,22 +36,21 @@ the FTS5 index at `~/.ucore/indices/library.db`. Scheduled syncs run daily. ## 2. MCP Integration -### 2.1. MCP Server Setup +### 2.1. Canonical Gateway -MCP servers provide tools and resources that uCore can leverage. Ensure the necessary MCP servers are running and accessible locally. +uCore provides one local stdio gateway, `udos-mcp`. Build and test it using +[`MCP_SETUP.md`](MCP_SETUP.md). The MCP host launches the gateway; it is not an +always-running uCore HTTP service. -* **Firewatch MCP**: For browser automation. Install globally (`npm install -g firewatch-mcp`) and configure in Cline settings. Refer to `docs/ZEN_PLAYWRIGHT_AUTOMATION_TOOLCHAIN.md` for detailed setup. -* **Zapier MCP**: For connecting to Zapier's ecosystem. Refer to Zapier MCP documentation for setup. +External MCP servers are configured directly in the client that owns them. They +are not proxied, registered, or supervised by uCore. ### 2.2. Using MCP Tools -MCP tools are accessed via the Cline CLI or directly through API calls. +MCP tools are accessed through an MCP client using the stdio command documented +in `MCP_SETUP.md`. -* **Cline CLI**: - ```bash - cline --yolo "Use Firewatch to navigate to http://localhost:5173 and click 'Import'" - ``` -* **API Calls**: Refer to `backend/app/api/mcp/` for direct API endpoint usage. +There is no `/api/mcp/*` compatibility facade. ## 3. Workspace Switching @@ -66,8 +65,8 @@ layers in the sidebar and knowledge tools. ## 4. Integrating with Automation Tools Use the existing skills (`vault_sync`, `tasker_sync`, `brain_sync`) directly -instead of third-party sync tools. Workflow automation can call these through -the MCP/skill API. +instead of third-party sync tools. Workflow automation uses the canonical +uFlow and uKnowledge APIs. ## 5. Scheduling Automation @@ -113,4 +112,4 @@ Refer to `docs/ZEN_PLAYWRIGHT_AUTOMATION_TOOLCHAIN.md` for detailed examples and **Last Updated**: 2026-06-22T21:45:00+08:00 **Session**: Phase 9B Developer wiring and endpoint verification -**Status**: Ready for user setup documentation completion. \ No newline at end of file +**Status**: Ready for user setup documentation completion. diff --git a/docs/mcp-diagnostics.md b/docs/mcp-diagnostics.md deleted file mode 100644 index eb5b037a..00000000 --- a/docs/mcp-diagnostics.md +++ /dev/null @@ -1,9 +0,0 @@ -# MCP Diagnostics Endpoint (Prototype) - -- Expose an endpoint at /api/mcp/diagnostics that returns: - - current tool registry snapshot (names, ids, and schemas) - - last N tool calls attempted (payload, response, status) - - health status and a quick router health check - - a short recommended remediation path - -- This is a dev-mode endpoint to accelerate triage during MCP integration work. diff --git a/frontend-vue/src/router/index.ts b/frontend-vue/src/router/index.ts index 330287f9..96a75ee9 100644 --- a/frontend-vue/src/router/index.ts +++ b/frontend-vue/src/router/index.ts @@ -83,7 +83,7 @@ const routes: RouteRecordRaw[] = [ const tab = String(to.query.tab || "snacks"); if (tab === "workflows") return "/workflow?tab=publish"; if (tab === "vault") return "/workflow?tab=binder"; - if (tab === "mcp") return "/snackbar?tab=mcp"; + if (tab === "mcp") return "/developer"; if (tab === "variables") return "/system?tab=variables"; if (tab === "scheduler") return "/snackbar?tab=dashboard"; return "/snackbar?tab=snacks"; diff --git a/frontend-vue/src/stores/snackbarOps.ts b/frontend-vue/src/stores/snackbarOps.ts index f59910af..ce7b956d 100644 --- a/frontend-vue/src/stores/snackbarOps.ts +++ b/frontend-vue/src/stores/snackbarOps.ts @@ -15,8 +15,7 @@ export type SnackbarOpsTab = | "skills" | "snacks" | "extensions" - | "logs" - | "mcp"; + | "logs"; export interface RuntimeSnack { id: string; @@ -45,7 +44,7 @@ export interface ServiceStatus { export interface UnifiedServiceInfo { id: string; name: string; - kind: "service" | "tool" | "mcp"; + kind: "service" | "tool"; description: string; status: "up" | "degraded" | "down"; port: number; @@ -87,19 +86,6 @@ export interface ExecutableInfo { actions: string[]; } -export interface MCPTool { - name: string; - description: string; - server: string; -} - -export interface MCPServerInfo { - name: string; - status: "online" | "offline" | "unknown"; - port: number; - tools: number; -} - export interface BudgetInfo { remaining: number; used: number; @@ -129,7 +115,6 @@ export const SNACKBAR_OPS_TABS: { { id: "snacks", label: "Snacks", icon: "storefront" }, { id: "extensions", label: "Extensions", icon: "extension" }, { id: "logs", label: "Logs", icon: "article" }, - { id: "mcp", label: "MCP", icon: "hub" }, ]; export const useSnackbarOpsStore = defineStore("snackbar-ops", () => { @@ -142,8 +127,6 @@ export const useSnackbarOpsStore = defineStore("snackbar-ops", () => { const modelUsage = ref([]); const agents = ref([]); const executables = ref([]); - const mcpTools = ref([]); - const mcpServers = ref([]); const budgetRemaining = ref(null); const budgetLimit = ref(50.0); const budgetUsed = ref(0.0); @@ -426,38 +409,6 @@ export const useSnackbarOpsStore = defineStore("snackbar-ops", () => { } } - async function fetchMCP(): Promise { - try { - const [toolsRes, controlRes] = await Promise.all([ - fetch("/api/mcp/tools"), - fetch("/api/control/status"), - ]); - - if (toolsRes.ok) { - const toolsData = await toolsRes.json(); - const raw = Array.isArray(toolsData?.tools) ? toolsData.tools : []; - mcpTools.value = raw.map((t: any) => ({ - name: t.name || "", - description: t.description || "", - server: t.server || "ucore", - })); - } - - if (controlRes.ok) { - const controlData = await controlRes.json(); - const mcpList = controlData?.mcp_servers || []; - mcpServers.value = mcpList.map((s: any) => ({ - name: s.name || s.id || "Unknown", - status: s.status || "unknown", - port: s.port || 0, - tools: s.tool_count || 0, - })); - } - } catch (e: any) { - console.warn("MCP status fetch failed:", e.message); - } - } - async function fetchAll(): Promise { loading.value = true; error.value = null; @@ -467,7 +418,6 @@ export const useSnackbarOpsStore = defineStore("snackbar-ops", () => { fetchUnifiedServices(), fetchSnacks(), fetchExecutables(), - fetchMCP(), fetchLogs(), fetchModels(), fetchAgents(), @@ -487,8 +437,6 @@ export const useSnackbarOpsStore = defineStore("snackbar-ops", () => { snacks, systemSnacks, executables, - mcpTools, - mcpServers, logs, modelUsage, agents, @@ -509,7 +457,6 @@ export const useSnackbarOpsStore = defineStore("snackbar-ops", () => { fetchLogs, fetchSnacks, fetchExecutables, - fetchMCP, fetchModels, fetchAgents, fetchBudget, diff --git a/frontend-vue/src/surfaces/snackbar/SnackbarSurface.vue b/frontend-vue/src/surfaces/snackbar/SnackbarSurface.vue index 1a220f1c..2152a1b6 100644 --- a/frontend-vue/src/surfaces/snackbar/SnackbarSurface.vue +++ b/frontend-vue/src/surfaces/snackbar/SnackbarSurface.vue @@ -55,10 +55,6 @@
- -
- -
@@ -105,9 +101,6 @@ const SnackbarExtensionsPanel = defineAsyncComponent( const SnackbarLogsPanel = defineAsyncComponent( () => import("./panels/SnackbarLogsPanel.vue"), ); -const SnackbarMCPServersPanel = defineAsyncComponent( - () => import("./panels/SnackbarMCPServersPanel.vue"), -); import { useSnackbarOpsStore, SNACKBAR_OPS_TABS, diff --git a/frontend-vue/src/surfaces/snackbar/panels/SnackbarMCPServersPanel.vue b/frontend-vue/src/surfaces/snackbar/panels/SnackbarMCPServersPanel.vue deleted file mode 100644 index 197a66da..00000000 --- a/frontend-vue/src/surfaces/snackbar/panels/SnackbarMCPServersPanel.vue +++ /dev/null @@ -1,210 +0,0 @@ - - - - - diff --git a/scripts/maintenance_test_run.py b/scripts/maintenance_test_run.py deleted file mode 100644 index f1821552..00000000 --- a/scripts/maintenance_test_run.py +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env python3 -"""Maintenance Skill Test Run — Execute on return to verify system health. - -This script runs a series of self-healing skills to verify: -- Port conflicts are resolved -- System diagnostics are clean -- Resources are cleaned up -- Database is healthy -- MCP bridge is responsive - -Run after system restart or when returning to work. -""" -from __future__ import annotations - -import asyncio -import json -import logging -import sys -from pathlib import Path - -# Add backend to path -sys.path.insert(0, str(Path(__file__).parent.parent / "backend")) - -from app.skills.self_heal import execute_skill, list_skills -from app.services.popcorn_manager import get_popcorn_status, is_popcorn_running -from app.services.mcp import get_bridge - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s [%(levelname)s] maintenance: %(message)s", -) -log = logging.getLogger("maintenance_test_run") - - -async def run_maintenance_tests() -> dict: - """Run all maintenance skills and return results.""" - results = { - "skills": {}, - "system": {}, - "mcp": {}, - } - - # ─── 1. List available skills ───────────────────────────────── - log.info("Available skills: %s", list_skills()) - - # ─── 2. Diagnose system ─────────────────────────────────────── - log.info("Running diagnose_system...") - results["skills"]["diagnose_system"] = await execute_skill("diagnose_system") - log.info("System status: %s", results["skills"]["diagnose_system"].details.get("status")) - - # ─── 3. Recover port conflicts ───────────────────────────────── - log.info("Running recover_port_conflict...") - results["skills"]["recover_port_conflict"] = await execute_skill("recover_port_conflict") - log.info("Port conflicts cleaned: %s", results["skills"]["recover_port_conflict"].details.get("cleaned", 0)) - - # ─── 4. Cleanup resources ───────────────────────────────────── - log.info("Running cleanup_resources...") - results["skills"]["cleanup_resources"] = await execute_skill("cleanup_resources") - log.info("Processes killed: %s", results["skills"]["cleanup_resources"].details.get("processes_killed", 0)) - - # ─── 5. Reset database (schema check) ───────────────────────── - log.info("Running reset_database (schema check)...") - results["skills"]["reset_database"] = await execute_skill("reset_database", keep_data=True) - log.info("Database migration: %s", results["skills"]["reset_database"].success) - - # ─── 6. Check Popcorn status ─────────────────────────────────── - log.info("Checking Popcorn status...") - results["system"]["popcorn"] = { - "running": is_popcorn_running(), - "status": get_popcorn_status(), - } - log.info("Popcorn running: %s", results["system"]["popcorn"]["running"]) - - # ─── 7. Check MCP bridge ─────────────────────────────────────── - log.info("Checking MCP bridge...") - try: - bridge = get_bridge() - results["mcp"]["peers"] = bridge.get_peers() - log.info("MCP peers: %s", list(results["mcp"]["peers"].keys())) - except Exception as e: - results["mcp"]["error"] = str(e) - log.warning("MCP bridge check failed: %s", e) - - return results - - -def main(): - """Main entry point.""" - log.info("=" * 50) - log.info("uCore Maintenance Test Run") - log.info("=" * 50) - - results = asyncio.run(run_maintenance_tests()) - - # Print summary - log.info("=" * 50) - log.info("Summary:") - for skill_name, result in results["skills"].items(): - status = "✓" if result.success else "✗" - log.info(" %s %s: %s", status, skill_name, result.message) - - # Save results - output_path = Path.home() / ".ucore/logs/maintenance_test_run.json" - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(json.dumps(results, indent=2, default=str)) - log.info("Results saved to: %s", output_path) - - # Return exit code - all_success = all(r.success for r in results["skills"].values()) - return 0 if all_success else 1 - - -if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file