forward: scan YAML directly for forward_inbound_auth opt-ins

Root-cause:  fast-agent's Settings(**merged_settings) validation pipeline
silently drops unknown keys on nested MCPServerSettings instances — even
after flipping extra='allow' and calling model_rebuild(force=True).  The
culprit is Settings(nested_model_default_partial_update=True) which takes
a model_construct path that discards model_extra on the nested model.

Verified live: MCPServerSettings.model_validate({'forward_inbound_auth': True})
preserves the field (model_extra={'forward_inbound_auth': True}), but
get_settings().mcp.servers['mnemosyne'] returns an instance where the
attribute is MISSING and model_extra is None.

Fix: parse fastagent.config.yaml ourselves at patch-install time and
record the set of opted-in server names in _FORWARD_SERVERS.  The patch
and multimodal_server's forwardable-config resolver both key off the
server name — stable, authoritative, and completely sidesteps Pydantic's
extras handling.
This commit is contained in:
2026-05-05 14:59:01 -04:00
parent 541b59b4e3
commit 711f54395d
2 changed files with 104 additions and 21 deletions

View File

@@ -38,15 +38,29 @@ TODO: drop after the equivalent change lands in fast-agent upstream.
from __future__ import annotations
import logging
import os
import threading
from pathlib import Path
from typing import Any
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("pallas.forward")
# ── Opt-in server names discovered from raw YAML ──────────────────────────────
# Fast-agent's ``Settings(**merged)`` pipeline silently discards unknown keys
# on nested ``MCPServerSettings`` instances — even with ``extra="allow"`` set
# on the parent and the model rebuilt — because ``nested_model_default_partial_update``
# takes a path through ``model_construct`` that drops ``model_extra``.
#
# Rather than fight pydantic's nested-model plumbing, we parse the YAML
# directly ourselves at patch-install time and build a set of server names
# that carry ``forward_inbound_auth: true``. The patched
# ``_prepare_headers_and_auth`` looks up the name (stable and authoritative
# regardless of Pydantic gymnastics) instead of asking the config object.
_FORWARD_SERVERS: set[str] = set()
_original_prepare = _mcm._prepare_headers_and_auth
# ── Per-request bearer registry ──────────────────────────────────────────────
@@ -118,7 +132,7 @@ def _prepare_headers_and_auth_with_forward(server_config, **kwargs):
headers, oauth_auth, user_auth_keys = _original_prepare(server_config, **kwargs)
server_name = getattr(server_config, "name", None) or "?"
forward_flag = getattr(server_config, "forward_inbound_auth", False)
forward_flag = server_name in _FORWARD_SERVERS
logger.debug(
"forward.check server=%s forward_inbound_auth=%s",
@@ -164,22 +178,82 @@ def _prepare_headers_and_auth_with_forward(server_config, **kwargs):
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":
def _candidate_config_paths() -> list[Path]:
"""Paths to scan for ``fastagent.config.yaml``.
Order matters: the first existing file wins. We mirror fast-agent's
``find_config`` discovery rule (cwd then ancestors) and additionally
honour a ``FASTAGENT_CONFIG_PATH`` override so tests / ansible-managed
deployments can point Pallas at a specific file.
"""
override = os.environ.get("FASTAGENT_CONFIG_PATH")
if override:
return [Path(override).expanduser()]
paths: list[Path] = []
cwd = Path.cwd()
for p in (cwd, *cwd.parents):
paths.append(p / "fastagent.config.yaml")
return paths
def _refresh_forward_servers() -> None:
"""Populate ``_FORWARD_SERVERS`` from the raw YAML config.
Parses the YAML ourselves (bypassing fast-agent's ``Settings`` pipeline)
because nested pydantic validation silently drops unknown keys on
``MCPServerSettings`` — so by the time we'd see the config object,
``forward_inbound_auth`` is gone.
Called both at ``install()`` time and lazily from
``_prepare_headers_and_auth_with_forward`` so hot-reloaded configs or
late-bound working directories still work. Failure is non-fatal: we
simply log and leave ``_FORWARD_SERVERS`` unchanged.
"""
try:
import yaml
except ImportError:
logger.warning("pyyaml not available; cannot scan forward_inbound_auth opt-ins")
return
cfg["extra"] = "allow"
_MCPServerSettings.model_config = cfg
_MCPServerSettings.model_rebuild(force=True)
for path in _candidate_config_paths():
if not path.exists():
continue
try:
with open(path) as fh:
data = yaml.safe_load(fh) or {}
except Exception as exc:
logger.warning("forward.config_parse_failed path=%s error=%s", path, exc)
continue
servers = (data.get("mcp") or {}).get("servers") or {}
if not isinstance(servers, dict):
return
names: set[str] = set()
for server_name, server_cfg in servers.items():
if not isinstance(server_cfg, dict):
continue
if server_cfg.get("forward_inbound_auth"):
names.add(server_name)
if names != _FORWARD_SERVERS:
_FORWARD_SERVERS.clear()
_FORWARD_SERVERS.update(names)
logger.info(
"forward.opt_in servers=%s source=%s",
sorted(_FORWARD_SERVERS),
path,
)
return
logger.debug("forward.no_config_found searched=%s", _candidate_config_paths())
def install() -> None:
if getattr(_mcm._prepare_headers_and_auth, "_pallas_forward_patched", False):
return
_allow_extras_on_server_settings()
_refresh_forward_servers()
_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
# INFO so it always appears in the journal at boot — greppable proof

View File

@@ -29,7 +29,7 @@ from fast_agent.mcp.auth.context import request_bearer_token
from fast_agent.mcp.server import AgentMCPServer
from fast_agent.types import PromptMessageExtended, RequestParams
from pallas._fastagent_patch import publish_bearer, revoke_bearer
from pallas._fastagent_patch import _FORWARD_SERVERS, publish_bearer, revoke_bearer
from pallas.progress import EnrichedMCPToolProgressManager
from fastmcp import Context as MCPContext
from fastmcp.prompts import Message
@@ -74,11 +74,19 @@ def _get_request_bearer_token() -> str | None:
def _forwardable_server_configs(agent) -> list[Any]:
"""Return the ``MCPServerSettings`` objects the agent is entitled to and
which are marked ``forward_inbound_auth: true``.
which are listed in ``_FORWARD_SERVERS`` (opt-in via
``forward_inbound_auth: true`` in ``fastagent.config.yaml``).
Restricting to the agent's own ``servers`` list ensures a request never
publishes its bearer against a server the calling agent does not use —
e.g. a Harper→Mnemosyne call must not flag Scotty→Mnemosyne's config.
Restricting to the intersection of (agent-attached-servers ∩
opted-in-servers) ensures a request never publishes its bearer against
a server the calling agent does not use — e.g. a Harper→Mnemosyne call
must not flag Scotty→Mnemosyne's config.
We read the opt-in set from ``pallas._fastagent_patch._FORWARD_SERVERS``
rather than a pydantic attribute on the config object because fast-agent's
``Settings(**merged)`` validation silently drops unknown keys on nested
``MCPServerSettings`` instances (see ``_fastagent_patch._refresh_forward_servers``
for the gory details).
Safe to call before the agent is constructed: returns an empty list if
any attribute lookup fails.
@@ -87,15 +95,16 @@ def _forwardable_server_configs(agent) -> list[Any]:
agent_servers = set(getattr(agent.config, "servers", []) or [])
if not agent_servers:
return []
opt_in = agent_servers & _FORWARD_SERVERS
if not opt_in:
return []
registry = agent.context.server_registry
if registry is None:
return []
configs: list[Any] = []
for name in agent_servers:
for name in opt_in:
cfg = registry.registry.get(name)
if cfg is None:
continue
if getattr(cfg, "forward_inbound_auth", False):
if cfg is not None:
configs.append(cfg)
return configs
except Exception as exc: