deer-flow/backend/tests/test_mcp_context_headers.py
Terminator666666 c17aa8b98f
fix(mcp): reject credentials that cannot travel as HTTP header values (#5066)
* fix(mcp): reject credentials that cannot travel as HTTP header values

A request-scoped secret or user_auth credential with a trailing newline
(the usual result of reading a token from a file, or a CRLF env-file),
CR/LF, surrounding whitespace, or characters outside Latin-1 sailed
through the credential interceptors into the HTTP client, where httpx/h11
reject it with an exception that echoes the full value:

    LocalProtocolError: Illegal header value b'Bearer sk-...\n'

ToolErrorHandlingMiddleware copies that message into a model-visible
ToolMessage, so the secret landed in the prompt, the checkpoint, and
traces - everywhere headers_from_context promises it never goes.

Add illegal_header_value_reason to mcp/headers.py, mirroring the
transport's own rules (Latin-1 encodable; h11's field_vchar is [^\x00\s]
with SP/HTAB legal only between visible characters), and fail closed in
both interceptors before the value can reach the client. The denial names
only the secret key (plus the reason) and never repeats the value.

Illegal values are denied regardless of on_missing: the key is present,
so a passthrough fallback would silently run the call under the shared
discovery credential - the exact authority confusion the deny default
exists to prevent.

Values the transport accepts are not rejected: embedded SP/HTAB
('Bearer <token>'), Latin-1 high bytes, and DEL all still pass, pinned
by tests against h11's observed behaviour.

* fix(mcp): tighten header value validation to httpx's ASCII boundary

The validator mirrored h11's Latin-1 boundary, but the transport rejects
more than h11 does: build_server_params hands dict[str, str] headers
through the MCP SDK's create_mcp_http_client into httpx.AsyncClient, and
httpx (pinned 0.28.1) encodes str header values as ASCII - so a Latin-1
high byte like 'Bearer caf\xe9' passed validation here only to raise
UnicodeEncodeError inside httpx before h11 ever ran, with the exception
message repeating the offending value.

Validate str values against ASCII instead, flip the tests that pinned
Latin-1 high bytes as transportable, and pin the boundary against the
real client: create_mcp_http_client must reject what the validator
flags and construct cleanly for what it accepts (embedded SP/HTAB and
DEL still pass).

Addresses review feedback on the ASCII vs Latin-1 boundary.

* fix(mcp): validate OAuth and static header values at the same boundary

The validator added for headers_from_context and user_auth left two paths
uncovered. A token endpoint returning an access_token or token_type with a
newline reached httpx/h11, which raise with the full token in the message, and
ToolErrorHandlingMiddleware copies that message into a model-visible
ToolMessage -- the leak this PR set out to close. The operator's static headers
had the same hole.

OAuthTokenManager.get_authorization_header now renders the Authorization value
through one checked helper, so the tool interceptor, the initial discovery
headers and the durable task path are all covered by a single guard. The
rendered value is what gets checked rather than the two fields separately,
because that is what the transport sees: an access_token with leading
whitespace is legal once it follows "Bearer ".

build_server_params applies the same check to statically configured headers.
build_servers_config already isolates a per-server failure, so a bad value
drops that one server and logs the reason instead of the value.

* docs(mcp): correct which transport echoes the full header value

The rationale claimed httpx and h11 both render the full value into their
exception message. Only h11 does, on the line break and surrounding whitespace
cases. httpx's ASCII failure is a UnicodeEncodeError naming the offending
character and its position, not the credential, so at most one character
escapes there; refusing the value up front buys an actionable error rather than
an encode failure raised from inside the client.

Corrected in headers.py and in every copy of the claim: context_headers.py,
user_scoped_auth.py, oauth.py, client.py, mcp/AGENTS.md, docs/MCP_SERVER.md,
the frontend mcp.mdx, and the test comments carrying the same wording. No
behavior change.

---------

Co-authored-by: Terminator666666 <Terminator666666@users.noreply.github.com>
2026-08-31 15:07:30 +08:00

980 lines
41 KiB
Python

"""Tests for request-scoped secret injection into MCP HTTP/SSE headers."""
import asyncio
import logging
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
from langchain.agents import AgentState as _AgentState
from langchain_core.tools import ToolException
from langchain_mcp_adapters.interceptors import MCPToolCallRequest
from deerflow.config.extensions_config import (
ExtensionsConfig,
McpContextHeadersConfig,
McpServerConfig,
McpTaskToolsetConfig,
McpUserScopedAuthConfig,
)
from deerflow.mcp.context_headers import build_context_headers_interceptor
from deerflow.mcp.interceptors import build_mcp_tool_interceptors
TENANT_TOKEN = "Bearer tenant-scoped-token"
def _config(**context_headers_kwargs) -> ExtensionsConfig:
return ExtensionsConfig(
mcp_servers={
"shared-http": McpServerConfig(
enabled=True,
type="http",
url="https://mcp.example.com/mcp",
headers={"Authorization": "Bearer discovery-token"},
headers_from_context=McpContextHeadersConfig(**context_headers_kwargs),
),
"other": McpServerConfig(enabled=True, type="http", url="https://other.example.com/mcp"),
},
skills={},
)
def _request(server_name: str = "shared-http", headers: dict | None = None, runtime: object | None = None) -> MCPToolCallRequest:
return MCPToolCallRequest(
name="act",
args={},
server_name=server_name,
headers=headers,
runtime=runtime,
)
def _runtime_with_secrets(**secrets: str) -> object:
return SimpleNamespace(context={"secrets": dict(secrets), "thread_id": "th-1"})
async def _echo_handler(request: MCPToolCallRequest) -> MCPToolCallRequest:
return request
# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------
def test_no_declaring_server_returns_none():
config = ExtensionsConfig(
mcp_servers={"plain": McpServerConfig(enabled=True, type="http", url="https://x.example.com")},
skills={},
)
assert build_context_headers_interceptor(config) is None
def test_disabled_block_returns_none():
config = _config(headers={"X-Tenant-Token": "tenant_token"}, enabled=False)
assert build_context_headers_interceptor(config) is None
def test_empty_mapping_returns_none():
"""An enabled block with no mappings has nothing to inject."""
assert build_context_headers_interceptor(_config(headers={})) is None
def test_disabled_server_is_ignored():
config = _config(headers={"X-Tenant-Token": "tenant_token"})
config.mcp_servers["shared-http"].enabled = False
assert build_context_headers_interceptor(config) is None
def test_stdio_server_is_skipped_with_warning(caplog):
"""A stdio server has no HTTP headers; warn and skip rather than deny its calls."""
config = ExtensionsConfig(
mcp_servers={
"local": McpServerConfig(
enabled=True,
type="stdio",
command="npx",
headers_from_context=McpContextHeadersConfig(headers={"X-Tenant-Token": "tenant_token"}),
)
},
skills={},
)
with caplog.at_level(logging.WARNING, logger="deerflow.mcp.context_headers"):
assert build_context_headers_interceptor(config) is None
assert "stdio" in caplog.text
# ---------------------------------------------------------------------------
# Header injection
# ---------------------------------------------------------------------------
def test_request_secret_is_injected_as_header():
interceptor = build_context_headers_interceptor(_config(headers={"X-Tenant-Token": "tenant_token"}))
result = asyncio.run(interceptor(_request(runtime=_runtime_with_secrets(tenant_token=TENANT_TOKEN)), _echo_handler))
assert result.headers["X-Tenant-Token"] == TENANT_TOKEN
def test_static_headers_are_preserved():
interceptor = build_context_headers_interceptor(_config(headers={"X-Tenant-Token": "tenant_token"}))
request = _request(headers={"Accept": "application/json"}, runtime=_runtime_with_secrets(tenant_token=TENANT_TOKEN))
result = asyncio.run(interceptor(request, _echo_handler))
assert result.headers == {"Accept": "application/json", "X-Tenant-Token": TENANT_TOKEN}
def test_context_mapping_overrides_a_static_header():
"""The per-request credential must win over the discovery credential."""
interceptor = build_context_headers_interceptor(_config(headers={"Authorization": "tenant_token"}))
request = _request(headers={"Authorization": "Bearer discovery-token"}, runtime=_runtime_with_secrets(tenant_token=TENANT_TOKEN))
result = asyncio.run(interceptor(request, _echo_handler))
assert result.headers["Authorization"] == TENANT_TOKEN
def test_multiple_headers_are_mapped():
interceptor = build_context_headers_interceptor(_config(headers={"X-Tenant-Id": "tenant_id", "X-Org": "org"}))
runtime = _runtime_with_secrets(tenant_id="acme", org="engineering")
result = asyncio.run(interceptor(_request(runtime=runtime), _echo_handler))
assert result.headers == {"X-Tenant-Id": "acme", "X-Org": "engineering"}
def test_request_headers_are_not_mutated_in_place():
interceptor = build_context_headers_interceptor(_config(headers={"X-Tenant-Token": "tenant_token"}))
original = {"Accept": "application/json"}
asyncio.run(interceptor(_request(headers=original, runtime=_runtime_with_secrets(tenant_token=TENANT_TOKEN)), _echo_handler))
assert original == {"Accept": "application/json"}
def test_other_server_passes_through_untouched():
interceptor = build_context_headers_interceptor(_config(headers={"X-Tenant-Token": "tenant_token"}))
request = _request(server_name="other", headers={"Authorization": "Bearer static"}, runtime=_runtime_with_secrets(tenant_token=TENANT_TOKEN))
result = asyncio.run(interceptor(request, _echo_handler))
assert result is request
def test_falls_back_to_ambient_runtime_when_request_runtime_is_missing():
interceptor = build_context_headers_interceptor(_config(headers={"X-Tenant-Token": "tenant_token"}))
with patch(
"deerflow.mcp.context_headers._current_runtime",
return_value=_runtime_with_secrets(tenant_token=TENANT_TOKEN),
):
result = asyncio.run(interceptor(_request(runtime=None), _echo_handler))
assert result.headers["X-Tenant-Token"] == TENANT_TOKEN
# ---------------------------------------------------------------------------
# Header-name casing
#
# HTTP field names are case-insensitive, but every dict on the path to the wire
# is case-sensitive — including the adapter's ``{**connection_headers,
# **override_headers}`` merge. A mapped name spelled differently from the static
# one would therefore travel *alongside* it rather than replacing it, and a
# server reading the field with a single-value accessor would see the static
# discovery credential first.
# ---------------------------------------------------------------------------
def _config_with_static_headers(static: dict[str, str], **context_headers_kwargs) -> ExtensionsConfig:
config = _config(**context_headers_kwargs)
config.mcp_servers["shared-http"].headers = static
return config
def test_mapped_name_is_emitted_in_the_servers_static_spelling():
config = _config_with_static_headers({"authorization": "Bearer discovery-token"}, headers={"Authorization": "tenant_token"})
interceptor = build_context_headers_interceptor(config)
result = asyncio.run(interceptor(_request(runtime=_runtime_with_secrets(tenant_token=TENANT_TOKEN)), _echo_handler))
assert result.headers == {"authorization": TENANT_TOKEN}
def test_mapped_name_replaces_a_differently_cased_header_from_an_earlier_interceptor():
"""OAuth and user_auth write before this interceptor; their value must not survive.
The surviving spelling is whichever one is already on the request, so the
write lands on the existing field rather than adding a second one.
"""
config = _config_with_static_headers({}, headers={"authorization": "tenant_token"})
interceptor = build_context_headers_interceptor(config)
request = _request(headers={"Authorization": "Bearer per-user"}, runtime=_runtime_with_secrets(tenant_token=TENANT_TOKEN))
result = asyncio.run(interceptor(request, _echo_handler))
assert list(result.headers.values()) == [TENANT_TOKEN]
def test_unrelated_headers_keep_their_own_spelling():
config = _config_with_static_headers({"Authorization": "Bearer discovery-token"}, headers={"X-Tenant-Token": "tenant_token"})
interceptor = build_context_headers_interceptor(config)
request = _request(headers={"Accept": "application/json"}, runtime=_runtime_with_secrets(tenant_token=TENANT_TOKEN))
result = asyncio.run(interceptor(request, _echo_handler))
assert result.headers == {"Accept": "application/json", "X-Tenant-Token": TENANT_TOKEN}
def _connection_headers_for_adapter_call(config: ExtensionsConfig) -> dict[str, str]:
"""Return the headers the adapter would open the remote session with.
Goes through the real connection merge rather than seeding static headers
onto ``request.headers``: the adapter builds the request with
``headers=None``, so an interceptor never sees the connection's static
headers and the collision can only be observed here.
"""
from langchain_core.messages import AIMessage
from langchain_mcp_adapters.tools import convert_mcp_tool_to_langchain_tool
from langgraph.graph import END, START, StateGraph
from langgraph.prebuilt import ToolNode
from mcp.types import CallToolResult, TextContent
from mcp.types import Tool as MCPTool
from deerflow.mcp.client import build_server_params
opened: dict[str, str] = {}
class _Session:
async def initialize(self) -> None:
return None
async def call_tool(self, *_args, **_kwargs):
return CallToolResult(content=[TextContent(type="text", text="done")], isError=False)
class _SessionContext:
def __init__(self, connection, **_kwargs):
opened.update(connection.get("headers") or {})
async def __aenter__(self):
return _Session()
async def __aexit__(self, *_exc):
return False
tool = convert_mcp_tool_to_langchain_tool(
None,
MCPTool(name="act", description="act", inputSchema={"type": "object", "properties": {}}),
connection=build_server_params("shared-http", config.mcp_servers["shared-http"]),
server_name="shared-http",
tool_interceptors=build_mcp_tool_interceptors(config, oauth_builder=lambda _cfg: None),
)
builder = StateGraph(_AgentState, context_schema=dict)
builder.add_node("tools", ToolNode([tool]))
builder.add_edge(START, "tools")
builder.add_edge("tools", END)
graph = builder.compile()
with patch("langchain_mcp_adapters.tools.create_session", _SessionContext):
asyncio.run(
graph.ainvoke(
{"messages": [AIMessage(content="", tool_calls=[{"name": "act", "args": {}, "id": "call_1", "type": "tool_call"}])]},
context={"secrets": {"tenant_token": TENANT_TOKEN}, "thread_id": "th-1"},
)
)
return opened
def test_connection_carries_one_authorization_header_despite_a_casing_mismatch():
"""The reviewed failure: two spellings both reach httpx, static one first."""
config = _config_with_static_headers({"authorization": "Bearer discovery-token"}, headers={"Authorization": "tenant_token"})
opened = _connection_headers_for_adapter_call(config)
assert [name for name in opened if name.lower() == "authorization"] == ["authorization"]
assert opened["authorization"] == TENANT_TOKEN
def test_connection_keeps_static_headers_the_mapping_does_not_touch():
config = _config_with_static_headers({"Authorization": "Bearer discovery-token", "X-Api-Version": "2"}, headers={"X-Tenant-Token": "tenant_token"})
opened = _connection_headers_for_adapter_call(config)
assert opened == {"Authorization": "Bearer discovery-token", "X-Api-Version": "2", "X-Tenant-Token": TENANT_TOKEN}
def test_mapping_the_same_header_under_two_spellings_is_rejected():
with pytest.raises(ValueError, match="two spellings"):
McpContextHeadersConfig(headers={"Authorization": "tenant_token", "authorization": "other_token"})
def test_gateway_rejects_the_same_header_under_two_spellings():
from pydantic import ValidationError
from app.gateway.routers.mcp import McpContextHeadersConfigResponse
with pytest.raises(ValidationError, match="two spellings"):
McpContextHeadersConfigResponse(headers={"Authorization": "tenant_token", "AUTHORIZATION": "other_token"})
# ---------------------------------------------------------------------------
# Fail-closed behaviour
# ---------------------------------------------------------------------------
def test_missing_secret_denies_without_calling_handler():
interceptor = build_context_headers_interceptor(_config(headers={"X-Tenant-Token": "tenant_token"}))
handler = AsyncMock()
with pytest.raises(ToolException, match="tenant_token"):
asyncio.run(interceptor(_request(runtime=_runtime_with_secrets(unrelated="x")), handler))
handler.assert_not_awaited()
def test_empty_secret_value_is_denied():
"""An unset $ENV_VAR on the caller side arrives as "" and must fail closed."""
interceptor = build_context_headers_interceptor(_config(headers={"X-Tenant-Token": "tenant_token"}))
with pytest.raises(ToolException):
asyncio.run(interceptor(_request(runtime=_runtime_with_secrets(tenant_token="")), AsyncMock()))
def test_absent_run_context_is_denied():
interceptor = build_context_headers_interceptor(_config(headers={"X-Tenant-Token": "tenant_token"}))
with patch("deerflow.mcp.context_headers._current_runtime", return_value=None), pytest.raises(ToolException):
asyncio.run(interceptor(_request(runtime=None), AsyncMock()))
def test_deny_message_does_not_leak_other_secret_values():
interceptor = build_context_headers_interceptor(_config(headers={"X-Tenant-Token": "tenant_token"}))
runtime = _runtime_with_secrets(other_secret="super-secret-value")
with pytest.raises(ToolException) as excinfo:
asyncio.run(interceptor(_request(runtime=runtime), AsyncMock()))
assert "super-secret-value" not in str(excinfo.value)
def test_on_missing_passthrough_keeps_static_headers():
interceptor = build_context_headers_interceptor(_config(headers={"Authorization": "tenant_token"}, on_missing="passthrough"))
request = _request(headers={"Authorization": "Bearer discovery-token"}, runtime=_runtime_with_secrets())
result = asyncio.run(interceptor(request, _echo_handler))
assert result.headers["Authorization"] == "Bearer discovery-token"
def test_passthrough_still_injects_the_secrets_that_are_present():
interceptor = build_context_headers_interceptor(_config(headers={"X-Tenant-Id": "tenant_id", "X-Org": "org"}, on_missing="passthrough"))
result = asyncio.run(interceptor(_request(runtime=_runtime_with_secrets(tenant_id="acme")), _echo_handler))
assert result.headers == {"X-Tenant-Id": "acme"}
# ---------------------------------------------------------------------------
# Illegal header values
#
# A secret that cannot travel as an HTTP header value (trailing newline from
# reading a token file, CR/LF, characters outside ASCII) must be rejected
# here, before it reaches the HTTP client. On the line break and whitespace
# cases h11 renders the full value into its LocalProtocolError message,
# ToolErrorHandlingMiddleware copies that message into a model-visible
# ToolMessage, and the secret lands in the prompt, the checkpoint, and traces —
# everywhere this module promises it never goes. Non-ASCII fails earlier,
# inside httpx, with only the offending character in the message.
# ---------------------------------------------------------------------------
def test_secret_with_trailing_newline_is_denied_without_calling_handler():
interceptor = build_context_headers_interceptor(_config(headers={"X-Tenant-Token": "tenant_token"}))
handler = AsyncMock()
with pytest.raises(ToolException, match="tenant_token"):
asyncio.run(interceptor(_request(runtime=_runtime_with_secrets(tenant_token=TENANT_TOKEN + "\n")), handler))
handler.assert_not_awaited()
def test_illegal_value_deny_message_does_not_contain_the_value():
interceptor = build_context_headers_interceptor(_config(headers={"X-Tenant-Token": "tenant_token"}))
with pytest.raises(ToolException) as excinfo:
asyncio.run(interceptor(_request(runtime=_runtime_with_secrets(tenant_token="sk-secret-value\n")), AsyncMock()))
assert "sk-secret-value" not in str(excinfo.value)
def test_illegal_value_warning_log_does_not_contain_the_value(caplog):
interceptor = build_context_headers_interceptor(_config(headers={"X-Tenant-Token": "tenant_token"}))
with caplog.at_level(logging.WARNING, logger="deerflow.mcp.context_headers"), pytest.raises(ToolException):
asyncio.run(interceptor(_request(runtime=_runtime_with_secrets(tenant_token="sk-secret-value\n")), AsyncMock()))
assert "sk-secret-value" not in caplog.text
assert "tenant_token" in caplog.text
def test_embedded_crlf_is_denied():
interceptor = build_context_headers_interceptor(_config(headers={"X-Tenant-Token": "tenant_token"}))
with pytest.raises(ToolException, match="tenant_token"):
asyncio.run(interceptor(_request(runtime=_runtime_with_secrets(tenant_token="a\r\nX-Injected: b")), AsyncMock()))
def test_non_ascii_value_is_denied():
"""httpx encodes str header values as ASCII and raises UnicodeEncodeError otherwise."""
interceptor = build_context_headers_interceptor(_config(headers={"X-Tenant-Token": "tenant_token"}))
for value in ("пароль", "Bearer caf\xe9"):
with pytest.raises(ToolException, match="tenant_token"):
asyncio.run(interceptor(_request(runtime=_runtime_with_secrets(tenant_token=value)), AsyncMock()))
def test_leading_or_trailing_whitespace_is_denied():
"""h11 rejects field values with leading/trailing SP or HTAB."""
interceptor = build_context_headers_interceptor(_config(headers={"X-Tenant-Token": "tenant_token"}))
for value in ("Bearer x ", " Bearer x", "Bearer x\t"):
with pytest.raises(ToolException, match="tenant_token"):
asyncio.run(interceptor(_request(runtime=_runtime_with_secrets(tenant_token=value)), AsyncMock()))
def test_illegal_value_is_denied_even_with_on_missing_passthrough():
"""passthrough covers an *absent* key; a present-but-broken value must not
silently fall back to the shared discovery credential."""
interceptor = build_context_headers_interceptor(_config(headers={"X-Tenant-Token": "tenant_token"}, on_missing="passthrough"))
with pytest.raises(ToolException, match="tenant_token"):
asyncio.run(interceptor(_request(runtime=_runtime_with_secrets(tenant_token=TENANT_TOKEN + "\n")), AsyncMock()))
def test_values_with_embedded_spaces_and_tabs_are_not_rejected():
"""h11 allows SP/HTAB between visible characters — 'Bearer <token>' must pass."""
interceptor = build_context_headers_interceptor(_config(headers={"X-Tenant-Token": "tenant_token"}))
result = asyncio.run(interceptor(_request(runtime=_runtime_with_secrets(tenant_token="Bearer abc\tdef ghi")), _echo_handler))
assert result.headers["X-Tenant-Token"] == "Bearer abc\tdef ghi"
def test_one_illegal_mapping_denies_the_whole_call():
"""One broken credential denies the call; it must not partially inject."""
interceptor = build_context_headers_interceptor(_config(headers={"X-Tenant-Id": "tenant_id", "X-Org": "org"}))
handler = AsyncMock()
with pytest.raises(ToolException, match="org"):
asyncio.run(interceptor(_request(runtime=_runtime_with_secrets(tenant_id="acme", org="bad\n")), handler))
handler.assert_not_awaited()
# ---------------------------------------------------------------------------
# Config model
# ---------------------------------------------------------------------------
def test_blank_header_name_is_rejected():
with pytest.raises(ValueError, match="header name"):
McpContextHeadersConfig(headers={" ": "tenant_token"})
def test_blank_secret_key_is_rejected():
with pytest.raises(ValueError, match="secret key"):
McpContextHeadersConfig(headers={"X-Tenant-Token": ""})
def test_config_round_trips_from_file(tmp_path):
config_file = tmp_path / "extensions_config.json"
config_file.write_text(
"""
{
"mcpServers": {
"shared-http": {
"enabled": true,
"transport": "http",
"url": "https://mcp.example.com/mcp",
"headers_from_context": {"headers": {"X-Tenant-Token": "tenant_token"}}
}
}
}
"""
)
config = ExtensionsConfig.from_file(str(config_file))
block = config.mcp_servers["shared-http"].headers_from_context
assert block is not None
assert block.enabled is True
assert block.on_missing == "deny"
assert block.headers == {"X-Tenant-Token": "tenant_token"}
def test_mapping_values_are_not_env_resolved(tmp_path, monkeypatch):
"""The right-hand side names a run-context key, not an environment variable."""
monkeypatch.setenv("tenant_token", "must-not-be-substituted")
config_file = tmp_path / "extensions_config.json"
config_file.write_text(
"""
{
"mcpServers": {
"shared-http": {
"enabled": true,
"transport": "http",
"url": "https://mcp.example.com/mcp",
"headers_from_context": {"headers": {"X-Tenant-Token": "tenant_token"}}
}
}
}
"""
)
config = ExtensionsConfig.from_file(str(config_file))
assert config.mcp_servers["shared-http"].headers_from_context.headers == {"X-Tenant-Token": "tenant_token"}
# ---------------------------------------------------------------------------
# Interceptor chain assembly
# ---------------------------------------------------------------------------
def test_registered_last_so_request_secrets_win():
"""Later interceptors run closer to the transport, so per-request values win."""
config = _config(headers={"Authorization": "tenant_token"})
config.mcp_servers["shared-http"].user_auth = McpUserScopedAuthConfig(users={"u1": "Bearer per-user"})
async def oauth(request, handler): # pragma: no cover - identity only
return await handler(request)
interceptors = build_mcp_tool_interceptors(config, oauth_builder=lambda _cfg: oauth)
assert [getattr(i, "__name__", type(i).__name__) for i in interceptors] == [
"oauth",
"user_scoped_auth_interceptor",
"context_headers_interceptor",
]
def test_shared_assembly_skips_when_not_configured():
config = ExtensionsConfig(
mcp_servers={"plain": McpServerConfig(enabled=True, type="http", url="https://x.example.com")},
skills={},
)
assert build_mcp_tool_interceptors(config, oauth_builder=lambda _cfg: None) == []
# ---------------------------------------------------------------------------
# End-to-end contract with LangGraph + langchain-mcp-adapters
# ---------------------------------------------------------------------------
def _run_adapter_tool_in_graph(*, isolate_request_runtime: bool = False) -> dict[str, Any]:
"""Drive a real adapter tool through a real graph; return the headers it sent.
DeerFlow does not wrap HTTP/SSE MCP tools, so the tool under test here is the
one ``langchain_mcp_adapters`` builds, invoked by LangGraph's own tool node.
With *isolate_request_runtime* the ambient-runtime fallback is disabled, so
the secrets can only arrive through the runtime LangGraph injected into the
adapter tool's ``runtime`` parameter.
"""
from langchain_core.messages import AIMessage
from langchain_mcp_adapters.tools import convert_mcp_tool_to_langchain_tool
from langgraph.graph import END, START, StateGraph
from langgraph.prebuilt import ToolNode
from mcp.types import CallToolResult, TextContent
from mcp.types import Tool as MCPTool
seen_headers: dict[str, Any] = {}
class _FakeSession:
async def call_tool(self, name, args, **kwargs):
return CallToolResult(content=[TextContent(type="text", text="done")], isError=False)
async def _capture_headers(request, handler):
seen_headers.update(request.headers or {})
return await handler(request)
tool = convert_mcp_tool_to_langchain_tool(
_FakeSession(),
MCPTool(name="act", description="act", inputSchema={"type": "object", "properties": {}}),
server_name="shared-http",
tool_interceptors=[
build_context_headers_interceptor(_config(headers={"X-Tenant-Token": "tenant_token"})),
_capture_headers,
],
)
builder = StateGraph(_AgentState, context_schema=dict)
builder.add_node("tools", ToolNode([tool]))
builder.add_edge(START, "tools")
builder.add_edge("tools", END)
graph = builder.compile()
def _invoke() -> None:
asyncio.run(
graph.ainvoke(
{"messages": [AIMessage(content="", tool_calls=[{"name": "act", "args": {}, "id": "call_1", "type": "tool_call"}])]},
context={"secrets": {"tenant_token": TENANT_TOKEN}, "thread_id": "th-1"},
)
)
if isolate_request_runtime:
with patch("deerflow.mcp.context_headers._current_runtime", return_value=None):
_invoke()
else:
_invoke()
return seen_headers
def test_request_secret_reaches_a_real_adapter_tool_call():
"""The user-facing contract: a per-request secret lands on the outgoing call."""
assert _run_adapter_tool_in_graph().get("X-Tenant-Token") == TENANT_TOKEN
def test_adapter_tool_receives_the_runtime_langgraph_injects():
"""Pin the injection rule the HTTP/SSE path depends on.
``langchain_mcp_adapters`` names its tool parameter ``runtime``, and
LangGraph's tool node injects a ``ToolRuntime`` into any parameter with that
name. With the ambient-runtime fallback disabled, that channel is the only
way the secrets can arrive — so an upstream rename or a change to the
injection rule fails here instead of silently dropping every header.
"""
assert _run_adapter_tool_in_graph(isolate_request_runtime=True).get("X-Tenant-Token") == TENANT_TOKEN
# ---------------------------------------------------------------------------
# Durable background tasks
# ---------------------------------------------------------------------------
def _task_config() -> ExtensionsConfig:
return ExtensionsConfig.model_validate(
{
"mcpServers": {
"reports": {
"type": "http",
"url": "https://reports.example.com/mcp",
"headers": {"Authorization": "Bearer discovery-token"},
"headers_from_context": {"headers": {"Authorization": "tenant_token"}},
"task_toolsets": [{"name": "reports", "submit_tool": "submit", "status_tool": "status", "cancel_tool": "cancel"}],
}
}
}
)
def _task_caller(config: ExtensionsConfig) -> tuple[Any, dict[str, str], Any]:
"""Build a task caller whose remote session records the headers it opened with."""
from deerflow.mcp.task_tool_caller import McpTaskToolCaller
opened: dict[str, str] = {}
result = SimpleNamespace(structuredContent={"task_id": "remote-1", "status": "running"}, isError=False)
class _SessionContext:
def __init__(self, connection, **_kwargs):
opened.clear()
opened.update(connection.get("headers") or {})
async def __aenter__(self):
return SimpleNamespace(initialize=AsyncMock(), call_tool=AsyncMock(return_value=result))
async def __aexit__(self, *_exc):
return False
caller = McpTaskToolCaller(
config,
oauth_token_manager=SimpleNamespace(has_oauth_servers=lambda: False, get_authorization_header=AsyncMock(return_value=None)),
)
return caller, opened, _SessionContext
_DRIVER_DATA = {"submit_tool": "submit", "status_tool": "status", "cancel_tool": "cancel"}
def test_durable_submit_carries_the_request_scoped_headers():
"""Submit is awaited inside the Agent run, so it can — and must — carry them.
Driven through a real tool node with no ambient-runtime patching: the run
context reaches the driver through the contextvar LangGraph sets around the
tool coroutine, several awaits below it.
"""
from langchain_core.messages import AIMessage
from langchain_core.tools import tool as make_tool
from langgraph.graph import END, START, StateGraph
from langgraph.prebuilt import ToolNode
from deerflow.mcp.tasks import TaskSubmitRequest
from deerflow.mcp.tasks.ordinary import OrdinaryMcpTaskDriver
caller, opened, session_context = _task_caller(_task_config())
driver = OrdinaryMcpTaskDriver(caller)
@make_tool
async def submit_report() -> str:
"""Submit a durable report task."""
await driver.submit(
TaskSubmitRequest(
user_id="user-1",
thread_id="thread-1",
run_id=None,
tool_call_id=None,
server_name="reports",
task_name="reports",
arguments={},
driver_data=dict(_DRIVER_DATA),
)
)
return "submitted"
builder = StateGraph(_AgentState, context_schema=dict)
builder.add_node("tools", ToolNode([submit_report]))
builder.add_edge(START, "tools")
builder.add_edge("tools", END)
graph = builder.compile()
with patch("langchain_mcp_adapters.sessions.create_session", session_context):
asyncio.run(
graph.ainvoke(
{"messages": [AIMessage(content="", tool_calls=[{"name": "submit_report", "args": {}, "id": "call_1", "type": "tool_call"}])]},
context={"secrets": {"tenant_token": TENANT_TOKEN}, "thread_id": "thread-1"},
)
)
assert opened == {"Authorization": TENANT_TOKEN}
@pytest.mark.asyncio
async def test_durable_status_poll_keeps_the_server_credential():
"""The poller runs after the Agent run ended: no run context, no deny."""
from deerflow.mcp.tasks.models import TaskReference
from deerflow.mcp.tasks.ordinary import OrdinaryMcpTaskDriver
caller, opened, session_context = _task_caller(_task_config())
driver = OrdinaryMcpTaskDriver(caller)
with patch("langchain_mcp_adapters.sessions.create_session", session_context):
snapshot = await driver.get_status(
TaskReference(
local_task_id="local-1",
user_id="user-1",
thread_id="thread-1",
server_name="reports",
remote_task_id="remote-1",
driver_data=dict(_DRIVER_DATA),
)
)
assert snapshot is not None
assert opened == {"Authorization": "Bearer discovery-token"}
def test_declaring_both_request_headers_and_task_toolsets_warns(caplog):
"""Background polls run outside the Agent run that carried the secrets."""
config = _config(headers={"X-Tenant-Token": "tenant_token"})
config.mcp_servers["shared-http"].task_toolsets = [McpTaskToolsetConfig(name="reports", submit_tool="submit", status_tool="status", cancel_tool="cancel")]
with caplog.at_level(logging.WARNING, logger="deerflow.mcp.context_headers"):
assert build_context_headers_interceptor(config) is not None
assert "task_toolsets" in caplog.text
@pytest.mark.asyncio
async def test_durable_task_calls_are_not_denied_for_a_missing_run_context():
"""The task runtime must keep polling on server-level auth, not fail closed."""
from unittest.mock import MagicMock
from deerflow.mcp.task_tool_caller import McpTaskToolCaller
config = ExtensionsConfig.model_validate(
{
"mcpServers": {
"reports": {
"type": "http",
"url": "https://reports.example.com/mcp",
"headers": {"X-Static": "configured"},
"headers_from_context": {"headers": {"X-Tenant-Token": "tenant_token"}},
}
}
}
)
result = SimpleNamespace(structuredContent={"task_id": "remote-1", "status": "running"}, isError=False)
session = SimpleNamespace(initialize=AsyncMock(), call_tool=AsyncMock(return_value=result))
class _SessionContext:
async def __aenter__(self):
return session
async def __aexit__(self, *_exc):
return False
caller = McpTaskToolCaller(
config,
oauth_token_manager=SimpleNamespace(has_oauth_servers=lambda: False, get_authorization_header=AsyncMock(return_value=None)),
)
with patch("langchain_mcp_adapters.sessions.create_session", MagicMock(return_value=_SessionContext())):
actual = await caller.call_tool(
server_name="reports",
tool_name="status",
arguments={"task_id": "remote-1"},
user_id="user-1",
thread_id="thread-1",
)
assert actual is result
# ---------------------------------------------------------------------------
# Gateway API surface
# ---------------------------------------------------------------------------
def test_gateway_exposes_mapping_without_masking():
"""The block holds header names and run-context key names, never a credential."""
from app.gateway.routers.mcp import (
McpContextHeadersConfigResponse,
McpServerConfigResponse,
_mask_server_config,
)
server = McpServerConfigResponse(
type="http",
url="https://mcp.example.com/mcp",
headers_from_context=McpContextHeadersConfigResponse(headers={"X-Tenant-Token": "tenant_token"}),
)
masked = _mask_server_config(server)
assert masked.headers_from_context.headers == {"X-Tenant-Token": "tenant_token"}
def test_gateway_masks_sensitive_extras_inside_the_block():
"""``extra="allow"`` means an operator can still store a secret-bearing key here."""
from app.gateway.routers.mcp import (
McpContextHeadersConfigResponse,
McpServerConfigResponse,
_mask_server_config,
)
server = McpServerConfigResponse(
type="http",
url="https://mcp.example.com/mcp",
headers_from_context=McpContextHeadersConfigResponse(headers={"X-Tenant-Token": "tenant_token"}, api_key="real-secret"),
)
masked = _mask_server_config(server)
assert masked.headers_from_context.model_extra["api_key"] == "***"
assert masked.headers_from_context.headers == {"X-Tenant-Token": "tenant_token"}
def test_gateway_merge_preserves_block_when_field_omitted():
from app.gateway.routers.mcp import (
McpContextHeadersConfigResponse,
McpServerConfigResponse,
_merge_preserving_secrets,
)
existing = McpServerConfigResponse(
type="http",
url="https://mcp.example.com/mcp",
headers_from_context=McpContextHeadersConfigResponse(headers={"X-Tenant-Token": "tenant_token"}),
)
incoming = McpServerConfigResponse(type="http", url="https://mcp.example.com/mcp")
merged = _merge_preserving_secrets(incoming, existing)
assert merged.headers_from_context is not None
assert merged.headers_from_context.headers == {"X-Tenant-Token": "tenant_token"}
def test_gateway_put_can_replace_the_mapping():
from app.gateway.routers.mcp import (
McpContextHeadersConfigResponse,
McpServerConfigResponse,
_merge_preserving_secrets,
)
existing = McpServerConfigResponse(
type="http",
url="https://mcp.example.com/mcp",
headers_from_context=McpContextHeadersConfigResponse(headers={"X-Tenant-Token": "tenant_token"}),
)
incoming = McpServerConfigResponse(
type="http",
url="https://mcp.example.com/mcp",
headers_from_context=McpContextHeadersConfigResponse(headers={"X-Org": "org"}, on_missing="passthrough"),
)
merged = _merge_preserving_secrets(incoming, existing)
assert merged.headers_from_context.headers == {"X-Org": "org"}
assert merged.headers_from_context.on_missing == "passthrough"
def test_gateway_partial_block_preserves_stored_mapping_and_policy():
"""A partial headers_from_context PUT must not wipe omitted declared fields."""
from app.gateway.routers.mcp import (
McpContextHeadersConfigResponse,
McpServerConfigResponse,
_merge_preserving_secrets,
)
existing = McpServerConfigResponse(
type="http",
url="https://mcp.example.com/mcp",
headers_from_context=McpContextHeadersConfigResponse(
headers={"X-Tenant-Token": "tenant_token"},
on_missing="passthrough",
),
)
incoming = McpServerConfigResponse(
type="http",
url="https://mcp.example.com/mcp",
headers_from_context=McpContextHeadersConfigResponse(enabled=False),
)
merged = _merge_preserving_secrets(incoming, existing)
assert merged.headers_from_context is not None
assert merged.headers_from_context.enabled is False
assert merged.headers_from_context.headers == {"X-Tenant-Token": "tenant_token"}
assert merged.headers_from_context.on_missing == "passthrough"
def test_gateway_partial_block_explicit_empty_mapping_still_clears():
"""An explicitly supplied empty mapping must clear the stored mapping, not preserve it."""
from app.gateway.routers.mcp import (
McpContextHeadersConfigResponse,
McpServerConfigResponse,
_merge_preserving_secrets,
)
existing = McpServerConfigResponse(
type="http",
url="https://mcp.example.com/mcp",
headers_from_context=McpContextHeadersConfigResponse(
headers={"X-Tenant-Token": "tenant_token"},
on_missing="passthrough",
),
)
incoming = McpServerConfigResponse(
type="http",
url="https://mcp.example.com/mcp",
headers_from_context=McpContextHeadersConfigResponse(headers={}),
)
merged = _merge_preserving_secrets(incoming, existing)
assert merged.headers_from_context is not None
assert merged.headers_from_context.headers == {}
assert merged.headers_from_context.on_missing == "passthrough"
def test_gateway_round_trip_restores_masked_extras_inside_the_block():
"""GET masks the block's extras, so PUT must swap the sentinel back."""
from app.gateway.routers.mcp import (
McpContextHeadersConfigResponse,
McpServerConfigResponse,
_mask_server_config,
_merge_preserving_secrets,
)
existing = McpServerConfigResponse(
type="http",
url="https://mcp.example.com/mcp",
headers_from_context=McpContextHeadersConfigResponse(headers={"X-Tenant-Token": "tenant_token"}, api_key="real-secret", note="kept"),
)
merged = _merge_preserving_secrets(_mask_server_config(existing), existing)
assert merged.headers_from_context.model_extra["api_key"] == "real-secret"
assert merged.headers_from_context.model_extra["note"] == "kept"
def test_gateway_keeps_block_extras_a_put_does_not_mention():
"""Matches how user_auth and server-level extras survive a partial PUT."""
from app.gateway.routers.mcp import (
McpContextHeadersConfigResponse,
McpServerConfigResponse,
_merge_preserving_secrets,
)
existing = McpServerConfigResponse(
type="http",
url="https://mcp.example.com/mcp",
headers_from_context=McpContextHeadersConfigResponse(headers={"X-Tenant-Token": "tenant_token"}, vendor_note="keep-me"),
)
incoming = McpServerConfigResponse(
type="http",
url="https://mcp.example.com/mcp",
headers_from_context=McpContextHeadersConfigResponse(headers={"X-Org": "org"}),
)
merged = _merge_preserving_secrets(incoming, existing)
assert merged.headers_from_context.headers == {"X-Org": "org"}
assert merged.headers_from_context.model_extra["vendor_note"] == "keep-me"
def test_gateway_rejects_a_masked_value_for_an_unknown_block_extra():
"""A sentinel with nothing stored behind it must not be written to disk."""
from fastapi import HTTPException
from app.gateway.routers.mcp import (
McpContextHeadersConfigResponse,
McpServerConfigResponse,
_merge_preserving_secrets,
)
existing = McpServerConfigResponse(
type="http",
url="https://mcp.example.com/mcp",
headers_from_context=McpContextHeadersConfigResponse(headers={"X-Tenant-Token": "tenant_token"}),
)
incoming = McpServerConfigResponse(
type="http",
url="https://mcp.example.com/mcp",
headers_from_context=McpContextHeadersConfigResponse(headers={"X-Tenant-Token": "tenant_token"}, api_key="***"),
)
with pytest.raises(HTTPException):
_merge_preserving_secrets(incoming, existing)