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
81 lines
3.0 KiB
Python
81 lines
3.0 KiB
Python
"""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
|