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,108 @@
"""Conversation-aware HTTP reasoning with the same MCP executor as the relay."""
import asyncio
from contextlib import suppress
import httpx
from fastapi import HTTPException, Request
from fastapi.responses import JSONResponse
from vocero_api.core import config as settings
from vocero_api.models.mcp import IntegrationError
from vocero_api.models.requests import ReasoningRequest
from vocero_api.providers import mcp
from vocero_api.services import conversations, mcp_tools
async def response_text(messages: list[dict[str, str]], headers: dict[str, str]) -> str:
try:
async with httpx.AsyncClient(timeout=30, follow_redirects=False) as client:
response = await client.post(
f"{settings.OPENAI_BASE_URL}/chat/completions",
headers=headers,
json={"model": settings.OPENAI_REASONING_MODEL, "messages": messages},
)
response.raise_for_status()
text = response.json()["choices"][0]["message"]["content"]
if isinstance(text, str) and text.strip() and len(text) <= 64000:
return text.strip()
except (httpx.HTTPError, ValueError, KeyError, IndexError, TypeError):
pass
raise IntegrationError(502, "El servicio de razonamiento no devolvió una respuesta válida.")
async def run_turn(
req: ReasoningRequest, conversation: conversations.Conversation, turn: mcp_tools.ToolTurn, headers: dict[str, str]
) -> str:
catalog = await mcp.discover(conversation.backend_token)
turn.require_current()
conversation.catalog = catalog
base = settings.S2S_PRIMARY_PROMPT or req.instructions
prior = [message for previous_turn in conversation.history for message in previous_turn]
notes = f"User-provided reference notes:\n{req.user_context[:12000]}\n\n" if req.user_context else ""
current = [{"role": "user", "content": notes + req.transcript.strip()}]
system = {"role": "system", "content": mcp_tools.instructions(base, conversation)}
while True:
answer = await response_text([system, *prior, *current], headers)
turn.require_current()
chosen = mcp_tools.selection(answer)
current.append({"role": "assistant", "content": answer})
if chosen is None:
conversation.history.append(current)
while (
len(conversation.history) > 6
or sum(len(message["content"]) for history in conversation.history for message in history) > 96000
):
conversation.history.pop(0)
return answer
name, arguments = chosen
result = await turn.call(name, arguments)
current.append({"role": "user", "content": mcp_tools.result_text(name, result)})
async def reply(req: ReasoningRequest, request: Request, headers: dict[str, str]) -> JSONResponse:
conversation: conversations.Conversation | None = None
try:
conversation = conversations.resolve(request)
if not req.transcript.strip() or len(req.transcript) > 8000:
raise IntegrationError(400, "La consulta debe tener entre 1 y 8000 caracteres.")
try:
client_turn = int(request.headers.get("x-vocero-turn", "0"))
except ValueError:
raise IntegrationError(400, "El turno de la conversación no es válido.") from None
if client_turn <= conversation.client_turn:
raise IntegrationError(409, "Este turno ya fue reemplazado.")
conversation.client_turn = client_turn
turn = mcp_tools.ToolTurn(conversation, conversation.interrupt())
task = asyncio.create_task(run_turn(req, conversation, turn, headers))
conversation.pending = task
async def watch_connection() -> None:
while not task.done():
if await request.is_disconnected():
if conversation.pending is task:
conversation.interrupt()
else:
task.cancel()
return
await asyncio.sleep(0.1)
watcher = asyncio.create_task(watch_connection())
try:
async with asyncio.timeout(180):
answer = await task
return JSONResponse({"answer": answer}, headers={"Cache-Control": "no-store"})
finally:
watcher.cancel()
with suppress(asyncio.CancelledError):
await watcher
if conversation.pending is task:
conversation.pending = None
except IntegrationError as exc:
if exc.status == 401 and conversation is not None:
conversations.store.remove(conversation.id)
raise HTTPException(exc.status, exc.message) from None
except TimeoutError:
raise HTTPException(504, "La consulta tardó demasiado. Intenta con un período más corto.") from None
except asyncio.CancelledError:
return JSONResponse({"detail": "La consulta fue cancelada."}, status_code=409)