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>
This commit is contained in:
青榆牧 2026-08-27 10:42:42 +08:00 committed by GitHub
parent cb24bc2699
commit a94b2d8897
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1734 additions and 22 deletions

View File

@ -370,6 +370,43 @@ class McpUserScopedAuthConfigResponse(BaseModel):
return value
class McpContextHeadersConfigResponse(BaseModel):
"""Per-request credential injection configuration for an MCP server.
Holds header names and run-context key names only never a credential
so unlike ``user_auth`` its declared fields are returned unmasked by GET.
"""
enabled: bool = Field(default=True, description="Whether request-scoped header injection is enabled")
headers: dict[str, str] = Field(
default_factory=dict,
description="Map of HTTP header name to the key read from the run request's config.context.secrets",
)
on_missing: Literal["deny", "passthrough"] = Field(default="deny", description="Behavior when a mapped key is absent from the request secrets")
# Mirror the harness-side McpContextHeadersConfig (extra="allow"): without
# this, an operator's unknown key inside headers_from_context would be
# silently stripped by the next admin PUT.
model_config = ConfigDict(extra="allow")
# Mirror the harness-side entry check: a blank name accepted here would be
# persisted, then fail ExtensionsConfig validation on reload — wedging every
# subsequent config load until the file is hand-edited.
@field_validator("headers")
@classmethod
def _validate_mapping_entries(cls, value: dict[str, str]) -> dict[str, str]:
seen: dict[str, str] = {}
for header_name, secret_key in value.items():
if not header_name.strip():
raise ValueError("headers_from_context.headers must not contain a blank header name")
if not isinstance(secret_key, str) or not secret_key.strip():
raise ValueError(f"headers_from_context.headers[{header_name!r}] must name a non-blank secret key from config.context.secrets")
lowered = header_name.lower()
if lowered in seen:
raise ValueError(f"headers_from_context.headers maps the same HTTP header under two spellings ({seen[lowered]!r} and {header_name!r}); header names are case-insensitive, so keep only one")
seen[lowered] = header_name
return value
class McpOAuthConfigResponse(BaseModel):
"""OAuth configuration for an MCP server."""
@ -401,6 +438,7 @@ class McpServerConfigResponse(BaseModel):
headers: dict[str, str] = Field(default_factory=dict, description="HTTP headers to send (for sse or http type)")
oauth: McpOAuthConfigResponse | None = Field(default=None, description="OAuth configuration for MCP HTTP/SSE servers")
user_auth: McpUserScopedAuthConfigResponse | None = Field(default=None, description="Per-user credential injection for MCP HTTP/SSE servers")
headers_from_context: McpContextHeadersConfigResponse | None = Field(default=None, description="Per-request credential injection for MCP HTTP/SSE servers: map header names to config.context.secrets keys")
description: str = Field(default="", description="Human-readable description of what this MCP server provides")
routing: McpRoutingConfig = Field(default_factory=McpRoutingConfig, description="Soft routing hints for tools from this MCP server")
tools: dict[str, McpToolOverride] = Field(default_factory=dict, description="Per-original-tool MCP configuration overrides")
@ -422,6 +460,22 @@ class McpServerConfigResponse(BaseModel):
)
model_config = ConfigDict(extra="allow")
@field_validator("headers")
@classmethod
def _validate_header_names(cls, value: dict[str, str]) -> dict[str, str]:
# Mirror the harness-side McpServerConfig check: HTTP field names are
# case-insensitive, so a config carrying one header under two spellings
# would persist, reload into a connection with both fields, and let a
# later override replace only one of them. Reject at the API boundary
# instead of wedging the next ExtensionsConfig reload.
seen: dict[str, str] = {}
for header_name in value:
lowered = header_name.lower()
if lowered in seen:
raise ValueError(f"headers maps the same HTTP header under two spellings ({seen[lowered]!r} and {header_name!r}); header names are case-insensitive, so keep only one")
seen[lowered] = header_name
return value
@model_validator(mode="before")
@classmethod
def _accept_transport_alias(cls, data: Any) -> Any:
@ -685,6 +739,14 @@ def _mask_server_config(server: McpServerConfigResponse) -> McpServerConfigRespo
# cleartext from GET while the identical key at server level is masked.
masked_ua_extra = {key: _MASKED_VALUE if _is_sensitive_extra_key(key) else _mask_sensitive_extra_value(value) for key, value in (server.user_auth.model_extra or {}).items()}
masked_user_auth = server.user_auth.model_copy(update={"users": {k: _MASKED_VALUE for k in server.user_auth.users}, **masked_ua_extra})
masked_headers_from_context = None
if server.headers_from_context is not None:
# The declared fields hold names only and stay in cleartext — masking
# them would show operators `***` where a header name belongs. Extras
# get the same treatment as everywhere else, since `extra="allow"` lets
# an operator store a secret-bearing key that round-trips through PUT.
masked_ch_extra = {key: _MASKED_VALUE if _is_sensitive_extra_key(key) else _mask_sensitive_extra_value(value) for key, value in (server.headers_from_context.model_extra or {}).items()}
masked_headers_from_context = server.headers_from_context.model_copy(update=masked_ch_extra)
masked_extra = {key: _MASKED_VALUE if _is_sensitive_extra_key(key) else _mask_sensitive_extra_value(value) for key, value in (server.model_extra or {}).items()}
return server.model_copy(
update={
@ -692,6 +754,7 @@ def _mask_server_config(server: McpServerConfigResponse) -> McpServerConfigRespo
"headers": masked_headers,
"oauth": masked_oauth,
"user_auth": masked_user_auth,
"headers_from_context": masked_headers_from_context,
**masked_extra,
}
)
@ -801,14 +864,47 @@ def _merge_preserving_secrets(
effective["users"] = merged_users
merged_user_auth = McpUserScopedAuthConfigResponse(**effective)
merged_context_headers = incoming.headers_from_context
if incoming.headers_from_context is not None:
# Sub-field-aware merge, mirroring user_auth above: an explicit partial
# block (e.g. {"enabled": false}) must not wipe the stored mapping or
# reset on_missing back to its default. Only fields the request set are
# replaced, so an explicitly supplied ``headers`` (even {}) replaces the
# map while omitted fields carry over from the stored block.
incoming_ch = incoming.headers_from_context
base_ch = existing.headers_from_context
set_fields = incoming_ch.model_fields_set
effective: dict[str, Any] = {}
if base_ch is not None:
effective.update({name: getattr(base_ch, name) for name in ("enabled", "headers", "on_missing")})
effective.update(base_ch.model_extra or {})
for name in ("enabled", "headers", "on_missing"):
if name in set_fields:
effective[name] = getattr(incoming_ch, name)
# Extras are masked by GET (see _mask_server_config), so a round-trip
# PUT must swap masked sentinel values back for the stored ones — the
# same contract user_auth extras and server-level extras get.
base_ch_extra = (base_ch.model_extra or {}) if base_ch is not None else {}
for key, value in (incoming_ch.model_extra or {}).items():
effective[key] = _merge_extra_value_preserving_masked(
key,
value,
base_ch_extra.get(key),
existing_present=key in base_ch_extra,
)
merged_context_headers = McpContextHeadersConfigResponse(**effective)
update = {
"env": merged_env,
"headers": merged_headers,
"oauth": merged_oauth,
"user_auth": merged_user_auth,
"headers_from_context": merged_context_headers,
}
if "user_auth" not in incoming.model_fields_set:
update["user_auth"] = existing.user_auth
if "headers_from_context" not in incoming.model_fields_set:
update["headers_from_context"] = existing.headers_from_context
if "routing" not in incoming.model_fields_set:
update["routing"] = existing.routing
if "tools" not in incoming.model_fields_set:

View File

@ -305,7 +305,11 @@ normally use an independently running HTTP/SSE service.
Server-level OAuth works during background polling and refreshes normally.
Request-scoped secrets from a particular Agent run are not durable task
credentials and are unavailable to later background polls; use server-level
authentication for a task toolset. Restart DeerFlow after changing
authentication for a task toolset. `headers_from_context` follows the same
rule: submit is awaited inside the Agent run and carries the mapped headers,
while status and cancel polls skip them and authenticate with the server's
static or OAuth credentials — so `on_missing: "deny"` guards the submit but not
those polls. Declaring both on one server logs a warning at startup. Restart DeerFlow after changing
`mcp_tasks`, `task_toolsets`, `mcpInterceptors`, or any connection,
authentication, transport, or timeout setting on a task-enabled server.
DeerFlow rejects task-tool reloads that no longer match the Gateway's startup
@ -344,6 +348,81 @@ Example:
}
```
## Request-Scoped Headers (HTTP/SSE MCP Servers)
When the credential is chosen by the *caller* rather than by the operator —
multi-tenant gateways, per-run API keys, one shared MCP server fronting several
environments — declare a `headers_from_context` block instead of registering one
MCP server per credential.
Each entry maps an HTTP header name to a key of the run request's
`config.context.secrets` carrier:
```json
{
"mcpServers": {
"shared-api": {
"enabled": true,
"type": "http",
"url": "https://mcp.example.com/mcp",
"headers": { "Authorization": "Bearer $MCP_DISCOVERY_TOKEN" },
"headers_from_context": {
"enabled": true,
"headers": {
"X-Tenant-Id": "tenant_id",
"Authorization": "tenant_token"
},
"on_missing": "deny"
}
}
}
}
```
The caller supplies the values on each run request:
```json
{
"config": {
"context": {
"secrets": {
"tenant_id": "acme",
"tenant_token": "Bearer <request-scoped credential>"
}
}
}
}
```
- The config file stores **names only**, never a credential, so the block is
returned unmasked by `GET /api/mcp/config`. The values travel out-of-band with
each run and are stripped from persisted run configuration, API responses, and
trace payloads.
- The server's static `headers` are used for startup tool discovery. A mapped
header replaces the static one for that tool call, as shown above for
`Authorization`. Header names are matched case-insensitively, so a mapped
`Authorization` still replaces a static `authorization` instead of putting a
second copy of the field on the wire. Mapping one header under two spellings
is rejected at config load.
- `on_missing` defaults to `"deny"`: if the run carries no value for a mapped
key, the tool call fails with an actionable error rather than falling back to
the discovery credential — which in a multi-tenant deployment would send one
tenant's request under another tenant's authority. Set `"passthrough"` to opt
out and forward the static headers instead.
- Precedence for a server declaring several sources: static `headers` <
`oauth` < `user_auth` < `headers_from_context`. The value chosen for this one
request is the most specific, so it wins.
- `sse`/`http` only. A stdio server has no HTTP headers; declaring the block
there logs a warning and is ignored.
- Durable background tasks are the one exception, and only half of one: a
`task_toolsets` submit is awaited inside the Agent run and carries these
headers, but the status and cancel polls run after that run ends, so they skip
them and use the server's static/OAuth credentials. See *Durable Background
Tasks* above.
Use `user_auth` instead when the credential belongs to a configured DeerFlow
user rather than to the individual request.
## Custom Tool Interceptors
You can register custom interceptors that run before every MCP tool call. This is useful for injecting per-request headers (e.g., user auth tokens from the LangGraph execution context), logging, or metrics.
@ -362,16 +441,19 @@ Declare interceptors in `extensions_config.json` using the `mcpInterceptors` fie
Each entry is a Python import path in `module:variable` format (resolved via `resolve_variable`). The variable must be a **no-arg builder function** that returns an async interceptor compatible with `MultiServerMCPClient`s `tool_interceptors` interface, or `None` to skip.
Example interceptor that injects an authorization header from the request-scoped
LangGraph secret context:
LangGraph secret context. For a plain header mapping prefer the declarative
`headers_from_context` block above; write an interceptor when the header value
needs logic (signing, exchanging the secret for another token, routing on the
tool name):
```python
from langgraph.config import get_config
from deerflow.runtime.secret_context import extract_request_secrets
def build_auth_interceptor():
async def interceptor(request, handler):
config = get_config()
secrets = (config.get("context") or {}).get("secrets") or {}
runtime = getattr(request, "runtime", None)
secrets = extract_request_secrets(getattr(runtime, "context", None))
token = secrets.get("MCP_AUTH_TOKEN")
if token:
request = request.override(
@ -382,6 +464,16 @@ def build_auth_interceptor():
return interceptor
```
Read the run context from `request.runtime`, not from
`langgraph.config.get_config()`. The context is carried on the LangGraph
runtime, not on the `RunnableConfig` propagated to child runnables, so
`get_config().get("context")` is `None` inside a tool call. LangGraph's tool
node injects the runtime into any tool parameter named `runtime`, which is how
both the pooled stdio wrapper and `langchain-mcp-adapters`' HTTP/SSE tool
receive it. When the call originates outside a tool node, fall back to
`langgraph.runtime.get_runtime()` (see
`deerflow/mcp/context_headers.py::_current_runtime`).
Supply the credential on each run request through `config.context.secrets`:
```json

View File

@ -132,6 +132,51 @@ class McpUserScopedAuthConfig(BaseModel):
return value
class McpContextHeadersConfig(BaseModel):
"""Per-request credential injection for an MCP server (HTTP/SSE transports).
Maps HTTP header names to keys of the run request's ``config.context.secrets``
carrier, so one configured MCP server can serve callers that each supply their
own credential *per request* rather than per configured user. The built-in
context-headers interceptor resolves the mapping on every tool call; the
server entry's static ``headers`` are only used for startup tool discovery.
Unlike ``user_auth``, this block stores **no credential** only header names
and run-context key names so it is safe to return unmasked from the config
API. The values arrive out-of-band with each run and never enter the prompt,
tool arguments, or trace payloads (see ``runtime/secret_context.py``).
"""
enabled: bool = Field(default=True, description="Whether request-scoped header injection is enabled")
headers: dict[str, str] = Field(
default_factory=dict,
description="Map of HTTP header name to the key to read from the run request's config.context.secrets (e.g. {'X-Tenant-Id': 'tenant_id'})",
)
on_missing: Literal["deny", "passthrough"] = Field(
default="deny",
description=("Behavior when a mapped key is absent from the request secrets (or resolved empty): 'deny' fails the tool call with an actionable error; 'passthrough' forwards the request with the server's static headers"),
)
model_config = ConfigDict(extra="allow")
@field_validator("headers")
@classmethod
def _validate_mapping_entries(cls, value: dict[str, str]) -> dict[str, str]:
seen: dict[str, str] = {}
for header_name, secret_key in value.items():
if not header_name.strip():
raise ValueError("headers_from_context.headers must not contain a blank header name")
if not isinstance(secret_key, str) or not secret_key.strip():
raise ValueError(f"headers_from_context.headers[{header_name!r}] must name a non-blank secret key from config.context.secrets")
# HTTP field names are case-insensitive, so two spellings of one
# header are one header with two candidate values, and which one
# reaches the remote would depend on dict ordering.
lowered = header_name.lower()
if lowered in seen:
raise ValueError(f"headers_from_context.headers maps the same HTTP header under two spellings ({seen[lowered]!r} and {header_name!r}); header names are case-insensitive, so keep only one")
seen[lowered] = header_name
return value
class McpOAuthConfig(BaseModel):
"""OAuth configuration for an MCP server (HTTP/SSE transports)."""
@ -170,6 +215,10 @@ class McpServerConfig(BaseModel):
default=None,
description="Per-user credential injection (for sse or http type): map DeerFlow user ids to per-user credential header values",
)
headers_from_context: McpContextHeadersConfig | None = Field(
default=None,
description="Per-request credential injection (for sse or http type): map HTTP header names to keys of the run request's config.context.secrets",
)
description: str = Field(default="", description="Human-readable description of what this MCP server provides")
routing: McpRoutingConfig = Field(default_factory=McpRoutingConfig, description="Soft routing hints for tools from this MCP server")
tools: dict[str, McpToolOverride] = Field(default_factory=dict, description="Per-original-tool MCP configuration overrides")
@ -196,6 +245,23 @@ class McpServerConfig(BaseModel):
)
model_config = ConfigDict(extra="allow")
@field_validator("headers")
@classmethod
def _validate_header_names(cls, value: dict[str, str]) -> dict[str, str]:
# HTTP field names are case-insensitive, so two spellings of one header
# are one field with two candidate values. The adapter copies the static
# mapping verbatim, so both would reach the wire; a later per-request or
# OAuth override only replaces one spelling, leaving the other to leak a
# shared credential across tenant authority. Reject at config time so a
# bad mapping cannot reach the connection.
seen: dict[str, str] = {}
for header_name in value:
lowered = header_name.lower()
if lowered in seen:
raise ValueError(f"headers maps the same HTTP header under two spellings ({seen[lowered]!r} and {header_name!r}); header names are case-insensitive, so keep only one")
seen[lowered] = header_name
return value
@model_validator(mode="before")
@classmethod
def _accept_transport_alias(cls, data: Any) -> Any:

View File

@ -11,6 +11,9 @@
- **Per-server tool-name prefixing**: `mcpServers.<server>.tool_name_prefix` defaults to `true`, preserving the collision-safe `<server_name>_` prefix. Servers whose tools already carry a stable namespace may set it to `false`; discovery then calls `langchain_mcp_adapters.tools.load_mcp_tools` with that server's flag. Source routing and stdio session-pool wrapping are based on the producing server and transport, never on whether the visible tool name starts with the server prefix.
- **OAuth (HTTP/SSE)**: Supports token endpoint flows (`client_credentials`, `refresh_token`) with automatic token refresh + Authorization header injection
- **Per-user credentials (HTTP/SSE)**: `mcpServers.<server>.user_auth` maps DeerFlow user ids to credential header values (`$ENV_VAR` references supported). `mcp/user_scoped_auth.py::build_user_scoped_auth_interceptor` rewrites the configured header on every tool call from the authenticated runtime user (registered after OAuth in `mcp/interceptors.py`, so its per-user value wins the header for servers declaring both). Fail-closed: an unmapped user or an empty resolved credential raises a `ToolException` unless `on_missing: "passthrough"` is set. The server's static `headers` serve startup tool discovery only. Gateway GET masks `user_auth.users` values; PUT round-trips masked values by preserving stored credentials.
- **Per-request credentials (HTTP/SSE)**: `mcpServers.<server>.headers_from_context` maps HTTP header names to keys of the run request's `config.context.secrets` carrier, for credentials the caller chooses per request (multi-tenant gateways, per-run API keys) rather than per configured user. `mcp/context_headers.py::build_context_headers_interceptor` resolves the mapping on every tool call and rewrites those headers. Registered **after** `user_auth` in `mcp/interceptors.py`, so for a server declaring several sources the per-request value wins the final header: precedence is static `headers` < `oauth` < `user_auth` < `headers_from_context`. Fail-closed: a mapped key absent from the request secrets (or resolved empty) raises a `ToolException` naming only the missing key, unless `on_missing: "passthrough"` is set a silent fallback would send one tenant's call under the discovery credential's authority. `sse`/`http` only; a stdio server warns and is skipped, as with `user_auth`. The block stores names, never a credential, so the Gateway returns it unmasked and a `PUT` replaces the declared mapping verbatim; only `extra="allow"` keys inside it get sensitive-key masking, and those are restored from the stored block on a round-trip like every other masked extra. Durable `task_toolsets` calls split: submit is awaited inside the Agent run and carries the mapped headers (`McpTaskToolCaller.call_tool(request_scoped_headers=True)`, set only by `OrdinaryMcpTaskDriver.submit`), while status and cancel run after that run ended and keep the server-level credentials so `on_missing: "deny"` covers submit but not those polls, which is what the startup warning is about.
- **Header names are case-insensitive** (`mcp/headers.py`): every credential interceptor writes through `apply_header_overrides`, which drops a key differing only in case and emits the spelling the connection already uses. Without it a static `authorization` and an injected `Authorization` both reach httpx — the adapter merges connection and override headers with a plain `{**static, **override}` splat — and a server reading the field with a single-value accessor gets the static entry, silently inverting the precedence above. `headers_from_context.headers` also rejects two spellings of one header at config load.
- **Reading the run context from an interceptor**: use `request.runtime` (LangGraph's tool node injects a `ToolRuntime` into any tool parameter named `runtime`, which covers both the pooled stdio wrapper and `langchain-mcp-adapters`' own HTTP/SSE tool), falling back to ambient `langgraph.runtime.get_runtime()`. Do **not** use `langgraph.config.get_config()["context"]`: the run context rides the runtime, not the `RunnableConfig` propagated to child runnables, so that key is `None` inside a tool call. `tests/test_mcp_context_headers.py::test_adapter_tool_receives_the_runtime_langgraph_injects` pins the injection rule against an upstream rename by disabling the ambient fallback and driving a real adapter tool through a real graph.
- **Routing hints**: `extensions_config.json -> mcpServers.<server>.routing` and
`tools.<original_tool_name>.routing` are soft preference metadata. The effective
routing is resolved while `mcp/tools.py::get_mcp_tools()` still has both

View File

@ -0,0 +1,162 @@
"""Per-request credential injection for shared MCP servers.
``user_auth`` binds a credential to a *configured* DeerFlow user, which forces
one MCP server entry per credential when the credential is chosen by the caller
at request time (multi-tenant gateways, per-run API keys). This module closes
that gap: a server opts in by declaring a ``headers_from_context`` block
(:class:`McpContextHeadersConfig`) mapping HTTP header names to keys of the run
request's ``config.context.secrets`` carrier. On every tool call the interceptor
resolves the mapping from the live run context and rewrites those headers via
``request.override(headers=...)`` the same per-call mechanism the OAuth and
user-scoped auth interceptors use.
The secret values arrive out-of-band with the run request and stay there: they
are never rendered into the prompt, the tool arguments, or trace payloads (see
``runtime/secret_context.py``). Only the *names* live in the config file, so no
credential is written to disk or returned by the config API.
Registered last in ``mcp/interceptors.py``, so for a server declaring several
credential sources the per-request value wins the final header interceptors
wrap outermost-first, and the later-registered one runs closer to the transport.
Header names are written case-insensitively through ``mcp/headers.py``, so a
mapped ``Authorization`` replaces a static ``authorization`` rather than putting
a second copy of the field on the wire ahead of it.
Fail-closed by default: a mapped key that is absent from the request secrets
(or resolved empty) gets an actionable ``ToolException`` rather than silently
falling back to the server's static discovery credential, which in a
multi-tenant deployment would send one tenant's request under another
tenant's authority. ``on_missing: "passthrough"`` is the explicit opt-out.
"""
from __future__ import annotations
import logging
from typing import Any
from langchain_core.tools import ToolException
from deerflow.config.extensions_config import ExtensionsConfig, McpContextHeadersConfig
from deerflow.mcp.headers import apply_header_overrides, header_spellings
from deerflow.runtime.secret_context import extract_request_secrets
logger = logging.getLogger(__name__)
def _current_runtime() -> Any | None:
"""Best-effort access to the LangGraph runtime for the current tool call.
``get_runtime()`` raises outside a runtime context (embedded clients, unit
tests, discovery paths). Mirrors ``mcp/user_scoped_auth.py``: a failure here
only means the request carries no resolvable secrets, which the caller then
handles through ``on_missing``.
"""
try:
from langgraph.runtime import get_runtime
return get_runtime()
except Exception:
return None
def _request_secrets(request: Any) -> dict[str, str]:
"""Return the run request's ``config.context.secrets``, or ``{}``.
Prefer the runtime attached to the request: LangGraph's tool node injects it
into any tool parameter named ``runtime``, which covers both the pooled
stdio wrapper and ``langchain_mcp_adapters``' own HTTP/SSE tool. Fall back to
the ambient runtime for call paths outside a tool node.
Deliberately not read from ``langgraph.config.get_config()``: the run context
is carried on the runtime, not on the ``RunnableConfig`` propagated to child
runnables, so ``get_config().get("context")`` is ``None`` inside a tool call.
"""
runtime = getattr(request, "runtime", None)
if runtime is None:
runtime = _current_runtime()
return extract_request_secrets(getattr(runtime, "context", None))
def build_context_headers_interceptor(extensions_config: ExtensionsConfig) -> Any | None:
"""Build a tool interceptor injecting per-request headers, or ``None``.
Returns ``None`` when no enabled server declares a usable
``headers_from_context`` block, so callers can skip registration entirely
(mirrors ``build_oauth_tool_interceptor`` / ``build_user_scoped_auth_interceptor``).
"""
mapping_by_server: dict[str, McpContextHeadersConfig] = {}
# The server's static header spellings, so a mapped name that differs from
# the configured one only in case still *replaces* it at the adapter's
# case-sensitive connection merge instead of riding alongside it.
spellings_by_server: dict[str, dict[str, str]] = {}
for server_name, server_config in extensions_config.get_enabled_mcp_servers().items():
context_headers = server_config.headers_from_context
if context_headers is None or not context_headers.enabled or not context_headers.headers:
continue
if server_config.type not in ("sse", "http"):
# A stdio server has no HTTP headers: the pooled stdio path forwards
# rewritten headers as call meta, never a transport header, so the
# credential would go nowhere while deny errors still fired for
# runs that carry no secrets. Warn-and-skip matches user_auth.
logger.warning(
"MCP server '%s' declares headers_from_context but uses the '%s' transport; request-scoped headers only apply to 'sse'/'http' servers — ignoring headers_from_context for this server",
server_name,
server_config.type,
)
continue
if server_config.task_toolsets:
# Submitting a durable task happens inside the Agent run and carries
# the request secrets; the later status/cancel polls do not, because
# the task runtime drives them long after that run ended. Those calls
# deliberately skip this interceptor (see McpTaskToolCaller), so the
# background half authenticates with the server's own credentials.
logger.warning(
"MCP server '%s' declares both headers_from_context and task_toolsets; background task status/cancel polls run outside an Agent run and will use this server's static/OAuth credentials instead of the per-request headers",
server_name,
)
mapping_by_server[server_name] = context_headers
spellings_by_server[server_name] = header_spellings(server_config.headers)
if not mapping_by_server:
return None
async def context_headers_interceptor(request: Any, handler: Any) -> Any:
context_headers = mapping_by_server.get(request.server_name)
if context_headers is None:
return await handler(request)
secrets = _request_secrets(request)
resolved: dict[str, str] = {}
missing: list[str] = []
for header_name, secret_key in context_headers.headers.items():
# Empty string covers a caller-side `$ENV_VAR` that was unset: an
# empty credential must fail closed rather than send an empty header.
value = secrets.get(secret_key, "")
if value:
resolved[header_name] = value
else:
missing.append(secret_key)
if missing and context_headers.on_missing == "deny":
missing_keys = ", ".join(sorted(missing))
logger.warning(
"Denied MCP tool call to server '%s': request context is missing secret(s) %s",
request.server_name,
missing_keys,
)
# Only the configured *key names* are surfaced — they already live in
# the config file, so this leaks nothing the operator has not written
# down, while telling the caller exactly what to send.
raise ToolException(f"MCP server '{request.server_name}' needs request-scoped credential(s) {missing_keys}. Send them in config.context.secrets, or set this server's headers_from_context.on_missing to 'passthrough'.")
if not resolved:
return await handler(request)
updated_headers = apply_header_overrides(
request.headers,
resolved,
spellings=spellings_by_server.get(request.server_name),
)
return await handler(request.override(headers=updated_headers))
return context_headers_interceptor

View File

@ -0,0 +1,61 @@
"""Case-insensitive header writes for MCP tool-call interceptors.
HTTP field names are case-insensitive (RFC 9110 §5.1), but every dictionary on
the path from config to the wire is case-*sensitive*: ``build_server_params``
copies the operator's static ``headers`` spelling verbatim, and
``langchain_mcp_adapters`` merges interceptor overrides with a plain
``{**connection_headers, **override_headers}`` splat. So a static
``authorization`` and an interceptor-written ``Authorization`` do not collide
both survive, httpx puts both on the wire, and a server reading the field with a
single-value accessor sees the *first* one, which is the static entry the
override was supposed to replace.
The credential interceptors therefore write header names through
:func:`apply_header_overrides`, which drops any key differing only in case and
emits the spelling the connection already uses, so the adapter's merge replaces
the static entry instead of duplicating it.
"""
from __future__ import annotations
from collections.abc import Iterable, Mapping
def header_spellings(names: Iterable[str] | None) -> dict[str, str]:
"""Index header names by their lowercased form.
Used to pin the spelling an interceptor should emit: the connection's own
static ``headers`` keys, which the adapter merges the override into. A
server that declares none passes ``None`` here rather than being special-
cased at each call site.
"""
return {name.lower(): name for name in (names or ())}
def apply_header_overrides(
base: Mapping[str, str] | None,
overrides: Mapping[str, str],
*,
spellings: Mapping[str, str] | None = None,
) -> dict[str, str]:
"""Return ``base`` with ``overrides`` applied, case-insensitively.
``spellings`` maps a lowercased header name to the spelling to emit and
takes priority over ``base``'s own keys, so an override lands on the static
connection header it is meant to replace even when an earlier interceptor
already wrote a differently-cased variant. Any key of ``base`` that differs
from the emitted name only in case is removed, so the result never carries
one header under two spellings.
"""
merged = dict(base or {})
lookup = dict(spellings or {})
for key in merged:
lookup.setdefault(key.lower(), key)
for name, value in overrides.items():
lowered = name.lower()
canonical = lookup.get(lowered, name)
for existing in [key for key in merged if key != canonical and key.lower() == lowered]:
del merged[existing]
merged[canonical] = value
return merged

View File

@ -6,6 +6,7 @@ import logging
from typing import Any
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.mcp.context_headers import build_context_headers_interceptor
from deerflow.mcp.oauth import build_oauth_tool_interceptor
from deerflow.mcp.user_scoped_auth import build_user_scoped_auth_interceptor
from deerflow.reflection import resolve_variable
@ -18,10 +19,11 @@ def build_mcp_tool_interceptors(
*,
oauth_builder: Any = build_oauth_tool_interceptor,
user_auth_builder: Any = build_user_scoped_auth_interceptor,
context_headers_builder: Any = build_context_headers_interceptor,
resolver: Any = resolve_variable,
target_logger: logging.Logger = logger,
) -> list[Any]:
"""Build OAuth, user-scoped auth, then configured custom MCP interceptors."""
"""Build OAuth, user-scoped auth, context headers, then custom MCP interceptors."""
interceptors: list[Any] = []
oauth_interceptor = oauth_builder(extensions_config)
if oauth_interceptor is not None:
@ -34,6 +36,14 @@ def build_mcp_tool_interceptors(
if user_auth_interceptor is not None:
interceptors.append(user_auth_interceptor)
# Last of the built-ins, by the same rule: a credential the caller chose for
# this one request is more specific than a configured per-user or per-server
# credential, so it must win the final header value for a server declaring
# more than one source.
context_headers_interceptor = context_headers_builder(extensions_config)
if context_headers_interceptor is not None:
interceptors.append(context_headers_interceptor)
raw_paths = (extensions_config.model_extra or {}).get("mcpInterceptors")
if isinstance(raw_paths, str):
raw_paths = [raw_paths]

View File

@ -10,6 +10,7 @@ from datetime import UTC, datetime, timedelta
from typing import Any
from deerflow.config.extensions_config import ExtensionsConfig, McpOAuthConfig
from deerflow.mcp.headers import apply_header_overrides, header_spellings
logger = logging.getLogger(__name__)
@ -188,13 +189,21 @@ def build_oauth_tool_interceptor(
if not token_manager.has_oauth_servers():
return None
# The servers' static header spellings, so the injected token replaces a
# static header spelled 'authorization' at the adapter's case-sensitive
# connection merge instead of riding alongside it (see ``mcp/headers.py``).
spellings_by_server = {server_name: header_spellings(server_config.headers) for server_name, server_config in extensions_config.get_enabled_mcp_servers().items()}
async def oauth_interceptor(request: Any, handler: Any) -> Any:
header = await token_manager.get_authorization_header(request.server_name)
if not header:
return await handler(request)
updated_headers = dict(request.headers or {})
updated_headers["Authorization"] = header
updated_headers = apply_header_overrides(
request.headers,
{"Authorization": header},
spellings=spellings_by_server.get(request.server_name),
)
return await handler(request.override(headers=updated_headers))
return oauth_interceptor

View File

@ -12,6 +12,8 @@ from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.config.paths import get_paths
from deerflow.constants import MCP_TMP_SUBDIR
from deerflow.mcp.client import build_server_params
from deerflow.mcp.context_headers import build_context_headers_interceptor
from deerflow.mcp.headers import apply_header_overrides
from deerflow.mcp.interceptors import build_mcp_tool_interceptors
from deerflow.mcp.oauth import OAuthTokenManager, build_oauth_tool_interceptor
from deerflow.mcp.session_pool import get_session_pool
@ -58,13 +60,29 @@ class McpTaskToolCaller:
) -> None:
self._extensions_config = extensions_config
self._oauth_token_manager = oauth_token_manager or OAuthTokenManager.from_extensions_config(extensions_config)
self._interceptors = build_mcp_tool_interceptors(
context_headers_interceptor = build_context_headers_interceptor(extensions_config)
# Built once so the two chains keep an identical interceptor order and a
# custom ``mcpInterceptors`` builder is invoked exactly once.
self._submit_interceptors = build_mcp_tool_interceptors(
extensions_config,
oauth_builder=lambda config: build_oauth_tool_interceptor(
config,
token_manager=self._oauth_token_manager,
),
context_headers_builder=lambda _config: context_headers_interceptor,
)
# Submitting a durable task is awaited inline inside the Agent's tool
# call, so ``config.context.secrets`` is still reachable through the
# ambient LangGraph runtime and the submit goes out under the caller's
# own credential. The later status/cancel polls are driven by the task
# runtime long after that run ended: there is no run context to read, so
# the fail-closed interceptor would deny every poll. Those keep using
# server-level credentials (see docs/MCP_SERVER.md), which is what
# ``build_context_headers_interceptor`` warns about at startup.
if context_headers_interceptor is None:
self._interceptors = self._submit_interceptors
else:
self._interceptors = [interceptor for interceptor in self._submit_interceptors if interceptor is not context_headers_interceptor]
async def call_tool(
self,
@ -74,7 +92,16 @@ class McpTaskToolCaller:
arguments: dict[str, Any],
user_id: str,
thread_id: str,
request_scoped_headers: bool = False,
) -> Any:
"""Call a raw MCP tool.
``request_scoped_headers`` opts this call into the ``headers_from_context``
interceptor. Only the durable *submit* may set it: submit is awaited
inside the Agent run that carries the secrets, while status and cancel
run after that run ended.
"""
interceptors = self._submit_interceptors if request_scoped_headers else self._interceptors
server_config = self._extensions_config.get_enabled_mcp_servers().get(server_name)
if server_config is None:
raise LookupError(f"MCP task server {server_name!r} is missing or disabled in the startup configuration")
@ -116,6 +143,7 @@ class McpTaskToolCaller:
timeout_seconds=server_config.tool_call_timeout,
session_init_timeout_seconds=None,
persistent_session=True,
interceptors=interceptors,
)
except Exception:
# A dead pooled subprocess must not poison every later status
@ -125,9 +153,10 @@ class McpTaskToolCaller:
authorization = await self._oauth_token_manager.get_authorization_header(server_name)
if authorization:
headers = dict(connection.get("headers") or {})
headers["Authorization"] = authorization
connection["headers"] = headers
connection["headers"] = apply_header_overrides(
connection.get("headers") or {},
{"Authorization": authorization},
)
return await self._invoke(
session=None,
connection=connection,
@ -137,6 +166,7 @@ class McpTaskToolCaller:
timeout_seconds=server_config.tool_call_timeout,
session_init_timeout_seconds=server_config.session_init_timeout,
persistent_session=False,
interceptors=interceptors,
)
async def _invoke(
@ -150,6 +180,7 @@ class McpTaskToolCaller:
timeout_seconds: float | None,
session_init_timeout_seconds: float | None,
persistent_session: bool,
interceptors: list[Any],
) -> Any:
from langchain_mcp_adapters.interceptors import MCPToolCallRequest
from langchain_mcp_adapters.sessions import create_session
@ -173,9 +204,10 @@ class McpTaskToolCaller:
effective_connection = dict(connection)
if request.headers:
headers = dict(effective_connection.get("headers") or {})
headers.update(dict(request.headers))
effective_connection["headers"] = headers
effective_connection["headers"] = apply_header_overrides(
effective_connection.get("headers") or {},
dict(request.headers),
)
captured: BaseException | None = None
call_result: Any | None = None
async with create_session(effective_connection) as remote_session:
@ -209,7 +241,7 @@ class McpTaskToolCaller:
return call_result
handler = execute
for interceptor in reversed(self._interceptors):
for interceptor in reversed(interceptors):
inner = handler
async def wrapped(request: Any, _interceptor: Any = interceptor, _inner: Any = inner) -> Any:

View File

@ -32,6 +32,7 @@ class McpTaskToolCaller(Protocol):
arguments: dict[str, Any],
user_id: str,
thread_id: str,
request_scoped_headers: bool = False,
) -> Any: ...
@ -158,6 +159,10 @@ class OrdinaryMcpTaskDriver:
arguments=request.arguments,
user_id=request.user_id,
thread_id=request.thread_id,
# Submit alone is awaited inside the Agent run, so it is the one
# durable-task call that can carry the run's request-scoped
# credentials; status and cancel run after that run ended.
request_scoped_headers=True,
)
payload = _parse(
_SubmitPayload,

View File

@ -22,6 +22,7 @@ from deerflow.config.extensions_config import ExtensionsConfig, McpServerConfig,
from deerflow.config.paths import VIRTUAL_PATH_PREFIX, Paths, get_paths
from deerflow.constants import DEFAULT_MCP_SESSION_INIT_TIMEOUT, MCP_TMP_SUBDIR
from deerflow.mcp.client import build_servers_config
from deerflow.mcp.headers import apply_header_overrides
from deerflow.mcp.interceptors import build_mcp_tool_interceptors, compose_tool_interceptors
from deerflow.mcp.oauth import build_oauth_tool_interceptor, get_initial_oauth_headers
from deerflow.mcp.session_pool import MCPSessionPool, get_session_pool
@ -820,9 +821,12 @@ async def get_mcp_tools() -> list[BaseTool]:
if server_name not in servers_config:
continue
if servers_config[server_name].get("transport") in ("sse", "http"):
existing_headers = dict(servers_config[server_name].get("headers", {}))
existing_headers["Authorization"] = auth_header
servers_config[server_name]["headers"] = existing_headers
# Case-insensitive write: a static header spelled 'authorization'
# must be replaced, not joined on the wire by a second field.
servers_config[server_name]["headers"] = apply_header_overrides(
servers_config[server_name].get("headers", {}),
{"Authorization": auth_header},
)
tool_interceptors = build_mcp_tool_interceptors(
extensions_config,

View File

@ -27,6 +27,7 @@ from typing import Any
from langchain_core.tools import ToolException
from deerflow.config.extensions_config import ExtensionsConfig, McpUserScopedAuthConfig
from deerflow.mcp.headers import apply_header_overrides, header_spellings
from deerflow.runtime.user_context import resolve_runtime_user_id
logger = logging.getLogger(__name__)
@ -56,6 +57,10 @@ def build_user_scoped_auth_interceptor(extensions_config: ExtensionsConfig) -> A
``build_oauth_tool_interceptor``).
"""
user_auth_by_server: dict[str, McpUserScopedAuthConfig] = {}
# The server's static header spellings, so a configured ``header`` that
# differs from the static one only in case still *replaces* it at the
# adapter's case-sensitive connection merge (see ``mcp/headers.py``).
spellings_by_server: dict[str, dict[str, str]] = {}
for server_name, server_config in extensions_config.get_enabled_mcp_servers().items():
if server_config.user_auth is None or not server_config.user_auth.enabled:
continue
@ -72,6 +77,7 @@ def build_user_scoped_auth_interceptor(extensions_config: ExtensionsConfig) -> A
)
continue
user_auth_by_server[server_name] = server_config.user_auth
spellings_by_server[server_name] = header_spellings(server_config.headers)
if not user_auth_by_server:
return None
@ -110,8 +116,11 @@ def build_user_scoped_auth_interceptor(extensions_config: ExtensionsConfig) -> A
f"No credential is configured for your account (user id '{user_id}') on MCP server '{request.server_name}'. Ask the operator to add this exact id to that server's user_auth.users map (or set its environment variable)."
)
updated_headers = dict(request.headers or {})
updated_headers[user_auth.header] = credential
updated_headers = apply_header_overrides(
request.headers,
{user_auth.header: credential},
spellings=spellings_by_server.get(request.server_name),
)
return await handler(request.override(headers=updated_headers))
return user_scoped_auth_interceptor

View File

@ -21,7 +21,7 @@
Lets a caller pass per-request, short-lived end-user credentials (e.g. an ERP token) to a skill's sandbox scripts without the value entering the prompt, tool arguments, the executed command string, or traces (issue #3861).
- **Declare**: a skill lists the secrets it needs in `SKILL.md` frontmatter — `required-secrets:` as a string list or `{name, optional}` mappings. `name` is both the lookup key and the env var name exposed to scripts. Parsed by `skills/parser.py::parse_required_secrets` into `Skill.required_secrets` (`SecretRequirement`); malformed entries are dropped with a warning.
- **Carry**: the caller sends values out-of-band in the run request's `context.secrets` mapping (never a message). `runtime/secret_context.py` owns the contract (`SECRETS_CONTEXT_KEY`, `extract_request_secrets`). The existing `context` passthrough carries it to `runtime.context` without mirroring into `configurable`. `build_run_config` still sets `configurable.thread_id` on the context path — the checkpointer requires it. MCP tool interceptors can read the same live carrier from `langgraph.config.get_config()["context"]["secrets"]`; see `docs/MCP_SERVER.md`.
- **Carry**: the caller sends values out-of-band in the run request's `context.secrets` mapping (never a message). `runtime/secret_context.py` owns the contract (`SECRETS_CONTEXT_KEY`, `extract_request_secrets`). The existing `context` passthrough carries it to `runtime.context` without mirroring into `configurable`. `build_run_config` still sets `configurable.thread_id` on the context path — the checkpointer requires it. MCP servers can read the same live carrier declaratively through `mcpServers.<server>.headers_from_context`, and custom MCP interceptors through `extract_request_secrets(request.runtime.context)`**not** `langgraph.config.get_config()["context"]`, which is `None` inside a tool call because the run context rides the LangGraph runtime rather than the propagated `RunnableConfig`; see `docs/MCP_SERVER.md`.
- **Admission and redaction ownership**: `services.py::start_run()` validates both legacy request mappings, `metadata.auth_token` and `config.metadata.auth_token`, before any run or thread persistence. `runtime/secret_context.py::redact_config_secrets()` also removes nested config metadata secrets from observable and persisted config copies; historical `RunResponse.kwargs` applies the same redaction non-mutatively, leaving stored `RunRecord` data unchanged. Keep callers on `config.context.secrets` rather than adding another credential carrier. Scheduled task definitions have no durable credential carrier: `ScheduledTaskService` supplies only `scheduled_task_id`, `scheduled_task_run_id`, and `scheduled_trigger` as run metadata.
- **Bind (point A+)**: `SkillActivationMiddleware._resolve_secret_bindings` recomputes the injection set (`runtime.context[__active_skill_secrets]`) on every model call from two unioned sources, then REPLACES the key. (1) *Slash*: the run's most recent `/skill` activation, persisted as a source on the run context (only the activated skill's **canonical container path**, never its declared secrets) so the whole tool loop after the activation call keeps the binding; a new activation replaces it. Slash reads the genuine user text via `get_original_user_content_text`; `InputSanitizationMiddleware` preserves it (`ORIGINAL_USER_CONTENT_KEY`), so activation fires even after sanitization. (2) *In-context* (autonomous invocation): skills the model actually loaded in this thread — `ThreadState.skill_context` entries. **Both sources resolve the live registry skill by normalized container path on every call** (`_resolve_registry_skill`) and bind only that skill's own declared secrets — enabled + allowlist checked for both; the `secrets-autonomous: false` opt-out (malformed values fail closed to `false`) additionally gates the in-context path but exempts explicit slash. Resolving by registry — not by trusting the source's stored data — is what makes a caller-forged `__slash_skill_secret_source` harmless (`runtime.context` is caller-mergeable; the gateway also strips caller `__`-keys in `build_run_config`), #3938. Authorization is three-gated regardless of activation style: skill **enabled** by the operator × values **supplied per-request** by the caller (`context.secrets`) × names **declared** in frontmatter (∩ semantics). Because the set is recomputed per call, a skill evicted from `skill_context` (capacity) or a caller that stops supplying a value loses injection on the next call. The injected value always comes from the caller's request, never the host environment (scrubbed first — see below), so a declared name that also exists in the host env is safe: the caller's value wins and the host value is dropped (the #3861 per-user-key-overrides-shared-key case). Missing required secrets are logged once per binding change, not injected; binding changes are recorded as a `middleware:skill_secrets` journal event (skill and secret names only, never values).
- **Inject**: `bash_tool` reads the injection set and passes it as `execute_command(env=...)`. Scope is the activation turn/run only — a run without `/skill` activation injects nothing.

View File

@ -0,0 +1,896 @@
"""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"}
# ---------------------------------------------------------------------------
# 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)

View File

@ -0,0 +1,202 @@
"""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"}

View File

@ -74,6 +74,9 @@ async def test_submit_uses_structured_content_and_keeps_remote_id_out_of_driver_
"arguments": {"topic": "MCP"},
"user_id": "user-1",
"thread_id": "thread-1",
# Submit is the one durable-task call awaited inside the Agent run,
# so it is the only one that may carry request-scoped credentials.
"request_scoped_headers": True,
}
]

View File

@ -154,6 +154,68 @@ to get the exact key is to have the user attempt a call before being mapped:
the fail-closed error message includes their resolved id verbatim, ready to
copy into `user_auth.users`.
## Per-request credentials
`user_auth` binds a credential to a *configured* DeerFlow user. When the caller
picks the credential at request time instead — a multi-tenant gateway, a per-run
API key, one shared MCP server fronting several environments — declare a
`headers_from_context` block. Each entry maps an HTTP header name to a key of
the run request's `config.context.secrets` carrier:
```json
{
"mcpServers": {
"shared-api": {
"type": "http",
"url": "https://api.example.com/mcp",
"headers": { "Authorization": "$SERVICE_DISCOVERY_TOKEN" },
"headers_from_context": {
"headers": {
"X-Tenant-Id": "tenant_id",
"Authorization": "tenant_token"
},
"on_missing": "deny"
}
}
}
}
```
The caller supplies the values with each run request, out of band from the
conversation:
```json
{
"config": {
"context": {
"secrets": {
"tenant_id": "acme",
"tenant_token": "Bearer <request-scoped credential>"
}
}
}
}
```
- The config file stores **names only**, never a credential, so the block is
returned unmasked by the config API. The values travel with each run and are
stripped from persisted run configuration, API responses, and traces.
- The entry's static `headers` are used only for startup tool discovery; a
mapped header replaces the static one for the tool call. Header names are
matched case-insensitively, so a mapped `Authorization` replaces a static
`authorization` rather than travelling alongside it.
- **Fail-closed by default**: if the run carries no value for a mapped key, the
call fails with an error naming the missing key rather than falling back to
the discovery credential — which would send one tenant's request under
another tenant's authority. Set `"on_missing": "passthrough"` to opt out.
- For a server declaring several sources, precedence is static `headers` <
`oauth` < `user_auth` < `headers_from_context`: the value chosen for this one
request is the most specific, so it wins.
- On a server that also declares `task_toolsets`, the durable submit is awaited
inside the run and carries these headers, but the later status and cancel
polls run after the run ends and fall back to the server's own credentials —
so the fail-closed guarantee covers the submit, not those polls.
## Managing MCP servers
MCP servers can be managed in several ways: