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
+189
View File
@@ -0,0 +1,189 @@
"""Conversation ownership and real SDK transport against isolated HTTP fixtures."""
import asyncio
import json
import os
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from fastapi.testclient import TestClient
with patch.dict(os.environ, {}, clear=True), patch("dotenv.load_dotenv", return_value=False):
from vocero_api.core import config as settings
from vocero_api.main import app
from vocero_api.models.mcp import BackendLogin, IntegrationError
from vocero_api.providers import backend_login, mcp
from vocero_api.services import conversations
@pytest.fixture
def enabled(monkeypatch):
monkeypatch.setattr(settings, "S2S_MCP_URL", "http://mcp.test/mcp")
monkeypatch.setattr(settings, "S2S_LOGIN_BY_PHONE", "http://backend.test/phone-login")
conversations.store.clear()
yield
conversations.store.clear()
def login_payload(user="alice"):
return {"sessionToken": f"demo-secret-{user}", "user": {"id": user, "name": user.title()}}
def test_cookie_bound_conversations_keep_tokens_private_and_identity_per_tab(enabled, monkeypatch):
login = AsyncMock(side_effect=[BackendLogin.model_validate(login_payload(user)) for user in ("alice", "bob")])
monkeypatch.setattr(backend_login, "login", login)
with TestClient(app) as client:
first = client.post("/api/conversations", json={"phoneNumber": "+56000000001"})
cookie = client.cookies.get(conversations.COOKIE_NAME)
assert "HttpOnly" in first.headers["set-cookie"]
assert "SameSite=strict" in first.headers["set-cookie"]
assert "demo-secret" not in first.text
alice = first.json()["conversationId"]
bob = client.post("/api/conversations", json={"phoneNumber": "+56000000002"}).json()["conversationId"]
assert conversations.store.get(cookie, alice).backend_token == "demo-secret-alice"
assert conversations.store.get(cookie, bob).backend_token == "demo-secret-bob"
client.cookies.clear()
assert client.get("/api/mcp/tools", headers={conversations.CONVERSATION_HEADER: alice}).status_code == 401
client.cookies.set(conversations.COOKIE_NAME, cookie)
assert (
client.delete("/api/conversations/current", headers={conversations.CONVERSATION_HEADER: alice}).status_code
== 204
)
with pytest.raises(IntegrationError):
conversations.store.get(cookie, alice)
assert conversations.store.get(cookie, bob).profile.id == "bob"
def test_auth_failure_and_cross_origin_requests_never_create_a_conversation(enabled, monkeypatch):
login = AsyncMock(side_effect=IntegrationError(401, "Sesión no válida."))
monkeypatch.setattr(backend_login, "login", login)
with TestClient(app) as client:
assert (
client.post(
"/api/conversations", json={"phoneNumber": "+56000000001"}, headers={"Origin": "https://elsewhere.test"}
).status_code
== 403
)
login.assert_not_called()
response = client.post("/api/conversations", json={"phoneNumber": "+56000000001"})
assert response.status_code == 401
assert "set-cookie" not in response.headers
def test_store_expires_bounds_and_cancels_pending_work(enabled, monkeypatch):
async def scenario():
store = conversations.ConversationStore()
monkeypatch.setattr(settings, "S2S_MAX_CONVERSATIONS", 1)
item = store.create("browser", BackendLogin.model_validate(login_payload()))
with pytest.raises(IntegrationError, match="capacidad"):
store.create("other-browser", BackendLogin.model_validate(login_payload("bob")))
item.pending = asyncio.create_task(asyncio.sleep(100))
pending = item.pending
item.expires_at = 0
with pytest.raises(IntegrationError):
store.get("browser", item.id)
await asyncio.gather(pending, return_exceptions=True)
assert pending.cancelled()
assert item.backend_token == ""
assert item.closed.is_set()
store.create("browser", BackendLogin.model_validate(login_payload()))
asyncio.run(scenario())
def test_backend_login_validates_exact_contract_without_leaking_credentials(enabled, monkeypatch, caplog):
original = httpx.AsyncClient
calls = []
def upstream(request):
calls.append(request)
return httpx.Response(200, json=login_payload())
monkeypatch.setattr(
backend_login.httpx, "AsyncClient", lambda **kwargs: original(transport=httpx.MockTransport(upstream), **kwargs)
)
result = asyncio.run(backend_login.login("+56000000001"))
assert result.sessionToken.get_secret_value() == "demo-secret-alice"
assert json.loads(calls[0].content) == {"phoneNumber": "+56000000001"}
assert "demo-secret" not in caplog.text
assert "demo-secret" not in repr(result)
@pytest.mark.parametrize("use_sse", [False, True])
def test_sdk_discovery_and_execution_preserve_nested_schema_results_and_per_user_headers(enabled, monkeypatch, use_sse):
calls = []
def upstream(request):
calls.append(request)
if request.method == "GET":
return httpx.Response(405)
message = json.loads(request.content)
method = message["method"]
if "id" not in message:
return httpx.Response(202)
if method == "initialize":
result = {
"protocolVersion": "2025-11-25",
"capabilities": {"tools": {}},
"serverInfo": {"name": "test", "version": "1"},
}
elif method == "tools/list":
result = {
"tools": [
{
"name": "get_report",
"inputSchema": {"type": "object", "properties": {"request": {"type": "object"}}},
},
{"name": "delete_user", "inputSchema": {"type": "object"}},
]
}
else:
assert method == "tools/call"
assert message["params"]["arguments"] == {"request": {"report_id": "r1"}}
result = {
"content": [],
"structuredContent": {"result": {"status": "found", "caller": request.headers["authorization"]}},
}
body = {"jsonrpc": "2.0", "id": message["id"], "result": result}
if use_sse:
return httpx.Response(
200, text=f"event: message\ndata: {json.dumps(body)}\n\n", headers={"content-type": "text/event-stream"}
)
return httpx.Response(200, json=body)
monkeypatch.setattr(
mcp,
"http_client",
lambda token: httpx.AsyncClient(
transport=httpx.MockTransport(upstream), headers={"Authorization": f"Bearer {token}"}
),
)
async def scenario():
catalog = await mcp.discover("alice")
assert [tool.name for tool in catalog] == ["get_report"]
assert catalog[0].parameters["properties"]["request"] == {"type": "object"}
outputs = await asyncio.gather(
*(mcp.execute(user, "get_report", {"request": {"report_id": "r1"}}) for user in ("alice", "bob"))
)
assert [output["data"]["result"]["caller"] for output in outputs] == ["Bearer alice", "Bearer bob"]
asyncio.run(scenario())
assert all(request.headers.get("authorization") in {"Bearer alice", "Bearer bob"} for request in calls)
assert all("x-demo-session" not in request.headers and "x-api-key" not in request.headers for request in calls)
@pytest.mark.parametrize("status", [401, 403, 500])
def test_mcp_transport_errors_are_safe_and_preserve_authentication_status(enabled, monkeypatch, status):
monkeypatch.setattr(
mcp,
"http_client",
lambda token: httpx.AsyncClient(
transport=httpx.MockTransport(lambda req: httpx.Response(status, text="secret-upstream-body"))
),
)
with pytest.raises(IntegrationError) as error:
asyncio.run(mcp.discover("secret-token"))
assert error.value.status == (status if status in {401, 403} else 502)
assert "secret" not in str(error.value)