feat(mcp): connect authenticated AquaPortal tools to Vocero (#1)

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
This commit is contained in:
2026-10-08 08:49:39 -04:00
parent ccb1f708ca
commit 96895996d9
37 changed files with 2498 additions and 55 deletions
@@ -0,0 +1,21 @@
"""Resolve the phone selector once, keeping the backend credential server-side."""
import httpx
from pydantic import ValidationError
from vocero_api.core import config as settings
from vocero_api.models.mcp import BackendLogin, IntegrationError
async def login(phone_number: str) -> BackendLogin:
if not settings.S2S_LOGIN_BY_PHONE:
raise IntegrationError(503, "El acceso por teléfono no está configurado.")
try:
async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client:
response = await client.post(settings.S2S_LOGIN_BY_PHONE, json={"phoneNumber": phone_number})
if response.status_code in {401, 403, 404, 422}:
raise IntegrationError(401, "No se pudo iniciar sesión con este número de teléfono.")
response.raise_for_status()
return BackendLogin.model_validate(response.json())
except (httpx.HTTPError, ValidationError, ValueError) as exc:
raise IntegrationError(502, "No se pudo validar la sesión con AquaPortal.") from exc
+117
View File
@@ -0,0 +1,117 @@
"""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