feat: MCP endpoint — governance primitives as MCP tools
POST /mcp — Streamable HTTP JSON-RPC 2.0 (MCP spec 2024-11-05)
11 governance tools for consortia builders:
request_ac — AC issuance (wraps /governance/authorize/)
validate_ac — AC validation (wraps /governance/authorize/{token}/)
post_cr — CR posting (wraps /governance/complete/)
query_accord — AccordTemplate lookup
request_delegation — proxy to LLM Principal Broker
revoke_delegation — proxy to LLM Principal Broker
get_delegation — proxy to LLM Principal Broker
list_agents — proxy to LLM Principal Broker
get_posture — DEFCON level and restrictions (30s cache)
check_operation — dry-run operation check against posture
session_info — current session context
Tool handlers call existing broker internals — no logic duplication.
Delegation tools proxy to LLM Principal Broker via HTTP.
Every tool call recorded in Chronicle (MCP_TOOL_CALL 0x3020).
Any MCP-compatible agent can discover and use governance operations
through standard protocol — no Capstone, no Django required.
All 7 smoke tests pass (init, list, posture, check_op, session, accord, error).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
590c267f03
commit
3ed75169c7
2 changed files with 497 additions and 0 deletions
|
|
@ -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"])
|
||||
|
|
|
|||
495
gsap_broker/mcp.py
Normal file
495
gsap_broker/mcp.py
Normal file
|
|
@ -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))
|
||||
Loading…
Reference in a new issue