Opening a report or podcast link now establishes an authenticated starting reference for the conversation. Users can select other accessible content or compare reports while retaining the original link, and TALKFIRST greetings can name the verified report or podcast. Release notes: - Added authenticated report and episode link resolution with bounded lookups and unavailable-content handling - Added explicit conversation selection without restricting searches or changing the reference during comparisons - Added contextual TALKFIRST greetings through both voice-proxy and HTTP reasoning paths - Preserved backend permission checks, conversation isolation, cancellation, and the existing speech protocol Reviewed-on: AquaPortal/s2s#2
121 lines
4.7 KiB
Python
121 lines
4.7 KiB
Python
"""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
|