feat(mcp): identify DeerFlow in Parallel request headers (#5501)

This commit is contained in:
George Pickett 2026-09-16 20:35:13 -07:00 committed by GitHub
parent 582a632868
commit 6b0ebe6702
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 102 additions and 3 deletions

View File

@ -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.

View File

@ -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"},
}

View 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()

View File

@ -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": {