Vocero needs to query AquaPortal on behalf of the caller selected by the existing phone link. This adds server-owned conversations and MCP tool execution for voice and HTTP reasoning while leaving the speech pipeline unchanged. Release notes: - Added browser-bound conversations that keep backend credentials server-side and isolate callers. - Connected the eight-tool AquaPortal catalog through authenticated Streamable HTTP with full input schemas and structured results. - Added bounded multi-step tool execution with cancellation, expiry, and stale-result protection. - Updated browser bootstrap, tool progress, reasoning headers, and conversation cleanup. - Disabled legacy unscoped content preloads in MCP mode and documented configuration and deployment constraints. - Preserved disabled-mode contracts and added local SDK, WebSocket, and browser regression coverage. Reviewed-on: AquaPortal/s2s#1
118 lines
4.5 KiB
Python
118 lines
4.5 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) -> httpx.AsyncClient:
|
|
return httpx.AsyncClient(
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
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) -> 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) 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]) -> dict[str, Any]:
|
|
if name not in ALLOWED_TOOLS:
|
|
return {"error": "Unknown tool. Choose a tool from the supplied catalog."}
|
|
async with session(token) 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
|