234 lines
10 KiB
Python
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}
|