mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-13 08:15:21 +02:00
Merge remote-tracking branch 'origin/main' into feat/text-chat
This commit is contained in:
commit
129a6d700c
160 changed files with 9287 additions and 3935 deletions
|
|
@ -15,16 +15,14 @@ import pytest
|
|||
|
||||
from api.services.workflow.dto import (
|
||||
AgentNodeData,
|
||||
AgentRFNode,
|
||||
EdgeDataDTO,
|
||||
EndCallNodeData,
|
||||
EndCallRFNode,
|
||||
ExtractionVariableDTO,
|
||||
Position,
|
||||
ReactFlowDTO,
|
||||
RFEdgeDTO,
|
||||
RFNodeDTO,
|
||||
StartCallNodeData,
|
||||
StartCallRFNode,
|
||||
VariableType,
|
||||
)
|
||||
from api.services.workflow.workflow_graph import WorkflowGraph
|
||||
|
|
@ -270,8 +268,9 @@ def simple_workflow() -> WorkflowGraph:
|
|||
"""
|
||||
dto = ReactFlowDTO(
|
||||
nodes=[
|
||||
StartCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="start",
|
||||
type="startCall",
|
||||
position=Position(x=0, y=0),
|
||||
data=StartCallNodeData(
|
||||
name="Start Call",
|
||||
|
|
@ -290,8 +289,9 @@ def simple_workflow() -> WorkflowGraph:
|
|||
],
|
||||
),
|
||||
),
|
||||
EndCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="end",
|
||||
type="endCall",
|
||||
position=Position(x=0, y=200),
|
||||
data=EndCallNodeData(
|
||||
name="End Call",
|
||||
|
|
@ -333,8 +333,9 @@ def three_node_workflow() -> WorkflowGraph:
|
|||
"""
|
||||
dto = ReactFlowDTO(
|
||||
nodes=[
|
||||
StartCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="start",
|
||||
type="startCall",
|
||||
position=Position(x=0, y=0),
|
||||
data=StartCallNodeData(
|
||||
name="Start Call",
|
||||
|
|
@ -353,8 +354,9 @@ def three_node_workflow() -> WorkflowGraph:
|
|||
],
|
||||
),
|
||||
),
|
||||
AgentRFNode(
|
||||
RFNodeDTO(
|
||||
id="agent",
|
||||
type="agentNode",
|
||||
position=Position(x=0, y=200),
|
||||
data=AgentNodeData(
|
||||
name="Collect Info",
|
||||
|
|
@ -372,8 +374,9 @@ def three_node_workflow() -> WorkflowGraph:
|
|||
],
|
||||
),
|
||||
),
|
||||
EndCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="end",
|
||||
type="endCall",
|
||||
position=Position(x=0, y=400),
|
||||
data=EndCallNodeData(
|
||||
name="End Call",
|
||||
|
|
@ -424,8 +427,9 @@ def three_node_workflow_extraction_start_only() -> WorkflowGraph:
|
|||
"""
|
||||
dto = ReactFlowDTO(
|
||||
nodes=[
|
||||
StartCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="start",
|
||||
type="startCall",
|
||||
position=Position(x=0, y=0),
|
||||
data=StartCallNodeData(
|
||||
name="Start Call",
|
||||
|
|
@ -444,8 +448,9 @@ def three_node_workflow_extraction_start_only() -> WorkflowGraph:
|
|||
],
|
||||
),
|
||||
),
|
||||
AgentRFNode(
|
||||
RFNodeDTO(
|
||||
id="agent",
|
||||
type="agentNode",
|
||||
position=Position(x=0, y=200),
|
||||
data=AgentNodeData(
|
||||
name="Collect Info",
|
||||
|
|
@ -455,8 +460,9 @@ def three_node_workflow_extraction_start_only() -> WorkflowGraph:
|
|||
extraction_enabled=False, # Explicitly disabled for testing
|
||||
),
|
||||
),
|
||||
EndCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="end",
|
||||
type="endCall",
|
||||
position=Position(x=0, y=400),
|
||||
data=EndCallNodeData(
|
||||
name="End Call",
|
||||
|
|
@ -503,8 +509,9 @@ def three_node_workflow_no_variable_extraction() -> WorkflowGraph:
|
|||
"""
|
||||
dto = ReactFlowDTO(
|
||||
nodes=[
|
||||
StartCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="start",
|
||||
type="startCall",
|
||||
position=Position(x=0, y=0),
|
||||
data=StartCallNodeData(
|
||||
name="Start Call",
|
||||
|
|
@ -515,8 +522,9 @@ def three_node_workflow_no_variable_extraction() -> WorkflowGraph:
|
|||
extraction_enabled=False,
|
||||
),
|
||||
),
|
||||
AgentRFNode(
|
||||
RFNodeDTO(
|
||||
id="agent",
|
||||
type="agentNode",
|
||||
position=Position(x=0, y=200),
|
||||
data=AgentNodeData(
|
||||
name="Collect Info",
|
||||
|
|
@ -526,8 +534,9 @@ def three_node_workflow_no_variable_extraction() -> WorkflowGraph:
|
|||
extraction_enabled=False, # Explicitly disabled for testing
|
||||
),
|
||||
),
|
||||
EndCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="end",
|
||||
type="endCall",
|
||||
position=Position(x=0, y=400),
|
||||
data=EndCallNodeData(
|
||||
name="End Call",
|
||||
|
|
|
|||
|
|
@ -63,7 +63,6 @@
|
|||
},
|
||||
"data": {
|
||||
"prompt": "Hello, I am Abhishek from Dograh. ",
|
||||
"is_static": true,
|
||||
"name": "Start Call",
|
||||
"is_start": true
|
||||
},
|
||||
|
|
@ -83,7 +82,6 @@
|
|||
},
|
||||
"data": {
|
||||
"prompt": "Thank you for calling Dograh. Have a great day!",
|
||||
"is_static": true,
|
||||
"name": "End Call"
|
||||
},
|
||||
"measured": {
|
||||
|
|
@ -161,4 +159,4 @@
|
|||
"y": 0,
|
||||
"zoom": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
0
api/tests/support/__init__.py
Normal file
0
api/tests/support/__init__.py
Normal file
103
api/tests/support/mcp_mock_server.py
Normal file
103
api/tests/support/mcp_mock_server.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""A real FastMCP server exposing 2 tools over streamable-HTTP, run in a
|
||||
background uvicorn thread on an ephemeral port. Used to exercise the real
|
||||
MCP protocol path in tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import socket
|
||||
import threading
|
||||
from typing import AsyncIterator
|
||||
|
||||
import httpx
|
||||
import uvicorn
|
||||
from fastmcp import FastMCP
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
|
||||
def _build_app(required_headers: dict[str, str] | None = None):
|
||||
mcp = FastMCP("mock-mcp")
|
||||
|
||||
@mcp.tool()
|
||||
def echo(text: str) -> str:
|
||||
"""Echo the provided text back."""
|
||||
return f"echo:{text}"
|
||||
|
||||
@mcp.tool()
|
||||
def add(a: int, b: int) -> int:
|
||||
"""Add two integers."""
|
||||
return a + b
|
||||
|
||||
# FastMCP 3.x: ASGI app for streamable-HTTP transport at "/mcp".
|
||||
app = mcp.http_app()
|
||||
if not required_headers:
|
||||
return app
|
||||
|
||||
normalized = {k.lower(): v for k, v in required_headers.items()}
|
||||
|
||||
async def guarded_app(scope, receive, send):
|
||||
if scope["type"] == "http":
|
||||
headers = {
|
||||
key.decode("latin-1").lower(): value.decode("latin-1")
|
||||
for key, value in scope.get("headers", [])
|
||||
}
|
||||
for header_name, expected_value in normalized.items():
|
||||
if headers.get(header_name) != expected_value:
|
||||
response = JSONResponse(
|
||||
{"detail": f"Missing or invalid header: {header_name}"},
|
||||
status_code=401,
|
||||
)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
await app(scope, receive, send)
|
||||
|
||||
return guarded_app
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def running_mcp_server(
|
||||
*, required_headers: dict[str, str] | None = None
|
||||
) -> AsyncIterator[str]:
|
||||
"""Yield the base streamable-HTTP URL of a live mock MCP server."""
|
||||
port = _free_port()
|
||||
config = uvicorn.Config(
|
||||
_build_app(required_headers), host="127.0.0.1", port=port, log_level="warning"
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
thread = threading.Thread(target=server.run, daemon=True)
|
||||
thread.start()
|
||||
|
||||
base_url = f"http://127.0.0.1:{port}/mcp"
|
||||
server_ready = False
|
||||
for _ in range(50):
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
await client.get(base_url, timeout=0.5)
|
||||
server_ready = True
|
||||
break
|
||||
except Exception:
|
||||
await asyncio.sleep(0.1)
|
||||
if not server_ready:
|
||||
server.should_exit = True
|
||||
thread.join(timeout=5)
|
||||
raise RuntimeError(f"Mock MCP server at {base_url} failed to start within 5s")
|
||||
try:
|
||||
yield base_url
|
||||
finally:
|
||||
server.should_exit = True
|
||||
thread.join(timeout=5)
|
||||
if thread.is_alive():
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"Mock MCP server thread did not terminate within 5s",
|
||||
ResourceWarning,
|
||||
)
|
||||
|
|
@ -153,45 +153,16 @@ async def test_verify_inbound_signature_rejects_missing_config_public_key():
|
|||
_, headers = _signed_headers(body)
|
||||
provider = _provider()
|
||||
|
||||
# REMOVE-AFTER 2026-05-15: drop the patch wrapper once
|
||||
# TELNYX_WEBHOOK_VERIFICATION_OPTIONAL is removed; the bare call below
|
||||
# will then assert the only path.
|
||||
with patch(
|
||||
"api.services.telephony.providers.telnyx.provider.TELNYX_WEBHOOK_VERIFICATION_OPTIONAL",
|
||||
False,
|
||||
):
|
||||
result = await provider.verify_inbound_signature(
|
||||
"https://example.test/api/v1/telephony/inbound/run",
|
||||
json.loads(body),
|
||||
headers,
|
||||
body,
|
||||
)
|
||||
result = await provider.verify_inbound_signature(
|
||||
"https://example.test/api/v1/telephony/inbound/run",
|
||||
json.loads(body),
|
||||
headers,
|
||||
body,
|
||||
)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
# REMOVE-AFTER 2026-05-15: delete this whole test along with the
|
||||
# TELNYX_WEBHOOK_VERIFICATION_OPTIONAL flag.
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_inbound_signature_allows_missing_key_when_optional_flag_set():
|
||||
body = _body()
|
||||
_, headers = _signed_headers(body)
|
||||
provider = _provider()
|
||||
|
||||
with patch(
|
||||
"api.services.telephony.providers.telnyx.provider.TELNYX_WEBHOOK_VERIFICATION_OPTIONAL",
|
||||
True,
|
||||
):
|
||||
result = await provider.verify_inbound_signature(
|
||||
"https://example.test/api/v1/telephony/inbound/run",
|
||||
json.loads(body),
|
||||
headers,
|
||||
body,
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_inbound_signature_reads_headers_case_insensitively():
|
||||
body = _body()
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from dograh_sdk.typed import (
|
|||
Qa,
|
||||
StartCall,
|
||||
Trigger,
|
||||
Tuner,
|
||||
TypedNode,
|
||||
Webhook,
|
||||
)
|
||||
|
|
@ -50,6 +51,7 @@ def client() -> _StubClient:
|
|||
(Trigger, "trigger"),
|
||||
(Webhook, "webhook"),
|
||||
(Qa, "qa"),
|
||||
(Tuner, "tuner"),
|
||||
],
|
||||
ids=lambda v: v.__name__ if isinstance(v, type) else v,
|
||||
)
|
||||
|
|
@ -68,8 +70,15 @@ def test_typed_class_declares_spec_name(cls: type[TypedNode], expected_type: str
|
|||
inst = cls(name="t")
|
||||
elif cls is Webhook:
|
||||
inst = cls(name="wh")
|
||||
else: # Qa
|
||||
elif cls is Qa:
|
||||
inst = cls(name="qa")
|
||||
else: # Tuner
|
||||
inst = cls(
|
||||
name="tuner",
|
||||
tuner_agent_id="agent",
|
||||
tuner_workspace_id=1,
|
||||
tuner_api_key="secret",
|
||||
)
|
||||
assert inst.type == expected_type
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,37 @@ async def test_dto():
|
|||
assert dto is not None
|
||||
|
||||
|
||||
def test_dto_ignores_legacy_unknown_node_data_fields():
|
||||
dto = ReactFlowDTO.model_validate(
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "n1",
|
||||
"type": "startCall",
|
||||
"position": {"x": 0, "y": 0},
|
||||
"data": {
|
||||
"name": "Start",
|
||||
"prompt": "Hello",
|
||||
"is_static": True,
|
||||
"detect_voicemail": True,
|
||||
"wait_for_user_response": False,
|
||||
"wait_for_user_response_timeout": 2.5,
|
||||
"legacy_field": "ignored",
|
||||
},
|
||||
}
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
)
|
||||
|
||||
data = dto.nodes[0].data.model_dump()
|
||||
assert "is_static" not in data
|
||||
assert "detect_voicemail" not in data
|
||||
assert "wait_for_user_response" not in data
|
||||
assert "wait_for_user_response_timeout" not in data
|
||||
assert "legacy_field" not in data
|
||||
|
||||
|
||||
def test_sanitize_strips_ui_runtime_fields():
|
||||
definition = {
|
||||
"viewport": {"x": 0, "y": 0, "zoom": 1},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
from api.routes.webrtc_signaling import is_private_ip_candidate
|
||||
from api.enums import Environment
|
||||
from api.routes.webrtc_signaling import (
|
||||
NonRelayFilterPolicy,
|
||||
_keep_candidate,
|
||||
is_local_or_cgnat_ip,
|
||||
is_private_ip_candidate,
|
||||
resolve_ice_filter_policies,
|
||||
)
|
||||
|
||||
|
||||
class TestIsPrivateIpCandidate:
|
||||
|
|
@ -142,3 +149,78 @@ class TestIsPrivateIpCandidate:
|
|||
"candidate:999 1 tcp 1518280447 192.168.1.100 9 typ host tcptype active"
|
||||
)
|
||||
assert is_private_ip_candidate(candidate) is True
|
||||
|
||||
|
||||
class TestIsLocalOrCgnatIp:
|
||||
def test_loopback_is_local(self):
|
||||
assert is_local_or_cgnat_ip("127.0.0.1") is True
|
||||
|
||||
def test_link_local_is_local(self):
|
||||
assert is_local_or_cgnat_ip("169.254.1.1") is True
|
||||
|
||||
def test_cgnat_is_local(self):
|
||||
assert is_local_or_cgnat_ip("100.64.0.1") is True
|
||||
|
||||
def test_public_ipv4_is_not_local(self):
|
||||
assert is_local_or_cgnat_ip("8.8.8.8") is False
|
||||
|
||||
|
||||
class TestKeepCandidate:
|
||||
def test_private_relay_candidate_survives_private_policy(self):
|
||||
candidate = (
|
||||
"candidate:111 1 udp 41885439 192.168.1.50 50000 typ relay raddr 0.0.0.0 rport 0"
|
||||
)
|
||||
assert _keep_candidate(candidate, NonRelayFilterPolicy.PRIVATE) is True
|
||||
|
||||
def test_private_host_candidate_drops_under_private_policy(self):
|
||||
candidate = (
|
||||
"candidate:123 1 udp 2122260223 192.168.50.24 63603 typ host generation 0"
|
||||
)
|
||||
assert _keep_candidate(candidate, NonRelayFilterPolicy.PRIVATE) is False
|
||||
|
||||
|
||||
class TestResolveIceFilterPolicies:
|
||||
def test_local_deployment_keeps_all_candidates(self):
|
||||
outbound, inbound = resolve_ice_filter_policies(
|
||||
Environment.LOCAL.value,
|
||||
False,
|
||||
"",
|
||||
)
|
||||
assert outbound == NonRelayFilterPolicy.NONE
|
||||
assert inbound == NonRelayFilterPolicy.NONE
|
||||
|
||||
def test_private_lan_remote_keeps_all_candidates(self):
|
||||
outbound, inbound = resolve_ice_filter_policies(
|
||||
Environment.PRODUCTION.value,
|
||||
False,
|
||||
"192.168.50.24",
|
||||
)
|
||||
assert outbound == NonRelayFilterPolicy.NONE
|
||||
assert inbound == NonRelayFilterPolicy.NONE
|
||||
|
||||
def test_public_remote_filters_private_candidates(self):
|
||||
outbound, inbound = resolve_ice_filter_policies(
|
||||
Environment.PRODUCTION.value,
|
||||
False,
|
||||
"8.8.8.8",
|
||||
)
|
||||
assert outbound == NonRelayFilterPolicy.PRIVATE
|
||||
assert inbound == NonRelayFilterPolicy.PRIVATE
|
||||
|
||||
def test_force_turn_relay_stays_relay_only_on_private_lan(self):
|
||||
outbound, inbound = resolve_ice_filter_policies(
|
||||
Environment.PRODUCTION.value,
|
||||
True,
|
||||
"192.168.50.24",
|
||||
)
|
||||
assert outbound == NonRelayFilterPolicy.ALL
|
||||
assert inbound == NonRelayFilterPolicy.NONE
|
||||
|
||||
def test_force_turn_relay_keeps_public_remote_private_filter(self):
|
||||
outbound, inbound = resolve_ice_filter_policies(
|
||||
Environment.PRODUCTION.value,
|
||||
True,
|
||||
"8.8.8.8",
|
||||
)
|
||||
assert outbound == NonRelayFilterPolicy.ALL
|
||||
assert inbound == NonRelayFilterPolicy.PRIVATE
|
||||
|
|
|
|||
63
api/tests/test_mcp_auth.py
Normal file
63
api/tests/test_mcp_auth.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from api.mcp_server.auth import authenticate_mcp_request
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_mcp_request_accepts_bearer_authorization():
|
||||
user = MagicMock()
|
||||
user.id = 1
|
||||
user.selected_organization_id = 90
|
||||
|
||||
with (
|
||||
patch(
|
||||
"api.mcp_server.auth.get_http_headers",
|
||||
return_value={"authorization": "Bearer secret-api-key"},
|
||||
) as get_headers,
|
||||
patch(
|
||||
"api.mcp_server.auth._handle_api_key_auth",
|
||||
AsyncMock(return_value=user),
|
||||
) as handle_auth,
|
||||
):
|
||||
authed = await authenticate_mcp_request()
|
||||
|
||||
assert authed is user
|
||||
get_headers.assert_called_once_with(include={"authorization"})
|
||||
handle_auth.assert_awaited_once_with("secret-api-key")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_mcp_request_accepts_x_api_key():
|
||||
user = MagicMock()
|
||||
user.id = 2
|
||||
user.selected_organization_id = 91
|
||||
|
||||
with (
|
||||
patch(
|
||||
"api.mcp_server.auth.get_http_headers",
|
||||
return_value={"x-api-key": "secret-api-key"},
|
||||
) as get_headers,
|
||||
patch(
|
||||
"api.mcp_server.auth._handle_api_key_auth",
|
||||
AsyncMock(return_value=user),
|
||||
) as handle_auth,
|
||||
):
|
||||
authed = await authenticate_mcp_request()
|
||||
|
||||
assert authed is user
|
||||
get_headers.assert_called_once_with(include={"authorization"})
|
||||
handle_auth.assert_awaited_once_with("secret-api-key")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_mcp_request_rejects_missing_api_key():
|
||||
with patch("api.mcp_server.auth.get_http_headers", return_value={}) as get_headers:
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await authenticate_mcp_request()
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "Missing API key" in str(exc_info.value.detail)
|
||||
get_headers.assert_called_once_with(include={"authorization"})
|
||||
181
api/tests/test_mcp_custom_tool_manager.py
Normal file
181
api/tests/test_mcp_custom_tool_manager.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from api.enums import ToolCategory
|
||||
from api.services.workflow.mcp_tool_session import McpToolSession
|
||||
from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager
|
||||
from api.tests.support.mcp_mock_server import running_mcp_server
|
||||
|
||||
|
||||
def _mcp_tool():
|
||||
t = MagicMock()
|
||||
t.tool_uuid = "uuid-" + uuid.uuid4().hex[:8]
|
||||
t.name = "Acme MCP"
|
||||
t.category = ToolCategory.MCP.value
|
||||
t.definition = {"type": "mcp", "config": {"url": "https://x/mcp"}}
|
||||
return t
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tool_schemas_and_handler_for_mcp(monkeypatch):
|
||||
async with running_mcp_server() as base_url:
|
||||
tool = _mcp_tool()
|
||||
session = McpToolSession(
|
||||
tool_uuid=tool.tool_uuid,
|
||||
tool_name=tool.name,
|
||||
url=base_url,
|
||||
credential=None,
|
||||
tools_filter=[],
|
||||
timeout_secs=10,
|
||||
sse_read_timeout_secs=10,
|
||||
)
|
||||
await session.start()
|
||||
|
||||
engine = MagicMock()
|
||||
engine._mcp_sessions = {tool.tool_uuid: session}
|
||||
registered = {}
|
||||
reg_kwargs = {}
|
||||
|
||||
def _reg(name, fn, **kw):
|
||||
registered[name] = fn
|
||||
reg_kwargs[name] = kw
|
||||
|
||||
engine.llm.register_function = _reg
|
||||
|
||||
mgr = CustomToolManager(engine)
|
||||
mgr.get_organization_id = AsyncMock(return_value=42)
|
||||
|
||||
from api.db import db_client
|
||||
|
||||
monkeypatch.setattr(
|
||||
db_client, "get_tools_by_uuids", AsyncMock(return_value=[tool])
|
||||
)
|
||||
|
||||
try:
|
||||
schemas = await mgr.get_tool_schemas([tool.tool_uuid])
|
||||
names = sorted(s.name for s in schemas)
|
||||
assert names == ["mcp__acme_mcp__add", "mcp__acme_mcp__echo"]
|
||||
|
||||
await mgr.register_handlers([tool.tool_uuid])
|
||||
assert "mcp__acme_mcp__echo" in registered
|
||||
assert reg_kwargs["mcp__acme_mcp__echo"]["timeout_secs"] == pytest.approx(
|
||||
15.0
|
||||
)
|
||||
|
||||
captured = {}
|
||||
|
||||
class P:
|
||||
function_name = "mcp__acme_mcp__echo"
|
||||
arguments = {"text": "yo"}
|
||||
|
||||
async def result_callback(self, r, *, properties=None):
|
||||
captured["r"] = r
|
||||
|
||||
await registered["mcp__acme_mcp__echo"](P())
|
||||
assert "echo:yo" in str(captured["r"])
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unavailable_mcp_session_contributes_nothing(monkeypatch):
|
||||
tool = _mcp_tool()
|
||||
session = McpToolSession(
|
||||
tool_uuid=tool.tool_uuid,
|
||||
tool_name=tool.name,
|
||||
url="http://127.0.0.1:1/mcp",
|
||||
credential=None,
|
||||
tools_filter=[],
|
||||
timeout_secs=1,
|
||||
sse_read_timeout_secs=1,
|
||||
)
|
||||
await session.start() # degrades
|
||||
|
||||
engine = MagicMock()
|
||||
engine._mcp_sessions = {tool.tool_uuid: session}
|
||||
mgr = CustomToolManager(engine)
|
||||
mgr.get_organization_id = AsyncMock(return_value=42)
|
||||
|
||||
from api.db import db_client
|
||||
|
||||
monkeypatch.setattr(db_client, "get_tools_by_uuids", AsyncMock(return_value=[tool]))
|
||||
|
||||
schemas = await mgr.get_tool_schemas([tool.tool_uuid])
|
||||
assert schemas == []
|
||||
await mgr.register_handlers([tool.tool_uuid]) # must not raise
|
||||
|
||||
|
||||
def test_call_timeout_secs_is_read_timeout_plus_buffer():
|
||||
session = McpToolSession(
|
||||
tool_uuid="uuid-abc123",
|
||||
tool_name="Acme MCP",
|
||||
url="https://x/mcp",
|
||||
credential=None,
|
||||
tools_filter=[],
|
||||
timeout_secs=10,
|
||||
sse_read_timeout_secs=20,
|
||||
)
|
||||
assert session.call_timeout_secs == 25.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_node_mcp_filter_intersection(monkeypatch):
|
||||
async with running_mcp_server() as base_url:
|
||||
tool = _mcp_tool()
|
||||
session = McpToolSession(
|
||||
tool_uuid=tool.tool_uuid,
|
||||
tool_name=tool.name,
|
||||
url=base_url,
|
||||
credential=None,
|
||||
tools_filter=[],
|
||||
timeout_secs=10,
|
||||
sse_read_timeout_secs=10,
|
||||
)
|
||||
await session.start()
|
||||
|
||||
engine = MagicMock()
|
||||
engine._mcp_sessions = {tool.tool_uuid: session}
|
||||
registered = {}
|
||||
engine.llm.register_function = lambda name, fn, **kw: registered.__setitem__(
|
||||
name, fn
|
||||
)
|
||||
|
||||
mgr = CustomToolManager(engine)
|
||||
mgr.get_organization_id = AsyncMock(return_value=42)
|
||||
|
||||
from api.db import db_client
|
||||
|
||||
monkeypatch.setattr(
|
||||
db_client, "get_tools_by_uuids", AsyncMock(return_value=[tool])
|
||||
)
|
||||
try:
|
||||
# Allow only raw "echo" for this node
|
||||
filters = {tool.tool_uuid: ["echo"]}
|
||||
schemas = await mgr.get_tool_schemas(
|
||||
[tool.tool_uuid], mcp_tool_filters=filters
|
||||
)
|
||||
# Check only "echo" schema returned (namespaced name depends on tool.name)
|
||||
assert len(schemas) == 1
|
||||
assert all("echo" in s.name for s in schemas)
|
||||
|
||||
await mgr.register_handlers([tool.tool_uuid], mcp_tool_filters=filters)
|
||||
assert len(registered) == 1
|
||||
assert all("echo" in k for k in registered)
|
||||
|
||||
# No filter entry for this uuid = none (default-none)
|
||||
registered.clear()
|
||||
result = await mgr.get_tool_schemas([tool.tool_uuid], mcp_tool_filters={})
|
||||
assert result == []
|
||||
await mgr.register_handlers([tool.tool_uuid], mcp_tool_filters={})
|
||||
assert registered == {}
|
||||
|
||||
# mcp_tool_filters=None = backward-compatible (all tools)
|
||||
registered.clear()
|
||||
all_schemas = await mgr.get_tool_schemas([tool.tool_uuid])
|
||||
assert len(all_schemas) == 2 # both echo and add
|
||||
await mgr.register_handlers([tool.tool_uuid])
|
||||
assert len(registered) == 2 # both handlers registered
|
||||
finally:
|
||||
await session.close()
|
||||
359
api/tests/test_mcp_docs_search.py
Normal file
359
api/tests/test_mcp_docs_search.py
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
"""Unit tests for the MCP docs discovery tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from api.mcp_server.tools import docs_search as docs_search_module
|
||||
from api.mcp_server.tools.docs_search import (
|
||||
_docs_index,
|
||||
_extract_page_title,
|
||||
_resolve_docs_root,
|
||||
_score_page,
|
||||
_strip_frontmatter,
|
||||
_tokenize_query,
|
||||
list_docs,
|
||||
read_doc,
|
||||
search_docs,
|
||||
)
|
||||
|
||||
|
||||
def _clear_docs_caches() -> None:
|
||||
docs_search_module._docs_index.cache_clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_docs_root(tmp_path: Path) -> Path:
|
||||
docs_root = tmp_path / "docs"
|
||||
docs_root.mkdir()
|
||||
|
||||
(docs_root / "getting-started").mkdir()
|
||||
(docs_root / "getting-started" / "index.mdx").write_text(
|
||||
"---\n"
|
||||
'title: "Getting started"\n'
|
||||
'description: "Start using Dograh."\n'
|
||||
"---\n\n"
|
||||
"# Getting started\n\n"
|
||||
"Welcome to Dograh.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
(docs_root / "voice-agent").mkdir()
|
||||
(docs_root / "voice-agent" / "introduction.mdx").write_text(
|
||||
"---\n"
|
||||
'title: "Voice Agent Builder"\n'
|
||||
'description: "Build conversational workflows."\n'
|
||||
"---\n\n"
|
||||
"# Voice Agent Builder\n\n"
|
||||
"Build workflows with nodes and tools.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
(docs_root / "voice-agent" / "tools").mkdir()
|
||||
(docs_root / "voice-agent" / "tools" / "mcp-tool.mdx").write_text(
|
||||
"---\n"
|
||||
'title: "MCP Tool"\n'
|
||||
'description: "Connect external MCP servers."\n'
|
||||
'llm_hint: "Use for MCP server setup, remote tools, or model context protocol questions."\n'
|
||||
"aliases:\n"
|
||||
' - "model context protocol"\n'
|
||||
"---\n\n"
|
||||
"# MCP Tool\n\n"
|
||||
"Connect an external MCP server to your voice agent.\n\n"
|
||||
"## Authentication\n\n"
|
||||
"Provide the MCP endpoint URL and headers.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
(docs_root / "deployment").mkdir()
|
||||
(docs_root / "deployment" / "docker.mdx").write_text(
|
||||
"---\n"
|
||||
'title: "Docker"\n'
|
||||
'description: "Deploy Dograh with Docker."\n'
|
||||
'llm_hint: "Use for Docker deployment, local setup, remote setup, TURN server, coturn, or WebRTC connectivity questions."\n'
|
||||
"aliases:\n"
|
||||
' - "coturn"\n'
|
||||
' - "turn server"\n'
|
||||
"---\n\n"
|
||||
"# Docker\n\n"
|
||||
"Run Dograh with Docker.\n\n"
|
||||
"## Troubleshooting WebRTC Connectivity\n\n"
|
||||
"If audio fails or ICE fails, configure a TURN server. Coturn is the recommended choice.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Hidden/orphaned docs page: present on disk but not in docs.json, so it
|
||||
# must not be indexed by the MCP tools.
|
||||
(docs_root / "internal-only.mdx").write_text(
|
||||
"---\n"
|
||||
'title: "Internal TURN Notes"\n'
|
||||
"---\n\n"
|
||||
"# Internal TURN Notes\n\n"
|
||||
"This page mentions zyxinternalturntoken but is not user-facing.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
(docs_root / "AGENTS.md").write_text("# Internal instructions\n", encoding="utf-8")
|
||||
|
||||
(docs_root / "docs.json").write_text(
|
||||
"""{
|
||||
"navigation": {
|
||||
"tabs": [
|
||||
{
|
||||
"tab": "Guides",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Getting started",
|
||||
"pages": [
|
||||
"getting-started/index"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Voice Agent Builder",
|
||||
"pages": [
|
||||
"voice-agent/introduction",
|
||||
{
|
||||
"group": "Tools",
|
||||
"pages": [
|
||||
"voice-agent/tools/mcp-tool"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "Developer",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Deployment",
|
||||
"pages": [
|
||||
"deployment/docker"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
_clear_docs_caches()
|
||||
with patch.dict(os.environ, {"DOGRAH_DOCS_PATH": str(docs_root)}):
|
||||
yield docs_root
|
||||
_clear_docs_caches()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authed_user():
|
||||
class _FakeUser:
|
||||
selected_organization_id = 1
|
||||
id = 42
|
||||
|
||||
with patch(
|
||||
"api.mcp_server.tools.docs_search.authenticate_mcp_request",
|
||||
new=AsyncMock(return_value=_FakeUser()),
|
||||
):
|
||||
yield _FakeUser()
|
||||
|
||||
|
||||
def test_tokenize_query_dedupes_and_drops_stopwords():
|
||||
assert _tokenize_query("How do I configure a TURN server TURN?") == [
|
||||
"configure",
|
||||
"turn",
|
||||
"server",
|
||||
]
|
||||
|
||||
|
||||
def test_tokenize_query_empty_input_returns_empty():
|
||||
assert _tokenize_query("") == []
|
||||
assert _tokenize_query("?? // !!") == []
|
||||
|
||||
|
||||
def test_strip_frontmatter_removes_yaml_block():
|
||||
body = '---\ntitle: "X"\n---\n\n# Heading\n'
|
||||
assert _strip_frontmatter(body).startswith("# Heading")
|
||||
|
||||
|
||||
def test_extract_page_title_prefers_frontmatter():
|
||||
body = '---\ntitle: "Front Title"\n---\n\n# Heading Title\n'
|
||||
assert _extract_page_title(body, fallback="x.mdx") == "Front Title"
|
||||
|
||||
|
||||
def test_extract_page_title_falls_back_to_first_heading():
|
||||
body = "# Heading Title\nbody\n"
|
||||
assert _extract_page_title(body, fallback="x.mdx") == "Heading Title"
|
||||
|
||||
|
||||
def test_score_page_uses_llm_hint_and_aliases():
|
||||
page = docs_search_module.DocPage(
|
||||
path="deployment/docker",
|
||||
file_path="deployment/docker.mdx",
|
||||
title="Docker",
|
||||
description="Deploy Dograh with Docker.",
|
||||
llm_hint="Use for TURN server and coturn setup.",
|
||||
aliases=("coturn",),
|
||||
breadcrumb=("Developer", "Deployment"),
|
||||
content="Docker deployment.",
|
||||
sections=(
|
||||
docs_search_module.DocSection(
|
||||
title="Troubleshooting WebRTC Connectivity",
|
||||
slug="troubleshooting-webrtc-connectivity",
|
||||
level=2,
|
||||
content="Configure a TURN server with coturn.",
|
||||
),
|
||||
),
|
||||
order=0,
|
||||
)
|
||||
score, section = _score_page(page, ["coturn"])
|
||||
assert score > 0
|
||||
assert section is not None
|
||||
assert section.slug == "troubleshooting-webrtc-connectivity"
|
||||
|
||||
|
||||
def test_resolve_docs_root_honors_env_override(tmp_path: Path):
|
||||
docs = tmp_path / "custom_docs"
|
||||
docs.mkdir()
|
||||
(docs / "docs.json").write_text("{}", encoding="utf-8")
|
||||
with patch.dict(os.environ, {"DOGRAH_DOCS_PATH": str(docs)}):
|
||||
assert _resolve_docs_root() == docs.resolve()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_docs_ranks_turn_doc_and_uses_route_path(
|
||||
fake_docs_root, authed_user
|
||||
):
|
||||
results = await search_docs("How do I configure coturn for WebRTC?")
|
||||
assert results
|
||||
assert results[0]["path"] == "deployment/docker"
|
||||
assert results[0]["section_slug"] == "troubleshooting-webrtc-connectivity"
|
||||
assert "TURN server" in results[0]["llm_hint"]
|
||||
assert "snippet" not in results[0]
|
||||
assert "score" not in results[0]
|
||||
assert "url" not in results[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_docs_indexes_only_docs_json_pages(fake_docs_root, authed_user):
|
||||
results = await search_docs("zyxinternalturntoken")
|
||||
assert results == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_docs_respects_limit(fake_docs_root, authed_user):
|
||||
results = await search_docs("dograh", limit=1)
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_docs_returns_empty_when_no_match(fake_docs_root, authed_user):
|
||||
assert await search_docs("xyzzy unrelated zzz") == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_docs_returns_empty_when_no_corpus(
|
||||
tmp_path, authed_user, monkeypatch
|
||||
):
|
||||
nonexistent = tmp_path / "no-docs-here"
|
||||
monkeypatch.setenv("DOGRAH_DOCS_PATH", str(nonexistent))
|
||||
_clear_docs_caches()
|
||||
with patch(
|
||||
"api.mcp_server.tools.docs_search._resolve_docs_root", return_value=None
|
||||
):
|
||||
assert await search_docs("anything") == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_docs_rejects_empty_query(fake_docs_root, authed_user):
|
||||
with pytest.raises(ValueError, match="non-empty string"):
|
||||
await search_docs("")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_docs_rejects_query_with_only_stopwords(
|
||||
fake_docs_root, authed_user
|
||||
):
|
||||
with pytest.raises(ValueError, match="non-stopword"):
|
||||
await search_docs("how do I")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_docs_rejects_zero_limit(fake_docs_root, authed_user):
|
||||
with pytest.raises(ValueError, match="at least 1"):
|
||||
await search_docs("Dograh", limit=0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_docs_returns_top_level_sections(fake_docs_root, authed_user):
|
||||
results = await list_docs()
|
||||
assert results[0]["kind"] == "section"
|
||||
assert results[0]["path"] == "guides/getting-started"
|
||||
assert results[1]["path"] == "guides/voice-agent-builder"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_docs_depth_expands_children(fake_docs_root, authed_user):
|
||||
results = await list_docs("guides/voice-agent-builder", depth=2)
|
||||
paths = [item["path"] for item in results]
|
||||
assert "voice-agent/introduction" in paths
|
||||
assert "guides/voice-agent-builder/tools" in paths
|
||||
assert "voice-agent/tools/mcp-tool" in paths
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_docs_rejects_unknown_section(fake_docs_root, authed_user):
|
||||
with pytest.raises(HTTPException, match="Unknown docs section"):
|
||||
await list_docs("nope")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_doc_returns_full_page_and_sections(fake_docs_root, authed_user):
|
||||
result = await read_doc("deployment/docker")
|
||||
assert result["path"] == "deployment/docker"
|
||||
assert result["title"] == "Docker"
|
||||
assert "url" not in result
|
||||
section_slugs = [section["slug"] for section in result["sections"]]
|
||||
assert "docker" in section_slugs
|
||||
assert "troubleshooting-webrtc-connectivity" in section_slugs
|
||||
assert "Coturn" in result["content"] or "coturn" in result["content"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_doc_can_target_section(fake_docs_root, authed_user):
|
||||
result = await read_doc(
|
||||
"deployment/docker",
|
||||
section="troubleshooting-webrtc-connectivity",
|
||||
)
|
||||
assert result["section_slug"] == "troubleshooting-webrtc-connectivity"
|
||||
assert "ICE fails" in result["content"] or "TURN server" in result["content"]
|
||||
assert "Run Dograh with Docker." not in result["content"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_doc_rejects_unknown_page(fake_docs_root, authed_user):
|
||||
with pytest.raises(HTTPException, match="Unknown docs page"):
|
||||
await read_doc("missing/page")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_doc_rejects_unknown_section(fake_docs_root, authed_user):
|
||||
with pytest.raises(HTTPException, match="Unknown section"):
|
||||
await read_doc("deployment/docker", section="missing-section")
|
||||
|
||||
|
||||
def test_docs_index_uses_docs_json_navigation(fake_docs_root):
|
||||
index = _docs_index()
|
||||
assert "internal-only" not in index.pages_by_path
|
||||
assert "guides/voice-agent-builder/tools" in index.sections_by_path
|
||||
assert index.pages_by_path["voice-agent/tools/mcp-tool"].breadcrumb == (
|
||||
"Guides",
|
||||
"Voice Agent Builder",
|
||||
"Tools",
|
||||
)
|
||||
115
api/tests/test_mcp_instructions_drift.py
Normal file
115
api/tests/test_mcp_instructions_drift.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""Drift guards between the static MCP guide and the live tool surface.
|
||||
|
||||
`api/mcp_server/instructions.py` is free text baked into the client
|
||||
system prompt. It is *not* the authoritative description of the tools —
|
||||
names, signatures, and per-tool error codes reach the model dynamically
|
||||
via `tools/list`, derived from each tool's own function signature and
|
||||
docstring. These tests fail on the two classic drift modes:
|
||||
|
||||
1. The guide references a tool that is no longer registered (renamed or
|
||||
removed) — the model would be told to call something that 404s.
|
||||
2. A tool returns an `error_code` that is absent from the description it
|
||||
ships via `tools/list` — the model can't learn to recover from it.
|
||||
|
||||
Keep the guide about orchestration (call order, hard constraints) and let
|
||||
the tools describe themselves.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from api.mcp_server import instructions as instructions_module
|
||||
from api.mcp_server.server import mcp
|
||||
from api.mcp_server.tools import create_workflow as create_workflow_module
|
||||
from api.mcp_server.tools import save_workflow as save_workflow_module
|
||||
|
||||
# Every registered MCP tool name starts with one of these verbs. A
|
||||
# backticked snake_case token in the guide whose leading word is a verb is
|
||||
# treated as a tool reference; field/reference names like `tool_refs`,
|
||||
# `credential_ref`, or `pre_call_fetch` don't start with a verb and are
|
||||
# correctly ignored. Extend this only when a new tool introduces a new
|
||||
# leading verb (a missing verb under-checks, it never false-fails).
|
||||
_TOOL_VERB_PREFIXES = frozenset(
|
||||
{
|
||||
"search",
|
||||
"read",
|
||||
"list",
|
||||
"get",
|
||||
"save",
|
||||
"create",
|
||||
"update",
|
||||
"delete",
|
||||
"add",
|
||||
"remove",
|
||||
"set",
|
||||
}
|
||||
)
|
||||
|
||||
# A backtick immediately followed by a snake_case identifier (>= 1
|
||||
# underscore). Anchoring on the opening backtick captures the leading
|
||||
# identifier of a code span whether it is bare (`read_doc`) or a call
|
||||
# (`read_doc(path)`), while skipping DSL constructs like `wf.edge` or
|
||||
# `new Workflow` whose first char after the backtick isn't `[a-z_]`.
|
||||
_BACKTICKED_SNAKE_RE = re.compile(r"`([a-z][a-z0-9]*(?:_[a-z0-9]+)+)")
|
||||
|
||||
# Error codes are emitted as the first string arg to `_error_result(...)`.
|
||||
_ERROR_RESULT_LITERAL_RE = re.compile(r'_error_result\(\s*"([a-z_]+)"')
|
||||
# `parse_error` / `validation_error` are picked by a `code_key` ternary
|
||||
# rather than passed as a literal to `_error_result`, so match them too.
|
||||
_CODE_KEY_LITERAL_RE = re.compile(r'"(parse_error|validation_error)"')
|
||||
|
||||
|
||||
def _referenced_tool_names(text: str) -> set[str]:
|
||||
return {
|
||||
token
|
||||
for token in _BACKTICKED_SNAKE_RE.findall(text)
|
||||
if token.split("_", 1)[0] in _TOOL_VERB_PREFIXES
|
||||
}
|
||||
|
||||
|
||||
def _returned_error_codes(module) -> set[str]:
|
||||
source = Path(module.__file__).read_text(encoding="utf-8")
|
||||
return set(_ERROR_RESULT_LITERAL_RE.findall(source)) | set(
|
||||
_CODE_KEY_LITERAL_RE.findall(source)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_guide_only_references_registered_tools():
|
||||
registered = {tool.name for tool in await mcp.list_tools()}
|
||||
referenced = _referenced_tool_names(instructions_module.DOGRAH_MCP_INSTRUCTIONS)
|
||||
|
||||
assert referenced, "no tool references extracted — the regex likely broke"
|
||||
unknown = sorted(referenced - registered)
|
||||
assert not unknown, (
|
||||
f"instructions.py references tools that are not registered: {unknown}. "
|
||||
f"Rename/remove the reference or register the tool. "
|
||||
f"Registered tools: {sorted(registered)}."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"tool_name, module",
|
||||
[
|
||||
("save_workflow", save_workflow_module),
|
||||
("create_workflow", create_workflow_module),
|
||||
],
|
||||
)
|
||||
async def test_tool_documents_every_error_code_it_returns(tool_name, module):
|
||||
descriptions = {
|
||||
tool.name: tool.description or "" for tool in await mcp.list_tools()
|
||||
}
|
||||
description = descriptions[tool_name]
|
||||
returned = _returned_error_codes(module)
|
||||
|
||||
assert returned, f"no error codes detected in {tool_name} source — regex broke"
|
||||
undocumented = sorted(code for code in returned if code not in description)
|
||||
assert not undocumented, (
|
||||
f"{tool_name} returns error_code(s) {undocumented} absent from the description "
|
||||
f"shipped via tools/list. Document them in the {tool_name} docstring."
|
||||
)
|
||||
107
api/tests/test_mcp_integration.py
Normal file
107
api/tests/test_mcp_integration.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from api.enums import ToolCategory
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
from api.tests.support.mcp_mock_server import running_mcp_server
|
||||
|
||||
|
||||
def _mcp_tool(url: str):
|
||||
t = MagicMock()
|
||||
t.tool_uuid = "uuid-" + uuid.uuid4().hex[:8]
|
||||
t.name = "Acme MCP"
|
||||
t.category = ToolCategory.MCP.value
|
||||
t.definition = {
|
||||
"schema_version": 1,
|
||||
"type": "mcp",
|
||||
"config": {"transport": "streamable_http", "url": url},
|
||||
}
|
||||
return t
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_engine_opens_and_closes_mcp_sessions(monkeypatch):
|
||||
async with running_mcp_server() as base_url:
|
||||
tool = _mcp_tool(base_url)
|
||||
|
||||
engine = PipecatEngine.__new__(PipecatEngine)
|
||||
node = MagicMock()
|
||||
node.tool_uuids = [tool.tool_uuid]
|
||||
workflow = MagicMock()
|
||||
workflow.nodes = {"n1": node}
|
||||
engine.workflow = workflow
|
||||
engine._mcp_sessions = {}
|
||||
|
||||
from api.db import db_client
|
||||
|
||||
monkeypatch.setattr(
|
||||
db_client, "get_tools_by_uuids", AsyncMock(return_value=[tool])
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
db_client, "get_credential_by_uuid", AsyncMock(return_value=None)
|
||||
)
|
||||
engine._get_organization_id = AsyncMock(return_value=42)
|
||||
|
||||
await engine._open_mcp_sessions()
|
||||
try:
|
||||
assert tool.tool_uuid in engine._mcp_sessions
|
||||
sess = engine._mcp_sessions[tool.tool_uuid]
|
||||
assert sess.available is True
|
||||
assert len(sess.function_schemas()) == 2
|
||||
finally:
|
||||
await engine._close_mcp_sessions()
|
||||
assert engine._mcp_sessions == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_mcp_sessions_swallows_db_error(monkeypatch):
|
||||
engine = PipecatEngine.__new__(PipecatEngine)
|
||||
node = MagicMock()
|
||||
node.tool_uuids = ["uuid-deadbeef"]
|
||||
workflow = MagicMock()
|
||||
workflow.nodes = {"n1": node}
|
||||
engine.workflow = workflow
|
||||
engine._mcp_sessions = {}
|
||||
|
||||
from api.db import db_client
|
||||
|
||||
monkeypatch.setattr(
|
||||
db_client,
|
||||
"get_tools_by_uuids",
|
||||
AsyncMock(side_effect=RuntimeError("db down")),
|
||||
)
|
||||
engine._get_organization_id = AsyncMock(return_value=42)
|
||||
|
||||
# Must NOT raise
|
||||
await engine._open_mcp_sessions()
|
||||
assert engine._mcp_sessions == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_mcp_sessions_skips_tool_when_credential_fetch_fails(monkeypatch):
|
||||
tool = _mcp_tool("http://127.0.0.1:1/mcp")
|
||||
tool.definition["config"]["credential_uuid"] = "cred-1234"
|
||||
|
||||
engine = PipecatEngine.__new__(PipecatEngine)
|
||||
node = MagicMock()
|
||||
node.tool_uuids = [tool.tool_uuid]
|
||||
workflow = MagicMock()
|
||||
workflow.nodes = {"n1": node}
|
||||
engine.workflow = workflow
|
||||
engine._mcp_sessions = {}
|
||||
|
||||
from api.db import db_client
|
||||
|
||||
monkeypatch.setattr(db_client, "get_tools_by_uuids", AsyncMock(return_value=[tool]))
|
||||
monkeypatch.setattr(
|
||||
db_client,
|
||||
"get_credential_by_uuid",
|
||||
AsyncMock(side_effect=RuntimeError("cred store down")),
|
||||
)
|
||||
engine._get_organization_id = AsyncMock(return_value=42)
|
||||
|
||||
# Must NOT raise, and must skip the tool (no futile unauthenticated start)
|
||||
await engine._open_mcp_sessions()
|
||||
assert engine._mcp_sessions == {}
|
||||
112
api/tests/test_mcp_tool_definition.py
Normal file
112
api/tests/test_mcp_tool_definition.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
from api.enums import ToolCategory
|
||||
from api.routes.tool import McpToolConfig as RouteMcpToolConfig
|
||||
from api.routes.tool import McpToolDefinition as RouteMcpToolDefinition
|
||||
from api.services.workflow.tools.mcp_tool import (
|
||||
McpDefinitionError,
|
||||
McpToolConfig,
|
||||
McpToolDefinition,
|
||||
namespace_function_name,
|
||||
validate_mcp_definition,
|
||||
)
|
||||
|
||||
|
||||
def test_mcp_category_exists():
|
||||
assert ToolCategory.MCP.value == "mcp"
|
||||
assert ToolCategory("mcp") is ToolCategory.MCP
|
||||
|
||||
|
||||
def test_mcp_migration_present_and_chained(monkeypatch):
|
||||
mod = importlib.import_module(
|
||||
"api.alembic.versions.0a1b2c3d4e5f_add_mcp_in_toolcategory"
|
||||
)
|
||||
assert mod.revision == "0a1b2c3d4e5f"
|
||||
assert mod.down_revision == "4c1f1e3e8ef2"
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_sync_enum_values(**kwargs):
|
||||
calls.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(mod.op, "sync_enum_values", fake_sync_enum_values)
|
||||
|
||||
mod.upgrade()
|
||||
mod.downgrade()
|
||||
|
||||
assert len(calls) == 2
|
||||
assert calls[0]["enum_name"] == "tool_category"
|
||||
assert "mcp" in calls[0]["new_values"]
|
||||
assert "mcp" not in calls[1]["new_values"]
|
||||
|
||||
|
||||
def test_route_reuses_shared_mcp_models():
|
||||
assert RouteMcpToolConfig is McpToolConfig
|
||||
assert RouteMcpToolDefinition is McpToolDefinition
|
||||
|
||||
|
||||
def test_validate_mcp_definition_ok():
|
||||
cfg = validate_mcp_definition(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"type": "mcp",
|
||||
"config": {
|
||||
"transport": "streamable_http",
|
||||
"url": "https://acme.example.com/mcp",
|
||||
"credential_uuid": "cred-123",
|
||||
"tools_filter": ["lookup_patient"],
|
||||
"timeout_secs": 30,
|
||||
"sse_read_timeout_secs": 300,
|
||||
},
|
||||
}
|
||||
)
|
||||
assert cfg["url"] == "https://acme.example.com/mcp"
|
||||
assert cfg["transport"] == "streamable_http"
|
||||
assert cfg["tools_filter"] == ["lookup_patient"]
|
||||
assert cfg["timeout_secs"] == 30
|
||||
assert cfg["sse_read_timeout_secs"] == 300
|
||||
assert cfg["credential_uuid"] == "cred-123"
|
||||
|
||||
|
||||
def test_validate_mcp_definition_defaults():
|
||||
cfg = validate_mcp_definition({"type": "mcp", "config": {"url": "https://x/mcp"}})
|
||||
assert cfg["transport"] == "streamable_http"
|
||||
assert cfg["tools_filter"] == []
|
||||
assert cfg["timeout_secs"] == 30
|
||||
assert cfg["sse_read_timeout_secs"] == 300
|
||||
assert cfg["credential_uuid"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"definition",
|
||||
[
|
||||
{"type": "mcp", "config": {}},
|
||||
{"type": "mcp", "config": {"url": ""}},
|
||||
{"type": "mcp", "config": {"url": "ftp://x"}},
|
||||
{"type": "mcp"},
|
||||
{"type": "mcp", "config": {"url": "https://x", "transport": "stdio"}},
|
||||
],
|
||||
)
|
||||
def test_validate_mcp_definition_rejects(definition):
|
||||
with pytest.raises(McpDefinitionError):
|
||||
validate_mcp_definition(definition)
|
||||
|
||||
|
||||
def test_validate_mcp_definition_zero_timeout_preserved():
|
||||
cfg = validate_mcp_definition(
|
||||
{"type": "mcp", "config": {"url": "https://x/mcp", "timeout_secs": 0}}
|
||||
)
|
||||
assert cfg["timeout_secs"] == 0
|
||||
|
||||
|
||||
def test_namespace_function_name():
|
||||
assert (
|
||||
namespace_function_name("Acme MCP", "lookup_patient")
|
||||
== "mcp__acme_mcp__lookup_patient"
|
||||
)
|
||||
assert (
|
||||
namespace_function_name("", "ping", fallback="abcd1234")
|
||||
== "mcp__abcd1234__ping"
|
||||
)
|
||||
437
api/tests/test_mcp_tool_route.py
Normal file
437
api/tests/test_mcp_tool_route.py
Normal file
|
|
@ -0,0 +1,437 @@
|
|||
"""Route-level tests for the MCP tool definition schema.
|
||||
|
||||
These tests exercise the Pydantic request models (CreateToolRequest /
|
||||
UpdateToolRequest) to catch schema gaps at the route/request-model layer —
|
||||
the layer where the pre-fix defect lived (HTTP 422 on every MCP tool
|
||||
creation attempt).
|
||||
|
||||
Test coverage:
|
||||
- CreateToolRequest validates a valid MCP definition (was 422 before Part A).
|
||||
- UpdateToolRequest validates a valid MCP definition.
|
||||
- Invalid MCP bodies are rejected (ftp:// url, missing url).
|
||||
- Round-trip: validated definition dict passes through validate_mcp_definition
|
||||
unchanged, proving the request schema and call-time validator agree.
|
||||
- Full HTTP round-trip via the ASGI test client (POST /api/v1/tools/).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from api.routes.tool import CreateToolRequest, McpToolDefinition, UpdateToolRequest
|
||||
from api.services.workflow.tools.mcp_tool import (
|
||||
validate_mcp_definition,
|
||||
)
|
||||
|
||||
# ── Canonical valid MCP request body ─────────────────────────────────────────
|
||||
|
||||
VALID_MCP_DEFINITION = {
|
||||
"schema_version": 1,
|
||||
"type": "mcp",
|
||||
"config": {
|
||||
"transport": "streamable_http",
|
||||
"url": "https://x/mcp",
|
||||
"credential_uuid": None,
|
||||
"tools_filter": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── Part A regression: CreateToolRequest / UpdateToolRequest validation ───────
|
||||
|
||||
|
||||
def test_create_tool_request_accepts_mcp_definition():
|
||||
"""CreateToolRequest must accept an MCP definition (was HTTP 422 before fix)."""
|
||||
req = CreateToolRequest(
|
||||
name="My MCP Tool",
|
||||
description="Integration via MCP",
|
||||
category="mcp",
|
||||
definition=VALID_MCP_DEFINITION,
|
||||
)
|
||||
assert isinstance(req.definition, McpToolDefinition)
|
||||
assert req.definition.type == "mcp"
|
||||
assert req.definition.config.url == "https://x/mcp"
|
||||
assert req.definition.config.transport == "streamable_http"
|
||||
assert req.definition.config.credential_uuid is None
|
||||
assert req.definition.config.tools_filter == []
|
||||
assert req.definition.config.timeout_secs == 30
|
||||
assert req.definition.config.sse_read_timeout_secs == 300
|
||||
|
||||
|
||||
def test_update_tool_request_accepts_mcp_definition():
|
||||
"""UpdateToolRequest must also accept an MCP definition."""
|
||||
req = UpdateToolRequest(
|
||||
name="Updated MCP Tool",
|
||||
definition=VALID_MCP_DEFINITION,
|
||||
)
|
||||
assert isinstance(req.definition, McpToolDefinition)
|
||||
assert req.definition.type == "mcp"
|
||||
assert req.definition.config.url == "https://x/mcp"
|
||||
|
||||
|
||||
def test_create_tool_request_accepts_mcp_with_all_fields():
|
||||
"""All optional MCP config fields are accepted and preserved."""
|
||||
req = CreateToolRequest(
|
||||
name="Full MCP Tool",
|
||||
category="mcp",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": "mcp",
|
||||
"config": {
|
||||
"transport": "streamable_http",
|
||||
"url": "https://acme.example.com/mcp",
|
||||
"credential_uuid": "cred-abc-123",
|
||||
"tools_filter": ["lookup_patient", "schedule_appointment"],
|
||||
"timeout_secs": 60,
|
||||
"sse_read_timeout_secs": 600,
|
||||
},
|
||||
},
|
||||
)
|
||||
cfg = req.definition.config # type: ignore[union-attr]
|
||||
assert cfg.url == "https://acme.example.com/mcp"
|
||||
assert cfg.credential_uuid == "cred-abc-123"
|
||||
assert cfg.tools_filter == ["lookup_patient", "schedule_appointment"]
|
||||
assert cfg.timeout_secs == 60
|
||||
assert cfg.sse_read_timeout_secs == 600
|
||||
|
||||
|
||||
# ── Invalid bodies are rejected ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"definition",
|
||||
[
|
||||
# ftp:// URL — rejected by McpToolConfig.validate_url
|
||||
{
|
||||
"schema_version": 1,
|
||||
"type": "mcp",
|
||||
"config": {"transport": "streamable_http", "url": "ftp://x/mcp"},
|
||||
},
|
||||
# Empty url — rejected by McpToolConfig.validate_url
|
||||
{
|
||||
"schema_version": 1,
|
||||
"type": "mcp",
|
||||
"config": {"transport": "streamable_http", "url": ""},
|
||||
},
|
||||
# Missing url — rejected by McpToolConfig (required field)
|
||||
{
|
||||
"schema_version": 1,
|
||||
"type": "mcp",
|
||||
"config": {"transport": "streamable_http"},
|
||||
},
|
||||
# Unsupported transport — rejected because Literal["streamable_http"] constraint
|
||||
{
|
||||
"schema_version": 1,
|
||||
"type": "mcp",
|
||||
"config": {"url": "https://x/mcp", "transport": "stdio"},
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_create_tool_request_rejects_invalid_mcp_definition(definition):
|
||||
"""Invalid MCP definitions must raise ValidationError."""
|
||||
with pytest.raises(ValidationError):
|
||||
CreateToolRequest(
|
||||
name="Bad MCP Tool",
|
||||
category="mcp",
|
||||
definition=definition,
|
||||
)
|
||||
|
||||
|
||||
# ── Round-trip compatibility: request schema ↔ validate_mcp_definition ───────
|
||||
|
||||
|
||||
def test_mcp_definition_round_trips_through_validate_mcp_definition():
|
||||
"""The dict produced by CreateToolRequest.definition.model_dump() must be
|
||||
accepted by validate_mcp_definition without raising, and the result must
|
||||
contain the expected fields. This proves the request-layer schema and the
|
||||
call-time validator agree on the stored config shape."""
|
||||
req = CreateToolRequest(
|
||||
name="Round-Trip MCP Tool",
|
||||
category="mcp",
|
||||
definition={
|
||||
"schema_version": 1,
|
||||
"type": "mcp",
|
||||
"config": {
|
||||
"transport": "streamable_http",
|
||||
"url": "https://roundtrip.example.com/mcp",
|
||||
"credential_uuid": "cred-rt-456",
|
||||
"tools_filter": ["ping"],
|
||||
"timeout_secs": 45,
|
||||
"sse_read_timeout_secs": 400,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# Simulate what the route does: persist definition as a plain dict
|
||||
persisted = req.definition.model_dump() # type: ignore[union-attr]
|
||||
|
||||
# validate_mcp_definition must accept the persisted shape without raising
|
||||
normalized = validate_mcp_definition(persisted)
|
||||
|
||||
assert normalized["url"] == "https://roundtrip.example.com/mcp"
|
||||
assert normalized["transport"] == "streamable_http"
|
||||
assert normalized["credential_uuid"] == "cred-rt-456"
|
||||
assert normalized["tools_filter"] == ["ping"]
|
||||
assert normalized["timeout_secs"] == 45
|
||||
assert normalized["sse_read_timeout_secs"] == 400
|
||||
|
||||
|
||||
def test_mcp_definition_round_trip_defaults():
|
||||
"""Round-trip with minimal body: defaults fill in correctly and
|
||||
validate_mcp_definition agrees on them."""
|
||||
req = CreateToolRequest(
|
||||
name="Minimal MCP Tool",
|
||||
category="mcp",
|
||||
definition=VALID_MCP_DEFINITION,
|
||||
)
|
||||
|
||||
persisted = req.definition.model_dump() # type: ignore[union-attr]
|
||||
normalized = validate_mcp_definition(persisted)
|
||||
|
||||
assert normalized["transport"] == "streamable_http"
|
||||
assert normalized["tools_filter"] == []
|
||||
assert normalized["timeout_secs"] == 30
|
||||
assert normalized["sse_read_timeout_secs"] == 300
|
||||
assert normalized["credential_uuid"] is None
|
||||
# Part B: auth_header / auth_scheme must NOT be present in the normalized
|
||||
# config dict (they were dead config removed in the fix)
|
||||
assert "auth_header" not in normalized
|
||||
assert "auth_scheme" not in normalized
|
||||
|
||||
|
||||
# ── Full HTTP round-trip via ASGI test client ─────────────────────────────────
|
||||
|
||||
|
||||
async def test_post_tool_mcp_returns_200(test_client_factory, db_session):
|
||||
"""POST /api/v1/tools/ with an MCP definition must return HTTP 200 and
|
||||
persist the definition with type='mcp'. Before Part A this always
|
||||
returned 422."""
|
||||
# Create a user and an organization, then link them so the route's
|
||||
# selected_organization_id check passes.
|
||||
user, _ = await db_session.get_or_create_user_by_provider_id("mcp_route_test_user")
|
||||
org, _ = await db_session.get_or_create_organization_by_provider_id(
|
||||
"mcp_route_test_org", user.id
|
||||
)
|
||||
await db_session.update_user_selected_organization(user.id, org.id)
|
||||
# Reload the user so selected_organization_id is populated on the object.
|
||||
user = await db_session.get_user_by_id(user.id)
|
||||
|
||||
async with test_client_factory(user) as client:
|
||||
response = await client.post(
|
||||
"/api/v1/tools/",
|
||||
json={
|
||||
"name": "HTTP Round-Trip MCP Tool",
|
||||
"description": "Testing the full route",
|
||||
"category": "mcp",
|
||||
"definition": {
|
||||
"schema_version": 1,
|
||||
"type": "mcp",
|
||||
"config": {
|
||||
"transport": "streamable_http",
|
||||
"url": "https://roundtrip.example.com/mcp",
|
||||
"credential_uuid": None,
|
||||
"tools_filter": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, (
|
||||
f"Expected 200, got {response.status_code}: {response.text}"
|
||||
)
|
||||
body = response.json()
|
||||
assert body["definition"]["type"] == "mcp"
|
||||
assert body["definition"]["config"]["url"] == "https://roundtrip.example.com/mcp"
|
||||
assert body["category"] == "mcp"
|
||||
|
||||
|
||||
async def test_post_tool_mcp_invalid_url_returns_422(test_client_factory, db_session):
|
||||
"""POST /api/v1/tools/ with an ftp:// URL must return HTTP 422."""
|
||||
user, _ = await db_session.get_or_create_user_by_provider_id(
|
||||
"mcp_route_test_user_422"
|
||||
)
|
||||
org, _ = await db_session.get_or_create_organization_by_provider_id(
|
||||
"mcp_route_test_org_422", user.id
|
||||
)
|
||||
await db_session.update_user_selected_organization(user.id, org.id)
|
||||
user = await db_session.get_user_by_id(user.id)
|
||||
|
||||
async with test_client_factory(user) as client:
|
||||
response = await client.post(
|
||||
"/api/v1/tools/",
|
||||
json={
|
||||
"name": "Bad MCP Tool",
|
||||
"category": "mcp",
|
||||
"definition": {
|
||||
"schema_version": 1,
|
||||
"type": "mcp",
|
||||
"config": {
|
||||
"transport": "streamable_http",
|
||||
"url": "ftp://invalid.example.com/mcp",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
# ── Task 6: discovered_tools field and _populate_discovered_tools helper ──────
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from api.routes.tool import McpToolConfig, _populate_discovered_tools
|
||||
|
||||
|
||||
def test_mcp_config_accepts_discovered_tools():
|
||||
cfg = McpToolConfig(
|
||||
url="https://x/mcp",
|
||||
discovered_tools=[{"name": "echo", "description": "Echo"}],
|
||||
)
|
||||
assert cfg.discovered_tools == [{"name": "echo", "description": "Echo"}]
|
||||
# Defaults to [] when omitted
|
||||
assert McpToolConfig(url="https://x/mcp").discovered_tools == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_populate_discovered_tools_overwrites_cache(monkeypatch):
|
||||
import api.routes.tool as tool_mod
|
||||
|
||||
monkeypatch.setattr(
|
||||
tool_mod,
|
||||
"discover_mcp_tools",
|
||||
AsyncMock(return_value=[{"name": "echo", "description": "Echo"}]),
|
||||
)
|
||||
definition = {
|
||||
"schema_version": 1,
|
||||
"type": "mcp",
|
||||
"config": {
|
||||
"url": "https://x/mcp",
|
||||
"tools_filter": [],
|
||||
"discovered_tools": [{"name": "stale", "description": "old"}],
|
||||
},
|
||||
}
|
||||
out = await _populate_discovered_tools(definition, organization_id=1)
|
||||
assert out["config"]["discovered_tools"] == [
|
||||
{"name": "echo", "description": "Echo"}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_populate_discovered_tools_non_mcp_is_noop():
|
||||
definition = {"schema_version": 1, "type": "http_api", "config": {}}
|
||||
out = await _populate_discovered_tools(definition, organization_id=1)
|
||||
assert out == definition # untouched
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_populate_discovered_tools_server_down_sets_empty(monkeypatch):
|
||||
import api.routes.tool as tool_mod
|
||||
|
||||
monkeypatch.setattr(
|
||||
tool_mod,
|
||||
"discover_mcp_tools",
|
||||
AsyncMock(side_effect=RuntimeError("connection refused")),
|
||||
)
|
||||
definition = {
|
||||
"schema_version": 1,
|
||||
"type": "mcp",
|
||||
"config": {"url": "https://x/mcp", "tools_filter": []},
|
||||
}
|
||||
out = await _populate_discovered_tools(definition, organization_id=1)
|
||||
assert out["config"]["discovered_tools"] == []
|
||||
|
||||
|
||||
# ── Task 7: POST /{tool_uuid}/mcp/refresh ─────────────────────────────────────
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from api.routes.tool import refresh_mcp_tools
|
||||
|
||||
|
||||
def _fake_user(org_id=1):
|
||||
u = MagicMock()
|
||||
u.selected_organization_id = org_id
|
||||
u.id = 1
|
||||
u.provider_id = "p1"
|
||||
return u
|
||||
|
||||
|
||||
def _mcp_tool_model(org_id=1):
|
||||
t = MagicMock()
|
||||
t.tool_uuid = "tu-mcp"
|
||||
t.name = "Mock MCP"
|
||||
t.category = "mcp"
|
||||
t.definition = {
|
||||
"schema_version": 1,
|
||||
"type": "mcp",
|
||||
"config": {"url": "https://x/mcp", "tools_filter": []},
|
||||
}
|
||||
return t
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_success(monkeypatch):
|
||||
import api.routes.tool as tool_mod
|
||||
|
||||
tool = _mcp_tool_model()
|
||||
monkeypatch.setattr(
|
||||
tool_mod.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
tool_mod.db_client,
|
||||
"update_tool",
|
||||
AsyncMock(return_value=tool),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
tool_mod,
|
||||
"discover_mcp_tools",
|
||||
AsyncMock(return_value=[{"name": "echo", "description": "Echo"}]),
|
||||
)
|
||||
resp = await refresh_mcp_tools("tu-mcp", user=_fake_user())
|
||||
assert resp.discovered_tools == [{"name": "echo", "description": "Echo"}]
|
||||
assert resp.error is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_server_down_returns_200_with_error(monkeypatch):
|
||||
import api.routes.tool as tool_mod
|
||||
|
||||
tool = _mcp_tool_model()
|
||||
monkeypatch.setattr(
|
||||
tool_mod.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
|
||||
)
|
||||
monkeypatch.setattr(tool_mod.db_client, "update_tool", AsyncMock(return_value=tool))
|
||||
monkeypatch.setattr(tool_mod, "discover_mcp_tools", AsyncMock(return_value=[]))
|
||||
resp = await refresh_mcp_tools("tu-mcp", user=_fake_user())
|
||||
assert resp.discovered_tools == []
|
||||
assert resp.error # non-empty human-readable message
|
||||
# update_tool should NOT be called when discovery returns empty
|
||||
tool_mod.db_client.update_tool.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_non_mcp_is_400(monkeypatch):
|
||||
import api.routes.tool as tool_mod
|
||||
|
||||
tool = _mcp_tool_model()
|
||||
tool.category = "http_api"
|
||||
monkeypatch.setattr(
|
||||
tool_mod.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
|
||||
)
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
await refresh_mcp_tools("tu-mcp", user=_fake_user())
|
||||
assert ei.value.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_not_found_is_404(monkeypatch):
|
||||
import api.routes.tool as tool_mod
|
||||
|
||||
monkeypatch.setattr(
|
||||
tool_mod.db_client, "get_tool_by_uuid", AsyncMock(return_value=None)
|
||||
)
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
await refresh_mcp_tools("nope", user=_fake_user())
|
||||
assert ei.value.status_code == 404
|
||||
274
api/tests/test_mcp_tool_session.py
Normal file
274
api/tests/test_mcp_tool_session.py
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from api.services.workflow.mcp_tool_session import (
|
||||
McpToolSession,
|
||||
build_streamable_http_params,
|
||||
discover_mcp_tools,
|
||||
)
|
||||
from api.tests.support.mcp_mock_server import running_mcp_server
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mock_server_starts_and_serves():
|
||||
async with running_mcp_server() as base_url:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(base_url, timeout=5.0)
|
||||
assert resp.status_code in (400, 404, 405, 406)
|
||||
|
||||
|
||||
def test_build_streamable_http_params_with_credential():
|
||||
cred = MagicMock()
|
||||
cred.credential_type = "bearer_token"
|
||||
cred.credential_data = {"token": "abc"}
|
||||
params = build_streamable_http_params(
|
||||
url="https://acme.example.com/mcp",
|
||||
credential=cred,
|
||||
timeout_secs=30,
|
||||
sse_read_timeout_secs=300,
|
||||
)
|
||||
assert params.url == "https://acme.example.com/mcp"
|
||||
assert params.headers == {"Authorization": "Bearer abc"}
|
||||
assert params.timeout == timedelta(seconds=30)
|
||||
assert params.sse_read_timeout == timedelta(seconds=300)
|
||||
|
||||
|
||||
def test_build_streamable_http_params_no_credential():
|
||||
params = build_streamable_http_params(
|
||||
url="https://acme.example.com/mcp",
|
||||
credential=None,
|
||||
timeout_secs=10,
|
||||
sse_read_timeout_secs=20,
|
||||
)
|
||||
assert params.headers is None or params.headers == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_start_passes_auth_header_to_real_server():
|
||||
cred = MagicMock()
|
||||
cred.credential_type = "bearer_token"
|
||||
cred.credential_data = {"token": "abc"}
|
||||
|
||||
async with running_mcp_server(
|
||||
required_headers={"Authorization": "Bearer abc"}
|
||||
) as base_url:
|
||||
session = McpToolSession(
|
||||
tool_uuid="uuid-auth-ok",
|
||||
tool_name="Secure MCP",
|
||||
url=base_url,
|
||||
credential=cred,
|
||||
tools_filter=[],
|
||||
timeout_secs=10,
|
||||
sse_read_timeout_secs=20,
|
||||
)
|
||||
await session.start()
|
||||
try:
|
||||
assert session.available is True
|
||||
names = sorted(s.name for s in session.function_schemas())
|
||||
assert names == ["mcp__secure_mcp__add", "mcp__secure_mcp__echo"]
|
||||
result = await session.call("mcp__secure_mcp__echo", {"text": "hi"})
|
||||
assert "echo:hi" in result
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_auth_failure_degrades_not_raises():
|
||||
async with running_mcp_server(
|
||||
required_headers={"Authorization": "Bearer abc"}
|
||||
) as base_url:
|
||||
session = McpToolSession(
|
||||
tool_uuid="uuid-auth-fail",
|
||||
tool_name="Secure MCP",
|
||||
url=base_url,
|
||||
credential=None,
|
||||
tools_filter=[],
|
||||
timeout_secs=2,
|
||||
sse_read_timeout_secs=2,
|
||||
)
|
||||
await session.start() # must degrade instead of raising on 401
|
||||
try:
|
||||
assert session.available is False
|
||||
assert session.function_schemas() == []
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_start_lists_and_calls_real_server():
|
||||
async with running_mcp_server() as base_url:
|
||||
session = McpToolSession(
|
||||
tool_uuid="uuid-1234abcd",
|
||||
tool_name="Acme MCP",
|
||||
url=base_url,
|
||||
credential=None,
|
||||
tools_filter=[],
|
||||
timeout_secs=10,
|
||||
sse_read_timeout_secs=20,
|
||||
)
|
||||
await session.start()
|
||||
try:
|
||||
assert session.available is True
|
||||
schemas = session.function_schemas()
|
||||
names = sorted(s.name for s in schemas)
|
||||
assert names == ["mcp__acme_mcp__add", "mcp__acme_mcp__echo"]
|
||||
result = await session.call("mcp__acme_mcp__echo", {"text": "hi"})
|
||||
assert "echo:hi" in result
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_tools_filter_applied():
|
||||
async with running_mcp_server() as base_url:
|
||||
session = McpToolSession(
|
||||
tool_uuid="uuid-1234abcd",
|
||||
tool_name="Acme MCP",
|
||||
url=base_url,
|
||||
credential=None,
|
||||
tools_filter=["echo"],
|
||||
timeout_secs=10,
|
||||
sse_read_timeout_secs=20,
|
||||
)
|
||||
await session.start()
|
||||
try:
|
||||
names = sorted(s.name for s in session.function_schemas())
|
||||
assert names == ["mcp__acme_mcp__echo"]
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_unreachable_degrades_not_raises():
|
||||
session = McpToolSession(
|
||||
tool_uuid="uuid-1234abcd",
|
||||
tool_name="Acme MCP",
|
||||
url="http://127.0.0.1:1/mcp",
|
||||
credential=None,
|
||||
tools_filter=[],
|
||||
timeout_secs=2,
|
||||
sse_read_timeout_secs=2,
|
||||
)
|
||||
await session.start() # must NOT raise
|
||||
assert session.available is False
|
||||
assert session.function_schemas() == []
|
||||
await session.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_on_unavailable_session_raises():
|
||||
session = McpToolSession(
|
||||
tool_uuid="uuid-1234abcd",
|
||||
tool_name="Acme MCP",
|
||||
url="http://127.0.0.1:1/mcp",
|
||||
credential=None,
|
||||
tools_filter=[],
|
||||
timeout_secs=2,
|
||||
sse_read_timeout_secs=2,
|
||||
)
|
||||
await session.start()
|
||||
with pytest.raises(RuntimeError):
|
||||
await session.call("mcp__acme_mcp__echo", {"text": "x"})
|
||||
await session.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_unknown_function_raises():
|
||||
async with running_mcp_server() as base_url:
|
||||
session = McpToolSession(
|
||||
tool_uuid="uuid-1234abcd",
|
||||
tool_name="Acme MCP",
|
||||
url=base_url,
|
||||
credential=None,
|
||||
tools_filter=[],
|
||||
timeout_secs=10,
|
||||
sse_read_timeout_secs=10,
|
||||
)
|
||||
await session.start()
|
||||
try:
|
||||
with pytest.raises(RuntimeError):
|
||||
await session.call("mcp__acme_mcp__does_not_exist", {})
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_function_schemas_filter_by_raw_name():
|
||||
async with running_mcp_server() as base_url:
|
||||
session = McpToolSession(
|
||||
tool_uuid="t-filter",
|
||||
tool_name="Mock MCP",
|
||||
url=base_url,
|
||||
credential=None,
|
||||
tools_filter=[],
|
||||
timeout_secs=10,
|
||||
sse_read_timeout_secs=10,
|
||||
)
|
||||
await session.start()
|
||||
try:
|
||||
# No arg = all (backward compatible)
|
||||
all_names = sorted(s.name for s in session.function_schemas())
|
||||
assert all_names == ["mcp__mock_mcp__add", "mcp__mock_mcp__echo"]
|
||||
|
||||
# Allow only raw "echo"
|
||||
only_echo = session.function_schemas(allowed_raw_names={"echo"})
|
||||
assert [s.name for s in only_echo] == ["mcp__mock_mcp__echo"]
|
||||
|
||||
# Empty set = none (default-none semantics)
|
||||
assert session.function_schemas(allowed_raw_names=set()) == []
|
||||
|
||||
# Unknown raw name = skipped (pure intersection)
|
||||
assert session.function_schemas(allowed_raw_names={"nope"}) == []
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_mcp_tools_success():
|
||||
async with running_mcp_server() as base_url:
|
||||
tools = await discover_mcp_tools(
|
||||
url=base_url,
|
||||
credential=None,
|
||||
timeout_secs=10,
|
||||
sse_read_timeout_secs=10,
|
||||
)
|
||||
names = sorted(t["name"] for t in tools)
|
||||
assert names == ["add", "echo"]
|
||||
by_name = {t["name"]: t for t in tools}
|
||||
assert by_name["echo"]["description"] # non-empty description
|
||||
assert set(by_name["echo"]) == {"name", "description"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_mcp_tools_server_down_returns_empty():
|
||||
# Unroutable port, short timeouts: must degrade to [] (never raise).
|
||||
tools = await discover_mcp_tools(
|
||||
url="http://127.0.0.1:1/mcp",
|
||||
credential=None,
|
||||
timeout_secs=1,
|
||||
sse_read_timeout_secs=1,
|
||||
)
|
||||
assert tools == []
|
||||
|
||||
|
||||
def test_agent_node_data_carries_mcp_tool_filters():
|
||||
from api.services.workflow.dto import AgentNodeData, NodeType
|
||||
from api.services.workflow.workflow_graph import Node
|
||||
|
||||
data = AgentNodeData(
|
||||
name="N1",
|
||||
tool_uuids=["tu-1"],
|
||||
mcp_tool_filters={"tu-1": ["echo"]},
|
||||
)
|
||||
assert data.mcp_tool_filters == {"tu-1": ["echo"]}
|
||||
|
||||
node = Node("n1", NodeType.agentNode, data)
|
||||
assert node.mcp_tool_filters == {"tu-1": ["echo"]}
|
||||
|
||||
# Absent field defaults to None (backward compatible)
|
||||
data2 = AgentNodeData(name="N2")
|
||||
assert data2.mcp_tool_filters is None
|
||||
assert Node("n2", NodeType.agentNode, data2).mcp_tool_filters is None
|
||||
42
api/tests/test_message_sanitization.py
Normal file
42
api/tests/test_message_sanitization.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
from pipecat.utils.context.message_sanitization import (
|
||||
strip_thought_from_id,
|
||||
strip_thought_ids_from_messages,
|
||||
)
|
||||
|
||||
|
||||
def test_strip_thought_from_id():
|
||||
assert strip_thought_from_id("call_123__thought__abc") == "call_123"
|
||||
assert strip_thought_from_id("call_123") == "call_123"
|
||||
assert strip_thought_from_id(None) is None
|
||||
|
||||
|
||||
def test_strip_thought_ids_from_messages_does_not_mutate_input():
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1__thought__hidden",
|
||||
"type": "function",
|
||||
"function": {"name": "lookup", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1__thought__hidden",
|
||||
"content": '{"status":"ok"}',
|
||||
},
|
||||
]
|
||||
original = deepcopy(messages)
|
||||
|
||||
cleaned = strip_thought_ids_from_messages(messages)
|
||||
|
||||
assert messages == original
|
||||
assert cleaned is not messages
|
||||
assert cleaned[0]["tool_calls"][0]["id"] == "call_1"
|
||||
assert cleaned[1]["tool_call_id"] == "call_1"
|
||||
|
|
@ -14,7 +14,12 @@ import re
|
|||
|
||||
import pytest
|
||||
|
||||
from api.services.workflow.dto import NodeType, ReactFlowDTO
|
||||
from api.services.workflow.dto import (
|
||||
ReactFlowDTO,
|
||||
all_node_type_names,
|
||||
get_node_data_model,
|
||||
)
|
||||
from api.services.workflow.node_data import BaseNodeData
|
||||
from api.services.workflow.node_specs import (
|
||||
NodeSpec,
|
||||
PropertySpec,
|
||||
|
|
@ -118,9 +123,9 @@ def test_fixed_collection_has_sub_properties(spec: NodeSpec):
|
|||
|
||||
@pytest.mark.parametrize("spec", all_specs(), ids=lambda s: s.name)
|
||||
def test_spec_name_matches_dto_discriminator(spec: NodeSpec):
|
||||
valid_names = {t.value for t in NodeType}
|
||||
valid_names = all_node_type_names()
|
||||
assert spec.name in valid_names, (
|
||||
f"NodeSpec {spec.name!r} doesn't match any NodeType discriminator. "
|
||||
f"NodeSpec {spec.name!r} doesn't match any registered node type. "
|
||||
f"Valid: {sorted(valid_names)}"
|
||||
)
|
||||
|
||||
|
|
@ -187,10 +192,226 @@ def test_examples_validate_against_dto(spec: NodeSpec):
|
|||
|
||||
|
||||
def test_all_dto_types_have_specs():
|
||||
"""Every NodeType discriminator value must have a registered NodeSpec —
|
||||
catches the case where someone adds a new node type to dto.py but
|
||||
forgets to author a spec."""
|
||||
"""Every registered node type must have a registered NodeSpec."""
|
||||
spec_names = {s.name for s in all_specs()}
|
||||
type_values = {t.value for t in NodeType}
|
||||
type_values = all_node_type_names()
|
||||
missing = type_values - spec_names
|
||||
assert not missing, f"NodeType discriminators without specs: {sorted(missing)}"
|
||||
assert not missing, f"Registered node types without specs: {sorted(missing)}"
|
||||
|
||||
|
||||
def test_all_registered_node_models_inherit_base_node_data():
|
||||
for type_name in sorted(all_node_type_names()):
|
||||
data_model = get_node_data_model(type_name)
|
||||
assert data_model is not None, f"{type_name}: missing node data model"
|
||||
assert issubclass(data_model, BaseNodeData), (
|
||||
f"{type_name}: node data model must inherit BaseNodeData"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("spec_name", "expected_order"),
|
||||
[
|
||||
(
|
||||
"startCall",
|
||||
[
|
||||
"name",
|
||||
"greeting_type",
|
||||
"greeting",
|
||||
"greeting_recording_id",
|
||||
"prompt",
|
||||
"allow_interrupt",
|
||||
"add_global_prompt",
|
||||
"delayed_start",
|
||||
"delayed_start_duration",
|
||||
"extraction_enabled",
|
||||
"extraction_prompt",
|
||||
"extraction_variables",
|
||||
"tool_uuids",
|
||||
"document_uuids",
|
||||
"pre_call_fetch_enabled",
|
||||
"pre_call_fetch_url",
|
||||
"pre_call_fetch_credential_uuid",
|
||||
],
|
||||
),
|
||||
(
|
||||
"agentNode",
|
||||
[
|
||||
"name",
|
||||
"prompt",
|
||||
"allow_interrupt",
|
||||
"add_global_prompt",
|
||||
"extraction_enabled",
|
||||
"extraction_prompt",
|
||||
"extraction_variables",
|
||||
"tool_uuids",
|
||||
"document_uuids",
|
||||
],
|
||||
),
|
||||
(
|
||||
"endCall",
|
||||
[
|
||||
"name",
|
||||
"prompt",
|
||||
"add_global_prompt",
|
||||
"extraction_enabled",
|
||||
"extraction_prompt",
|
||||
"extraction_variables",
|
||||
],
|
||||
),
|
||||
("globalNode", ["name", "prompt"]),
|
||||
("trigger", ["name", "enabled", "trigger_path"]),
|
||||
(
|
||||
"webhook",
|
||||
[
|
||||
"name",
|
||||
"enabled",
|
||||
"http_method",
|
||||
"endpoint_url",
|
||||
"credential_uuid",
|
||||
"custom_headers",
|
||||
"payload_template",
|
||||
],
|
||||
),
|
||||
(
|
||||
"qa",
|
||||
[
|
||||
"name",
|
||||
"qa_enabled",
|
||||
"qa_system_prompt",
|
||||
"qa_min_call_duration",
|
||||
"qa_voicemail_calls",
|
||||
"qa_sample_rate",
|
||||
"qa_use_workflow_llm",
|
||||
"qa_provider",
|
||||
"qa_model",
|
||||
"qa_api_key",
|
||||
"qa_endpoint",
|
||||
],
|
||||
),
|
||||
(
|
||||
"tuner",
|
||||
[
|
||||
"name",
|
||||
"tuner_enabled",
|
||||
"tuner_agent_id",
|
||||
"tuner_workspace_id",
|
||||
"tuner_api_key",
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_node_spec_property_order_stable(spec_name: str, expected_order: list[str]):
|
||||
spec = next(spec for spec in all_specs() if spec.name == spec_name)
|
||||
assert [prop.name for prop in spec.properties] == expected_order
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# `to_mcp_dict` projection — the lean view served by the `get_node_type`
|
||||
# MCP tool. UI-only metadata is dropped so it doesn't poison LLM context;
|
||||
# the full spec stays available to the frontend and SDK via other paths.
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Keys that are UI-rendering concerns and must never reach the LLM view, at
|
||||
# either the node or property level.
|
||||
_UI_ONLY_KEYS = frozenset(
|
||||
{
|
||||
"display_name",
|
||||
"icon",
|
||||
"category",
|
||||
"version",
|
||||
"placeholder",
|
||||
"display_options",
|
||||
"editor",
|
||||
"extra",
|
||||
"label", # PropertyOption display string
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _walk_dicts(node):
|
||||
"""Yield every dict nested anywhere inside a projected structure."""
|
||||
if isinstance(node, dict):
|
||||
yield node
|
||||
for value in node.values():
|
||||
yield from _walk_dicts(value)
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
yield from _walk_dicts(item)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spec", all_specs(), ids=lambda s: s.name)
|
||||
def test_to_mcp_dict_drops_ui_only_keys(spec: NodeSpec):
|
||||
projected = spec.to_mcp_dict()
|
||||
for d in _walk_dicts(projected):
|
||||
leaked = _UI_ONLY_KEYS & d.keys()
|
||||
assert not leaked, f"{spec.name}: UI-only keys leaked into LLM view: {leaked}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spec", all_specs(), ids=lambda s: s.name)
|
||||
def test_to_mcp_dict_omits_null_and_empty(spec: NodeSpec):
|
||||
"""The lean view never emits null values — absent means unset/optional,
|
||||
which is what halves the noise versus the full model dump."""
|
||||
for d in _walk_dicts(spec.to_mcp_dict()):
|
||||
for key, value in d.items():
|
||||
assert value is not None, f"{spec.name}: {key!r} emitted as null"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spec", all_specs(), ids=lambda s: s.name)
|
||||
def test_to_mcp_dict_keeps_property_essentials(spec: NodeSpec):
|
||||
"""Every property in the LLM view carries the minimum an LLM needs to
|
||||
author a value: machine name, type, and a description."""
|
||||
|
||||
def _check(props: list[dict]):
|
||||
for prop in props:
|
||||
assert prop.get("name"), f"{spec.name}: property missing name"
|
||||
assert prop.get("type"), f"{spec.name}.{prop.get('name')}: missing type"
|
||||
assert prop.get("description"), (
|
||||
f"{spec.name}.{prop.get('name')}: missing description"
|
||||
)
|
||||
if prop.get("properties"):
|
||||
_check(prop["properties"])
|
||||
|
||||
_check(spec.to_mcp_dict()["properties"])
|
||||
|
||||
|
||||
def test_to_mcp_dict_retains_authoring_signal_startcall():
|
||||
"""startCall is the richest core node — lock in that the projection
|
||||
keeps the fields an LLM actually authors against while shedding the rest."""
|
||||
spec = next(s for s in all_specs() if s.name == "startCall")
|
||||
projected = spec.to_mcp_dict()
|
||||
|
||||
assert set(projected) == {
|
||||
"name",
|
||||
"description",
|
||||
"llm_hint",
|
||||
"properties",
|
||||
"examples",
|
||||
"graph_constraints",
|
||||
}
|
||||
|
||||
props = {p["name"]: p for p in projected["properties"]}
|
||||
|
||||
# Required field keeps `required`; optional fields omit it.
|
||||
assert props["prompt"]["required"] is True
|
||||
assert "required" not in props["greeting"]
|
||||
|
||||
# Enum options project to bare values, dropping the UI label.
|
||||
assert props["greeting_type"]["options"] == [{"value": "text"}, {"value": "audio"}]
|
||||
|
||||
# Validation bounds survive (they constrain valid authored values).
|
||||
assert props["delayed_start_duration"]["min_value"] == 0.1
|
||||
assert props["delayed_start_duration"]["max_value"] == 10.0
|
||||
|
||||
# llm_hint survives where present (catalog-tool references).
|
||||
assert "list_recordings" in props["greeting_recording_id"]["llm_hint"]
|
||||
|
||||
# fixed_collection rows recurse through the same projection.
|
||||
var_rows = {p["name"]: p for p in props["extraction_variables"]["properties"]}
|
||||
assert var_rows["type"]["options"] == [
|
||||
{"value": "string"},
|
||||
{"value": "number"},
|
||||
{"value": "boolean"},
|
||||
]
|
||||
|
||||
# graph_constraints drops its null sub-fields.
|
||||
assert projected["graph_constraints"] == {"min_incoming": 0, "max_incoming": 0}
|
||||
|
|
|
|||
|
|
@ -45,12 +45,11 @@ from api.enums import ToolCategory
|
|||
from api.services.workflow.dto import (
|
||||
EdgeDataDTO,
|
||||
EndCallNodeData,
|
||||
EndCallRFNode,
|
||||
Position,
|
||||
ReactFlowDTO,
|
||||
RFEdgeDTO,
|
||||
RFNodeDTO,
|
||||
StartCallNodeData,
|
||||
StartCallRFNode,
|
||||
)
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager
|
||||
|
|
@ -1014,8 +1013,9 @@ class TestEndCallExtractionBehavior:
|
|||
# Create a workflow where start node has NO extraction
|
||||
dto = ReactFlowDTO(
|
||||
nodes=[
|
||||
StartCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="start",
|
||||
type="startCall",
|
||||
position=Position(x=0, y=0),
|
||||
data=StartCallNodeData(
|
||||
name="Start Call",
|
||||
|
|
@ -1026,8 +1026,9 @@ class TestEndCallExtractionBehavior:
|
|||
extraction_enabled=False, # No extraction
|
||||
),
|
||||
),
|
||||
EndCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="end",
|
||||
type="endCall",
|
||||
position=Position(x=0, y=200),
|
||||
data=EndCallNodeData(
|
||||
name="End Call",
|
||||
|
|
|
|||
|
|
@ -34,12 +34,11 @@ from api.services.pipecat.recording_audio_cache import RecordingAudio
|
|||
from api.services.workflow.dto import (
|
||||
EdgeDataDTO,
|
||||
EndCallNodeData,
|
||||
EndCallRFNode,
|
||||
Position,
|
||||
ReactFlowDTO,
|
||||
RFEdgeDTO,
|
||||
RFNodeDTO,
|
||||
StartCallNodeData,
|
||||
StartCallRFNode,
|
||||
)
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager
|
||||
|
|
@ -65,8 +64,9 @@ def text_workflow() -> WorkflowGraph:
|
|||
"""Start->End workflow with text greeting and text transition speech."""
|
||||
dto = ReactFlowDTO(
|
||||
nodes=[
|
||||
StartCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="start",
|
||||
type="startCall",
|
||||
position=Position(x=0, y=0),
|
||||
data=StartCallNodeData(
|
||||
name="Start Call",
|
||||
|
|
@ -79,8 +79,9 @@ def text_workflow() -> WorkflowGraph:
|
|||
extraction_enabled=False,
|
||||
),
|
||||
),
|
||||
EndCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="end",
|
||||
type="endCall",
|
||||
position=Position(x=0, y=200),
|
||||
data=EndCallNodeData(
|
||||
name="End Call",
|
||||
|
|
@ -114,8 +115,9 @@ def audio_workflow() -> WorkflowGraph:
|
|||
"""Start->End workflow with audio greeting and audio transition speech."""
|
||||
dto = ReactFlowDTO(
|
||||
nodes=[
|
||||
StartCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="start",
|
||||
type="startCall",
|
||||
position=Position(x=0, y=0),
|
||||
data=StartCallNodeData(
|
||||
name="Start Call",
|
||||
|
|
@ -128,8 +130,9 @@ def audio_workflow() -> WorkflowGraph:
|
|||
extraction_enabled=False,
|
||||
),
|
||||
),
|
||||
EndCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="end",
|
||||
type="endCall",
|
||||
position=Position(x=0, y=200),
|
||||
data=EndCallNodeData(
|
||||
name="End Call",
|
||||
|
|
@ -290,8 +293,9 @@ class TestStartGreeting:
|
|||
"""No greeting configured should return None."""
|
||||
dto = ReactFlowDTO(
|
||||
nodes=[
|
||||
StartCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="start",
|
||||
type="startCall",
|
||||
position=Position(x=0, y=0),
|
||||
data=StartCallNodeData(
|
||||
name="Start",
|
||||
|
|
@ -301,8 +305,9 @@ class TestStartGreeting:
|
|||
extraction_enabled=False,
|
||||
),
|
||||
),
|
||||
EndCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="end",
|
||||
type="endCall",
|
||||
position=Position(x=0, y=200),
|
||||
data=EndCallNodeData(
|
||||
name="End",
|
||||
|
|
@ -333,8 +338,9 @@ class TestStartGreeting:
|
|||
"""Text greeting with {{variable}} placeholders should be rendered."""
|
||||
dto = ReactFlowDTO(
|
||||
nodes=[
|
||||
StartCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="start",
|
||||
type="startCall",
|
||||
position=Position(x=0, y=0),
|
||||
data=StartCallNodeData(
|
||||
name="Start",
|
||||
|
|
@ -346,8 +352,9 @@ class TestStartGreeting:
|
|||
extraction_enabled=False,
|
||||
),
|
||||
),
|
||||
EndCallRFNode(
|
||||
RFNodeDTO(
|
||||
id="end",
|
||||
type="endCall",
|
||||
position=Position(x=0, y=200),
|
||||
data=EndCallNodeData(
|
||||
name="End",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,25 @@ def _qa_node(node_id="qa-1", api_key="", **extra_data):
|
|||
return {"id": node_id, "type": "qa", "position": {"x": 0, "y": 0}, "data": data}
|
||||
|
||||
|
||||
def _tuner_node(node_id="tuner-1", api_key="", **extra_data):
|
||||
"""Helper to build a Tuner node."""
|
||||
data = {
|
||||
"name": "Tuner",
|
||||
"tuner_enabled": True,
|
||||
"tuner_agent_id": "sales-bot",
|
||||
"tuner_workspace_id": 7,
|
||||
**extra_data,
|
||||
}
|
||||
if api_key:
|
||||
data["tuner_api_key"] = api_key
|
||||
return {
|
||||
"id": node_id,
|
||||
"type": "tuner",
|
||||
"position": {"x": 0, "y": 0},
|
||||
"data": data,
|
||||
}
|
||||
|
||||
|
||||
def _agent_node(node_id="agent-1"):
|
||||
"""Helper to build a non-QA node."""
|
||||
return {
|
||||
|
|
@ -66,6 +85,19 @@ class TestMaskWorkflowDefinition:
|
|||
assert "qa_api_key" not in masked["nodes"][0]["data"]
|
||||
assert masked["nodes"][1]["data"]["qa_api_key"] == mask_key("sk-secret1234")
|
||||
|
||||
def test_masks_tuner_api_key(self):
|
||||
"""Tuner node api_key is masked, showing only last 4 chars."""
|
||||
real_key = "tuner_live_abcdefghijklmnop"
|
||||
wf = _make_workflow_def([_tuner_node(api_key=real_key)])
|
||||
|
||||
masked = mask_workflow_definition(wf)
|
||||
|
||||
masked_key = masked["nodes"][0]["data"]["tuner_api_key"]
|
||||
assert masked_key == mask_key(real_key)
|
||||
assert masked_key.endswith("mnop")
|
||||
assert masked_key.startswith("*")
|
||||
assert real_key not in str(masked)
|
||||
|
||||
def test_qa_node_without_api_key(self):
|
||||
"""QA node with no api_key is left as-is."""
|
||||
wf = _make_workflow_def([_qa_node()])
|
||||
|
|
@ -154,6 +186,16 @@ class TestMergeWorkflowApiKeys:
|
|||
|
||||
assert result["nodes"][0]["data"]["qa_api_key"] == new_key
|
||||
|
||||
def test_masked_tuner_key_is_restored(self):
|
||||
"""Masked Tuner keys round-trip without losing the stored secret."""
|
||||
real_key = "tuner_live_abcdefghijklmnop"
|
||||
existing = _make_workflow_def([_tuner_node(api_key=real_key)])
|
||||
incoming = _make_workflow_def([_tuner_node(api_key=mask_key(real_key))])
|
||||
|
||||
result = merge_workflow_api_keys(incoming, existing)
|
||||
|
||||
assert result["nodes"][0]["data"]["tuner_api_key"] == real_key
|
||||
|
||||
def test_no_incoming_api_key(self):
|
||||
"""QA node without api_key in incoming is left alone."""
|
||||
existing = _make_workflow_def([_qa_node(api_key="sk-existing-key1")])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue