mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-17 01:56:18 +00:00
* 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>
163 lines
8.0 KiB
Python
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
|