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,366 @@
|
||||
"""Caller isolation, bounded tool loops, and the unchanged speech protocol."""
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from websockets.sync.server import serve
|
||||
|
||||
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, McpToolDefinition
|
||||
from vocero_api.providers import backend_login, caller, mcp
|
||||
from vocero_api.services import conversations, mcp_reasoning, mcp_tools
|
||||
|
||||
|
||||
def catalog():
|
||||
return [
|
||||
McpToolDefinition(
|
||||
name=name,
|
||||
description=f"Published {name} content",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"request": {
|
||||
"type": "object",
|
||||
"properties": {"report_id": {"type": "string"}, "offset": {"type": "integer"}},
|
||||
"additionalProperties": False,
|
||||
}
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
)
|
||||
for name in ("list_reports", "get_report")
|
||||
]
|
||||
|
||||
|
||||
def marker(name, arguments):
|
||||
return f"<<<VOCERO_MCP_CALL:NAME={name},ARGUMENTS={json.dumps(arguments)}>>>"
|
||||
|
||||
|
||||
def test_mcp_marker_survives_the_existing_speech_sanitizer_without_model_dependencies():
|
||||
# Execute only the pure sanitizer and its constants, not the GPU pipeline imports.
|
||||
path = Path(__file__).resolve().parents[2] / "speech_to_speech/LLM/lm_output_processor.py"
|
||||
tree = ast.parse(path.read_text())
|
||||
selected = [
|
||||
node
|
||||
for node in tree.body
|
||||
if (
|
||||
isinstance(node, ast.Assign)
|
||||
and isinstance(node.targets[0], ast.Name)
|
||||
and node.targets[0].id.startswith("_APIPILOT_")
|
||||
)
|
||||
or (isinstance(node, ast.FunctionDef) and node.name == "_sanitize_apipilot_before_client_and_tts")
|
||||
]
|
||||
namespace = {"re": re}
|
||||
exec(compile(ast.Module(body=selected, type_ignores=[]), str(path), "exec"), namespace)
|
||||
sanitize = namespace["_sanitize_apipilot_before_client_and_tts"]
|
||||
selection = marker("list_reports", {})
|
||||
assert sanitize(selection) == (selection, False, "")
|
||||
assert sanitize(selection.replace("VOCERO_MCP", "APIPILOT"))[1] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
["<<<VOC", "<<<VOCERO_MCP_CALL:NAME=get_report,ARGUMENTS=not-json>>>", "Before " + marker("list_reports", {})],
|
||||
)
|
||||
def test_malformed_markers_are_not_treated_as_user_facing_answers(text):
|
||||
with pytest.raises(IntegrationError):
|
||||
mcp_tools.selection(text)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def enabled(monkeypatch):
|
||||
monkeypatch.setattr(settings, "S2S_MCP_URL", "http://mcp.test/mcp")
|
||||
monkeypatch.setattr(settings, "S2S_REASONING_TYPE", "OPENAI")
|
||||
monkeypatch.setattr(settings, "OPENAI_API_KEY", "local-model-key")
|
||||
monkeypatch.setattr(settings, "S2S_PRIMARY_PROMPT", "")
|
||||
login = AsyncMock(
|
||||
return_value=BackendLogin.model_validate(
|
||||
{"sessionToken": "demo-private-alice", "user": {"id": "alice", "name": "Alice"}}
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(backend_login, "login", login)
|
||||
monkeypatch.setattr(mcp, "discover", AsyncMock(return_value=catalog()))
|
||||
monkeypatch.setattr(caller, "_login_by_phone", AsyncMock(side_effect=AssertionError("Legacy login")))
|
||||
monkeypatch.setattr(caller, "_fetch_podcast_episode", AsyncMock(side_effect=AssertionError("Unscoped episode")))
|
||||
conversations.store.clear()
|
||||
yield login
|
||||
conversations.store.clear()
|
||||
|
||||
|
||||
def bootstrap(client):
|
||||
response = client.post("/api/conversations", json={"phoneNumber": "+56000000001"})
|
||||
assert response.status_code == 200
|
||||
return response.json()["conversationId"]
|
||||
|
||||
|
||||
def test_http_chains_tools_retains_history_and_ignores_browser_identity(enabled, monkeypatch):
|
||||
model = AsyncMock(
|
||||
side_effect=[
|
||||
marker("list_reports", {}),
|
||||
marker("get_report", {"request": {"report_id": "r1"}}),
|
||||
"Resumen r1",
|
||||
"Seguimiento r1",
|
||||
]
|
||||
)
|
||||
execute = AsyncMock(return_value={"data": {"result": {"id": "r1", "summary": "Accessible finding"}}})
|
||||
monkeypatch.setattr(mcp_reasoning, "response_text", model)
|
||||
monkeypatch.setattr(mcp, "execute", execute)
|
||||
with TestClient(app) as client:
|
||||
identity = bootstrap(client)
|
||||
headers = {conversations.CONVERSATION_HEADER: identity, "x-vocero-turn": "1"}
|
||||
body = {
|
||||
"transcript": "Resume el último reporte",
|
||||
"phoneNumber": "another-user",
|
||||
"episode_id": "private-episode",
|
||||
}
|
||||
assert client.post("/api/reasoning", headers=headers, json=body).json() == {"answer": "Resumen r1"}
|
||||
assert execute.await_args_list[0].args == ("demo-private-alice", "list_reports", {})
|
||||
assert execute.await_args_list[1].args == ("demo-private-alice", "get_report", {"request": {"report_id": "r1"}})
|
||||
assert client.post("/api/reasoning", headers=headers, json=body).status_code == 409
|
||||
headers["x-vocero-turn"] = "2"
|
||||
assert (
|
||||
client.post("/api/reasoning", headers=headers, json={"transcript": "¿Y la recomendación?"}).status_code
|
||||
== 200
|
||||
)
|
||||
messages = model.await_args_list[-1].args[0]
|
||||
assert any(message["content"] == "Resumen r1" for message in messages)
|
||||
prompt = messages[0]["content"]
|
||||
assert '"additionalProperties": false' in prompt
|
||||
assert '"request"' in prompt
|
||||
assert "Alice" in prompt
|
||||
assert "demo-private" not in json.dumps(messages)
|
||||
assert "private-episode" not in json.dumps(messages)
|
||||
assert client.post("/api/reasoning", json=body).status_code == 401
|
||||
assert client.get("/api/user/phone?phoneNumber=123").status_code == 409
|
||||
assert client.get("/api/user/reports").status_code == 409
|
||||
assert client.get("/api/apipilot/tools").status_code == 409
|
||||
assert client.post("/api/apipilot/execute", json={"name": "get_report"}).status_code == 409
|
||||
enabled.assert_awaited_once_with("+56000000001")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [401, 403, 502])
|
||||
def test_http_errors_fail_closed_and_revocation_removes_the_conversation(enabled, monkeypatch, status):
|
||||
monkeypatch.setattr(mcp, "discover", AsyncMock(side_effect=IntegrationError(status, "Consulta no disponible.")))
|
||||
model = AsyncMock()
|
||||
monkeypatch.setattr(mcp_reasoning, "response_text", model)
|
||||
with TestClient(app) as client:
|
||||
identity = bootstrap(client)
|
||||
response = client.post(
|
||||
"/api/reasoning",
|
||||
headers={conversations.CONVERSATION_HEADER: identity, "x-vocero-turn": "1"},
|
||||
json={"transcript": "Hola"},
|
||||
)
|
||||
assert response.status_code == status
|
||||
model.assert_not_called()
|
||||
if status == 401:
|
||||
with pytest.raises(IntegrationError):
|
||||
conversations.store.get(client.cookies.get(conversations.COOKIE_NAME), identity)
|
||||
|
||||
|
||||
def test_tool_arguments_duplicates_and_budgets_are_checked_before_execution(enabled, monkeypatch):
|
||||
execute = AsyncMock(return_value={"data": {"items": [], "next_offset": None}})
|
||||
monkeypatch.setattr(mcp, "execute", execute)
|
||||
monkeypatch.setattr(settings, "S2S_MCP_MAX_CALLS_PER_TURN", 3)
|
||||
|
||||
async def scenario():
|
||||
login = await backend_login.login("+56000000001")
|
||||
item = conversations.store.create("browser", login)
|
||||
item.catalog = catalog()
|
||||
turn = mcp_tools.ToolTurn(item, item.generation)
|
||||
assert "error" in await turn.call("delete_user", {})
|
||||
assert "error" in await turn.call("get_report", {"request": {"user_id": "bob"}})
|
||||
execute.assert_not_called()
|
||||
await turn.call("list_reports", {"request": {"offset": 0}})
|
||||
with pytest.raises(IntegrationError):
|
||||
await turn.call("list_reports", {"request": {"offset": 10}})
|
||||
fresh = mcp_tools.ToolTurn(item, item.interrupt())
|
||||
await fresh.call("list_reports", {})
|
||||
with pytest.raises(IntegrationError):
|
||||
await fresh.call("list_reports", {})
|
||||
assert execute.await_count == 2
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_cancelled_tool_result_cannot_reach_a_new_turn_even_if_transport_returns_late(enabled, monkeypatch):
|
||||
async def scenario():
|
||||
started = asyncio.Event()
|
||||
cancelled = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def execute(*args):
|
||||
started.set()
|
||||
try:
|
||||
await release.wait()
|
||||
except asyncio.CancelledError:
|
||||
cancelled.set()
|
||||
await release.wait()
|
||||
return {"data": "stale result"}
|
||||
|
||||
monkeypatch.setattr(mcp, "execute", execute)
|
||||
item = conversations.store.create("browser", await backend_login.login("+56000000001"))
|
||||
item.catalog = catalog()
|
||||
turn = mcp_tools.ToolTurn(item, item.generation)
|
||||
item.pending = asyncio.create_task(turn.call("get_report", {}))
|
||||
pending = item.pending
|
||||
await started.wait()
|
||||
item.interrupt()
|
||||
await cancelled.wait()
|
||||
release.set()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await pending
|
||||
assert item.history == []
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_interrupt_for_an_old_http_turn_cannot_cancel_a_new_turn(enabled):
|
||||
with TestClient(app) as client:
|
||||
identity = bootstrap(client)
|
||||
item = conversations.store.get(client.cookies.get(conversations.COOKIE_NAME), identity)
|
||||
item.client_turn = 2
|
||||
headers = {conversations.CONVERSATION_HEADER: identity, "x-vocero-turn": "1"}
|
||||
assert client.post("/api/conversations/current/interrupt", headers=headers).status_code == 204
|
||||
assert item.generation == 0
|
||||
headers["x-vocero-turn"] = "2"
|
||||
assert client.post("/api/conversations/current/interrupt", headers=headers).status_code == 204
|
||||
assert item.generation == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("speech_order", [False, True])
|
||||
def test_voice_relay_chains_tools_across_turns_hides_markers_and_preserves_audio(enabled, monkeypatch, speech_order):
|
||||
monkeypatch.setattr(settings, "S2S_REASONING_TYPE", "PROXY")
|
||||
monkeypatch.setattr(settings, "S2S_PRIMARY_PROMPT", "Deployment prompt")
|
||||
execute = AsyncMock(return_value={"data": {"id": "r1", "summary": "Accessible finding"}})
|
||||
monkeypatch.setattr(mcp, "execute", execute)
|
||||
received = []
|
||||
paths = []
|
||||
errors = []
|
||||
|
||||
def voice(socket):
|
||||
try:
|
||||
paths.append(socket.request.path)
|
||||
socket.send(json.dumps({"type": "session.created"}))
|
||||
count = 0
|
||||
for raw in socket:
|
||||
if isinstance(raw, bytes):
|
||||
socket.send(raw)
|
||||
continue
|
||||
event = json.loads(raw)
|
||||
received.append(event)
|
||||
if event["type"] == "session.update":
|
||||
socket.send(raw)
|
||||
if event["type"] != "response.create":
|
||||
continue
|
||||
step = count % 3
|
||||
name = "list_reports" if step == 0 else "get_report"
|
||||
text = (
|
||||
marker(name, {} if step == 0 else {"request": {"report_id": "r1"}})
|
||||
if step < 2
|
||||
else "Resultado publicado"
|
||||
)
|
||||
response_id = f"response-{count}"
|
||||
count += 1
|
||||
if speech_order:
|
||||
socket.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "response.output_audio_transcript.done",
|
||||
"response_id": response_id,
|
||||
"transcript": text,
|
||||
}
|
||||
)
|
||||
)
|
||||
socket.send(json.dumps({"type": "response.created", "response": {"id": response_id, "output": None}}))
|
||||
# Audio before text must wait until a normal answer is identified.
|
||||
socket.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "response.audio.delta",
|
||||
"response_id": response_id,
|
||||
"delta": "hidden-tool-audio" if step < 2 else "answer-audio",
|
||||
}
|
||||
)
|
||||
)
|
||||
if not speech_order:
|
||||
for part in (text[:3], text[3:]):
|
||||
socket.send(
|
||||
json.dumps({"type": "response.text.delta", "response_id": response_id, "delta": part})
|
||||
)
|
||||
done = {
|
||||
"type": "response.done",
|
||||
"response": {
|
||||
"id": response_id,
|
||||
"status": "completed",
|
||||
"output": None if speech_order else [{"content": [{"text": text}]}],
|
||||
},
|
||||
}
|
||||
socket.send(json.dumps(done))
|
||||
socket.send(json.dumps(done))
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
with serve(voice, "127.0.0.1", 0) as upstream:
|
||||
thread = threading.Thread(target=upstream.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
monkeypatch.setattr(
|
||||
settings, "S2S_PROXY_TARGET", f"ws://127.0.0.1:{upstream.socket.getsockname()[1]}/v1/realtime"
|
||||
)
|
||||
with TestClient(app) as client:
|
||||
identity = bootstrap(client)
|
||||
with client.websocket_connect(f"/v1/realtime?conversation_id={identity}") as socket:
|
||||
assert socket.receive_json() == {"type": "session.created"}
|
||||
socket.send_json(
|
||||
{"type": "session.update", "session": {"instructions": "Deployment prompt\nSession RAG notes"}}
|
||||
)
|
||||
prompt = socket.receive_json()["session"]["instructions"]
|
||||
assert '"request"' in prompt and '"offset"' in prompt
|
||||
assert "Alice" in prompt and "demo-private" not in prompt
|
||||
assert "Session RAG notes" in prompt
|
||||
socket.send_bytes(b"\x00\x01")
|
||||
assert socket.receive_bytes() == b"\x00\x01"
|
||||
for _ in range(5):
|
||||
socket.send_json(
|
||||
{
|
||||
"type": "conversation.item.create",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "Resume el reporte"}],
|
||||
},
|
||||
}
|
||||
)
|
||||
socket.send_json({"type": "response.create"})
|
||||
events = []
|
||||
while True:
|
||||
event = socket.receive_json()
|
||||
events.append(event)
|
||||
assert "hidden-tool-audio" not in json.dumps(event)
|
||||
assert "VOCERO_MCP" not in json.dumps(event)
|
||||
if event["type"] == "response.done" and any(e.get("delta") == "answer-audio" for e in events):
|
||||
break
|
||||
assert sum(event["type"] == "vocero.tool.started" for event in events) == 2
|
||||
assert any(event.get("delta") == "answer-audio" for event in events)
|
||||
assert execute.await_count == 10
|
||||
assert all(call.args[0] == "demo-private-alice" for call in execute.await_args_list)
|
||||
assert identity not in paths[0]
|
||||
assert "demo-private" not in json.dumps(received)
|
||||
thread.join(3)
|
||||
assert not errors
|
||||
|
||||
|
||||
def test_proxy_configuration_rejects_a_direct_speech_bypass(enabled, monkeypatch):
|
||||
monkeypatch.setattr(settings, "S2S_REASONING_TYPE", "PROXY")
|
||||
monkeypatch.setattr(settings, "SPEECH_TO_SPEECH_URL", "ws://speech.test/realtime")
|
||||
with TestClient(app) as client:
|
||||
assert client.get("/api/config").status_code == 503
|
||||
Reference in New Issue
Block a user