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:
@@ -2,13 +2,14 @@
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from vocero_api.api.routes import apipilot, caller, config, search, sessions
|
||||
from vocero_api.api.routes import apipilot, caller, config, conversations, search, sessions
|
||||
from vocero_api.models.responses import ReasoningResponse
|
||||
from vocero_api.realtime.proxy import realtime_proxy
|
||||
from vocero_api.services.reasoning import reasoning
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(config.router)
|
||||
router.include_router(conversations.router)
|
||||
router.include_router(apipilot.router)
|
||||
router.add_api_route("/api/reasoning", reasoning, methods=["POST"], response_model=ReasoningResponse)
|
||||
router.add_api_websocket_route("/v1/realtime", realtime_proxy)
|
||||
|
||||
@@ -30,6 +30,8 @@ async def apipilot_list_tools(
|
||||
A short in-process TTL cache is applied (default 5 min, configurable via
|
||||
S2S_APIPILOT_TTL_SEC). The client may force a refresh via `?refresh=true`
|
||||
(e.g. after a user just logged in and previously saw only requires_auth tools)."""
|
||||
if settings.S2S_MCP_URL:
|
||||
raise HTTPException(409, "Las herramientas de AquaPortal usan la sesión de conversación.")
|
||||
if not settings.S2S_APIPILOT_TOOLS_URL:
|
||||
return validated_json_response(
|
||||
model=ToolCatalogResponse,
|
||||
@@ -101,6 +103,8 @@ async def apipilot_execute(request: Request, body: models_requests.ApipilotExecu
|
||||
|
||||
Show only `error.message` to the end user. `error.code`, `error.status_code`
|
||||
and `error.raw` are for internal auditing (guide §2.2, recommended UI errors)."""
|
||||
if settings.S2S_MCP_URL:
|
||||
raise HTTPException(409, "Las herramientas de AquaPortal usan la sesión de conversación.")
|
||||
if not settings.S2S_APIPILOT_TOOLS_URL:
|
||||
return validated_json_response(
|
||||
model=ToolExecutionResponse,
|
||||
|
||||
@@ -35,6 +35,8 @@ async def user_by_phone(phoneNumber: str) -> JSONResponse:
|
||||
it as Authorization: Bearer on the subsequent profile GET.
|
||||
"""
|
||||
|
||||
if settings.S2S_MCP_URL:
|
||||
raise HTTPException(409, "Usa la sesión de conversación para consultar AquaPortal.")
|
||||
if not settings.VOCERO_USER_PHONE_AUTH_URL:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
@@ -118,6 +120,8 @@ async def user_reports(phoneNumber: str | None = None) -> JSONResponse:
|
||||
a subtle loophole where the browser could still fetch scheduled reports
|
||||
through /api/user/reports while the LLM prompt paths were gated.
|
||||
"""
|
||||
if settings.S2S_MCP_URL:
|
||||
raise HTTPException(409, "Los reportes se consultan durante la conversación con AquaPortal.")
|
||||
# ── MASS-INJECTION KILL SWITCH (user request) ─────────────────────────
|
||||
if not settings.S2S_INJECT_REPORTS:
|
||||
return validated_json_response(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from vocero_api.api.responses import validated_json_response
|
||||
@@ -44,6 +44,13 @@ async def config(request: Request) -> JSONResponse:
|
||||
"theme": settings.S2S_THEME,
|
||||
"apipilot": bool(settings.S2S_APIPILOT_TOOLS_URL),
|
||||
}
|
||||
if settings.S2S_MCP_URL:
|
||||
if settings.S2S_REASONING_TYPE == "PROXY" and (
|
||||
not settings.S2S_PROXY_TARGET or settings.SPEECH_TO_SPEECH_URL or settings.LOAD_BALANCER_URL
|
||||
):
|
||||
raise HTTPException(503, "MCP en modo PROXY requiere la conexión de voz a través de Vocero.")
|
||||
out["mcp"] = True
|
||||
out["apipilot"] = False
|
||||
app_logging.logger.debug(
|
||||
"[INTERNAL] /api/config returned promptLen=%d reasoning=%s startSessionMode=%s theme=%s apipilot=%s",
|
||||
len(primary_prompt),
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Browser-owned conversations; backend credentials never cross this boundary."""
|
||||
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
|
||||
from vocero_api.core import config as settings
|
||||
from vocero_api.models.mcp import ConversationRequest, ConversationResponse, IntegrationError, McpCatalogResponse
|
||||
from vocero_api.providers import backend_login, mcp
|
||||
from vocero_api.services import conversations
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def require_mcp() -> None:
|
||||
if not settings.S2S_MCP_URL:
|
||||
raise HTTPException(404, "No encontrado.")
|
||||
|
||||
|
||||
@router.post("/api/conversations", response_model=ConversationResponse)
|
||||
async def create_conversation(body: ConversationRequest, request: Request, response: Response) -> ConversationResponse:
|
||||
require_mcp()
|
||||
try:
|
||||
conversations.check_origin(request)
|
||||
login = await backend_login.login(body.phoneNumber.strip())
|
||||
browser = request.cookies.get(conversations.COOKIE_NAME) or secrets.token_urlsafe(32)
|
||||
item = conversations.store.create(browser, login)
|
||||
except IntegrationError as exc:
|
||||
raise HTTPException(exc.status, exc.message) from None
|
||||
response.set_cookie(
|
||||
conversations.COOKIE_NAME,
|
||||
browser,
|
||||
httponly=True,
|
||||
secure=request.url.scheme == "https" or request.headers.get("x-forwarded-proto") == "https",
|
||||
samesite="strict",
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return ConversationResponse(
|
||||
conversationId=item.id, expiresIn=settings.S2S_CONVERSATION_TTL_SEC, profile=item.profile
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/api/conversations/current", status_code=204)
|
||||
async def end_conversation(request: Request) -> Response:
|
||||
require_mcp()
|
||||
try:
|
||||
item = conversations.resolve(request)
|
||||
conversations.store.remove(item.id)
|
||||
except IntegrationError as exc:
|
||||
raise HTTPException(exc.status, exc.message) from None
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.post("/api/conversations/current/interrupt", status_code=204)
|
||||
async def interrupt_conversation(request: Request) -> Response:
|
||||
require_mcp()
|
||||
try:
|
||||
item = conversations.resolve(request)
|
||||
if request.headers.get("x-vocero-turn") == str(item.client_turn):
|
||||
item.interrupt()
|
||||
except IntegrationError as exc:
|
||||
raise HTTPException(exc.status, exc.message) from None
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.get("/api/mcp/tools", response_model=McpCatalogResponse)
|
||||
async def list_tools(request: Request, response: Response) -> McpCatalogResponse:
|
||||
require_mcp()
|
||||
item: conversations.Conversation | None = None
|
||||
try:
|
||||
item = conversations.resolve(request)
|
||||
catalog = await mcp.discover(item.backend_token)
|
||||
item.require_active()
|
||||
item.catalog = catalog
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return McpCatalogResponse(tools=catalog)
|
||||
except IntegrationError as exc:
|
||||
if exc.status == 401 and item is not None:
|
||||
conversations.store.remove(item.id)
|
||||
raise HTTPException(exc.status, exc.message) from None
|
||||
Reference in New Issue
Block a user