From 6b0ebe670222a502fe07c4b49bd52b6bdd012226 Mon Sep 17 00:00:00 2001 From: George Pickett Date: Wed, 16 Sep 2026 20:35:13 -0700 Subject: [PATCH] feat(mcp): identify DeerFlow in Parallel request headers (#5501) --- backend/docs/MCP_SERVER.md | 11 ++- backend/tests/test_mcp_client_config.py | 3 +- backend/tests/test_parallel_mcp_user_agent.py | 88 +++++++++++++++++++ extensions_config.example.json | 3 + 4 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_parallel_mcp_user_agent.py diff --git a/backend/docs/MCP_SERVER.md b/backend/docs/MCP_SERVER.md index 31d452753..68062823a 100644 --- a/backend/docs/MCP_SERVER.md +++ b/backend/docs/MCP_SERVER.md @@ -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 `, 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. diff --git a/backend/tests/test_mcp_client_config.py b/backend/tests/test_mcp_client_config.py index 0a79eb26a..233b22452 100644 --- a/backend/tests/test_mcp_client_config.py +++ b/backend/tests/test_mcp_client_config.py @@ -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"}, } diff --git a/backend/tests/test_parallel_mcp_user_agent.py b/backend/tests/test_parallel_mcp_user_agent.py new file mode 100644 index 000000000..0db015cff --- /dev/null +++ b/backend/tests/test_parallel_mcp_user_agent.py @@ -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() diff --git a/extensions_config.example.json b/extensions_config.example.json index ac336e7ac..8b318efa2 100644 --- a/extensions_config.example.json +++ b/extensions_config.example.json @@ -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": {