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:
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user