"""MCP transport with immutable caller credentials and bounded operations.""" import asyncio import json from collections.abc import AsyncIterator from contextlib import asynccontextmanager from datetime import timedelta from typing import Any import httpx from mcp import ClientSession from mcp.client.streamable_http import streamable_http_client from vocero_api.core import config as settings from vocero_api.models.mcp import IntegrationError, McpToolDefinition ALLOWED_TOOLS = frozenset( { "list_reports", "get_report", "list_insights", "get_metric_history", "list_podcasts", "get_podcast", "read_podcast_transcript", "get_my_briefing", } ) MAX_RESULT_CHARS = 64000 def http_client(token: str, podcast_id: str | None = None) -> httpx.AsyncClient: headers = {"Authorization": f"Bearer {token}"} if podcast_id is not None: headers["X-Vocero-Podcast-Id"] = podcast_id return httpx.AsyncClient( headers=headers, timeout=settings.S2S_MCP_TIMEOUT_SEC, follow_redirects=False, ) def _http_status(exc: BaseException) -> int | None: if isinstance(exc, httpx.HTTPStatusError): return exc.response.status_code if isinstance(exc, BaseExceptionGroup): for child in exc.exceptions: status = _http_status(child) if status is not None: return status return None @asynccontextmanager async def session(token: str, podcast_id: str | None = None) -> AsyncIterator[ClientSession]: if not settings.S2S_MCP_URL or not token: raise IntegrationError(401, "Se requiere una sesión válida para consultar AquaPortal.") try: async with asyncio.timeout(settings.S2S_MCP_TIMEOUT_SEC): async with http_client(token, podcast_id) as http: async with streamable_http_client(settings.S2S_MCP_URL, http_client=http, terminate_on_close=False) as ( reader, writer, _, ): async with ClientSession( reader, writer, read_timeout_seconds=timedelta(seconds=settings.S2S_MCP_TIMEOUT_SEC) ) as client: await client.initialize() yield client except IntegrationError: raise except Exception as exc: status = _http_status(exc) if status == 401: raise IntegrationError( 401, "La sesión de AquaPortal dejó de ser válida. Inicia una nueva conversación." ) from exc if status == 403: raise IntegrationError(403, "Tu usuario no tiene permiso para consultar este contenido.") from exc raise IntegrationError(502, "No se pudo completar la consulta a AquaPortal. Intenta nuevamente.") from exc async def discover(token: str) -> list[McpToolDefinition]: definitions: dict[str, McpToolDefinition] = {} async with session(token) as client: cursor: str | None = None seen: set[str] = set() for _ in range(10): result = await client.list_tools(cursor=cursor) for tool in result.tools: if tool.name in ALLOWED_TOOLS: definitions[tool.name] = McpToolDefinition( name=tool.name, description=tool.description or "", parameters=tool.inputSchema ) cursor = result.nextCursor if not cursor: return list(definitions.values()) if cursor in seen: break seen.add(cursor) raise IntegrationError(502, "El catálogo de AquaPortal no se pudo cargar por completo.") async def execute(token: str, name: str, arguments: dict[str, Any], *, podcast_id: str | None = None) -> dict[str, Any]: if name not in ALLOWED_TOOLS: return {"error": "Unknown tool. Choose a tool from the supplied catalog."} async with session(token, podcast_id) as client: result = await client.call_tool(name, arguments) text_parts = [part.text for part in result.content if part.type == "text"] if result.isError: message = "\n".join(text_parts) if message.startswith("Authentication required."): raise IntegrationError(401, "La sesión de AquaPortal dejó de ser válida. Inicia una nueva conversación.") output: dict[str, Any] = {"error": message or "The tool could not complete this request."} else: output = {"data": result.structuredContent if result.structuredContent is not None else "\n".join(text_parts)} if len(json.dumps(output, ensure_ascii=False)) > MAX_RESULT_CHARS: return {"error": "The result is too large. Request a smaller page or narrower filters."} return output