feat(pallas): add opt-in bearer token forwarding to downstream MCP servers
Introduce per-server `forward_inbound_auth` flag that controls whether the inbound MCP bearer token is propagated to outbound MCP transport calls. Implemented as a fast-agent monkey-patch auto-installed on package import, preventing accidental credential leakage to unrelated downstream servers. Update docs to describe the two bearer token consumers (LLM provider passthrough and opt-in downstream MCP forwarding) with a config example.
This commit is contained in:
@@ -417,7 +417,27 @@ For agents with `instance_scope != "request"`, a `{agent}_history` prompt is reg
|
|||||||
|
|
||||||
### Bearer Token Propagation
|
### Bearer Token Propagation
|
||||||
|
|
||||||
The server captures the authenticated bearer token from the incoming MCP request and propagates it via `request_bearer_token` context variable to downstream calls.
|
The server captures the authenticated bearer token from the incoming MCP request into the `request_bearer_token` context variable. Two consumers read it:
|
||||||
|
|
||||||
|
- **LLM-provider passthrough** — the agent's LLM provider key manager picks it up automatically (used by HuggingFace and any other token-passthrough providers).
|
||||||
|
- **Downstream MCP servers (opt-in)** — outgoing MCP calls inherit the same bearer when the downstream server is marked `forward_inbound_auth: true` in `fastagent.config.yaml`. Without that flag, `request_bearer_token` is **not** forwarded to MCP transport calls — `server_config.headers` is the only header source. This is implemented as a fast-agent monkey-patch in `pallas._fastagent_patch` and is per-server so a FastAgent attached to both a credentialed downstream (e.g. Mnemosyne) and an unrelated public server doesn't leak the bearer to the latter.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mcp:
|
||||||
|
servers:
|
||||||
|
mnemosyne:
|
||||||
|
transport: http
|
||||||
|
url: "https://mnemosyne.example/mcp/"
|
||||||
|
forward_inbound_auth: true # inbound bearer rides outbound
|
||||||
|
weather:
|
||||||
|
transport: http
|
||||||
|
url: "https://weather.example/mcp/"
|
||||||
|
# no flag → outbound calls go unauthenticated
|
||||||
|
```
|
||||||
|
|
||||||
|
When the agent receives a request with `Authorization: Bearer X`, `mnemosyne` will see `Authorization: Bearer X` on the outbound call; `weather` will see no `Authorization` header. If `mnemosyne.headers.Authorization` is set explicitly, that wins (the inbound bearer is not overwritten on top of an explicit header).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -6,3 +6,8 @@ Reads deployment topology from agents.yaml in the working directory.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.1.0"
|
||||||
|
|
||||||
|
from pallas import _fastagent_patch as _fastagent_patch
|
||||||
|
|
||||||
|
_fastagent_patch.install()
|
||||||
|
|
||||||
|
|||||||
75
pallas/_fastagent_patch.py
Normal file
75
pallas/_fastagent_patch.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""Forward the inbound bearer token to opted-in downstream MCP servers.
|
||||||
|
|
||||||
|
fast-agent (≤0.6.19) captures the inbound `Authorization: Bearer <X>` into
|
||||||
|
the `request_bearer_token` ContextVar but does NOT propagate it to
|
||||||
|
outgoing MCP transport calls — `_prepare_headers_and_auth` only reads
|
||||||
|
`server_config.headers`. This patch wraps that function so a
|
||||||
|
downstream server marked `forward_inbound_auth: true` in
|
||||||
|
fastagent.config.yaml receives the same bearer the FastAgent itself
|
||||||
|
was called with.
|
||||||
|
|
||||||
|
Opt-in is per-server because a FastAgent with multiple downstream MCP
|
||||||
|
attachments (e.g. Mnemosyne + a public weather server) must not leak
|
||||||
|
its credentials to every endpoint.
|
||||||
|
|
||||||
|
TODO: drop after the equivalent change lands in fast-agent upstream.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fast_agent.config import MCPServerSettings as _MCPServerSettings
|
||||||
|
from fast_agent.mcp import mcp_connection_manager as _mcm
|
||||||
|
from fast_agent.mcp.auth.context import request_bearer_token
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_AUTH_HEADER_KEYS = {"authorization", "x-hf-authorization"}
|
||||||
|
_original_prepare = _mcm._prepare_headers_and_auth
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_headers_and_auth_with_forward(server_config, **kwargs):
|
||||||
|
headers, oauth_auth, user_auth_keys = _original_prepare(server_config, **kwargs)
|
||||||
|
|
||||||
|
if not getattr(server_config, "forward_inbound_auth", False):
|
||||||
|
return headers, oauth_auth, user_auth_keys
|
||||||
|
|
||||||
|
if user_auth_keys:
|
||||||
|
return headers, oauth_auth, user_auth_keys
|
||||||
|
|
||||||
|
if oauth_auth is not None:
|
||||||
|
return headers, oauth_auth, user_auth_keys
|
||||||
|
|
||||||
|
inbound = request_bearer_token.get()
|
||||||
|
if not inbound:
|
||||||
|
return headers, oauth_auth, user_auth_keys
|
||||||
|
|
||||||
|
headers = dict(headers)
|
||||||
|
headers["Authorization"] = f"Bearer {inbound}"
|
||||||
|
user_auth_keys = set(user_auth_keys) | {"Authorization"}
|
||||||
|
logger.debug(
|
||||||
|
"fastagent_forward_inbound_auth",
|
||||||
|
extra={"server": getattr(server_config, "name", None)},
|
||||||
|
)
|
||||||
|
return headers, oauth_auth, user_auth_keys
|
||||||
|
|
||||||
|
|
||||||
|
def _allow_extras_on_server_settings() -> None:
|
||||||
|
# MCPServerSettings ships with default `extra="ignore"`, so unknown YAML
|
||||||
|
# keys (including our `forward_inbound_auth` opt-in) get silently dropped.
|
||||||
|
# Flip to `extra="allow"` and rebuild so the field round-trips.
|
||||||
|
cfg = dict(_MCPServerSettings.model_config)
|
||||||
|
if cfg.get("extra") == "allow":
|
||||||
|
return
|
||||||
|
cfg["extra"] = "allow"
|
||||||
|
_MCPServerSettings.model_config = cfg
|
||||||
|
_MCPServerSettings.model_rebuild(force=True)
|
||||||
|
|
||||||
|
|
||||||
|
def install() -> None:
|
||||||
|
if getattr(_mcm._prepare_headers_and_auth, "_pallas_forward_patched", False):
|
||||||
|
return
|
||||||
|
_allow_extras_on_server_settings()
|
||||||
|
_prepare_headers_and_auth_with_forward._pallas_forward_patched = True # type: ignore[attr-defined]
|
||||||
|
_mcm._prepare_headers_and_auth = _prepare_headers_and_auth_with_forward
|
||||||
Reference in New Issue
Block a user