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:
@@ -38,15 +38,29 @@ TODO: drop after the equivalent change lands in fast-agent upstream.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
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 import mcp_connection_manager as _mcm
|
||||||
from fast_agent.mcp.auth.context import request_bearer_token
|
from fast_agent.mcp.auth.context import request_bearer_token
|
||||||
|
|
||||||
logger = logging.getLogger("pallas.forward")
|
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
|
_original_prepare = _mcm._prepare_headers_and_auth
|
||||||
|
|
||||||
# ── Per-request bearer registry ──────────────────────────────────────────────
|
# ── 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)
|
headers, oauth_auth, user_auth_keys = _original_prepare(server_config, **kwargs)
|
||||||
|
|
||||||
server_name = getattr(server_config, "name", None) or "?"
|
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(
|
logger.debug(
|
||||||
"forward.check server=%s forward_inbound_auth=%s",
|
"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
|
return headers, oauth_auth, user_auth_keys
|
||||||
|
|
||||||
|
|
||||||
def _allow_extras_on_server_settings() -> None:
|
def _candidate_config_paths() -> list[Path]:
|
||||||
# MCPServerSettings ships with default `extra="ignore"`, so unknown YAML
|
"""Paths to scan for ``fastagent.config.yaml``.
|
||||||
# keys (including our `forward_inbound_auth` opt-in) get silently dropped.
|
|
||||||
# Flip to `extra="allow"` and rebuild so the field round-trips.
|
Order matters: the first existing file wins. We mirror fast-agent's
|
||||||
cfg = dict(_MCPServerSettings.model_config)
|
``find_config`` discovery rule (cwd then ancestors) and additionally
|
||||||
if cfg.get("extra") == "allow":
|
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
|
return
|
||||||
cfg["extra"] = "allow"
|
|
||||||
_MCPServerSettings.model_config = cfg
|
for path in _candidate_config_paths():
|
||||||
_MCPServerSettings.model_rebuild(force=True)
|
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:
|
def install() -> None:
|
||||||
if getattr(_mcm._prepare_headers_and_auth, "_pallas_forward_patched", False):
|
if getattr(_mcm._prepare_headers_and_auth, "_pallas_forward_patched", False):
|
||||||
return
|
return
|
||||||
_allow_extras_on_server_settings()
|
_refresh_forward_servers()
|
||||||
_prepare_headers_and_auth_with_forward._pallas_forward_patched = True # type: ignore[attr-defined]
|
_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
|
_mcm._prepare_headers_and_auth = _prepare_headers_and_auth_with_forward
|
||||||
# INFO so it always appears in the journal at boot — greppable proof
|
# INFO so it always appears in the journal at boot — greppable proof
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from fast_agent.mcp.auth.context import request_bearer_token
|
|||||||
from fast_agent.mcp.server import AgentMCPServer
|
from fast_agent.mcp.server import AgentMCPServer
|
||||||
from fast_agent.types import PromptMessageExtended, RequestParams
|
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 pallas.progress import EnrichedMCPToolProgressManager
|
||||||
from fastmcp import Context as MCPContext
|
from fastmcp import Context as MCPContext
|
||||||
from fastmcp.prompts import Message
|
from fastmcp.prompts import Message
|
||||||
@@ -74,11 +74,19 @@ def _get_request_bearer_token() -> str | None:
|
|||||||
|
|
||||||
def _forwardable_server_configs(agent) -> list[Any]:
|
def _forwardable_server_configs(agent) -> list[Any]:
|
||||||
"""Return the ``MCPServerSettings`` objects the agent is entitled to and
|
"""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
|
Restricting to the intersection of (agent-attached-servers ∩
|
||||||
publishes its bearer against a server the calling agent does not use —
|
opted-in-servers) ensures a request never publishes its bearer against
|
||||||
e.g. a Harper→Mnemosyne call must not flag Scotty→Mnemosyne's config.
|
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
|
Safe to call before the agent is constructed: returns an empty list if
|
||||||
any attribute lookup fails.
|
any attribute lookup fails.
|
||||||
@@ -87,15 +95,16 @@ def _forwardable_server_configs(agent) -> list[Any]:
|
|||||||
agent_servers = set(getattr(agent.config, "servers", []) or [])
|
agent_servers = set(getattr(agent.config, "servers", []) or [])
|
||||||
if not agent_servers:
|
if not agent_servers:
|
||||||
return []
|
return []
|
||||||
|
opt_in = agent_servers & _FORWARD_SERVERS
|
||||||
|
if not opt_in:
|
||||||
|
return []
|
||||||
registry = agent.context.server_registry
|
registry = agent.context.server_registry
|
||||||
if registry is None:
|
if registry is None:
|
||||||
return []
|
return []
|
||||||
configs: list[Any] = []
|
configs: list[Any] = []
|
||||||
for name in agent_servers:
|
for name in opt_in:
|
||||||
cfg = registry.registry.get(name)
|
cfg = registry.registry.get(name)
|
||||||
if cfg is None:
|
if cfg is not None:
|
||||||
continue
|
|
||||||
if getattr(cfg, "forward_inbound_auth", False):
|
|
||||||
configs.append(cfg)
|
configs.append(cfg)
|
||||||
return configs
|
return configs
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
Reference in New Issue
Block a user