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,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)
|
||||
Reference in New Issue
Block a user