青榆牧 a94b2d8897
feat(mcp): map request-scoped secrets to MCP HTTP/SSE headers (#5010)
* feat(mcp): map request-scoped secrets to HTTP/SSE headers

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

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

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

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

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

Closes #5005

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

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

Review follow-ups on `headers_from_context`.

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

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

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

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

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

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

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

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

---------

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

163 lines
8.0 KiB
Python

"""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