mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 11:06:18 +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.
|
||||
|
||||
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`:
|
||||
|
||||
```json
|
||||
{
|
||||
"headers": {
|
||||
"User-Agent": "deer-flow",
|
||||
"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 `
|
||||
in the environment variable because DeerFlow expands only whole-string
|
||||
`$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
|
||||
[Parallel Search MCP documentation](https://docs.parallel.ai/integrations/mcp/search-mcp)
|
||||
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["type"] == "http"
|
||||
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)
|
||||
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"] == {
|
||||
"transport": "http",
|
||||
"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,
|
||||
"type": "http",
|
||||
"url": "https://search.parallel.ai/mcp",
|
||||
"headers": {
|
||||
"User-Agent": "deer-flow"
|
||||
},
|
||||
"description": "Optional anonymous Parallel Search tools for web search and fetching requested URLs"
|
||||
},
|
||||
"openviking": {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user