From be71709608de2d222c3f4e991671e1d116d913ec Mon Sep 17 00:00:00 2001 From: Robert Helewka Date: Sun, 3 May 2026 17:17:50 -0400 Subject: [PATCH] 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. --- docs/pallas.md | 22 ++++++++++- pallas/__init__.py | 5 +++ pallas/_fastagent_patch.py | 75 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 pallas/_fastagent_patch.py diff --git a/docs/pallas.md b/docs/pallas.md index 9a45541..8848fc2 100644 --- a/docs/pallas.md +++ b/docs/pallas.md @@ -417,7 +417,27 @@ For agents with `instance_scope != "request"`, a `{agent}_history` prompt is reg ### 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). --- diff --git a/pallas/__init__.py b/pallas/__init__.py index 3de9e1c..6072604 100644 --- a/pallas/__init__.py +++ b/pallas/__init__.py @@ -6,3 +6,8 @@ Reads deployment topology from agents.yaml in the working directory. """ __version__ = "0.1.0" + +from pallas import _fastagent_patch as _fastagent_patch + +_fastagent_patch.install() + diff --git a/pallas/_fastagent_patch.py b/pallas/_fastagent_patch.py new file mode 100644 index 0000000..ea26ea5 --- /dev/null +++ b/pallas/_fastagent_patch.py @@ -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 ` 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