From 711f54395d6bf45a1d9c3be2c2a7bea233fc4a7d Mon Sep 17 00:00:00 2001 From: Robert Helewka Date: Tue, 5 May 2026 14:59:01 -0400 Subject: [PATCH] forward: scan YAML directly for forward_inbound_auth opt-ins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- pallas/_fastagent_patch.py | 98 ++++++++++++++++++++++++++++++++----- pallas/multimodal_server.py | 27 ++++++---- 2 files changed, 104 insertions(+), 21 deletions(-) diff --git a/pallas/_fastagent_patch.py b/pallas/_fastagent_patch.py index 3cdccbe..f7415b9 100644 --- a/pallas/_fastagent_patch.py +++ b/pallas/_fastagent_patch.py @@ -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 diff --git a/pallas/multimodal_server.py b/pallas/multimodal_server.py index b18c5a3..8a210ed 100644 --- a/pallas/multimodal_server.py +++ b/pallas/multimodal_server.py @@ -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: