mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-14 08:00:10 +00:00
* feat(mcp): add optional Parallel Search server * docs(mcp): document Parallel Search opt-in and data sharing * docs(mcp): address Parallel Search review feedback
256 lines
8.6 KiB
Python
256 lines
8.6 KiB
Python
"""Core behavior tests for MCP client server config building."""
|
|
|
|
import logging
|
|
|
|
import pytest
|
|
|
|
from deerflow.config.extensions_config import ExtensionsConfig, McpServerConfig
|
|
from deerflow.mcp.client import build_server_params, build_servers_config
|
|
|
|
|
|
def test_build_server_params_stdio_success():
|
|
config = McpServerConfig(
|
|
type="stdio",
|
|
command="npx",
|
|
args=["-y", "my-mcp-server"],
|
|
env={"API_KEY": "secret"},
|
|
)
|
|
|
|
params = build_server_params("my-server", config)
|
|
|
|
assert params == {
|
|
"transport": "stdio",
|
|
"command": "npx",
|
|
"args": ["-y", "my-mcp-server"],
|
|
"env": {"API_KEY": "secret"},
|
|
}
|
|
|
|
|
|
def test_extensions_config_resolves_env_variables_inside_nested_collections(monkeypatch):
|
|
monkeypatch.setenv("MCP_TOKEN", "secret")
|
|
monkeypatch.delenv("MISSING_TOKEN", raising=False)
|
|
raw_config = {
|
|
"args": ["--token", "$MCP_TOKEN", {"nested": ["$MCP_TOKEN", "$MISSING_TOKEN"]}],
|
|
"tuple_args": ("$MCP_TOKEN", "$MISSING_TOKEN"),
|
|
"env": {"API_KEY": "$MCP_TOKEN"},
|
|
"enabled": True,
|
|
"timeout": 30,
|
|
}
|
|
|
|
resolved = ExtensionsConfig.resolve_env_variables(raw_config)
|
|
|
|
assert resolved["args"] == ["--token", "secret", {"nested": ["secret", ""]}]
|
|
assert resolved["tuple_args"] == ("secret", "")
|
|
assert resolved["env"] == {"API_KEY": "secret"}
|
|
assert resolved["enabled"] is True
|
|
assert resolved["timeout"] == 30
|
|
|
|
|
|
def test_build_server_params_stdio_requires_command():
|
|
config = McpServerConfig(type="stdio", command=None)
|
|
|
|
with pytest.raises(ValueError, match="requires 'command' field"):
|
|
build_server_params("broken-stdio", config)
|
|
|
|
|
|
@pytest.mark.parametrize("transport", ["sse", "http"])
|
|
def test_build_server_params_http_like_success(transport: str):
|
|
config = McpServerConfig(
|
|
type=transport,
|
|
url="https://example.com/mcp",
|
|
headers={"Authorization": "Bearer token"},
|
|
)
|
|
|
|
params = build_server_params("remote-server", config)
|
|
|
|
assert params == {
|
|
"transport": transport,
|
|
"url": "https://example.com/mcp",
|
|
"headers": {"Authorization": "Bearer token"},
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("transport", ["sse", "http"])
|
|
def test_build_server_params_http_like_requires_url(transport: str):
|
|
config = McpServerConfig(type=transport, url=None)
|
|
|
|
with pytest.raises(ValueError, match="requires 'url' field"):
|
|
build_server_params("broken-remote", config)
|
|
|
|
|
|
def test_build_server_params_rejects_unsupported_transport():
|
|
config = McpServerConfig(type="websocket")
|
|
|
|
with pytest.raises(ValueError, match="unsupported transport type"):
|
|
build_server_params("bad-transport", config)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("value", "reason"),
|
|
[
|
|
("Bearer static-secret-123\n", "line break"),
|
|
("Bearer static-secret-caf\u00e9", "outside ASCII"),
|
|
("Bearer static-secret-456 ", "whitespace"),
|
|
],
|
|
ids=["trailing-newline", "non-ascii", "trailing-space"],
|
|
)
|
|
def test_build_server_params_rejects_illegal_header_value(value: str, reason: str):
|
|
"""A statically configured value the transport would refuse is denied here.
|
|
|
|
h11 renders the full value into its exception message on a line break or
|
|
surrounding whitespace, which ToolErrorHandlingMiddleware turns into a
|
|
model-visible ToolMessage. These values are API keys often enough that the
|
|
denial names the header and the reason instead.
|
|
"""
|
|
config = McpServerConfig(type="http", url="https://example.com/mcp", headers={"Authorization": value})
|
|
|
|
with pytest.raises(ValueError) as excinfo:
|
|
build_server_params("remote-server", config)
|
|
|
|
message = str(excinfo.value)
|
|
assert reason in message
|
|
assert "static-secret" not in message
|
|
assert "Authorization" in message
|
|
|
|
|
|
def test_build_servers_config_drops_only_the_server_with_an_illegal_header(caplog):
|
|
config = ExtensionsConfig.model_validate(
|
|
{
|
|
"mcpServers": {
|
|
"broken": {
|
|
"enabled": True,
|
|
"type": "http",
|
|
"url": "https://example.com/mcp",
|
|
"headers": {"Authorization": "Bearer static-secret-123\n"},
|
|
},
|
|
"healthy": {
|
|
"enabled": True,
|
|
"type": "http",
|
|
"url": "https://example.com/mcp",
|
|
"headers": {"Authorization": "Bearer fine"},
|
|
},
|
|
}
|
|
}
|
|
)
|
|
|
|
with caplog.at_level(logging.ERROR, logger="deerflow.mcp.client"):
|
|
servers_config = build_servers_config(config)
|
|
|
|
# One bad server does not take the others down with it, and the log that
|
|
# explains the drop does not carry the value either.
|
|
assert set(servers_config) == {"healthy"}
|
|
assert "static-secret" not in caplog.text
|
|
|
|
|
|
@pytest.mark.parametrize("transport", ["sse", "http"])
|
|
def test_mcp_server_config_accepts_transport_alias(transport: str):
|
|
"""The MCP-spec ``transport`` field should be accepted as an alias for ``type``.
|
|
|
|
Regression test for https://github.com/bytedance/deer-flow/issues/3238 — a
|
|
remote MCP server configured with only ``transport: sse`` was previously
|
|
misidentified as ``stdio`` (the default for ``type``).
|
|
"""
|
|
config = McpServerConfig.model_validate(
|
|
{
|
|
"transport": transport,
|
|
"url": "https://example.com/mcp",
|
|
}
|
|
)
|
|
|
|
assert config.type == transport
|
|
|
|
params = build_server_params("aliased-server", config)
|
|
assert params["transport"] == transport
|
|
assert params["url"] == "https://example.com/mcp"
|
|
|
|
|
|
def test_mcp_server_config_type_takes_precedence_over_transport():
|
|
"""When both ``type`` and ``transport`` are provided, ``type`` wins."""
|
|
config = McpServerConfig.model_validate(
|
|
{
|
|
"type": "http",
|
|
"transport": "sse",
|
|
"url": "https://example.com/mcp",
|
|
}
|
|
)
|
|
|
|
assert config.type == "http"
|
|
|
|
|
|
def test_build_servers_config_returns_empty_when_no_enabled_servers():
|
|
extensions = ExtensionsConfig(
|
|
mcp_servers={
|
|
"disabled-a": McpServerConfig(enabled=False, type="stdio", command="echo"),
|
|
"disabled-b": McpServerConfig(enabled=False, type="http", url="https://example.com"),
|
|
},
|
|
skills={},
|
|
)
|
|
|
|
assert build_servers_config(extensions) == {}
|
|
|
|
|
|
def test_build_servers_config_skips_invalid_server_and_keeps_valid_ones():
|
|
extensions = ExtensionsConfig(
|
|
mcp_servers={
|
|
"valid-stdio": McpServerConfig(enabled=True, type="stdio", command="npx", args=["server"]),
|
|
"invalid-stdio": McpServerConfig(enabled=True, type="stdio", command=None),
|
|
"disabled-http": McpServerConfig(enabled=False, type="http", url="https://disabled.example.com"),
|
|
},
|
|
skills={},
|
|
)
|
|
|
|
result = build_servers_config(extensions)
|
|
|
|
assert "valid-stdio" in result
|
|
assert result["valid-stdio"]["transport"] == "stdio"
|
|
assert "invalid-stdio" not in result
|
|
assert "disabled-http" not in result
|
|
|
|
|
|
def test_build_server_params_excludes_tool_call_timeout():
|
|
"""tool_call_timeout must NOT appear in the connection dict.
|
|
|
|
langchain-mcp-adapters passes the connection dict to create_session(),
|
|
which forwards unknown keys to _create_stdio_session(), causing TypeError.
|
|
The timeout is read from McpServerConfig at the tool wrapper call-site
|
|
instead. Regression for PR #3843 P1 bug.
|
|
"""
|
|
config = McpServerConfig(
|
|
type="stdio",
|
|
command="npx",
|
|
args=["-y", "my-mcp-server"],
|
|
tool_call_timeout=30.0,
|
|
)
|
|
|
|
params = build_server_params("my-server", config)
|
|
|
|
assert "tool_call_timeout" not in params
|
|
assert params == {
|
|
"transport": "stdio",
|
|
"command": "npx",
|
|
"args": ["-y", "my-mcp-server"],
|
|
}
|
|
|
|
|
|
def test_parallel_search_example_is_explicitly_opt_in_and_uses_anonymous_http_transport():
|
|
"""The shipped example must not enable or authenticate the optional free server."""
|
|
import json
|
|
from pathlib import Path
|
|
|
|
example = json.loads((Path(__file__).parents[2] / "extensions_config.example.json").read_text())
|
|
parallel = example["mcpServers"]["parallel-search"]
|
|
|
|
assert parallel["enabled"] is False
|
|
assert parallel["type"] == "http"
|
|
assert parallel["url"] == "https://search.parallel.ai/mcp"
|
|
assert "headers" not in parallel
|
|
|
|
config = ExtensionsConfig.model_validate(example)
|
|
assert "parallel-search" not in build_servers_config(config)
|
|
|
|
config.mcp_servers["parallel-search"].enabled = True
|
|
assert build_servers_config(config)["parallel-search"] == {
|
|
"transport": "http",
|
|
"url": "https://search.parallel.ai/mcp",
|
|
}
|