diff --git a/ai-hub/app/api/routes/api.py b/ai-hub/app/api/routes/api.py index 4980946..6aea324 100644 --- a/ai-hub/app/api/routes/api.py +++ b/ai-hub/app/api/routes/api.py @@ -12,6 +12,7 @@ from .skills import create_skills_router from .agent_update import create_agent_update_router from .admin import create_admin_router +from .mcp import create_mcp_router def create_api_router(services: ServiceContainer) -> APIRouter: """ @@ -34,4 +35,7 @@ from .agents import create_agents_router router.include_router(create_agents_router(services), prefix="/agents", tags=["Agents"]) + # MCP SSE Server (Anthropic Model Context Protocol) + router.include_router(create_mcp_router(services), prefix="/mcp", tags=["MCP"]) + return router \ No newline at end of file diff --git a/ai-hub/app/api/routes/mcp.py b/ai-hub/app/api/routes/mcp.py new file mode 100644 index 0000000..341d333 --- /dev/null +++ b/ai-hub/app/api/routes/mcp.py @@ -0,0 +1,322 @@ +""" +MCP (Model Context Protocol) Server Route — Anthropic / SSE Transport + +Endpoints registered under /api/v1/mcp/*: + GET /mcp/sse - Establish persistent SSE stream (per MCP spec) + POST /mcp/messages - Receive JSON-RPC 2.0 messages from client + +Discovery: + GET /.well-known/mcp/manifest.json (registered on root app in app.py) + +Protocol flow: + 1. Client GETs /mcp/sse — server streams an `endpoint` event with the /messages URL + 2. Client POSTs JSON-RPC requests to /mcp/messages?session_id= + 3. Server dispatches the tool, pushes JSON-RPC response back over the SSE stream +""" + +import asyncio +import json +import uuid +import logging +from typing import Optional, AsyncIterator + +from fastapi import APIRouter, Request, HTTPException, Query +from fastapi.responses import JSONResponse, StreamingResponse + +from app.api.dependencies import ServiceContainer +from app.config import settings + +logger = logging.getLogger(__name__) + +# ─── In-process SSE session registry ───────────────────────────────────────── +# Maps session_id → asyncio.Queue of JSON-serializable dicts +_sse_sessions: dict[str, asyncio.Queue] = {} + + +def create_mcp_router(services: ServiceContainer) -> APIRouter: + router = APIRouter(tags=["MCP"]) + + # ─── SSE Transport — Client Connection ──────────────────────────────────── + @router.get("/sse") + async def mcp_sse( + request: Request, + token: Optional[str] = Query(None, description="Optional user token (X-User-ID)"), + ): + """ + Establishes a Server-Sent Events (SSE) stream for an MCP client. + + Per the MCP spec the first event MUST be `endpoint`, whose data is the + URL the client should POST messages to. + """ + session_id = str(uuid.uuid4()) + queue: asyncio.Queue = asyncio.Queue() + _sse_sessions[session_id] = queue + + # Build the absolute messages URL from the request's base URL + base = str(request.base_url).rstrip("/") + messages_url = f"{base}/api/v1/mcp/messages?session_id={session_id}" + if token: + messages_url += f"&token={token}" + + logger.info(f"[MCP] New SSE session: {session_id}") + + async def event_generator() -> AsyncIterator[str]: + # Required first event — tells the client where to POST messages + yield f"event: endpoint\ndata: {messages_url}\n\n" + try: + while True: + if await request.is_disconnected(): + logger.info(f"[MCP] Client disconnected: {session_id}") + break + try: + msg = await asyncio.wait_for(queue.get(), timeout=25.0) + yield f"event: message\ndata: {json.dumps(msg)}\n\n" + except asyncio.TimeoutError: + yield ": keepalive\n\n" + finally: + _sse_sessions.pop(session_id, None) + logger.info(f"[MCP] Session cleaned up: {session_id}") + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", # Disable Nginx buffering for SSE + "Access-Control-Allow-Origin": "*", + }, + ) + + # ─── SSE Transport — Message Handler ───────────────────────────────────── + @router.post("/messages") + async def mcp_messages( + request: Request, + session_id: str = Query(...), + token: Optional[str] = Query(None), + ): + """ + Receives a JSON-RPC 2.0 message from an MCP client. + The response is pushed asynchronously back over the SSE stream. + Returns 202 Accepted immediately so the client doesn't time out. + """ + queue = _sse_sessions.get(session_id) + if not queue: + raise HTTPException(status_code=404, detail="MCP session not found or expired.") + + try: + body = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON body.") + + rpc_id = body.get("id") + method = body.get("method", "") + params = body.get("params", {}) + + logger.info(f"[MCP] [{session_id[:8]}] → {method}") + + # Dispatch asynchronously — don't block the HTTP response + asyncio.create_task(_dispatch(queue, rpc_id, method, params, token, services)) + + return JSONResponse( + {"status": "accepted"}, + status_code=202, + headers={"Access-Control-Allow-Origin": "*"}, + ) + + return router + + +# ─── Dispatcher ─────────────────────────────────────────────────────────────── + +async def _dispatch( + queue: asyncio.Queue, + rpc_id, + method: str, + params: dict, + token: Optional[str], + services: ServiceContainer, +): + """Run the method and push a JSON-RPC response onto the SSE queue.""" + try: + result = await _execute(method, params, token, services) + await queue.put({"jsonrpc": "2.0", "id": rpc_id, "result": result}) + except Exception as exc: + logger.exception(f"[MCP] Tool error for '{method}': {exc}") + await queue.put({ + "jsonrpc": "2.0", + "id": rpc_id, + "error": {"code": -32000, "message": str(exc)}, + }) + + +async def _execute(method: str, params: dict, token: Optional[str], services: ServiceContainer): + """Route a JSON-RPC method to its implementation.""" + + # ── MCP Handshake ───────────────────────────────────────────────────────── + if method == "initialize": + return { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "Cortex Hub", "version": "1.0.0"}, + } + + if method == "ping": + return {} + + # ── Tool Discovery ──────────────────────────────────────────────────────── + if method == "tools/list": + return { + "tools": [ + _tool_def("list_nodes", + "List all agent nodes in the Cortex swarm mesh and their status.", + {}), + _tool_def("get_app_info", + "Get metadata about this Cortex Hub instance.", + {}), + _tool_def("get_node_details", + "Get full details for a specific agent node.", + {"node_id": {"type": "string", "description": "Unique node ID"}}, + required=["node_id"]), + _tool_def("list_agents", + "List all autonomous agents configured in the system.", + {}), + _tool_def("list_skills", + "List all skill folders (tool libraries) registered in the system.", + {}), + ] + } + + # ── Tool Execution ──────────────────────────────────────────────────────── + if method == "tools/call": + name = params.get("name", "") + args = params.get("arguments", {}) + return await _call_tool(name, args, token, services) + + raise ValueError(f"Unknown method: '{method}'") + + +def _tool_def(name: str, description: str, properties: dict, required: list = None) -> dict: + schema = {"type": "object", "properties": properties} + if required: + schema["required"] = required + return {"name": name, "description": description, "inputSchema": schema} + + +# ─── Tool Implementations ───────────────────────────────────────────────────── + +async def _call_tool(name: str, args: dict, token: Optional[str], services: ServiceContainer) -> dict: + """Execute a named tool and return a standard MCP content block.""" + + def _ok(data) -> dict: + text = json.dumps(data, indent=2, default=str) if not isinstance(data, str) else data + return {"content": [{"type": "text", "text": text}]} + + # Run DB queries in a thread pool so we don't block the event loop + loop = asyncio.get_running_loop() + + if name == "list_nodes": + def _query(): + from app.db.session import get_db_session + from app.db import models + with get_db_session() as db: + rows = db.query(models.AgentNode).all() + return { + "nodes": [ + { + "id": n.node_id, + "name": n.display_name, + "status": n.last_status, + "os": (n.capabilities or {}).get("os"), + "is_active": n.is_active, + } + for n in rows + ] + } + return _ok(await loop.run_in_executor(None, _query)) + + if name == "get_app_info": + def _query(): + from app.db.session import get_db_session + from app.db import models + with get_db_session() as db: + total = db.query(models.AgentNode).count() + online = db.query(models.AgentNode).filter(models.AgentNode.last_status == "online").count() + return { + "name": "Cortex Hub", + "version": "1.0.0", + "capabilities": ["swarms", "webmcp", "mcp-sse", "voice-chat", "rag"], + "nodes": {"total": total, "online": online}, + "mcp_transport": "sse", + "sse_endpoint": f"{settings.HUB_PUBLIC_URL}/api/v1/mcp/sse", + } + return _ok(await loop.run_in_executor(None, _query)) + + if name == "get_node_details": + node_id = args.get("node_id") + if not node_id: + raise ValueError("node_id is required.") + def _query(): + from app.db.session import get_db_session + from app.db import models + with get_db_session() as db: + n = db.query(models.AgentNode).filter(models.AgentNode.node_id == node_id).first() + if not n: + return None + return { + "node_id": n.node_id, + "display_name": n.display_name, + "description": n.description, + "status": n.last_status, + "is_active": n.is_active, + "capabilities": n.capabilities, + "skill_config": n.skill_config, + "registered_by": n.registered_by, + "last_seen_at": str(n.last_seen_at) if n.last_seen_at else None, + } + result = await loop.run_in_executor(None, _query) + if result is None: + raise ValueError(f"Node '{node_id}' not found.") + return _ok(result) + + if name == "list_agents": + def _query(): + from app.db.session import get_db_session + from app.db import models + with get_db_session() as db: + rows = db.query(models.AgentInstance).all() + return { + "agents": [ + { + "id": str(a.id), + "name": a.template.name if a.template else None, + "status": a.status, + "node": a.mesh_node_id, + "last_heartbeat": str(a.last_heartbeat) if a.last_heartbeat else None, + "total_runs": a.total_runs, + "quality_score": a.latest_quality_score, + } + for a in rows + ] + } + return _ok(await loop.run_in_executor(None, _query)) + + if name == "list_skills": + def _query(): + from app.db.session import get_db_session + from app.db import models + with get_db_session() as db: + rows = db.query(models.Skill).filter(models.Skill.is_enabled == True).all() + return { + "skills": [ + { + "id": s.id, + "name": s.name, + "description": s.description, + "type": s.skill_type, + } + for s in rows + ] + } + return _ok(await loop.run_in_executor(None, _query)) + + raise ValueError(f"Unknown tool: '{name}'") diff --git a/ai-hub/app/app.py b/ai-hub/app/app.py index 7c0dc6d..0c24aeb 100644 --- a/ai-hub/app/app.py +++ b/ai-hub/app/app.py @@ -303,6 +303,22 @@ api_router = create_api_router(services=services) app.include_router(api_router, prefix="/api/v1") + # ── MCP Well-Known Discovery (root level, no /api/v1 prefix) ────────────── + # External MCP clients (Claude Desktop, IDE plugins) look for this at the root. + from fastapi.responses import JSONResponse as _JSONResponse + @app.get("/.well-known/mcp/manifest.json", include_in_schema=False, tags=["MCP"]) + async def _mcp_manifest_root(): + from app.config import settings as _s + base_url = _s.HUB_PUBLIC_URL or "https://ai.jerxie.com" + return _JSONResponse({ + "schema_version": "1.0", + "name": "Cortex Hub", + "description": "AI-powered swarm orchestration platform with distributed agent nodes, skill management, and multi-provider LLM routing.", + "version": "1.0.0", + "server_url": f"{base_url}/api/v1/mcp/sse", + "transport": "sse", + }, headers={"Access-Control-Allow-Origin": "*"}) + cors_origins = settings.CORS_ORIGINS if settings.HUB_PUBLIC_URL and settings.HUB_PUBLIC_URL not in cors_origins: cors_origins.append(settings.HUB_PUBLIC_URL) diff --git a/ai-hub/app/db/models/__init__.py b/ai-hub/app/db/models/__init__.py index a0692a7..311f0c7 100644 --- a/ai-hub/app/db/models/__init__.py +++ b/ai-hub/app/db/models/__init__.py @@ -4,11 +4,13 @@ from .document import Document, VectorMetadata from .asset import PromptTemplate, Skill, SkillFile, SkillGroupAccess, MCPServer, AssetPermission from .node import AgentNode, NodeGroupAccess +from .agent import AgentTemplate, AgentInstance, AgentTrigger __all__ = [ "User", "Group", "Session", "Message", "Document", "VectorMetadata", "PromptTemplate", "Skill", "SkillFile", "SkillGroupAccess", "MCPServer", "AssetPermission", - "AgentNode", "NodeGroupAccess" + "AgentNode", "NodeGroupAccess", + "AgentTemplate", "AgentInstance", "AgentTrigger", ] diff --git a/frontend/src/features/agents/components/AgentHarnessPage.js b/frontend/src/features/agents/components/AgentHarnessPage.js index 0e4cedd..8ad085f 100644 --- a/frontend/src/features/agents/components/AgentHarnessPage.js +++ b/frontend/src/features/agents/components/AgentHarnessPage.js @@ -28,14 +28,17 @@ name: 'list_agents', description: 'List all autonomous agents currently configured in the system.', inputSchema: { type: 'object', properties: {} }, - handler: async () => { - return { agents: agents.map(a => ({ - id: a.id, - name: a.template?.name, - status: a.status, - node: a.mesh_node_id, - last_heartbeat: a.last_heartbeat - })) }; + execute: async () => { + const result = { + agents: agents.map(a => ({ + id: a.id, + name: a.template?.name, + status: a.status, + node: a.mesh_node_id, + last_heartbeat: a.last_heartbeat + })) + }; + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; } }); diff --git a/frontend/src/features/nodes/pages/NodesPage.js b/frontend/src/features/nodes/pages/NodesPage.js index c943ee1..a588ddb 100644 --- a/frontend/src/features/nodes/pages/NodesPage.js +++ b/frontend/src/features/nodes/pages/NodesPage.js @@ -43,21 +43,6 @@ useEffect(() => { // Register WebMCP tools registerTool({ - name: 'list_nodes', - description: 'List all agent nodes in the mesh and their current status.', - inputSchema: { type: 'object', properties: {} }, - handler: async () => { - return { nodes: nodes.map(n => ({ - id: n.node_id, - name: n.display_name, - status: n.last_status, - is_active: n.is_active, - os: n.capabilities?.os - })) }; - } - }); - - registerTool({ name: 'get_node_details', description: 'Get full details for a specific agent node.', inputSchema: { @@ -67,10 +52,10 @@ }, required: ['node_id'] }, - handler: async ({ node_id }) => { + execute: async ({ node_id }) => { const node = nodes.find(n => n.node_id === node_id); - if (!node) return { error: `Node ${node_id} not found.` }; - return { node }; + if (!node) return { content: [{ type: 'text', text: `Node ${node_id} not found.` }], isError: true }; + return { content: [{ type: 'text', text: JSON.stringify({ node }, null, 2) }] }; } }); @@ -85,25 +70,24 @@ }, required: ['node_id', 'active'] }, - handler: async ({ node_id, active }) => { + execute: async ({ node_id, active }) => { const node = nodes.find(n => n.node_id === node_id); - if (!node) return { error: `Node ${node_id} not found.` }; + if (!node) return { content: [{ type: 'text', text: `Node ${node_id} not found.` }], isError: true }; try { await adminUpdateNode(node_id, { is_active: active }); fetchData(); - return { success: true, message: `Node ${node_id} ${active ? 'enabled' : 'disabled'}.` }; + return { content: [{ type: 'text', text: `Node ${node_id} ${active ? 'enabled' : 'disabled'}.` }] }; } catch (err) { - return { error: err.message }; + return { content: [{ type: 'text', text: `Error updating node: ${err.message}` }], isError: true }; } } }); return () => { - unregisterTool('list_nodes'); unregisterTool('get_node_details'); unregisterTool('toggle_node_active'); }; - }, [nodes, registerTool, unregisterTool]); + }, [nodes, registerTool, unregisterTool, fetchData]); const fetchData = useCallback(async () => { setLoading(true); diff --git a/frontend/src/features/skills/pages/SkillsPage.js b/frontend/src/features/skills/pages/SkillsPage.js index 65af8d3..9b8c53a 100644 --- a/frontend/src/features/skills/pages/SkillsPage.js +++ b/frontend/src/features/skills/pages/SkillsPage.js @@ -19,15 +19,18 @@ name: 'list_skills', description: 'List all available skills (cortex node capabilities/folders) in the hub.', inputSchema: { type: 'object', properties: {} }, - handler: async () => { - return { skills: skills.map(s => ({ - id: s.id, - name: s.name, - description: s.description, - type: s.skill_type, - is_system: s.is_system, - is_enabled: s.is_enabled - })) }; + execute: async () => { + const result = { + skills: skills.map(s => ({ + id: s.id, + name: s.name, + description: s.description, + type: s.skill_type, + is_system: s.is_system, + is_enabled: s.is_enabled + })) + }; + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; } }); @@ -41,12 +44,12 @@ }, required: ['query'] }, - handler: async ({ query }) => { + execute: async ({ query }) => { const results = skills.filter(s => s.name.toLowerCase().includes(query.toLowerCase()) || (s.description || '').toLowerCase().includes(query.toLowerCase()) ); - return { skills: results }; + return { content: [{ type: 'text', text: JSON.stringify({ results }, null, 2) }] }; } }); diff --git a/frontend/src/features/swarm/pages/SwarmControlPage.js b/frontend/src/features/swarm/pages/SwarmControlPage.js index ccef058..913f6e9 100644 --- a/frontend/src/features/swarm/pages/SwarmControlPage.js +++ b/frontend/src/features/swarm/pages/SwarmControlPage.js @@ -12,9 +12,62 @@ SwarmControlFileExplorerOverlay } from "../components/SwarmControlOverlays"; import { SwarmControlNodeSelectorModal } from "../components/SwarmControlNodeSelectorModal"; +import { useWebMcp } from "../../../shared/components/WebMcpProvider"; const CodeAssistantPage = () => { const pageContainerRef = useRef(null); + const confirmClearHistory = async () => { + if (!sessionId) return; + setIsClearingHistory(true); + try { + await clearSessionHistory(sessionId); + // Reload the page to refresh chat history from the server + window.location.reload(); + } catch (e) { + alert(`Failed to clear history: ${e.message}`); + } finally { + setIsClearingHistory(false); + setShowClearChatModal(false); + } + }; + + const handleClearHistory = () => { + if (!sessionId) return; + setShowClearChatModal(true); + }; + + const [showNodeSelector, setShowNodeSelector] = useState(false); + const isEditingMeshRef = useRef(false); + useEffect(() => { + isEditingMeshRef.current = showNodeSelector; + }, [showNodeSelector]); + const [sidebarRefreshTick, setSidebarRefreshTick] = useState(0); + + // M3/M6 Node Integration State + const [sessionNodeStatus, setSessionNodeStatus] = useState({}); // node_id -> { status, last_sync } + const [accessibleNodes, setAccessibleNodes] = useState([]); + const [attachedNodeIds, setAttachedNodeIds] = useState([]); + const [workspaceId, setWorkspaceId] = useState(""); + const [showConsole, setShowConsole] = useState(false); + const [syncConfig, setSyncConfig] = useState({ source: 'server', path: '', source_node_id: '', read_only_node_ids: [] }); + const [activeSyncConfig, setActiveSyncConfig] = useState(null); + const [pathSuggestions, setPathSuggestions] = useState([]); + const [isSearchingPath, setIsSearchingPath] = useState(false); + const [showPathSuggestions, setShowPathSuggestions] = useState(false); + const [hasLoadedDefaults, setHasLoadedDefaults] = useState(false); + const [isInitiatingSync, setIsInitiatingSync] = useState(false); + const [showFileExplorer, setShowFileExplorer] = useState(false); + const [isConsoleExpanded, setIsConsoleExpanded] = useState(false); + const [consoleHeight, setConsoleHeight] = useState(256); // Default 64 * 4px = 256px + const [isDraggingConsole, setIsDraggingConsole] = useState(false); + const isDraggingConsoleRef = useRef(false); + + // Persistence for Auto-Collapse + const [autoCollapse, setAutoCollapse] = useState(() => { + return localStorage.getItem("swarm_auto_collapse") === "true"; + }); + + const { registerTool, unregisterTool } = useWebMcp(); const onNewSessionCreated = useCallback(async (newSid) => { try { @@ -75,56 +128,68 @@ const [showClearChatModal, setShowClearChatModal] = useState(false); const [isClearingHistory, setIsClearingHistory] = useState(false); - const confirmClearHistory = async () => { - if (!sessionId) return; - setIsClearingHistory(true); - try { - await clearSessionHistory(sessionId); - // Reload the page to refresh chat history from the server - window.location.reload(); - } catch (e) { - alert(`Failed to clear history: ${e.message}`); - } finally { - setIsClearingHistory(false); - setShowClearChatModal(false); - } - }; - - const handleClearHistory = () => { - if (!sessionId) return; - setShowClearChatModal(true); - }; - - const [showNodeSelector, setShowNodeSelector] = useState(false); - const isEditingMeshRef = useRef(false); + // ── WebMCP Tools for the Swarm Control page ────────────────────────────── useEffect(() => { - isEditingMeshRef.current = showNodeSelector; - }, [showNodeSelector]); - const [sidebarRefreshTick, setSidebarRefreshTick] = useState(0); + registerTool({ + name: 'get_session_nodes', + description: 'Get the list of agent nodes currently attached to the active Swarm session.', + inputSchema: { type: 'object', properties: {} }, + execute: async () => { + return { + content: [{ type: 'text', text: JSON.stringify({ + session_id: sessionId, + attached_nodes: accessibleNodes + .filter(n => attachedNodeIds.includes(n.node_id)) + .map(n => ({ id: n.node_id, name: n.display_name, status: n.last_status })) + }, null, 2) }] + }; + } + }); - // M3/M6 Node Integration State - const [sessionNodeStatus, setSessionNodeStatus] = useState({}); // node_id -> { status, last_sync } - const [accessibleNodes, setAccessibleNodes] = useState([]); - const [attachedNodeIds, setAttachedNodeIds] = useState([]); - const [workspaceId, setWorkspaceId] = useState(""); - const [showConsole, setShowConsole] = useState(false); - const [syncConfig, setSyncConfig] = useState({ source: 'server', path: '', source_node_id: '', read_only_node_ids: [] }); - const [activeSyncConfig, setActiveSyncConfig] = useState(null); - const [pathSuggestions, setPathSuggestions] = useState([]); - const [isSearchingPath, setIsSearchingPath] = useState(false); - const [showPathSuggestions, setShowPathSuggestions] = useState(false); - const [hasLoadedDefaults, setHasLoadedDefaults] = useState(false); - const [isInitiatingSync, setIsInitiatingSync] = useState(false); - const [showFileExplorer, setShowFileExplorer] = useState(false); - const [isConsoleExpanded, setIsConsoleExpanded] = useState(false); - const [consoleHeight, setConsoleHeight] = useState(256); // Default 64 * 4px = 256px - const [isDraggingConsole, setIsDraggingConsole] = useState(false); - const isDraggingConsoleRef = useRef(false); + registerTool({ + name: 'list_accessible_nodes', + description: 'List all agent nodes accessible to the current user in the swarm mesh.', + inputSchema: { type: 'object', properties: {} }, + execute: async () => { + try { + const nodes = await getUserAccessibleNodes(); + return { + content: [{ type: 'text', text: JSON.stringify({ + nodes: nodes.map(n => ({ id: n.node_id, name: n.display_name, status: n.last_status, os: n.capabilities?.os })) + }, null, 2) }] + }; + } catch (err) { + return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true }; + } + } + }); - // Persistence for Auto-Collapse - const [autoCollapse, setAutoCollapse] = useState(() => { - return localStorage.getItem("swarm_auto_collapse") === "true"; - }); + registerTool({ + name: 'get_swarm_status', + description: 'Get the current Swarm Control session status including attached nodes, workspace, and LLM provider.', + inputSchema: { type: 'object', properties: {} }, + execute: async () => { + return { + content: [{ type: 'text', text: JSON.stringify({ + session_id: sessionId, + active_llm: localActiveLLM, + attached_node_count: attachedNodeIds.length, + total_accessible_nodes: accessibleNodes.length, + workspace_id: workspaceId || null, + is_configured: isConfigured, + }, null, 2) }] + }; + } + }); + + return () => { + unregisterTool('get_session_nodes'); + unregisterTool('list_accessible_nodes'); + unregisterTool('get_swarm_status'); + }; + }, [sessionId, accessibleNodes, attachedNodeIds, workspaceId, localActiveLLM, isConfigured, registerTool, unregisterTool]); + // ───────────────────────────────────────────────────────────────────────── + const toggleAutoCollapse = () => { const newState = !autoCollapse; diff --git a/frontend/src/services/mcpService.js b/frontend/src/services/mcpService.js index 6ad6901..400a0f1 100644 --- a/frontend/src/services/mcpService.js +++ b/frontend/src/services/mcpService.js @@ -1,60 +1,146 @@ /** * mcpService.js - * - * Handles the registration of Model Context Protocol (MCP) tools for the browser. - * This allows AI agents to interact with the Hub UI as a set of structured tools. + * + * Handles registration of Model Context Protocol (MCP) tools with the browser. + * + * Key design decisions: + * 1. `navigator.modelContext` is injected by the WebMCP browser extension as a + * content script. It is NOT available synchronously at module load time. + * We must poll for it and queue tools registered before it's ready. + * 2. The spec is still in flux. We pass both `execute` (2025+ spec) and `callback` + * (older Chrome builds) so it works regardless of which draft is active. */ class McpService { constructor() { - this.isSupported = typeof window !== 'undefined' && !!window.navigator?.modelContext; - this.registeredTools = new Set(); - - if (!this.isSupported) { - console.warn('[MCP] WebMCP is not natively supported in this browser. Tools will not be exposed.'); + this._ready = false; // true once navigator.modelContext is confirmed available + this._pendingTools = []; // tools queued before context was available + this._registeredTools = new Map(); // name → tool definition + + // Start polling for the injected context + this._waitForContext(); + } + + // ── Dynamic check (always reads live from window) ───────────────────────── + get isSupported() { + return typeof window !== 'undefined' && !!window.navigator?.modelContext; + } + + // ── Poll until navigator.modelContext appears (up to 8 seconds) ─────────── + _waitForContext() { + if (this.isSupported) { + // Already available (e.g. flag built into browser, not injected) + this._onContextReady(); + return; + } + + let attempts = 0; + const MAX_ATTEMPTS = 80; // 80 × 100ms = 8s + + const poll = setInterval(() => { + attempts++; + if (this.isSupported) { + clearInterval(poll); + this._onContextReady(); + } else if (attempts >= MAX_ATTEMPTS) { + clearInterval(poll); + console.warn( + '[MCP] navigator.modelContext not detected after 8s.\n' + + ' → Ensure the WebMCP extension is installed OR Chrome flag is enabled:\n' + + ' chrome://flags/#enable-webmcp-testing' + ); + } + }, 100); + } + + // ── Called once modelContext becomes available ──────────────────────────── + _onContextReady() { + this._ready = true; + console.log( + `%c[MCP] ✓ navigator.modelContext detected — registering ${this._pendingTools.length} queued tools`, + 'color: #6366f1; font-weight: bold;' + ); + // Flush the queue + const pending = [...this._pendingTools]; + this._pendingTools = []; + for (const tool of pending) { + this._doRegister(tool); } } + // ── Internal registration (only call when _ready = true) ───────────────── + _doRegister(tool) { + try { + // Idempotent: if the tool is already registered, unregister first + if (this._registeredTools.has(tool.name)) { + try { + if (window.navigator.modelContext.unregisterTool) { + window.navigator.modelContext.unregisterTool(tool.name); + } + } catch (_) { /* ignore */ } + this._registeredTools.delete(tool.name); + } + + // Normalize handler function — spec has used multiple property names + const normalized = { ...tool }; + const fn = tool.execute || tool.callback || tool.handler; + if (fn) { + normalized.execute = fn; // MCP spec 2025-Q2+ + normalized.callback = fn; // older Chrome/extension builds + } + + window.navigator.modelContext.registerTool(normalized); + this._registeredTools.set(tool.name, tool); + console.log(`%c[MCP] ✓ ${tool.name}`, 'color: #22c55e; font-weight: bold;'); + } catch (err) { + console.error(`[MCP] ✗ Failed to register '${tool.name}':`, err); + } + } + + // ── Public API ──────────────────────────────────────────────────────────── + /** - * Registers a tool with the browser's model context. - * @param {Object} tool - Tool definition following the MCP spec. + * Register a tool. If modelContext isn't ready yet, the tool is queued + * and will be registered automatically when the context becomes available. */ registerTool(tool) { - if (!this.isSupported) return; - - try { - window.navigator.modelContext.registerTool(tool); - this.registeredTools.add(tool.name); - console.log(`[MCP] Registered tool: ${tool.name}`); - } catch (error) { - console.error(`[MCP] Failed to register tool ${tool.name}:`, error); + if (this._ready) { + this._doRegister(tool); + } else { + // Deduplicate: replace if already queued with same name + this._pendingTools = this._pendingTools.filter(t => t.name !== tool.name); + this._pendingTools.push(tool); + console.log(`[MCP] ⏳ Queued: ${tool.name}`); } } /** - * Unregisters a tool. - * @param {string} toolName + * Unregister a tool by name. */ unregisterTool(toolName) { - if (!this.isSupported) return; + // Always remove from pending queue + this._pendingTools = this._pendingTools.filter(t => t.name !== toolName); + if (!this.isSupported) return; try { - // Note: Native API might use different unregistration logic depending on the draft if (window.navigator.modelContext.unregisterTool) { window.navigator.modelContext.unregisterTool(toolName); } - this.registeredTools.delete(toolName); - console.log(`[MCP] Unregistered tool: ${toolName}`); - } catch (error) { - console.error(`[MCP] Failed to unregister tool ${toolName}:`, error); + this._registeredTools.delete(toolName); + console.log(`[MCP] ↩ Unregistered: ${toolName}`); + } catch (err) { + console.error(`[MCP] Failed to unregister '${toolName}':`, err); } } - /** - * Returns whether the protocol is active and supported. - */ + /** Returns true only after modelContext was confirmed available. */ isActive() { - return this.isSupported; + return this._ready; + } + + /** Debug helper — list all currently registered tool names. */ + listRegistered() { + return [...this._registeredTools.keys()]; } } diff --git a/frontend/src/shared/components/WebMcpProvider.js b/frontend/src/shared/components/WebMcpProvider.js index 0e1ffc1..22533cd 100644 --- a/frontend/src/shared/components/WebMcpProvider.js +++ b/frontend/src/shared/components/WebMcpProvider.js @@ -1,5 +1,6 @@ import React, { createContext, useContext, useEffect, useState } from 'react'; -import mcpService from '../../../services/mcpService'; +import mcpService from '../../services/mcpService'; +import { getUserAccessibleNodes } from '../../services/api/nodeService'; const WebMcpContext = createContext(null); @@ -7,17 +8,80 @@ const [isMcpActive, setIsMcpActive] = useState(false); useEffect(() => { - // Check if WebMCP is supported/active - if (mcpService.isActive()) { - setIsMcpActive(true); - console.log('[MCP] WebMCP Provider initialized and active.'); - } + // Register global tools immediately. + // mcpService queues them internally if navigator.modelContext isn't ready yet, + // and flushes the queue automatically once the extension injects it. + mcpService.registerTool({ + name: 'list_nodes', + description: 'List all agent nodes in the Cortex swarm mesh and their connectivity status.', + inputSchema: { type: 'object', properties: {} }, + execute: async () => { + try { + const nodes = await getUserAccessibleNodes(); + return { + content: [{ + type: 'text', + text: JSON.stringify({ + nodes: nodes.map(n => ({ + id: n.node_id, + name: n.display_name, + status: n.last_status, + os: n.capabilities?.os + })) + }, null, 2) + }] + }; + } catch (err) { + return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true }; + } + } + }); + + mcpService.registerTool({ + name: 'get_app_info', + description: 'Get metadata about the Cortex Hub environment.', + inputSchema: { type: 'object', properties: {} }, + execute: async () => { + return { + content: [{ + type: 'text', + text: JSON.stringify({ + name: 'Cortex Hub', + version: '1.0.0', + capabilities: ['swarms', 'webmcp', 'mcp-sse', 'voice-chat', 'rag'], + environment: window.location.hostname === 'localhost' ? 'development' : 'production', + mcp_server: `${window.location.origin}/api/v1/mcp/sse`, + }, null, 2) + }] + }; + } + }); + + // Poll isMcpActive state so UI can show a badge when WebMCP is live + const statusInterval = setInterval(() => { + const active = mcpService.isActive(); + setIsMcpActive(prev => { + if (prev !== active) { + if (active) console.log('[MCP] 🟢 WebMCP is now active — tools registered with browser.'); + return active; + } + return prev; + }); + }, 200); + + return () => clearInterval(statusInterval); }, []); const value = { isMcpActive, - registerTool: (tool) => mcpService.registerTool(tool), - unregisterTool: (toolName) => mcpService.unregisterTool(toolName) + registerTool: (tool) => { + // Normalize legacy 'handler' property + const aligned = { ...tool }; + if (tool.handler && !tool.execute) aligned.execute = tool.handler; + mcpService.registerTool(aligned); + }, + unregisterTool: (toolName) => mcpService.unregisterTool(toolName), + listRegistered: () => mcpService.listRegistered(), }; return ( @@ -28,9 +92,7 @@ }; export const useWebMcp = () => { - const context = useContext(WebMcpContext); - if (!context) { - throw new Error('useWebMcp must be used within a WebMcpProvider'); - } - return context; + const ctx = useContext(WebMcpContext); + if (!ctx) throw new Error('useWebMcp must be used within a WebMcpProvider'); + return ctx; }; diff --git a/nginx.conf b/nginx.conf index b9f469b..67bd92c 100644 --- a/nginx.conf +++ b/nginx.conf @@ -34,6 +34,18 @@ # Increase the max body size for audio uploads client_max_body_size 50M; + # MCP Discovery (Anthropic standard — must be at root, before SPA catch-all) + location /.well-known/mcp { + proxy_pass http://backend_service; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; + proxy_buffering off; + add_header Access-Control-Allow-Origin *; + } + # Frontend: Serve the static production build directly location / { root /usr/share/nginx/html; @@ -53,7 +65,7 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; - # Streaming optimization + # Streaming optimization (SSE + WebSocket) proxy_buffering off; proxy_read_timeout 300s; }