* fix(mcp): keep rotating OAuth refresh tokens across restarts and Gateway instances
MCP servers on the OAuth refresh_token grant lost their rotated refresh
token whenever a new process or manager started (restart, tool-cache
reset for non-task servers, every personal-connection run, a second
Gateway instance) and re-seeded with the consumed configured token,
getting invalid_grant for good.
- New table mcp_oauth_tokens (migration 0047_mcp_oauth_tokens), one row
per refresh_token connection keyed (scope, owner_user_id, server_key):
('deployment', '', name) or ('personal', owner, personal runtime
name). Access and refresh tokens are DEER_FLOW_CREDENTIALS_KEY
ciphertext; a config fingerprint (token_url, client_id, grant, scope,
audience, seed) makes a changed seed win.
- OAuthTokenManager re-reads the row under its process lock, adopts a
peer's valid token, and otherwise claims the refresh with a lease plus
a version compare-and-set (database clock), fetches outside any
transaction, writes under the lease and releases it on failure.
- The Gateway installs the store (sync engine via to_thread) around the
LangGraph runtime for sqlite/postgres; personal managers pass their
owner. client_credentials stays in process; an unloadable key falls
back to process memory; database errors fail the refresh.
- credentials_key_consumers lists refresh_token MCP OAuth servers, so a
declared multi-instance deployment needs the shared key.
Docs: MCP_SERVER.md, CONFIGURATION.md, Helm README, README, mcp and
migrations AGENTS.md, CHANGELOG (en/zh).
* docs(changelog): link the MCP OAuth token store entry to #7207
* fix(mcp): prune removed connections' OAuth token rows and ask owners to re-authorize on invalid_grant
Review follow-up (willem-bd):
- Both MCP config writers drop mcp_oauth_tokens rows of connections a
save removes (deployment: _commit_mcp_config_write; personal:
_mutate, keeping each connection's enabled/disabled runtime-name
variants so a toggle keeps its chain). Best effort; no startup sweep,
since a missing config file would look like every server removed.
- invalid_grant on a refresh_token request raises
McpOAuthReauthorizationRequired: the tool error names the server and
asks for a new refresh token, plus the shared-key hint when this
instance could not decrypt the stored chain.
- Annotate the gateway helpers' config as AppConfig.
* fix(mcp): renew the OAuth refresh lease, keep unstored rotations, and key access tokens by request parameters
Review follow-up (willem-bd, second round):
- HTTPX timeouts bound each I/O wait, not a whole response: the refresh
holder now renews its lease every third of REFRESH_LEASE_SECONDS while
the token request runs and abandons it after REFRESH_MAX_SECONDS (120 s);
waiters outlast a live holder. Regression uses a real loopback endpoint
that streams its body for three lease lengths.
- A refresh whose tokens could not be stored keeps them pending
(unsaved_base_version): they are written back before an older stored
token is adopted or refreshed with, retried every few seconds while the
access token is still valid, and dropped once a peer stored a newer
chain. A failed save releases the lease.
- New token_request_fingerprint (extra_token_params and response fields):
a stored access token is reused only under the same request parameters;
otherwise the stored refresh-token chain is refreshed.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
35 KiB
MCP (Model Context Protocol) Configuration
DeerFlow supports configurable MCP servers and skills to extend its capabilities, which are loaded from a dedicated extensions_config.json file in the project root directory.
Setup
-
Copy
extensions_config.example.jsontoextensions_config.jsonin the project root directory.# Copy example configuration cp extensions_config.example.json extensions_config.json -
Enable the desired MCP servers or skills by setting
"enabled": true. -
Configure each server’s command, arguments, and environment variables as needed.
-
Restart the application to load and register MCP tools.
Stdio Working Directory
Set cwd when a stdio server needs to resolve its entrypoint or data files
relative to a specific directory:
{
"mcpServers": {
"local": {
"type": "stdio",
"command": "python",
"args": ["server.py"],
"cwd": "/absolute/path/to/server"
}
}
}
The directory must exist on the Gateway host (inside the container for Docker).
Use an absolute path for consistent behavior across launch locations; a
whole-string environment reference such as "$MCP_SERVER_CWD" is also supported.
The configured directory applies to discovery and subsequent tool calls.
When cwd is omitted, null, or an empty string (including an unset environment
reference), discovery inherits the Gateway's working directory and pooled calls
use the thread workspace. HTTP/SSE servers ignore it.
Files created outside the thread's user-data tree are not exposed through the
sandbox/artifact API.
OpenViking MCP Tools
OpenViking's official server exposes a Streamable HTTP MCP endpoint at /mcp.
DeerFlow connects to it through the same generic MCP client used for other HTTP
servers:
{
"mcpServers": {
"openviking": {
"enabled": true,
"type": "http",
"url": "http://127.0.0.1:1933/mcp",
"headers": {
"X-API-Key": "$OPENVIKING_API_KEY"
}
}
}
}
Set OPENVIKING_API_KEY to a normal owner-bound OpenViking USER API key.
The key determines the OpenViking account and user. Do not use a root/admin
key, trusted mode, or add X-OpenViking-Account, X-OpenViking-User, or
X-OpenViking-Actor-Peer headers for this personal single-owner setup.
X-API-Key is used here because DeerFlow expands a whole-string $ENV_VAR
value without storing a credential in the checked-in configuration.
If OPENVIKING_API_KEY is missing or empty during initialization, OpenViking
authentication fails and DeerFlow skips that MCP server, so no OpenViking tools
appear. Changing only the environment variable does not invalidate DeerFlow's
already-populated, file-signature-based MCP tool cache; after setting or fixing
the key, restart DeerFlow, modify and re-save the extensions config, or call the
MCP cache-reset endpoint at POST /api/mcp/cache/reset. In a multi-worker
deployment whose workers share the writable extensions-config directory, that
endpoint publishes a shared generation so every worker refreshes before its
next MCP lookup. The response reports scope: shared_config to identify that
transport (it reaches only workers mounting that directory), or scope: process
if no config path can be resolved.
OpenViking owns the tool schemas and behavior. DeerFlow performs the standard
MCP initialization and discovery flow, prefixes the discovered names with
openviking_ by default, and routes calls back through the generic MCP client.
For capability parity with other official OpenViking harnesses, DeerFlow exposes
the native forget tool with the other discovered tools. forget permanently
deletes a viking:// URI and should be called only after explicit user
confirmation; DeerFlow does not enforce that confirmation.
Operators who do not want agents to call forget can block its default visible
name with DeerFlow's existing guardrail configuration:
guardrails:
enabled: true
provider:
use: deerflow.guardrails.builtin:AllowlistProvider
config:
denied_tools: ["openviking_forget"]
If tool_name_prefix is disabled for the OpenViking server, block forget
instead.
This explicit tool path is separate from the automatic OpenViking memory backend
configured under config.yaml -> memory. Both may be enabled at the same time:
the memory backend handles automatic turn capture and recall, while MCP tools
are model-selected operations.
For Docker, point url at the OpenViking address reachable from the Gateway
container, such as http://openviking:1933/mcp for a shared Compose network or
http://host.docker.internal:1933/mcp for a host-installed server.
Parallel Search (optional)
The parallel-search entry in extensions_config.example.json is disabled by
default. To opt in, copy that entry into mcpServers in your root
extensions_config.json, set "enabled": true, and restart DeerFlow. It connects
to https://search.parallel.ai/mcp over HTTP and adds Parallel's search and fetch
tools. With DeerFlow's default tool-name prefix, the agent sees
parallel-search_web_search and parallel-search_web_fetch. Existing search
providers and defaults stay unchanged.
This is a third-party service operated by Parallel.ai. Search calls send objectives and queries to Parallel; fetch calls send requested page URLs and any extraction objective. These inputs can contain information from your conversation, so enable it only if you are comfortable sending that data to Parallel.
Access is anonymous by default: no API key or authentication headers are needed.
Keep "User-Agent": "deer-flow" in the entry's headers. This stable,
project-wide identity lets Parallel measure aggregate usage from this
integration to understand adoption and support it; it does not identify an
individual user or installation. Preserve it on search and fetch HTTP requests
if the transport changes. Existing configurations can add the same header.
For higher rate limits, optionally add authorization to the headers field of the
parallel-search entry in your local extensions_config.json:
{
"headers": {
"User-Agent": "deer-flow",
"Authorization": "$PARALLEL_AUTHORIZATION"
}
}
Set PARALLEL_AUTHORIZATION in the DeerFlow backend's environment to the full
value Bearer <your-parallel-api-key>, then restart DeerFlow. Include Bearer
in the environment variable because DeerFlow expands only whole-string
$ENV_VAR references, not Bearer $ENV_VAR. Keep the actual key out of committed
files. Remove only Authorization and restart DeerFlow to return to anonymous
access. See the
Parallel Search MCP documentation
for details.
Routing Hints
Use routing when an MCP server should be preferred for specific requests, such
as internal database questions that should use a PostgreSQL MCP tool before web
search. Routing hints are soft model guidance: they add a
<mcp_routing_hints> prompt section, but they do not forbid other tools. Use
agent-level allow/deny policy for hard restrictions. If tool_search.enabled
defers MCP tool schemas, matching routing metadata can also auto-promote the
deferred schema before the model call. Auto-promotion is controlled by the
top-level config.yaml -> tool_search.auto_promote_top_k setting.
{
"mcpServers": {
"postgres": {
"enabled": true,
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/mydb"],
"routing": {
"mode": "prefer",
"priority": 50,
"keywords": ["orders", "users", "SQL", "database", "table"]
},
"tools": {
"query": {
"routing": {
"mode": "prefer",
"priority": 100,
"keywords": ["query database", "orders table", "metrics"]
}
}
}
}
}
}
routing.mode:offdisables hints;preferemits hints.routing.priority:0to100; higher-priority hints are rendered first. Whentool_search.enabled=true, priority also orders auto-promote matches.routing.keywords: operator-authored terms that describe when to prefer the MCP tool. Empty keywords are allowed but do not emit a hint line and do not trigger auto-promotion. Auto-promote matching is a case-insensitive substring test against the latest user message (not token/word-boundary matching), so prefer distinctive keywords — a short term likeapialso matchesrapid. Over-matching only exposes an extra tool schema (soft/additive), never disables other tools.tools.<original_tool_name>.routing: overrides only the fields explicitly set for that tool. The key is the MCP server's original tool name, before the<server>_prefix added for model binding. If the server-levelrouting.modeisoff, a tool override must setmode: "prefer"; setting onlypriorityorkeywordsstill inheritsoffand emits no hint.tool_search.auto_promote_top_k: global limit for auto-promoted deferred MCP schemas per model call. Default3; valid range1..5.
Tool Name Prefixes
DeerFlow prefixes discovered MCP tool names with <server_name>_ by default.
This avoids collisions when two enabled servers expose tools with the same
name. A server that already namespaces its own tools can opt out:
{
"mcpServers": {
"semantic-scholar": {
"type": "stdio",
"command": "uvx",
"args": ["s2-mcp-server"],
"tool_name_prefix": false
}
}
}
With this setting, a server tool named semantic_scholar_search_papers keeps
that name instead of becoming
semantic-scholar_semantic_scholar_search_papers. The default is true for
backward compatibility. Disable it only when every resulting tool name remains
unique across the enabled servers. Stdio tools continue to use DeerFlow's
persistent per-thread session pool regardless of this setting.
Session reuse also requires the same owning event loop. Parallel synchronous tool calls from the embedded client use separate loops and separate stdio sessions, so they can finish independently without cancelling a sibling's connection. They do not share server-side state. The synchronous wrapper closes its loop after each call; use the asynchronous path on a shared loop when session continuity is required. Explicit pool cleanup covers all loops for the selected server/thread scope.
If you manage event loops manually, close the pool or cancel and await its owner
tasks before closing their loop. Calling loop.close() with pending owners
prevents transport teardown and completion callbacks. Abandoned live-registry
records can be removed by LRU eviction or explicit cleanup, but those operations
cannot finish transport cleanup on a loop that has already closed.
Server Timeouts
Two independent settings bound stdio MCP servers and durable HTTP/SSE task
calls. session_init_timeout covers server bring-up — tool discovery
(subprocess spawn + initialize + tools/list) and persistent-session
initialization — plus ephemeral HTTP/SSE task-session connection setup and
initialization under a single deadline. This includes waiting for an SSE
endpoint event. Once initialization succeeds, this deadline is disabled;
the tool call uses its independent tool_call_timeout.
The initialization timeout defaults to 60s so a hung server (e.g. npx blocked
on a package download, or a server that never answers initialize) cannot block agent construction or
the task poller indefinitely. Set it to null to disable:
{
"mcpServers": {
"github": {
"enabled": true,
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_TOKEN": "$GITHUB_TOKEN"
},
"session_init_timeout": 60,
"tool_call_timeout": 60
}
}
}
tool_call_timeout limits each individual stdio tool call in seconds. Ordinary
durable-task submit/status/cancel calls also honor it for http and sse
servers, independently of transport idle timeouts, so a live connection that
never returns the matching MCP response cannot stall the task poller. Other
http and sse tools continue to use transport-level timeouts.
Filesystem MCP Servers
DeerFlow already provides built-in file tools for thread-scoped workspace access. Do not add an MCP filesystem server for the same DeerFlow workspace. The overlapping file tools use different path semantics, which can make LLM tool selection and file access behavior unstable.
DeerFlow does not currently adapt the MCP Roots mode for filesystem servers. In
particular, it does not publish per-thread MCP roots or map DeerFlow sandbox
paths such as /mnt/user-data/... to paths accepted by
@modelcontextprotocol/server-filesystem. Use DeerFlow's built-in file tools
for DeerFlow workspace files.
Durable Background Tasks with Ordinary MCP Tools
An MCP server can expose a fast submit tool plus status and cancel tools
for long-running work. DeerFlow keeps the remote task ID in SQL and polls it
outside the Agent run, so the model does not have to remember or repeatedly
send that ID.
Enable the restart-required runtime in config.yaml:
mcp_tasks:
enabled: true
poll_interval_seconds: 5
lease_seconds: 120
max_concurrent_polls: 8
Then bind exact remote tool names in extensions_config.json. These names are
the server's raw names, before DeerFlow adds any <server_name>_ prefix:
{
"mcpServers": {
"report-service": {
"enabled": true,
"type": "http",
"url": "https://reports.example.com/mcp",
"session_init_timeout": 60,
"tool_call_timeout": 60,
"task_toolsets": [
{
"name": "report-generation",
"submit_tool": "submit_report",
"status_tool": "get_report_status",
"cancel_tool": "cancel_report"
}
]
}
}
}
The three remote tools must use MCP structuredContent; ordinary text blocks
are never parsed as a task protocol:
submit_report(<business arguments>)returns{"task_id":"remote-123","status":"running"}quickly.get_report_status({"task_id":"remote-123"})returns a status fromrunning,input_required,completed,failed, orcancelled. It may also returnresult,result_artifact(uriplusmime_type),error,error_code,input_required, and a finite positivepoll_after_seconds. DeerFlow caps that remote scheduling hint at 24 hours.cancel_report({"task_id":"remote-123"})is idempotent and returns the actual terminal status:cancelled,completed, orfailed.
For the status tool, isError: true means that the status call itself failed;
DeerFlow records a bounded snippet of its first text content block and retries
with capped exponential backoff. It does not infer that the remote task failed,
because MCP tool errors do not distinguish transient from permanent conditions.
A server must report a permanent remote-task failure through a normal tool
result (isError: false or omitted) whose structuredContent contains
status: "failed" and an optional error. This distinction lets a temporary
server or network outage recover without terminalizing work that may still be
running remotely.
Persisted task errors are capped at 4,000 characters. An input_required
payload must be valid JSON no larger than 64 KiB; an oversized or invalid
payload is treated as a permanent protocol failure instead of being truncated
into a different question. result_artifact must likewise serialize as JSON
within 64 KiB; it is a small external reference, not a second result channel.
Remote task IDs and task names are limited to 255 characters, and a task-enabled
server name is limited to 128 characters, matching the durable SQL schema on
both SQLite and PostgreSQL.
error_code: "task_not_found" is a permanent failure. Network and transport
errors remain retryable with capped exponential backoff; the query API reports
tracking_degraded after repeated failures. Oversized JSON results are not
cut into invalid JSON: DeerFlow stores a text preview, marks
result_truncated, and preserves any external result_artifact reference.
Only submit remains in the Agent's normal tool list. Status and cancel are runtime-internal. Query the current thread through:
GET /api/threads/{thread_id}/mcp-tasksGET /api/threads/{thread_id}/mcp-tasks/{task_id}
Task toolsets require database.backend: sqlite or postgres; startup fails
instead of falling back to a synchronous submit when persistence or the task
runtime is disabled. Restart recovery also requires the remote service to keep
the task alive and recognize its ID after DeerFlow reconnects. A stdio server
must therefore persist its own tasks; multi-instance deployments should
normally use an independently running HTTP/SSE service.
A task-enabled server that fails or times out during tool discovery is skipped
like any other MCP server: its submit tool is absent, other servers keep their
tools, and the server returns on the next MCP tool reload. After the server
recovers, an administrator can force that reload with the cache-reset endpoint
(POST /api/mcp/cache/reset, described above) instead of editing the config or
restarting the Gateway; a config change or restart also reloads it. A server
that answers discovery without one of its configured raw tools is a
configuration error instead: no MCP tools load until the names match.
For deployment-level HTTP/SSE servers with task_toolsets, discovery, ordinary
tool calls, and background submit/status/cancel calls share the cached access
token, rotated refresh token, and refresh lock for one Gateway process lifetime.
Tool-cache resets and rediscovery keep that state. Token rotation does not
modify the parsed configuration or trigger the startup-snapshot drift guard;
real operator configuration changes still require a restart.
This state is never written back to extensions_config.json or an environment
variable. With a SQL database, a refresh_token connection's tokens are also kept
in the shared mcp_oauth_tokens table, so a restarted Gateway and its peers
continue the rotated chain (see
Refresh-token rotation).
Personal MCP connections remain owner-scoped and do not use this deployment
state, even if their runtime names match a deployment server. A personal
server's runtime name is derived from its complete definition, so editing,
disabling, or deleting it while a task is in flight leaves the persisted task
handle unresolvable. That task fails permanently with the connection error
instead of retrying forever; if the user had requested cancellation, it also
ends as failed because the remote task can no longer be reached. Restoring
the same definition does not revive an already terminal task.
When user_auth is enabled on an HTTP/SSE server, background status and
cancellation calls use the persisted task owner's configured credential,
including after a Gateway restart.
Only the user ID is carried from the task record; no request credential is stored.
An unmapped owner remains denied unless user_auth.on_missing is passthrough.
Request-scoped secrets from a particular Agent run are not durable task
credentials and are unavailable to later background polls; use configured
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/OAuth credentials or the owner's configured user_auth credential — so
headers_from_context.on_missing: "deny" guards the submit but not
those polls. Declaring both on one server logs a warning at startup.
When a request header overrides user_auth on submit, ensure that both
credentials can access the same remote task. If the background credential
cannot access it and the status tool returns a normal structured
error_code: "task_not_found" result, the task becomes permanently failed,
not a retryable authentication error.
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
snapshot instead of discovering tools with new settings while the background
poller still calls the old endpoint. Agent-facing description/routing changes
and changes to servers without task toolsets remain hot-reloadable.
OAuth Support (HTTP/SSE MCP Servers)
For http and sse MCP servers, DeerFlow supports OAuth token acquisition and automatic token refresh.
- Supported grants:
client_credentials,refresh_token - Configure per-server
oauthblock inextensions_config.json - Secrets should be provided via environment variables (for example:
$MCP_OAUTH_CLIENT_SECRET)
Example:
{
"mcpServers": {
"secure-http-server": {
"enabled": true,
"type": "http",
"url": "https://api.example.com/mcp",
"oauth": {
"enabled": true,
"token_url": "https://auth.example.com/oauth/token",
"grant_type": "client_credentials",
"client_id": "$MCP_OAUTH_CLIENT_ID",
"client_secret": "$MCP_OAUTH_CLIENT_SECRET",
"scope": "mcp.read",
"refresh_skew_seconds": 60
}
}
}
}
Refresh-token rotation across restarts and replicas
Providers such as Auth0, Okta and Google rotate refresh tokens: every refresh
returns a new one and invalidates the old one. With database.backend: sqlite
or postgres, the Gateway keeps each refresh_token connection's latest access
token and refresh token in the mcp_oauth_tokens table (migration
0047_mcp_oauth_tokens), encrypted with DEER_FLOW_CREDENTIALS_KEY (see
CONFIGURATION.md):
- A restart, a tool-cache reset, or a later run of a personal connection
continues from the stored chain instead of the configured
refresh_token. - Gateway replicas share one chain. Before refreshing, a replica re-reads the row and uses a peer's still-valid access token. Otherwise it claims the refresh with a 30-second lease, so only one replica calls the token endpoint and the others wait for its result. The holder renews the lease while the token request runs (HTTP timeouts bound each read, not the whole response) and abandons a request after 120 seconds. No row lock is held during the token request.
- If the new tokens cannot be stored (the database is briefly unavailable), the process keeps using them, retries storing them every few seconds, and stores them before its next refresh, so nobody falls back to the refresh token it already consumed.
- A stored access token is reused only by processes that request it the same
way (
extra_token_paramssuch asresource, and the response-field settings). After such a change the next request refreshes from the stored refresh token instead, so the chain is kept. - The configured
refresh_tokenis only the seed. The row records whichtoken_url,client_id,grant_type,scope,audienceand seed it continues (as a SHA-256 fingerprint). After you change any of them, the next refresh starts again from the configured value. To recover from a revoked chain (invalid_grant), configure a newrefresh_token; changing onlyclient_secretkeeps the stored chain. - Personal connections get one row per owner, keyed by their runtime name.
Editing a personal connection changes that name, so it starts again from its
configured refresh token. Saving a configuration deletes the rows of the
connections it no longer has: an edited or deleted personal connection, or a
deployment server removed through the MCP configuration API (a server removed
by hand-editing
extensions_config.jsonis dropped on the next API save). Disabling a connection keeps its row, so re-enabling it continues the chain. - When the provider rejects the refresh token (
invalid_grant), the tool call fails with an error that names the server and asks for a new refresh token. If the instance fell back to the seed because it could not decrypt the chain another instance stored, the error also says that every instance needs the sameDEER_FLOW_CREDENTIALS_KEY. client_credentialstokens are not stored; each process requests its own.- Tokens stay in process memory, as before, with
database.backend: memory, in the embeddedDeerFlowClient(no Gateway), or when the credentials key cannot be loaded. If the database cannot be read or written, the refresh fails instead of consuming a refresh token that nothing records; a still-valid access token keeps working. - A declared multi-instance deployment (
deployment.multi_instance: true) with an enabledrefresh_tokenOAuth server refuses to start without a sharedDEER_FLOW_CREDENTIALS_KEY. Personal OAuth connections are saved at runtime, so that startup check cannot see them; set the variable on every multi-instance deployment that uses them.
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:
{
"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:
{
"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
headersare used for startup tool discovery. A mapped header replaces the static one for that tool call, as shown above forAuthorization. Header names are matched case-insensitively, so a mappedAuthorizationstill replaces a staticauthorizationinstead of putting a second copy of the field on the wire. Mapping one header under two spellings is rejected at config load. on_missingdefaults 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.- A value that cannot be sent as an HTTP header — a stray newline picked up
when reading a token from a file, leading/trailing whitespace, characters
outside ASCII — is always denied, regardless of
on_missing. The error names the offending key but never repeats the value; without this check a newline or stray whitespace would reach h11, whose rejection echoes the full credential into a model-visible tool error. The same check covers every other way a value reaches these headers:user_auth, the OAuth token returned by the token endpoint, and the staticheadersin the config file. - 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/httponly. 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_toolsetssubmit 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 configured static/OAuth credentials or the persisted owner'suser_authcredential. 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.
Declare interceptors in extensions_config.json using the mcpInterceptors field:
{
"mcpInterceptors": [
"my_package.mcp.auth:build_auth_interceptor"
],
"mcpServers": { ... }
}
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.
Lifecycle. Builders run once per enabled MCP server, not once per discovery.
Tool discovery is server-scoped, so every server gets its own interceptor instance built
from a single-server configuration view, and a stateful interceptor (a counter, a cache,
a rate limiter) only ever observes that server's calls. Do not rely on one instance being
shared across servers. The built-in OAuth, per-user and context-header interceptors are
unaffected: they resolve everything per request.server_name. Servers that are not
discovered never run their builder.
Example interceptor that injects an authorization header from the request-scoped
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):
from deerflow.runtime.secret_context import extract_request_secrets
def build_auth_interceptor():
async def interceptor(request, handler):
runtime = getattr(request, "runtime", None)
secrets = extract_request_secrets(getattr(runtime, "context", None))
token = secrets.get("MCP_AUTH_TOKEN")
if token:
request = request.override(
headers={**(request.headers or {}), "Authorization": f"Bearer {token}"}
)
return await handler(request)
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:
{
"metadata": {"source": "my-client"},
"config": {
"context": {
"secrets": {"MCP_AUTH_TOKEN": "<request-scoped credential>"}
}
}
}
Both metadata.auth_token and config.metadata.auth_token are rejected with HTTP 422 at run admission and are never supported
interceptor paths. Do not put credentials in either metadata surface; use
config.context.secrets, whose values remain available to the live interceptor
but are removed from persisted and API-visible run configuration copies.
- A single string value is accepted and normalized to a one-element list.
- Invalid paths or builder failures are logged as warnings without blocking other interceptors.
- The builder return value must be
callable; non-callable values are skipped with a warning.
Migrating legacy MCP credentials
Deployments that previously sent metadata.auth_token or config.metadata.auth_token must:
- Update the caller and interceptor to use
config.context.secretsas shown above. - Rotate the exposed credential before resuming authenticated MCP traffic.
- Locate and remove every retained legacy copy according to the deployment's retention policy, including database rows, run events, application or proxy logs, snapshots, exports, and backups.
Current history APIs hide legacy metadata.auth_token and config.metadata.auth_token values, but hiding a response does not erase
material already retained by those systems. Restarting or upgrading DeerFlow does
not rotate credentials or perform historical cleanup; operators must complete
both actions explicitly.
Tool Discovery Cache
MCP tool discovery is cached per server. The cache is keyed by server name and holds, for each server, the discovered tools together with a canonical snapshot of that server's configuration, the normalized global interceptor chain and the pooled stdio session binding they were discovered against.
- Reuse. On every (re)initialization, a server's cached tools are reused only when its configuration snapshot and global interceptor chain are unchanged and it still owns the process's current binding. The chain is checked against each entry's published wrappers, including after a failed initialization attempt or an edit between reconciliation and initialization. An unchanged server keeps the exact same tool objects and is not re-discovered, so it does not re-fetch OAuth headers or respawn its stdio process. Only changed servers are re-discovered.
- Invalidation. Editing a server's configuration (connection, description, routing, tool prefixes, ...) drops that server's entry; deleting or disabling a server drops it; an explicit cache reset or a shared reset marker clears every server. Reordering servers only changes the flattened tool order.
- Failure handling. A server whose discovery fails is left absent for that pass and its previous entry is dropped, while healthy servers still publish. If every enabled server fails, initialization is aborted rather than publishing an empty tool set, and the next call retries. A server that legitimately returns zero tools is cached as a successful empty result and is not re-discovered.
- Remote tool changes. The cache is configuration-driven: a remote MCP server
that changes its
tools/listresponse without a configuration change is not picked up automatically. UsePOST /api/mcp/cache/reset(or disable/re-enable or edit the server) to force re-discovery, which the shared reset marker propagates to every worker mounting the same config directory.
Because reuse is scoped to one server, changing only one server never re-discovers, re-authenticates, or restarts the others.
How It Works
MCP servers expose tools that are automatically discovered and integrated into DeerFlow’s agent system at runtime. Once enabled, these tools become available to agents without additional code changes.
Example Capabilities
MCP servers can provide access to:
- Databases (e.g., PostgreSQL)
- External APIs (e.g., GitHub, Brave Search)
- Browser automation (e.g., Puppeteer)
- Custom MCP server implementations
Learn More
For detailed documentation about the Model Context Protocol, visit:
https://modelcontextprotocol.io