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:
@@ -568,6 +568,21 @@ class LegacyCompatibilityTests(unittest.TestCase):
|
||||
for name, response in actual["http"].items():
|
||||
with self.subTest(case=name):
|
||||
self.assertEqual(response, expected["http"][name])
|
||||
# Additive conversation/MCP routes have their own contract tests. Every
|
||||
# pre-existing operation and request schema still matches the capture.
|
||||
operations = actual["openapi_requests"]["operations"]
|
||||
added = set(operations) - set(expected["openapi_requests"]["operations"])
|
||||
self.assertEqual(
|
||||
added,
|
||||
{
|
||||
"POST /api/conversations",
|
||||
"DELETE /api/conversations/current",
|
||||
"POST /api/conversations/current/interrupt",
|
||||
"GET /api/mcp/tools",
|
||||
},
|
||||
)
|
||||
for key in added:
|
||||
del operations[key]
|
||||
self.assertEqual(actual["openapi_requests"], expected["openapi_requests"])
|
||||
self.assertEqual(actual["realtime"], expected["realtime"])
|
||||
self.assertEqual(set(actual["http"]), set(expected["http"]))
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -64,6 +64,10 @@ def test_http_routes_publish_response_schemas():
|
||||
for path, operations in spec["paths"].items():
|
||||
if path.startswith("/api/"):
|
||||
for operation in operations.values():
|
||||
if "204" in operation["responses"]:
|
||||
assert path in {"/api/conversations/current", "/api/conversations/current/interrupt"}
|
||||
assert "content" not in operation["responses"]["204"]
|
||||
continue
|
||||
schema = operation["responses"]["200"]["content"]["application/json"]["schema"]
|
||||
assert "$ref" in schema, path
|
||||
allocation = spec["components"]["schemas"]["AllocationResponse"]
|
||||
|
||||
Reference in New Issue
Block a user