"""MCP-host-local storage and browser delivery for generated PDF reports.""" from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timezone import json import os from pathlib import Path import re import secrets import tempfile import time from typing import Any from urllib.parse import urljoin, urlsplit from roll_calculation import calculate_roll from roll_report_service import ( create_roll_calculation_pdf_from_result, load_report_build_info, ) ARTIFACT_DIRECTORY_ENV = "ROLLCALC_MCP_ARTIFACT_DIRECTORY" ARTIFACT_PUBLIC_BASE_URL_ENV = "ROLLCALC_MCP_ARTIFACT_PUBLIC_BASE_URL" REPORT_TTL_SECONDS = 24 * 60 * 60 TOKEN_PATTERN = re.compile(r"[A-Za-z0-9_-]{43}") SAFE_FILENAME_PATTERN = re.compile(r"[A-Za-z0-9._-]+\.pdf") class McpReportNotFoundError(FileNotFoundError): """Raised for missing, malformed, or expired MCP-host report tokens.""" class McpArtifactConfigurationError(ValueError): """Raised when the MCP host lacks a browser-reachable artifact origin.""" @dataclass(frozen=True) class StoredMcpReport: token: str filename: str expires_at: float @property def expires_at_iso(self) -> str: return datetime.fromtimestamp(self.expires_at, timezone.utc).isoformat() class McpReportStore: """A local, opaque-token report store owned exclusively by the MCP host.""" def __init__(self, directory: Path | None = None) -> None: configured = os.getenv(ARTIFACT_DIRECTORY_ENV, "").strip() self.directory = directory or ( Path(configured).resolve() if configured else Path(tempfile.gettempdir(), "rollcalc-mcp-reports") ) def _paths(self, token: str) -> tuple[Path, Path]: if not TOKEN_PATTERN.fullmatch(token): raise McpReportNotFoundError(token) return ( self.directory / f"{token}.pdf", self.directory / f"{token}.json", ) def _metadata(self, metadata_path: Path) -> dict[str, Any]: try: data = json.loads(metadata_path.read_text(encoding="utf-8")) except (FileNotFoundError, json.JSONDecodeError) as error: raise McpReportNotFoundError(metadata_path.name) from error if not isinstance(data, dict): raise McpReportNotFoundError(metadata_path.name) return data def cleanup_expired(self, now: float | None = None) -> None: if not self.directory.is_dir(): return now = time.time() if now is None else now for metadata_path in self.directory.glob("*.json"): if not TOKEN_PATTERN.fullmatch(metadata_path.stem): continue try: expires_at = self._metadata(metadata_path).get("expires_at") if not isinstance(expires_at, (int, float)) or expires_at <= now: pdf_path, _ = self._paths(metadata_path.stem) pdf_path.unlink(missing_ok=True) metadata_path.unlink(missing_ok=True) except McpReportNotFoundError: pdf_path, _ = self._paths(metadata_path.stem) pdf_path.unlink(missing_ok=True) metadata_path.unlink(missing_ok=True) def create(self, pdf: bytes, filename: str) -> StoredMcpReport: if not SAFE_FILENAME_PATTERN.fullmatch(filename): raise ValueError("report filename is unsafe") self.directory.mkdir(mode=0o700, parents=True, exist_ok=True) self.cleanup_expired() token = secrets.token_urlsafe(32) expires_at = time.time() + REPORT_TTL_SECONDS pdf_path, metadata_path = self._paths(token) pdf_path.write_bytes(pdf) metadata_path.write_text( json.dumps({"filename": filename, "expires_at": expires_at}), encoding="utf-8", ) return StoredMcpReport(token, filename, expires_at) def read(self, token: str) -> tuple[bytes, StoredMcpReport]: pdf_path, metadata_path = self._paths(token) self.cleanup_expired() metadata = self._metadata(metadata_path) filename = metadata.get("filename") expires_at = metadata.get("expires_at") if ( not isinstance(filename, str) or not SAFE_FILENAME_PATTERN.fullmatch(filename) or not isinstance(expires_at, (int, float)) or expires_at <= time.time() ): raise McpReportNotFoundError(token) try: return pdf_path.read_bytes(), StoredMcpReport(token, filename, expires_at) except FileNotFoundError as error: raise McpReportNotFoundError(token) from error def mcp_artifact_public_base_url() -> str: value = os.getenv(ARTIFACT_PUBLIC_BASE_URL_ENV, "").strip() parsed = urlsplit(value) if parsed.scheme not in {"http", "https"} or not parsed.netloc: raise McpArtifactConfigurationError( f"{ARTIFACT_PUBLIC_BASE_URL_ENV} must be an absolute http(s) URL" ) return value.rstrip("/") def mcp_report_download_url(token: str) -> str: if not TOKEN_PATTERN.fullmatch(token): raise McpReportNotFoundError(token) return urljoin(f"{mcp_artifact_public_base_url()}/", f"reports/{token}.pdf") def generate_mcp_calculation_pdf(request: dict[str, Any]) -> dict[str, Any]: """Create an MCP-host artifact from authoritative direct-roll inputs.""" build_info = load_report_build_info() result = calculate_roll(request, build_info=build_info) if result.get("status") != "success": return result try: mcp_artifact_public_base_url() except McpArtifactConfigurationError as error: return { "status": "artifact_delivery_unavailable", "error": str(error), } pdf, filename = create_roll_calculation_pdf_from_result( result, build_info=build_info, ) stored = McpReportStore().create(pdf, filename) return { "status": "success", "filename": stored.filename, "expires_at": stored.expires_at_iso, "expires_in_seconds": REPORT_TTL_SECONDS, "download_url": mcp_report_download_url(stored.token), }