feat: add optional ai and mcp automation
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
"""Optional AI and automation endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import require_admin_auth
|
||||
from app.services.ai_assistance import (
|
||||
build_action_proposals,
|
||||
build_evidence_summary,
|
||||
build_safe_context,
|
||||
get_assistance_config,
|
||||
)
|
||||
from app.services.workspace_audit import record_workspace_audit_log
|
||||
from app.services.workspaces import assign_default_workspace_to_unscoped_rows
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SafeContextResponse(BaseModel):
|
||||
"""Redacted model/agent context response."""
|
||||
|
||||
context: Dict[str, Any]
|
||||
|
||||
|
||||
class EvidenceSummaryResponse(BaseModel):
|
||||
"""Evidence-first summary response."""
|
||||
|
||||
summary: Dict[str, Any]
|
||||
|
||||
|
||||
class ActionProposalResponse(BaseModel):
|
||||
"""Reviewable action proposals response."""
|
||||
|
||||
domain: str
|
||||
action_tools_enabled: bool
|
||||
proposals: list[Dict[str, Any]]
|
||||
|
||||
|
||||
class ProposalConfirmation(BaseModel):
|
||||
"""Human confirmation payload for a proposal."""
|
||||
|
||||
proposal_id: str
|
||||
confirmation_text: str
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
def _require_ai_enabled(db: Session) -> None:
|
||||
if not get_assistance_config(db).ai_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="AI assistance is disabled. Enable ai.enabled before using this endpoint.",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_ai_config(
|
||||
db: Session = Depends(get_db),
|
||||
_auth: dict = Depends(require_admin_auth),
|
||||
) -> Dict[str, Any]:
|
||||
"""Return safe AI/MCP configuration without secrets."""
|
||||
return {"config": get_assistance_config(db).to_dict()}
|
||||
|
||||
|
||||
@router.get("/domains/{domain}/context", response_model=SafeContextResponse)
|
||||
async def get_domain_safe_context(
|
||||
domain: str,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: dict = Depends(require_admin_auth),
|
||||
) -> SafeContextResponse:
|
||||
"""Return a redacted, evidence-linked context payload for one domain."""
|
||||
_require_ai_enabled(db)
|
||||
try:
|
||||
return {"context": build_safe_context(db, domain)}
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/domains/{domain}/summary", response_model=EvidenceSummaryResponse)
|
||||
async def get_domain_evidence_summary(
|
||||
domain: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: dict = Depends(require_admin_auth),
|
||||
) -> EvidenceSummaryResponse:
|
||||
"""Return deterministic evidence-first assistance for one domain."""
|
||||
_require_ai_enabled(db)
|
||||
try:
|
||||
summary = build_evidence_summary(db, domain)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
workspace = assign_default_workspace_to_unscoped_rows(db, commit=False)
|
||||
record_workspace_audit_log(
|
||||
db,
|
||||
workspace=workspace,
|
||||
action="ai.summary_generated",
|
||||
entity_type="domain",
|
||||
entity_id=domain,
|
||||
entity_name=domain,
|
||||
details={
|
||||
"provider": summary["provider"]["provider"],
|
||||
"recommendations": len(summary["recommendations"]),
|
||||
},
|
||||
auth_context=_auth,
|
||||
request=request,
|
||||
)
|
||||
db.commit()
|
||||
return {"summary": summary}
|
||||
|
||||
|
||||
@router.get("/domains/{domain}/action-proposals", response_model=ActionProposalResponse)
|
||||
async def get_domain_action_proposals(
|
||||
domain: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: dict = Depends(require_admin_auth),
|
||||
) -> ActionProposalResponse:
|
||||
"""Return reviewable proposals; this endpoint never applies changes."""
|
||||
_require_ai_enabled(db)
|
||||
try:
|
||||
payload = build_action_proposals(db, domain)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
workspace = assign_default_workspace_to_unscoped_rows(db, commit=False)
|
||||
record_workspace_audit_log(
|
||||
db,
|
||||
workspace=workspace,
|
||||
action="ai.action_proposals_generated",
|
||||
entity_type="domain",
|
||||
entity_id=domain,
|
||||
entity_name=domain,
|
||||
details={"proposal_count": len(payload["proposals"]), "mutates_state": False},
|
||||
auth_context=_auth,
|
||||
request=request,
|
||||
)
|
||||
db.commit()
|
||||
return payload
|
||||
|
||||
|
||||
@router.post("/domains/{domain}/action-proposals/confirm")
|
||||
async def confirm_action_proposal(
|
||||
domain: str,
|
||||
payload: ProposalConfirmation,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: dict = Depends(require_admin_auth),
|
||||
) -> Dict[str, Any]:
|
||||
"""Audit human confirmation for a proposal without applying external changes."""
|
||||
_require_ai_enabled(db)
|
||||
config = get_assistance_config(db)
|
||||
if not config.action_tools_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=(
|
||||
"Action tools are disabled. Enable ai.action_tools_enabled to confirm "
|
||||
"proposals."
|
||||
),
|
||||
)
|
||||
proposals = build_action_proposals(db, domain)["proposals"]
|
||||
proposal = next(
|
||||
(item for item in proposals if item["proposal_id"] == payload.proposal_id),
|
||||
None,
|
||||
)
|
||||
if proposal is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Proposal not found")
|
||||
if payload.confirmation_text != payload.proposal_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="confirmation_text must match proposal_id",
|
||||
)
|
||||
workspace = assign_default_workspace_to_unscoped_rows(db, commit=False)
|
||||
record_workspace_audit_log(
|
||||
db,
|
||||
workspace=workspace,
|
||||
action="ai.action_proposal_confirmed",
|
||||
entity_type="action_proposal",
|
||||
entity_id=payload.proposal_id,
|
||||
entity_name=proposal["title"],
|
||||
details={
|
||||
"domain": domain,
|
||||
"proposal_id": payload.proposal_id,
|
||||
"mutates_state": False,
|
||||
"note": payload.note,
|
||||
},
|
||||
auth_context=_auth,
|
||||
request=request,
|
||||
)
|
||||
db.commit()
|
||||
return {"status": "confirmed", "applied": False, "proposal": proposal}
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Read-only MCP-style JSON-RPC endpoint for agent integrations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import require_api_token_scope
|
||||
from app.services.ai_assistance import (
|
||||
build_action_proposals,
|
||||
build_evidence_summary,
|
||||
get_assistance_config,
|
||||
)
|
||||
from app.services.api_tokens import MCP_READ_SCOPE
|
||||
from app.services.report_persistence import hydrate_report_store_from_db
|
||||
from app.services.report_store import ReportStore
|
||||
from app.services.workspace_audit import record_workspace_audit_log
|
||||
from app.services.workspaces import assign_default_workspace_to_unscoped_rows
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class MCPRequest(BaseModel):
|
||||
"""Minimal JSON-RPC request for MCP HTTP integrations."""
|
||||
|
||||
jsonrpc: str = "2.0"
|
||||
id: Optional[Any] = None
|
||||
method: str
|
||||
params: Dict[str, Any] = {}
|
||||
|
||||
|
||||
READ_ONLY_TOOLS = [
|
||||
{
|
||||
"name": "list_domains",
|
||||
"description": "List monitored domains and aggregate counts.",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
"readOnlyHint": True,
|
||||
},
|
||||
{
|
||||
"name": "domain_summary",
|
||||
"description": "Return an evidence-first summary for one domain.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"domain": {"type": "string"}},
|
||||
"required": ["domain"],
|
||||
},
|
||||
"readOnlyHint": True,
|
||||
},
|
||||
{
|
||||
"name": "action_proposals",
|
||||
"description": "Return reviewable remediation proposals without applying changes.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"domain": {"type": "string"}},
|
||||
"required": ["domain"],
|
||||
},
|
||||
"readOnlyHint": True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _jsonrpc_response(request_id: Any, result: Any = None, error: Optional[Dict[str, Any]] = None):
|
||||
payload = {"jsonrpc": "2.0", "id": request_id}
|
||||
if error is not None:
|
||||
payload["error"] = error
|
||||
else:
|
||||
payload["result"] = result
|
||||
return payload
|
||||
|
||||
|
||||
def _require_mcp_enabled(db: Session) -> None:
|
||||
if not get_assistance_config(db).mcp_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="MCP access is disabled. Enable mcp.enabled before using this endpoint.",
|
||||
)
|
||||
|
||||
|
||||
def _list_domains(db: Session) -> Dict[str, Any]:
|
||||
store = ReportStore.get_instance()
|
||||
hydrate_report_store_from_db(db, store)
|
||||
summaries = store.get_all_domain_summaries()
|
||||
return {
|
||||
"domains": [
|
||||
{
|
||||
"domain": domain,
|
||||
"total_messages": int(summary.get("total_count", 0) or 0),
|
||||
"failed_messages": int(summary.get("failed_count", 0) or 0),
|
||||
"compliance_rate": float(summary.get("compliance_rate", 0.0) or 0.0),
|
||||
"reports_processed": int(summary.get("reports_processed", 0) or 0),
|
||||
}
|
||||
for domain, summary in sorted(summaries.items())
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def mcp_jsonrpc(
|
||||
payload: MCPRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: dict = Depends(require_api_token_scope(MCP_READ_SCOPE)),
|
||||
) -> Dict[str, Any]:
|
||||
"""Handle a small read-only MCP tool surface over JSON-RPC."""
|
||||
_require_mcp_enabled(db)
|
||||
if payload.method == "initialize":
|
||||
return _jsonrpc_response(
|
||||
payload.id,
|
||||
{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"serverInfo": {"name": "dmarq", "version": "1"},
|
||||
"capabilities": {"tools": {}},
|
||||
},
|
||||
)
|
||||
if payload.method == "tools/list":
|
||||
return _jsonrpc_response(payload.id, {"tools": READ_ONLY_TOOLS})
|
||||
if payload.method != "tools/call":
|
||||
return _jsonrpc_response(
|
||||
payload.id,
|
||||
error={"code": -32601, "message": f"Unsupported method: {payload.method}"},
|
||||
)
|
||||
|
||||
name = payload.params.get("name")
|
||||
arguments = payload.params.get("arguments") or {}
|
||||
try:
|
||||
if name == "list_domains":
|
||||
result = _list_domains(db)
|
||||
elif name == "domain_summary":
|
||||
result = build_evidence_summary(db, str(arguments.get("domain", "")))
|
||||
elif name == "action_proposals":
|
||||
result = build_action_proposals(db, str(arguments.get("domain", "")))
|
||||
else:
|
||||
return _jsonrpc_response(
|
||||
payload.id,
|
||||
error={"code": -32602, "message": f"Unsupported tool: {name}"},
|
||||
)
|
||||
except ValueError as exc:
|
||||
return _jsonrpc_response(payload.id, error={"code": -32004, "message": str(exc)})
|
||||
|
||||
workspace = assign_default_workspace_to_unscoped_rows(db, commit=False)
|
||||
record_workspace_audit_log(
|
||||
db,
|
||||
workspace=workspace,
|
||||
action="mcp.tool_called",
|
||||
entity_type="mcp_tool",
|
||||
entity_id=name,
|
||||
entity_name=name,
|
||||
details={"tool": name, "read_only": True},
|
||||
auth_context=_auth,
|
||||
request=request,
|
||||
)
|
||||
db.commit()
|
||||
return _jsonrpc_response(
|
||||
payload.id,
|
||||
{
|
||||
"content": [{"type": "json", "json": result}],
|
||||
"isError": False,
|
||||
},
|
||||
)
|
||||
@@ -270,6 +270,56 @@ SETTING_DEFAULTS: List[Dict[str, Any]] = [
|
||||
"value_type": "string",
|
||||
"category": "notifications",
|
||||
},
|
||||
# ── Optional AI / MCP ───────────────────────────────────────────────────
|
||||
{
|
||||
"key": "ai.enabled",
|
||||
"value": "false",
|
||||
"description": "Enable optional AI assistance endpoints",
|
||||
"value_type": "boolean",
|
||||
"category": "ai",
|
||||
},
|
||||
{
|
||||
"key": "ai.provider",
|
||||
"value": "template",
|
||||
"description": "AI provider: template, local, or remote",
|
||||
"value_type": "string",
|
||||
"category": "ai",
|
||||
},
|
||||
{
|
||||
"key": "ai.model",
|
||||
"value": "",
|
||||
"description": "Optional model name for local or remote providers",
|
||||
"value_type": "string",
|
||||
"category": "ai",
|
||||
},
|
||||
{
|
||||
"key": "ai.remote_base_url",
|
||||
"value": "",
|
||||
"description": "Optional remote provider base URL; credentials should be injected by environment",
|
||||
"value_type": "string",
|
||||
"category": "ai",
|
||||
},
|
||||
{
|
||||
"key": "ai.redaction_mode",
|
||||
"value": "strict",
|
||||
"description": "Redaction mode for AI-safe context: strict or balanced",
|
||||
"value_type": "string",
|
||||
"category": "ai",
|
||||
},
|
||||
{
|
||||
"key": "ai.action_tools_enabled",
|
||||
"value": "false",
|
||||
"description": "Allow human-confirmed action proposals to be recorded",
|
||||
"value_type": "boolean",
|
||||
"category": "ai",
|
||||
},
|
||||
{
|
||||
"key": "mcp.enabled",
|
||||
"value": "false",
|
||||
"description": "Enable the scoped read-only MCP endpoint",
|
||||
"value_type": "boolean",
|
||||
"category": "mcp",
|
||||
},
|
||||
]
|
||||
|
||||
# Keys whose values should be redacted in GET responses (treated as secrets)
|
||||
@@ -334,7 +384,7 @@ def _audit_value_for_setting(key: str, value: Optional[str]) -> Optional[str]:
|
||||
|
||||
|
||||
def _should_audit_setting(key: str) -> bool:
|
||||
return key.startswith(("notifications.", "forensics."))
|
||||
return key.startswith(("notifications.", "forensics.", "ai.", "mcp."))
|
||||
|
||||
|
||||
def _audit_setting_change(
|
||||
|
||||
Reference in New Issue
Block a user