deer-flow/backend/tests/test_mcp_header_names.py
青榆牧 a94b2d8897
feat(mcp): map request-scoped secrets to MCP HTTP/SSE headers (#5010)
* feat(mcp): map request-scoped secrets to HTTP/SSE headers

`user_auth` binds a credential to a configured DeerFlow user, so a caller
that picks the credential per request — a multi-tenant gateway, a per-run
API key, one shared MCP server fronting several environments — had to
register one MCP server entry per credential.

Add a declarative `mcpServers.<server>.headers_from_context` block mapping
HTTP header names to keys of the run request's `config.context.secrets`
carrier. A new built-in interceptor resolves the mapping on every tool call
and rewrites those headers, mirroring `user_scoped_auth`. The config file
stores names only, never a credential, so the Gateway returns the block
unmasked.

Registered after OAuth and `user_auth` in the interceptor chain: the later
interceptor runs closer to the transport, and the value chosen for this one
request is the most specific, so it wins. Fail-closed by default — a mapped
key missing from the request raises a `ToolException` naming only that key,
because falling back to the server's discovery credential would send one
tenant's call under another tenant's authority. `on_missing: "passthrough"`
opts out.

Durable background tasks are excluded: `McpTaskToolCaller` drives status and
cancel polls after the Agent run ends, where no run context exists, so the
fail-closed interceptor would deny every poll. Those calls keep using
server-level credentials, and a server declaring both `headers_from_context`
and `task_toolsets` now logs a warning.

Also corrects the custom-interceptor example in docs/MCP_SERVER.md (and the
matching claim in skills/AGENTS.md), which read request secrets from
`langgraph.config.get_config()["context"]`. That key is `None` inside a tool
call — the run context rides the LangGraph runtime, not the RunnableConfig
propagated to child runnables — so interceptors written from that example
never saw a value. The example now reads `request.runtime`, and
tests/test_mcp_context_headers.py pins LangGraph's runtime-injection rule by
driving a real langchain-mcp-adapters tool through a real graph with the
ambient-runtime fallback disabled.

Closes #5005

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(mcp): resolve credential headers case-insensitively, carry them on durable submit

Review follow-ups on `headers_from_context`.

HTTP field names are case-insensitive, but every dict on the path to the wire
is not: `build_server_params` copies the operator's static `headers` spelling
verbatim, and langchain-mcp-adapters merges interceptor overrides into the
connection with a plain `{**connection_headers, **override_headers}` splat. A
static `authorization` and an injected `Authorization` therefore both reached
httpx as separate field lines, and a server reading the field with a
single-value accessor got the static discovery credential — inverting the
documented `headers` < `oauth` < `user_auth` < `headers_from_context`
precedence and running a per-request call under the shared credential.

Normalizing inside the interceptor cannot fix that on its own: the adapter
builds the request with `headers=None`, so an interceptor never sees the
connection's static headers and cannot displace them however it spells its own
key. A new `mcp/headers.py::apply_header_overrides` therefore drops any key
differing only in case and emits the spelling the connection already uses.
Applied to `headers_from_context`, `user_auth`, the OAuth interceptor, the
OAuth discovery-header write, and the durable-task connection merge, which all
carried the same collision. `headers_from_context.headers` now also rejects one
header mapped under two spellings at config load, in both the harness model and
the Gateway mirror.

Durable submit now carries the mapped headers, as docs/MCP_SERVER.md already
promised. `McpTaskToolCaller` disabled the interceptor for the whole caller, but
that caller serves submit as well as the polls, and submit is awaited inline
inside the Agent's tool call — where the run's LangGraph runtime is still the
ambient contextvar, so no secret has to be threaded through `TaskSubmitRequest`
or reach durable storage. The caller builds one chain and keeps a second view of
it without the context-headers interceptor; `call_tool` takes
`request_scoped_headers`, set only by `OrdinaryMcpTaskDriver.submit`. Status and
cancel keep server-level credentials, so background polls still cannot fail
closed, and the startup warning now describes the half it actually covers.

`_merge_preserving_secrets` restores masked extras inside `headers_from_context`
instead of writing the `***` sentinel back over the stored value, matching the
treatment `user_auth` extras and server-level extras already get; extras a PUT
omits carry over as well, while the declared mapping still replaces verbatim so
a round trip can remove an entry. `extra="allow"` plus name-based sensitivity
detection means the usual casualty is a name-valued key such as `tokenHeader`,
not only a credential.

The existing override test seeded the static header onto `request.headers`,
which production never does, so it modelled a merge that really happens one
layer down; the new tests drive a real adapter tool through a real connection
and assert on the headers the session is opened with, and the durable-submit
test runs through a real tool node with no runtime patching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(mcp): reject case-insensitive duplicate static header names

* fix(mcp): preserve omitted headers_from_context fields on partial updates

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:42:42 +08:00

203 lines
7.5 KiB
Python

"""Tests for case-insensitive header writes shared by the MCP interceptors.
HTTP field names are case-insensitive, but the dicts carrying them are not: a
static ``authorization`` and an injected ``Authorization`` are two keys, both
reach httpx, and a server reading the field with a single-value accessor gets
the *static* one — the credential the injection was meant to replace.
"""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from langchain_mcp_adapters.interceptors import MCPToolCallRequest
from pydantic import ValidationError
from deerflow.config.extensions_config import ExtensionsConfig, McpServerConfig, McpUserScopedAuthConfig
from deerflow.mcp.headers import apply_header_overrides, header_spellings
from deerflow.mcp.oauth import build_oauth_tool_interceptor
from deerflow.mcp.user_scoped_auth import build_user_scoped_auth_interceptor
DISCOVERY = "Bearer discovery-token"
def _request(headers: dict | None = None, runtime: object | None = None, server_name: str = "shared-http") -> MCPToolCallRequest:
return MCPToolCallRequest(name="act", args={}, server_name=server_name, headers=headers, runtime=runtime)
async def _echo_handler(request: MCPToolCallRequest) -> MCPToolCallRequest:
return request
# ---------------------------------------------------------------------------
# apply_header_overrides
# ---------------------------------------------------------------------------
def test_override_replaces_a_differently_cased_key():
assert apply_header_overrides({"Authorization": DISCOVERY}, {"authorization": "Bearer new"}) == {"Authorization": "Bearer new"}
def test_override_prefers_the_connection_spelling():
"""The emitted name must match the connection's, since the adapter merges by key."""
merged = apply_header_overrides(
{"Authorization": "Bearer from-an-earlier-interceptor"},
{"AUTHORIZATION": "Bearer new"},
spellings=header_spellings(["authorization"]),
)
assert merged == {"authorization": "Bearer new"}
def test_override_keeps_unrelated_headers():
merged = apply_header_overrides({"Accept": "application/json"}, {"X-Tenant-Id": "acme"})
assert merged == {"Accept": "application/json", "X-Tenant-Id": "acme"}
def test_override_accepts_no_base():
assert apply_header_overrides(None, {"X-Tenant-Id": "acme"}) == {"X-Tenant-Id": "acme"}
def test_override_does_not_mutate_the_base():
base = {"Authorization": DISCOVERY}
apply_header_overrides(base, {"authorization": "Bearer new"})
assert base == {"Authorization": DISCOVERY}
# ---------------------------------------------------------------------------
# Static header spelling validation
# ---------------------------------------------------------------------------
def test_static_headers_reject_case_insensitive_duplicates():
"""Two spellings of one header in the static map must be rejected at config time."""
with pytest.raises(ValueError, match="two spellings"):
McpServerConfig(
type="http",
url="https://mcp.example.com/mcp",
headers={"Authorization": "Bearer a", "authorization": "Bearer b"},
)
def test_static_headers_allow_distinct_names():
config = McpServerConfig(
type="http",
url="https://mcp.example.com/mcp",
headers={"X-Tenant": "acme", "X-Org": "engineering"},
)
assert config.headers == {"X-Tenant": "acme", "X-Org": "engineering"}
def test_extensions_config_rejects_static_header_duplicates():
with pytest.raises(ValidationError, match="two spellings"):
ExtensionsConfig.model_validate(
{
"mcpServers": {
"shared-http": {
"type": "http",
"url": "https://mcp.example.com/mcp",
"headers": {"Authorization": "Bearer a", "authorization": "Bearer b"},
}
}
}
)
def test_gateway_rejects_static_header_case_insensitive_duplicates():
from app.gateway.routers.mcp import McpServerConfigResponse
with pytest.raises(ValidationError, match="two spellings"):
McpServerConfigResponse(headers={"Authorization": "Bearer a", "AUTHORIZATION": "Bearer b"})
# ---------------------------------------------------------------------------
# The credential interceptors
# ---------------------------------------------------------------------------
def _server(**overrides) -> ExtensionsConfig:
return ExtensionsConfig(
mcp_servers={
"shared-http": McpServerConfig(
enabled=True,
type="http",
url="https://mcp.example.com/mcp",
headers={"authorization": DISCOVERY},
**overrides,
)
},
skills={},
)
def test_user_auth_credential_replaces_a_differently_cased_static_header():
config = _server(user_auth=McpUserScopedAuthConfig(header="Authorization", users={"u1": "Bearer per-user"}))
interceptor = build_user_scoped_auth_interceptor(config)
runtime = SimpleNamespace(server_info=None, context={"user_id": "u1"})
result = asyncio.run(interceptor(_request(runtime=runtime), _echo_handler))
assert result.headers == {"authorization": "Bearer per-user"}
def test_oauth_token_replaces_a_differently_cased_static_header():
config = _server(
oauth={
"enabled": True,
"token_url": "https://auth.example.com/oauth/token",
"client_id": "id",
"client_secret": "secret",
}
)
token_manager = SimpleNamespace(
has_oauth_servers=lambda: True,
get_authorization_header=AsyncMock(return_value="Bearer oauth-token"),
)
interceptor = build_oauth_tool_interceptor(config, token_manager=token_manager)
result = asyncio.run(interceptor(_request(), _echo_handler))
assert result.headers == {"authorization": "Bearer oauth-token"}
@pytest.mark.asyncio
async def test_durable_task_call_sends_one_authorization_header():
"""The task caller merges OAuth and interceptor headers into the connection itself."""
from deerflow.mcp.task_tool_caller import McpTaskToolCaller
config = ExtensionsConfig.model_validate(
{
"mcpServers": {
"reports": {
"type": "http",
"url": "https://reports.example.com/mcp",
"headers": {"authorization": DISCOVERY},
}
}
}
)
opened: dict[str, str] = {}
result = SimpleNamespace(structuredContent={"task_id": "remote-1", "status": "running"}, isError=False)
class _SessionContext:
def __init__(self, connection, **_kwargs):
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="Bearer oauth-token")),
)
with patch("langchain_mcp_adapters.sessions.create_session", _SessionContext):
await caller.call_tool(
server_name="reports",
tool_name="status",
arguments={"task_id": "remote-1"},
user_id="user-1",
thread_id="thread-1",
)
assert opened == {"authorization": "Bearer oauth-token"}