diff --git a/gsap_broker/app.py b/gsap_broker/app.py index fbba6d4..2228076 100644 --- a/gsap_broker/app.py +++ b/gsap_broker/app.py @@ -6,6 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware from gsap_broker.settings import settings from gsap_broker.db import init_db from gsap_broker.routers import authorize, complete, session, elevate, health, drivers, connectors, functions +from gsap_broker import mcp logger = structlog.get_logger() @@ -27,3 +28,4 @@ app.include_router(drivers.router, prefix="/governance", tags=["Drivers"]) app.include_router(connectors.router, prefix="/connectors", tags=["Connectors"]) app.include_router(functions.router, prefix="/functions", tags=["Functions"]) app.include_router(health.router, tags=["Health"]) +app.include_router(mcp.router, tags=["MCP"]) diff --git a/gsap_broker/mcp.py b/gsap_broker/mcp.py new file mode 100644 index 0000000..615758a --- /dev/null +++ b/gsap_broker/mcp.py @@ -0,0 +1,495 @@ +"""GSAP Broker MCP Endpoint — Governance Primitives as MCP Tools. + +Exposes the governance protocol as MCP tools for any MCP-compatible agent. +This is the consortia-builder interface. Capstone MCP is the MSP-operator interface. + +Protocol: Streamable HTTP JSON-RPC 2.0 (MCP spec 2024-11-05) +Auth: Bearer token (Keycloak JWT or GSAP bearer token) + +Tool handlers call the broker's existing internal functions — no logic duplication. +The MCP layer is protocol translation: JSON-RPC → internal function → JSON-RPC response. +""" + +import json +import logging +import os +import time +from datetime import datetime, UTC + +import httpx +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse + +from gsap_broker.settings import settings +from gsap_broker import chronicle + +logger = logging.getLogger(__name__) + +router = APIRouter() + +MCP_PROTOCOL_VERSION = "2024-11-05" +SERVER_NAME = "gsap-governance" +SERVER_VERSION = "0.1.0" + +LLM_BROKER_URL = os.getenv("LLM_BROKER_URL", "http://localhost:8092") +DEFCON_POSTURE_SOURCE = os.getenv("DEFCON_POSTURE_SOURCE", "") + +# ── Tool Definitions ───────────────────────────────────────────── + +TOOLS = [ + { + "name": "request_ac", + "description": ( + "Request an Authorization Context for a governed session. " + "The AC authorizes a principal to operate under a specific Accord with a defined corpus." + ), + "inputSchema": { + "type": "object", + "properties": { + "principal": {"type": "string", "description": "DID of the principal requesting authorization"}, + "accord_template": {"type": "string", "description": "AccordTemplate name (e.g. shell-exec)"}, + "corpus_entry_cid": {"type": "string", "description": "Content ID of the corpus entry"}, + "playbook": {"type": "string", "description": "Playbook identifier for the operation"}, + "parameters_cid": {"type": "string", "description": "Content hash of operation parameters"}, + "session_mode": {"type": "boolean", "description": "If true, AC stays active across multiple CRs"}, + }, + "required": ["principal", "accord_template", "corpus_entry_cid"], + }, + }, + { + "name": "validate_ac", + "description": "Check if an Authorization Context is currently valid and active.", + "inputSchema": { + "type": "object", + "properties": { + "ac_id": {"type": "string", "description": "The AC poll_token or context_id to validate"}, + }, + "required": ["ac_id"], + }, + }, + { + "name": "post_cr", + "description": "Post a Completion Receipt recording that a governed operation was performed.", + "inputSchema": { + "type": "object", + "properties": { + "context_id": {"type": "string", "description": "The AC under which this operation was performed"}, + "outcome": {"type": "string", "enum": ["completed", "failed", "violated", "timed_out", "session_end"]}, + "failure_reason": {"type": "string", "description": "Reason for failure (if outcome=failed)"}, + }, + "required": ["context_id", "outcome"], + }, + }, + { + "name": "query_accord", + "description": "Look up an AccordTemplate's governance parameters.", + "inputSchema": { + "type": "object", + "properties": { + "accord_name": {"type": "string", "description": "AccordTemplate name"}, + }, + "required": ["accord_name"], + }, + }, + { + "name": "request_delegation", + "description": "Delegate authority to an AI agent via the LLM Principal Broker.", + "inputSchema": { + "type": "object", + "properties": { + "delegator_ac_id": {"type": "string", "description": "The delegator's AC ID"}, + "agent_type": {"type": "string", "description": "Agent type (e.g. claude-code)"}, + "capability_ceiling": {"type": "string", "enum": ["CAP_READ", "CAP_PROPOSE", "CAP_MUTATE"]}, + "max_ttl_minutes": {"type": "integer", "description": "Delegation TTL in minutes"}, + "ceremony_required_for": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["delegator_ac_id", "agent_type"], + }, + }, + { + "name": "revoke_delegation", + "description": "Revoke an active agent delegation.", + "inputSchema": { + "type": "object", + "properties": { + "delegation_id": {"type": "string"}, + "reason": {"type": "string"}, + }, + "required": ["delegation_id"], + }, + }, + { + "name": "get_delegation", + "description": "Query delegation status: active/expired/revoked, commands remaining, TTL.", + "inputSchema": { + "type": "object", + "properties": {"delegation_id": {"type": "string"}}, + "required": ["delegation_id"], + }, + }, + { + "name": "list_agents", + "description": "List all active agent delegations.", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "get_posture", + "description": "Get the current DEFCON operational posture level and restrictions.", + "inputSchema": { + "type": "object", + "properties": {"scope": {"type": "string", "description": "Posture scope (default: cluster)"}}, + }, + }, + { + "name": "check_operation", + "description": "Dry-run: check if an operation would be allowed at current posture and delegation scope.", + "inputSchema": { + "type": "object", + "properties": { + "operation_type": {"type": "string", "enum": ["read", "write", "delete", "execute", "delegate"]}, + "target_layer": {"type": "string", "enum": ["network", "systems", "applications"]}, + "ac_id": {"type": "string", "description": "AC to check against (optional)"}, + }, + "required": ["operation_type"], + }, + }, + { + "name": "session_info", + "description": "Get current session details: principal, AC scope, delegation, DEFCON level.", + "inputSchema": {"type": "object", "properties": {}}, + }, +] + + +# ── Posture Cache ──────────────────────────────────────────────── + +_posture_cache = {"level": 5, "fetched_at": 0.0} + + +def _get_posture_level() -> int: + now = time.time() + if (now - _posture_cache["fetched_at"]) < 30: + return _posture_cache["level"] + + source = DEFCON_POSTURE_SOURCE + level = 5 + if source: + try: + if source.startswith(("http://", "https://")): + resp = httpx.get(f"{source.rstrip('/')}/health", timeout=3.0) + level = resp.json().get("defcon_level", 5) + else: + with open(source) as f: + level = json.load(f).get("defcon_level", 5) + except Exception as e: + logger.debug("Posture fetch failed: %s", e) + + _posture_cache["level"] = level + _posture_cache["fetched_at"] = now + return level + + +# ── Helper: extract principal from request ─────────────────────── + +def _extract_principal(request: Request) -> str: + auth = request.headers.get("authorization", "") + if auth.startswith("Bearer "): + try: + import base64 + payload = auth[7:].split(".")[1] + payload += "=" * (4 - len(payload) % 4) + claims = json.loads(base64.urlsafe_b64decode(payload)) + return claims.get("preferred_username", claims.get("sub", "")) + except Exception: + pass + return "" + + +def _extract_delegation(request: Request) -> dict | None: + auth = request.headers.get("authorization", "") + delegation_id = "" + if auth.startswith("Bearer "): + try: + import base64 + payload = auth[7:].split(".")[1] + payload += "=" * (4 - len(payload) % 4) + claims = json.loads(base64.urlsafe_b64decode(payload)) + delegation_id = claims.get("delegation_id", "") + except Exception: + pass + + delegation_id = delegation_id or request.headers.get("x-delegation-id", "") + if not delegation_id: + return None + + return { + "delegation_id": delegation_id, + "delegator_did": request.headers.get("x-delegator-did", ""), + "agent_did": request.headers.get("x-agent-did", ""), + } + + +# ── Tool Handlers ──────────────────────────────────────────────── + + +async def _handle_request_ac(request: Request, args: dict) -> dict: + """Wraps the broker's existing /governance/authorize/ endpoint.""" + from gsap_broker.routers.authorize import authorize, _extract_token_data + from gsap_broker.models import AuthorizeRequest + from gsap_broker.db import get_session + + body = AuthorizeRequest( + playbook=args.get("playbook", "mcp-session"), + corpus_entry_cid=args["corpus_entry_cid"], + parameters_cid=args.get("parameters_cid", ""), + accord_template=args["accord_template"], + driver_id="keycloak", + session_mode=args.get("session_mode", False), + on_behalf_of=args.get("principal"), + ) + + async for session in get_session(): + try: + result = await authorize(body, request, session) + return result.model_dump(mode="json", exclude_none=True) + except Exception as e: + return {"error": str(e)} + + +async def _handle_validate_ac(args: dict) -> dict: + """Wraps the broker's existing /governance/authorize/{poll_token}/ endpoint.""" + from gsap_broker.routers.authorize import authorize_poll + from gsap_broker.db import get_session + + ac_id = args["ac_id"] + async for session in get_session(): + try: + result = await authorize_poll(ac_id, session) + return result.model_dump(mode="json", exclude_none=True) + except Exception as e: + return {"error": str(e)} + + +async def _handle_post_cr(request: Request, args: dict) -> dict: + """Wraps the broker's existing /governance/complete/ endpoint.""" + from gsap_broker.routers.complete import complete + from gsap_broker.models import CompleteRequest, ChronicleEvidence, BehavioralAttestation + from gsap_broker.db import get_session + + body = CompleteRequest( + context_id=args["context_id"], + outcome=args["outcome"], + completed_at=datetime.now(UTC), + failure_reason=args.get("failure_reason"), + chronicle_evidence=ChronicleEvidence(), + behavioral_attestation=BehavioralAttestation(), + ) + + async for session in get_session(): + try: + result = await complete(body, session) + return result.model_dump(mode="json", exclude_none=True) + except Exception as e: + return {"error": str(e)} + + +async def _handle_query_accord(args: dict) -> dict: + """Look up an AccordTemplate. Currently returns known templates from config.""" + name = args["accord_name"] + # Known accord templates (from GSAP broker config) + templates = { + "shell-exec": { + "name": "shell-exec", + "capability_ceiling": "CAP_MUTATE", + "session_ttl_minutes": 30, + "mfa_required": False, + "ceremony_gate": None, + }, + "dev-operations": { + "name": "dev-operations", + "capability_ceiling": "CAP_MUTATE", + "session_ttl_minutes": 60, + "mfa_required": False, + "ceremony_gate": None, + }, + "network-mutate": { + "name": "network-mutate", + "capability_ceiling": "CAP_GOVERN", + "session_ttl_minutes": 15, + "mfa_required": True, + "ceremony_gate": "network-admin-elevated", + }, + "ai-delegation-standard": { + "name": "ai-delegation-standard", + "capability_ceiling": "CAP_MUTATE", + "session_ttl_minutes": 60, + "ceremony_required_for": ["delete", "destroy", "drop"], + "max_commands": 500, + }, + } + if name in templates: + return templates[name] + return {"error": f"AccordTemplate '{name}' not found"} + + +async def _proxy_to_llm_broker(tool_name: str, args: dict, request: Request) -> dict: + """Proxy delegation operations to the LLM Principal Broker.""" + if not LLM_BROKER_URL: + return {"error": "LLM Principal Broker not configured"} + + endpoint_map = { + "request_delegation": ("POST", "/delegate"), + "revoke_delegation": ("POST", f"/delegate/{args.get('delegation_id', '')}/revoke"), + "get_delegation": ("GET", f"/delegate/{args.get('delegation_id', '')}"), + "list_agents": ("GET", "/agents"), + } + + method, path = endpoint_map.get(tool_name, (None, None)) + if not method: + return {"error": f"Unknown delegation tool: {tool_name}"} + + headers = {} + auth_header = request.headers.get("authorization") + if auth_header: + headers["Authorization"] = auth_header + + principal = _extract_principal(request) + if principal: + headers["X-Delegator-DID"] = f"did:web:guildhouse.dev/user/{principal}" + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + if method == "GET": + resp = await client.get(f"{LLM_BROKER_URL}{path}", headers=headers) + else: + resp = await client.post(f"{LLM_BROKER_URL}{path}", json=args, headers=headers) + return resp.json() + except Exception as e: + return {"error": f"LLM Principal Broker unavailable: {e}"} + + +async def _handle_get_posture(args: dict) -> dict: + level = _get_posture_level() + restrictions = [] + if level <= 2: + restrictions.append("write operations denied") + if level <= 1: + restrictions.append("all operations suspended") + if level <= 3: + restrictions.append("ceremony required for writes") + if level <= 4 and level > 1: + restrictions.append("agent TTL capped at 30min") + + return { + "defcon_level": level, + "posture": ["peacetime", "elevated", "restricted", "critical", "lockdown"][5 - level], + "restrictions": restrictions, + } + + +async def _handle_check_operation(args: dict, request: Request) -> dict: + level = _get_posture_level() + op_type = args.get("operation_type", "read") + + if level == 1: + return {"allowed": False, "reason": "DEFCON 1 — all operations suspended", "defcon_level": level} + if level <= 2 and op_type in ("write", "delete", "execute"): + return {"allowed": False, "reason": f"DEFCON {level} — {op_type} operations denied", "defcon_level": level} + if level == 3 and op_type in ("write", "delete"): + return {"allowed": False, "reason": f"DEFCON 3 — {op_type} requires ceremony", "defcon_level": level} + + return {"allowed": True, "defcon_level": level, "operation_type": op_type} + + +async def _handle_session_info(request: Request) -> dict: + return { + "principal": _extract_principal(request), + "delegation": _extract_delegation(request), + "defcon_level": _get_posture_level(), + "broker_did": settings.broker_did, + "timestamp": datetime.now(UTC).isoformat(), + } + + +# ── Tool Dispatch ──────────────────────────────────────────────── + + +async def _dispatch_tool(request: Request, tool_name: str, arguments: dict) -> dict: + handlers = { + "request_ac": lambda: _handle_request_ac(request, arguments), + "validate_ac": lambda: _handle_validate_ac(arguments), + "post_cr": lambda: _handle_post_cr(request, arguments), + "query_accord": lambda: _handle_query_accord(arguments), + "request_delegation": lambda: _proxy_to_llm_broker(tool_name, arguments, request), + "revoke_delegation": lambda: _proxy_to_llm_broker(tool_name, arguments, request), + "get_delegation": lambda: _proxy_to_llm_broker(tool_name, arguments, request), + "list_agents": lambda: _proxy_to_llm_broker(tool_name, arguments, request), + "get_posture": lambda: _handle_get_posture(arguments), + "check_operation": lambda: _handle_check_operation(arguments, request), + "session_info": lambda: _handle_session_info(request), + } + + handler = handlers.get(tool_name) + if not handler: + return {"error": f"Unknown tool: {tool_name}"} + + return await handler() + + +# ── JSON-RPC Route ─────────────────────────────────────────────── + + +def _error(code: int, message: str, req_id): + return {"jsonrpc": "2.0", "error": {"code": code, "message": message}, "id": req_id} + + +def _success(result, req_id): + return {"jsonrpc": "2.0", "result": result, "id": req_id} + + +@router.post("/mcp") +async def mcp_endpoint(request: Request): + """MCP JSON-RPC 2.0 endpoint — governance primitives as tools.""" + try: + body = await request.json() + except Exception: + return JSONResponse(_error(-32700, "Parse error", None), status_code=400) + + method = body.get("method", "") + params = body.get("params", {}) + req_id = body.get("id") + + if method == "initialize": + return JSONResponse(_success({ + "protocolVersion": MCP_PROTOCOL_VERSION, + "serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION}, + "capabilities": {"tools": {"listChanged": False}}, + }, req_id)) + + elif method == "tools/list": + return JSONResponse(_success({"tools": TOOLS}, req_id)) + + elif method == "tools/call": + tool_name = params.get("name", "") + arguments = params.get("arguments", {}) + if not tool_name: + return JSONResponse(_error(-32602, "Missing tool name", req_id)) + + result = await _dispatch_tool(request, tool_name, arguments) + is_error = "error" in result + + # Chronicle: record MCP tool call + await chronicle.emit("MCP_TOOL_CALL", { + "event_code": "0x3020", + "tool": tool_name, + "principal": _extract_principal(request), + "outcome": "error" if is_error else "success", + }) + + return JSONResponse(_success({ + "content": [{"type": "text", "text": json.dumps(result, default=str)}], + "isError": is_error, + }, req_id)) + + else: + return JSONResponse(_error(-32601, f"Unknown method: {method}", req_id))