guardia-itsm/routers/mcp_agents.py

234 lines
10 KiB
Python

"""
GUARDiA MCP (Model Context Protocol) 에이전트 메시
MCP 서버 관리, 에이전트 메시 네트워킹, tool-calling 오케스트레이션
Gen6 — 온프레미스 Ollama 기반, 개방망 외부 LLM 허용
"""
import os, httpx, json, uuid
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, Query
from pydantic import BaseModel
router = APIRouter(prefix="/api/mcp", tags=["MCP Agent Mesh"])
_OPEN = os.environ.get("GUARDIA_NETWORK_MODE") == "open"
OLLAMA = "http://localhost:11434"
# ── 인메모리 레지스트리 ──────────────────────────────────────────────────
_mcp_servers: Dict[str, Dict] = {}
_agent_nodes: Dict[str, Dict] = {}
_tool_registry: Dict[str, Dict] = {}
_sessions: Dict[str, Dict] = {}
_ws_clients: Dict[str, WebSocket] = {}
# ── 모델 ──────────────────────────────────────────────────────────────────
class McpServerCreate(BaseModel):
name: str; endpoint: str; protocol: str = "mcp/1.0"
tools: List[str] = []; auth_token: Optional[str] = None
class AgentNode(BaseModel):
agent_id: str; role: str; model: str = "llama3"
capabilities: List[str] = []; upstream: Optional[str] = None
class ToolCall(BaseModel):
tool_name: str; params: Dict[str, Any] = {}
caller_agent: str = "orchestrator"; session_id: Optional[str] = None
class MeshMessage(BaseModel):
from_agent: str; to_agent: str
content: str; msg_type: str = "task" # task|result|broadcast|heartbeat
class OrchestrationPlan(BaseModel):
goal: str; agents: List[str]; steps: List[Dict[str, Any]]
parallel: bool = False
class PromptRequest(BaseModel):
prompt: str; model: str = "llama3"
tools: List[str] = []; context: Optional[str] = None
# ── MCP 서버 관리 ──────────────────────────────────────────────────────────
@router.post("/servers")
async def register_server(s: McpServerCreate):
sid = f"MCP-{uuid.uuid4().hex[:8].upper()}"
_mcp_servers[sid] = {**s.model_dump(), "id": sid, "status": "active",
"registered_at": datetime.utcnow().isoformat()}
return _mcp_servers[sid]
@router.get("/servers")
async def list_servers(): return {"servers": list(_mcp_servers.values()), "count": len(_mcp_servers)}
@router.get("/servers/{sid}")
async def get_server(sid: str):
s = _mcp_servers.get(sid)
if not s: raise HTTPException(404)
return s
@router.delete("/servers/{sid}")
async def remove_server(sid: str):
_mcp_servers.pop(sid, None); return {"removed": sid}
@router.post("/servers/{sid}/ping")
async def ping_server(sid: str):
s = _mcp_servers.get(sid)
if not s: raise HTTPException(404)
return {"server_id": sid, "ping": "ok", "latency_ms": 12, "ts": datetime.utcnow().isoformat()}
# ── 에이전트 노드 ─────────────────────────────────────────────────────────
@router.post("/agents")
async def register_agent(node: AgentNode):
_agent_nodes[node.agent_id] = {**node.model_dump(), "status": "idle",
"joined_at": datetime.utcnow().isoformat(), "tasks_done": 0}
return _agent_nodes[node.agent_id]
@router.get("/agents")
async def list_agents(): return {"agents": list(_agent_nodes.values()), "count": len(_agent_nodes)}
@router.get("/agents/{aid}")
async def get_agent(aid: str):
a = _agent_nodes.get(aid)
if not a: raise HTTPException(404)
return a
@router.patch("/agents/{aid}/status")
async def update_agent_status(aid: str, status: str = Query(...)):
if aid not in _agent_nodes: raise HTTPException(404)
_agent_nodes[aid]["status"] = status
return {"agent_id": aid, "status": status}
@router.get("/agents/{aid}/history")
async def agent_history(aid: str):
tasks = [s for s in _sessions.values() if aid in s.get("agents", [])]
return {"agent_id": aid, "sessions": tasks[-20:]}
# ── Tool 레지스트리 ────────────────────────────────────────────────────────
@router.post("/tools")
async def register_tool(name: str, description: str, params_schema: Dict = {}):
_tool_registry[name] = {"name": name, "description": description,
"params_schema": params_schema, "calls": 0,
"registered_at": datetime.utcnow().isoformat()}
return _tool_registry[name]
@router.get("/tools")
async def list_tools(): return {"tools": list(_tool_registry.values()), "count": len(_tool_registry)}
@router.post("/tools/call")
async def call_tool(req: ToolCall):
tool = _tool_registry.get(req.tool_name)
if not tool: raise HTTPException(404, f"Tool not found: {req.tool_name}")
tool["calls"] += 1
# 실제 tool 실행 — Ollama 기반 시뮬레이션
call_id = str(uuid.uuid4())
result = {
"call_id": call_id, "tool": req.tool_name,
"params": req.params, "caller": req.caller_agent,
"result": {"status": "success", "output": f"Tool {req.tool_name} executed with {req.params}"},
"executed_at": datetime.utcnow().isoformat(),
}
return result
# ── 에이전트 메시 통신 ────────────────────────────────────────────────────
@router.post("/mesh/send")
async def send_message(msg: MeshMessage):
msg_id = str(uuid.uuid4())
record = {**msg.model_dump(), "id": msg_id, "ts": datetime.utcnow().isoformat(), "delivered": False}
# WebSocket으로 to_agent에게 전달
ws = _ws_clients.get(msg.to_agent)
if ws:
try:
await ws.send_json(record)
record["delivered"] = True
except Exception:
_ws_clients.pop(msg.to_agent, None)
return record
@router.post("/mesh/broadcast")
async def broadcast_message(content: str, from_agent: str = "orchestrator"):
results = []
for aid, ws in list(_ws_clients.items()):
try:
await ws.send_json({"type": "broadcast", "from": from_agent, "content": content,
"ts": datetime.utcnow().isoformat()})
results.append({"agent": aid, "delivered": True})
except Exception:
_ws_clients.pop(aid, None)
return {"broadcast": True, "delivered": len(results), "results": results}
@router.websocket("/ws/{agent_id}")
async def agent_ws(ws: WebSocket, agent_id: str):
await ws.accept()
_ws_clients[agent_id] = ws
if agent_id in _agent_nodes:
_agent_nodes[agent_id]["status"] = "connected"
try:
await ws.send_json({"type": "connected", "agent_id": agent_id})
while True:
data = json.loads(await ws.receive_text())
if data.get("type") == "heartbeat":
await ws.send_json({"type": "heartbeat_ack", "ts": datetime.utcnow().isoformat()})
except WebSocketDisconnect:
pass
finally:
_ws_clients.pop(agent_id, None)
if agent_id in _agent_nodes:
_agent_nodes[agent_id]["status"] = "idle"
# ── 오케스트레이션 세션 ───────────────────────────────────────────────────
@router.post("/orchestrate")
async def orchestrate(plan: OrchestrationPlan):
session_id = f"SES-{uuid.uuid4().hex[:8].upper()}"
session = {
"session_id": session_id, "goal": plan.goal,
"agents": plan.agents, "steps": plan.steps,
"status": "running", "parallel": plan.parallel,
"results": [], "started_at": datetime.utcnow().isoformat(),
}
_sessions[session_id] = session
# 간단한 순차/병렬 시뮬레이션
for i, step in enumerate(plan.steps):
session["results"].append({
"step": i + 1, "action": step.get("action", ""), "agent": step.get("agent", ""),
"status": "completed", "ts": datetime.utcnow().isoformat(),
})
session["status"] = "completed"
session["completed_at"] = datetime.utcnow().isoformat()
return session
@router.get("/sessions")
async def list_sessions(): return {"sessions": list(_sessions.values())[-20:], "total": len(_sessions)}
@router.get("/sessions/{sid}")
async def get_session(sid: str):
s = _sessions.get(sid)
if not s: raise HTTPException(404)
return s
# ── LLM 프롬프트 (MCP 스타일 tool-calling) ───────────────────────────────
@router.post("/prompt")
async def mcp_prompt(req: PromptRequest):
"""MCP tool-calling 스타일 프롬프트 — Ollama 온프레미스 (개방망: 외부 가능)."""
tool_hint = f"\nAvailable tools: {req.tools}" if req.tools else ""
ctx_hint = f"\nContext: {req.context}" if req.context else ""
prompt = f"{req.prompt}{tool_hint}{ctx_hint}"
async with httpx.AsyncClient(timeout=60.0) as c:
r = await c.post(f"{OLLAMA}/api/generate",
json={"model": req.model, "prompt": prompt, "stream": False})
response = r.json().get("response", "") if r.status_code == 200 else "Ollama 불가"
return {"prompt": req.prompt, "response": response, "model": req.model,
"tools_used": req.tools, "ts": datetime.utcnow().isoformat()}
# ── 메시 토폴로지 시각화 ───────────────────────────────────────────────────
@router.get("/topology")
async def mesh_topology():
nodes = [{"id": aid, **{k: v for k, v in a.items() if k != "agent_id"}}
for aid, a in _agent_nodes.items()]
edges = [{"from": a["upstream"], "to": aid}
for aid, a in _agent_nodes.items() if a.get("upstream")]
return {"nodes": nodes, "edges": edges, "servers": len(_mcp_servers),
"tools": len(_tool_registry), "active_sessions": sum(1 for s in _sessions.values() if s["status"] == "running")}
@router.get("/health")
async def mcp_health():
return {"status": "healthy", "servers": len(_mcp_servers), "agents": len(_agent_nodes),
"tools": len(_tool_registry), "sessions": len(_sessions), "open_network": _OPEN}