mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-24 13:36:19 +00:00
feat(mcp): identify DeerFlow in Parallel request headers (#5501)
This commit is contained in:
parent
582a632868
commit
6b0ebe6702
@ -97,12 +97,19 @@ conversation, so enable it only if you are comfortable sending that data to
|
|||||||
Parallel.
|
Parallel.
|
||||||
|
|
||||||
Access is anonymous by default: no API key or authentication headers are needed.
|
Access is anonymous by default: no API key or authentication headers are needed.
|
||||||
For higher rate limits, optionally add this `headers` field to the
|
Keep `"User-Agent": "deer-flow"` in the entry's `headers`. This stable,
|
||||||
|
project-wide identity lets Parallel measure aggregate usage from this
|
||||||
|
integration to understand adoption and support it; it does not identify an
|
||||||
|
individual user or installation. Preserve it on search and fetch HTTP requests
|
||||||
|
if the transport changes. Existing configurations can add the same header.
|
||||||
|
|
||||||
|
For higher rate limits, optionally add authorization to the `headers` field of the
|
||||||
`parallel-search` entry in your local `extensions_config.json`:
|
`parallel-search` entry in your local `extensions_config.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"headers": {
|
"headers": {
|
||||||
|
"User-Agent": "deer-flow",
|
||||||
"Authorization": "$PARALLEL_AUTHORIZATION"
|
"Authorization": "$PARALLEL_AUTHORIZATION"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -112,7 +119,7 @@ Set `PARALLEL_AUTHORIZATION` in the DeerFlow backend's environment to the full
|
|||||||
value `Bearer <your-parallel-api-key>`, then restart DeerFlow. Include `Bearer `
|
value `Bearer <your-parallel-api-key>`, then restart DeerFlow. Include `Bearer `
|
||||||
in the environment variable because DeerFlow expands only whole-string
|
in the environment variable because DeerFlow expands only whole-string
|
||||||
`$ENV_VAR` references, not `Bearer $ENV_VAR`. Keep the actual key out of committed
|
`$ENV_VAR` references, not `Bearer $ENV_VAR`. Keep the actual key out of committed
|
||||||
files. Remove the `headers` field and restart DeerFlow to return to anonymous
|
files. Remove only `Authorization` and restart DeerFlow to return to anonymous
|
||||||
access. See the
|
access. See the
|
||||||
[Parallel Search MCP documentation](https://docs.parallel.ai/integrations/mcp/search-mcp)
|
[Parallel Search MCP documentation](https://docs.parallel.ai/integrations/mcp/search-mcp)
|
||||||
for details.
|
for details.
|
||||||
|
|||||||
@ -243,7 +243,7 @@ def test_parallel_search_example_is_explicitly_opt_in_and_uses_anonymous_http_tr
|
|||||||
assert parallel["enabled"] is False
|
assert parallel["enabled"] is False
|
||||||
assert parallel["type"] == "http"
|
assert parallel["type"] == "http"
|
||||||
assert parallel["url"] == "https://search.parallel.ai/mcp"
|
assert parallel["url"] == "https://search.parallel.ai/mcp"
|
||||||
assert "headers" not in parallel
|
assert parallel["headers"] == {"User-Agent": "deer-flow"}
|
||||||
|
|
||||||
config = ExtensionsConfig.model_validate(example)
|
config = ExtensionsConfig.model_validate(example)
|
||||||
assert "parallel-search" not in build_servers_config(config)
|
assert "parallel-search" not in build_servers_config(config)
|
||||||
@ -252,4 +252,5 @@ def test_parallel_search_example_is_explicitly_opt_in_and_uses_anonymous_http_tr
|
|||||||
assert build_servers_config(config)["parallel-search"] == {
|
assert build_servers_config(config)["parallel-search"] == {
|
||||||
"transport": "http",
|
"transport": "http",
|
||||||
"url": "https://search.parallel.ai/mcp",
|
"url": "https://search.parallel.ai/mcp",
|
||||||
|
"headers": {"User-Agent": "deer-flow"},
|
||||||
}
|
}
|
||||||
|
|||||||
88
backend/tests/test_parallel_mcp_user_agent.py
Normal file
88
backend/tests/test_parallel_mcp_user_agent.py
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
"""Capture real HTTP requests through DeerFlow's MCP discovery and tool calls."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Thread
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from deerflow.mcp.tools import get_mcp_tools
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("authenticated", [False, True])
|
||||||
|
async def test_parallel_example_user_agent_reaches_tool_requests(tmp_path, monkeypatch, authenticated):
|
||||||
|
captured = []
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *_args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
request = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
||||||
|
captured.append((self.path, request, dict(self.headers)))
|
||||||
|
if "id" not in request:
|
||||||
|
self.send_response(202)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
method = request["method"]
|
||||||
|
if method == "initialize":
|
||||||
|
result = {"protocolVersion": request["params"]["protocolVersion"], "capabilities": {"tools": {}}, "serverInfo": {"name": "capture", "version": "1.0"}}
|
||||||
|
elif method == "tools/list":
|
||||||
|
result = {"tools": [{"name": name, "description": name, "inputSchema": {"type": "object", "properties": {}}} for name in ("web_search", "web_fetch")]}
|
||||||
|
else:
|
||||||
|
assert method == "tools/call"
|
||||||
|
result = {"content": [{"type": "text", "text": request["params"]["name"]}]}
|
||||||
|
body = json.dumps({"jsonrpc": "2.0", "id": request["id"], "result": result}).encode()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||||
|
thread = Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
example = json.loads((Path(__file__).parents[2] / "extensions_config.example.json").read_text())
|
||||||
|
parallel = example["mcpServers"]["parallel-search"]
|
||||||
|
parallel["enabled"] = True
|
||||||
|
parallel["url"] = f"http://127.0.0.1:{server.server_port}/parallel"
|
||||||
|
if authenticated:
|
||||||
|
monkeypatch.setenv("PARALLEL_AUTHORIZATION", "Bearer test-only")
|
||||||
|
parallel.setdefault("headers", {}).update({"Authorization": "$PARALLEL_AUTHORIZATION", "X-Caller": "test-caller"})
|
||||||
|
config = {"mcpServers": {"parallel-search": parallel, "other": {"type": "http", "url": f"http://127.0.0.1:{server.server_port}/other", "headers": {"User-Agent": "other-project/1.0", "X-Caller": "other-caller"}}}}
|
||||||
|
config_path = tmp_path / "extensions_config.json"
|
||||||
|
config_path.write_text(json.dumps(config))
|
||||||
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(config_path))
|
||||||
|
|
||||||
|
tools = {tool.name: tool for tool in await get_mcp_tools()}
|
||||||
|
for name in ("parallel-search_web_search", "parallel-search_web_fetch", "parallel-search_web_search", "other_web_search"):
|
||||||
|
assert await tools[name].ainvoke({})
|
||||||
|
|
||||||
|
parallel_requests = [(request, headers) for path, request, headers in captured if path == "/parallel"]
|
||||||
|
assert {request["method"] for request, _ in parallel_requests} >= {"initialize", "tools/list", "tools/call"}
|
||||||
|
# HTTP tools create a fresh session per call: attribution must survive
|
||||||
|
# discovery, reinitialization, and subsequent Search/Fetch requests.
|
||||||
|
assert [request["params"]["name"] for request, _ in parallel_requests if request["method"] == "tools/call"] == ["web_search", "web_fetch", "web_search"]
|
||||||
|
for _, headers in parallel_requests:
|
||||||
|
headers = {name.lower(): value for name, value in headers.items()}
|
||||||
|
assert headers["user-agent"] == "deer-flow"
|
||||||
|
if authenticated:
|
||||||
|
assert headers["authorization"] == "Bearer test-only"
|
||||||
|
assert headers["x-caller"] == "test-caller"
|
||||||
|
else:
|
||||||
|
assert "authorization" not in headers
|
||||||
|
other_requests = [headers for path, _, headers in captured if path == "/other"]
|
||||||
|
assert other_requests
|
||||||
|
for headers in other_requests:
|
||||||
|
headers = {name.lower(): value for name, value in headers.items()}
|
||||||
|
assert headers["user-agent"] == "other-project/1.0"
|
||||||
|
assert headers["x-caller"] == "other-caller"
|
||||||
|
assert "authorization" not in headers
|
||||||
|
finally:
|
||||||
|
await asyncio.to_thread(server.shutdown)
|
||||||
|
server.server_close()
|
||||||
|
thread.join()
|
||||||
@ -24,6 +24,9 @@
|
|||||||
"enabled": false,
|
"enabled": false,
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "https://search.parallel.ai/mcp",
|
"url": "https://search.parallel.ai/mcp",
|
||||||
|
"headers": {
|
||||||
|
"User-Agent": "deer-flow"
|
||||||
|
},
|
||||||
"description": "Optional anonymous Parallel Search tools for web search and fetching requested URLs"
|
"description": "Optional anonymous Parallel Search tools for web search and fetching requested URLs"
|
||||||
},
|
},
|
||||||
"openviking": {
|
"openviking": {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user