mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-22 20:46:20 +00:00
* feat(capabilities): unify catalog, plugin configuration and agent selection * fix(capabilities): address review isolation, validation and demo issues * fix(capabilities): preserve concurrent selections and guide launcher repair
169 lines
7.6 KiB
Python
169 lines
7.6 KiB
Python
"""Provider contracts: actual HTTP serialization, business errors and MCP tools."""
|
|
|
|
import json
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from deerflow.capabilities.business import BusinessClient, build_server, connection_config
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"provider,credentials,host",
|
|
[
|
|
("dingtalk", {"access_token": "robot-token", "sign_secret": "SEC-sign"}, "oapi.dingtalk.com"),
|
|
("wecom", {"webhook_key": "robot-key"}, "qyapi.weixin.qq.com"),
|
|
],
|
|
)
|
|
async def test_robot_wire_contract(provider, credentials, host):
|
|
requests = []
|
|
|
|
def handle(request):
|
|
requests.append(request)
|
|
return httpx.Response(200, json={"errcode": 0, "errmsg": "ok"})
|
|
|
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http:
|
|
client = BusinessClient(provider, credentials, http)
|
|
assert await client.send_message("通知", "markdown", "日报") == {"sent": True}
|
|
request = requests[0]
|
|
assert request.url.host == host
|
|
body = json.loads(request.content)
|
|
assert body["msgtype"] == "markdown"
|
|
if provider == "dingtalk":
|
|
assert request.url.params["access_token"] == "robot-token"
|
|
assert request.url.params["timestamp"] and request.url.params["sign"]
|
|
assert body["markdown"] == {"text": "通知", "title": "日报"}
|
|
else:
|
|
assert request.url.params["key"] == "robot-key"
|
|
assert body["markdown"] == {"content": "通知"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("status,body", [(200, {"errcode": 40014, "errmsg": "secret-token"}), (401, {"message": "secret-token"}), (302, {})])
|
|
async def test_errors_never_claim_success_or_expose_response_secrets(status, body):
|
|
async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(status, json=body))) as http:
|
|
client = BusinessClient("wecom", {"webhook_key": "secret-token"}, http)
|
|
with pytest.raises(ValueError) as error:
|
|
await client.send_message("hello")
|
|
assert "secret-token" not in str(error.value)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hubspot_pagination_and_contact_creation():
|
|
requests = []
|
|
|
|
def handle(request):
|
|
requests.append(request)
|
|
if request.method == "GET":
|
|
return httpx.Response(200, json={"results": [{"id": "12"}], "paging": {"next": {"after": "next-page"}}})
|
|
return httpx.Response(201, json={"id": "42", "properties": {"email": "person@example.test"}})
|
|
|
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http:
|
|
client = BusinessClient("hubspot", {"access_token": "private-token"}, http)
|
|
assert (await client.get_companies(2, "cursor"))["next_after"] == "next-page"
|
|
assert (await client.create_contact("person@example.test", firstname="A"))["id"] == "42"
|
|
assert requests[0].url.params["after"] == "cursor"
|
|
assert requests[0].url.params["limit"] == "2"
|
|
assert requests[0].headers["Authorization"] == "Bearer private-token"
|
|
assert requests[1].url.path == "/crm/v3/objects/contacts"
|
|
assert json.loads(requests[1].content) == {"properties": {"email": "person@example.test", "firstname": "A"}}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mcp_discovery_does_not_need_or_expose_credentials():
|
|
server = build_server("hubspot")
|
|
tools = await server.list_tools()
|
|
assert {tool.name for tool in tools} == {"get_companies", "create_contact"}
|
|
for tool in tools:
|
|
assert "access_token" not in json.dumps(tool.inputSchema)
|
|
assert tool.annotations.readOnlyHint == (tool.name == "get_companies")
|
|
|
|
|
|
@pytest.mark.parametrize("provider,values", [("wecom", {}), ("dingtalk", {"access_token": "x"}), ("hubspot", {"access_token": "x", "url": "http://localhost"}), ("hubspot", {"access_token": "***"})])
|
|
def test_invalid_configuration_rejected(provider, values):
|
|
with pytest.raises(ValueError):
|
|
connection_config(provider, values)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"provider,credentials,tool,arguments",
|
|
[
|
|
("dingtalk", {"access_token": "fixture-token", "sign_secret": "fixture-secret"}, "send_message", {"content": ""}),
|
|
("wecom", {"webhook_key": "fixture-key"}, "send_message", {"content": ""}),
|
|
("hubspot", {"access_token": "fixture-token"}, "create_contact", {"email": "invalid"}),
|
|
],
|
|
)
|
|
async def test_exact_installed_launcher_discovers_and_rejects_invalid_calls(provider, credentials, tool, arguments):
|
|
from mcp import ClientSession, StdioServerParameters
|
|
from mcp.client.stdio import stdio_client
|
|
|
|
config = connection_config(provider, credentials)
|
|
params = StdioServerParameters(command=config["command"], args=config["args"], env=config["env"])
|
|
async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
|
|
await session.initialize()
|
|
discovered = await session.list_tools()
|
|
assert tool in {item.name for item in discovered.tools}
|
|
result = await session.call_tool(tool, arguments)
|
|
assert result.isError
|
|
for secret in credentials.values():
|
|
assert secret not in result.model_dump_json()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"provider,credentials,tool,arguments,expected",
|
|
[
|
|
("dingtalk", {"access_token": "fixture-token", "sign_secret": "fixture-secret"}, "send_message", {"content": "Report ready"}, "sent"),
|
|
("wecom", {"webhook_key": "fixture-key"}, "send_message", {"content": "Report ready"}, "sent"),
|
|
("hubspot", {"access_token": "fixture-token"}, "get_companies", {"limit": 1}, "Acme"),
|
|
("hubspot", {"access_token": "fixture-token"}, "create_contact", {"email": "person@example.test"}, "contact-42"),
|
|
],
|
|
)
|
|
async def test_real_mcp_tool_invocation_with_simulated_provider(tmp_path, provider, credentials, tool, arguments, expected):
|
|
"""Real stdio protocol and client code; only external HTTP is simulated."""
|
|
import sys
|
|
|
|
from mcp import ClientSession, StdioServerParameters
|
|
from mcp.client.stdio import stdio_client
|
|
|
|
script = tmp_path / "business_fixture.py"
|
|
script.write_text(
|
|
"""import httpx
|
|
from deerflow.capabilities.business import build_server
|
|
import sys
|
|
original = httpx.AsyncClient
|
|
def handle(request):
|
|
if request.url.host == "api.hubapi.com":
|
|
if request.method == "GET":
|
|
return httpx.Response(200, json={"results": [{"id": "1", "properties": {"name": "Acme"}}]})
|
|
return httpx.Response(201, json={"id": "contact-42", "properties": {"email": "person@example.test"}})
|
|
return httpx.Response(200, json={"errcode": 0})
|
|
httpx.AsyncClient = lambda: original(transport=httpx.MockTransport(handle))
|
|
build_server(sys.argv[1]).run()
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
params = StdioServerParameters(command=sys.executable, args=["-I", str(script), provider], env=connection_config(provider, credentials)["env"])
|
|
async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
|
|
await session.initialize()
|
|
result = await session.call_tool(tool, arguments)
|
|
assert not result.isError, result
|
|
assert expected in result.model_dump_json()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_robot_missing_business_status_is_failure_and_network_exception_is_redacted():
|
|
async def missing(_):
|
|
return httpx.Response(200, json={"ok": True})
|
|
|
|
def broken(request):
|
|
raise httpx.ConnectError("https://example.test?key=super-secret", request=request)
|
|
|
|
for handler in (missing, broken):
|
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
|
with pytest.raises(ValueError) as error:
|
|
await BusinessClient("wecom", {"webhook_key": "super-secret"}, http).send_message("hello")
|
|
assert "super-secret" not in str(error.value)
|