From 0c136f2e740fdf88dfc368afefbcc6b7b9a894cd Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 12 Jul 2026 00:48:57 -0300 Subject: [PATCH 01/23] feat(mcp): derive a configurable transport host/Origin allowlist FastMCP auto-enables a loopback-only DNS-rebinding allowlist when built with a loopback host and no explicit transport_security. build_pipefy_mcp_server always constructs FastMCP with settings.mcp.host (default 127.0.0.1), so behind a proxy that forwards the public Host the transport answers 421 Misdirected Request before any handler runs. Add allowed_hosts / allowed_origins to McpSettings (env PIPEFY_MCP_ALLOWED_* as JSON) and a build_transport_security resolver that widens the allowlist: loopback plus the resource_server_url host (the public origin the remote profile already declares) plus any explicit entries. The builder passes the result into FastMCP. When nothing is configured it returns None, leaving FastMCP's loopback-only default in force, so the local subprocess case is unchanged. The resolver is configuration resolution, not an MCP extension: it produces a static SDK config value, mirroring build_resource_server_auth (settings to the SDK's AuthSettings). It lives in the composition tier (core/), keeping the mcp SDK out of the settings boundary. --- .../src/pipefy_mcp/core/transport_security.py | 102 ++++++++++++++++++ packages/mcp/src/pipefy_mcp/server.py | 8 ++ packages/mcp/src/pipefy_mcp/settings.py | 40 +++++++ .../mcp/tests/core/test_transport_security.py | 101 +++++++++++++++++ packages/mcp/tests/test_server.py | 79 ++++++++++++++ packages/mcp/tests/test_settings.py | 35 ++++++ 6 files changed, 365 insertions(+) create mode 100644 packages/mcp/src/pipefy_mcp/core/transport_security.py create mode 100644 packages/mcp/tests/core/test_transport_security.py diff --git a/packages/mcp/src/pipefy_mcp/core/transport_security.py b/packages/mcp/src/pipefy_mcp/core/transport_security.py new file mode 100644 index 00000000..4aede43c --- /dev/null +++ b/packages/mcp/src/pipefy_mcp/core/transport_security.py @@ -0,0 +1,102 @@ +"""Derive the HTTP transport's DNS-rebinding allowlist from settings. + +FastMCP auto-enables a loopback-only Host/Origin allowlist whenever it is built +with a loopback ``host`` and no explicit ``transport_security``. The builder always +passes ``host=settings.mcp.host`` (default ``127.0.0.1``), so behind a proxy that +forwards the public Host the transport answers ``421 Misdirected Request`` before +any handler runs. This module turns the resolved settings into the +``TransportSecuritySettings`` that widens the allowlist to the public host while +keeping DNS-rebinding protection on. + +This is configuration resolution, not an MCP extension: it produces a static +config value the SDK reads, mirroring +:func:`pipefy_mcp.auth.resource_server.build_resource_server_auth` (settings to the +SDK's ``AuthSettings``). It lives in the composition tier because transport security +owns no concern folder of its own, and the mcp-SDK type is kept out of +``settings.py`` so the config boundary stays framework-free. + +The public host is derived from ``resource_server_url`` (which the remote profile +already requires), so the standard fronted deployment needs no allowlist config; +``allowed_hosts`` / ``allowed_origins`` extend it. When nothing is configured the +function returns ``None``, leaving FastMCP's own loopback default in force. +""" + +from __future__ import annotations + +from urllib.parse import urlparse + +from mcp.server.transport_security import TransportSecuritySettings + +from pipefy_mcp.settings import Settings + +# Loopback entries kept in every explicit allowlist so widening for a proxy does +# not lock out local tooling on the box (a proxy on the same host still reaches the +# server over loopback). Mirrors FastMCP's own loopback auto-enable set. +_LOOPBACK_HOSTS = ("127.0.0.1", "localhost", "[::1]") + + +def _host_forms(host: str) -> tuple[str, str]: + """The two match forms for a Host entry: exact, and any-port (``host:*``). + + The SDK matches a Host header by exact string or a trailing ``:*`` port + wildcard, and ``localhost:*`` does not match a portless ``localhost``, so both + forms are listed to cover a Host sent with or without a port. + """ + return host, f"{host}:*" + + +def _resource_hosts(resource_server_url: str | None) -> list[str]: + """The public Host forms carried by ``resource_server_url``, if set.""" + if not resource_server_url: + return [] + parsed = urlparse(resource_server_url) + hosts: list[str] = [] + if parsed.hostname: + hosts.append(parsed.hostname) + # netloc carries host:port; include it when a port is present so a proxy that + # forwards "host:port" as the Host still matches the exact form. + if parsed.port and parsed.netloc not in hosts: + hosts.append(parsed.netloc) + return hosts + + +def build_transport_security(settings: Settings) -> TransportSecuritySettings | None: + """Resolve the transport allowlist, or ``None`` to keep FastMCP's default. + + Returns ``None`` when there is nothing to add beyond loopback (no + ``resource_server_url`` and no explicit ``allowed_hosts`` / ``allowed_origins``), + so FastMCP applies its own loopback auto-enable and current behavior is + preserved. Otherwise it enables DNS-rebinding protection over loopback plus the + derived public host and any explicit entries. + """ + public_hosts = _resource_hosts(settings.rs.resource_server_url) + public_hosts += settings.mcp.allowed_hosts or [] + explicit_origins = settings.mcp.allowed_origins + + if not public_hosts and not explicit_origins: + return None + + allowed_hosts: list[str] = [] + for host in (*_LOOPBACK_HOSTS, *public_hosts): + for form in _host_forms(host): + if form not in allowed_hosts: + allowed_hosts.append(form) + + if explicit_origins: + allowed_origins = list(explicit_origins) + else: + allowed_origins = [] + for host in allowed_hosts: + for scheme in ("http", "https"): + origin = f"{scheme}://{host}" + if origin not in allowed_origins: + allowed_origins.append(origin) + + return TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=allowed_hosts, + allowed_origins=allowed_origins, + ) + + +__all__ = ["build_transport_security"] diff --git a/packages/mcp/src/pipefy_mcp/server.py b/packages/mcp/src/pipefy_mcp/server.py index 8f07fb50..3769d052 100644 --- a/packages/mcp/src/pipefy_mcp/server.py +++ b/packages/mcp/src/pipefy_mcp/server.py @@ -12,6 +12,7 @@ ToolCallMiddleware, install_tool_call_middleware, ) +from pipefy_mcp.core.transport_security import build_transport_security from pipefy_mcp.observability.json_logging import configure_observability_logging from pipefy_mcp.observability.tool_log_middleware import tool_log_middleware from pipefy_mcp.settings import Settings @@ -100,6 +101,12 @@ def build_pipefy_mcp_server( remote-safe tool surface, and ``settings.mcp.host`` / ``settings.mcp.port`` give the HTTP bind (they matter only for the HTTP transport; stdio ignores them). + The DNS-rebinding allowlist for the HTTP transport is derived here by + :func:`pipefy_mcp.core.transport_security.build_transport_security` (from the + ``resource_server_url`` host plus any ``allowed_hosts`` / ``allowed_origins``) + and passed to FastMCP; it is ``None`` (FastMCP's own loopback default) when + nothing is configured, and irrelevant for stdio. + ``extra_tool_middlewares`` is the public registration seam for a consumer of this builder (a hosted serving layer that wants per-tool metrics, say): the chain installs once, so a consumer folds its middleware in here rather than reaching @@ -126,6 +133,7 @@ def build_pipefy_mcp_server( log_level=settings.mcp.log_level, token_verifier=verifier, auth=auth, + transport_security=build_transport_security(settings), ) _register_pipefy_tools(app, remote_mode=settings.mcp.profile == "remote") # Wrap the tool-call handler with the built-in chain plus any consumer middleware. diff --git a/packages/mcp/src/pipefy_mcp/settings.py b/packages/mcp/src/pipefy_mcp/settings.py index 8d06af48..eee87a55 100644 --- a/packages/mcp/src/pipefy_mcp/settings.py +++ b/packages/mcp/src/pipefy_mcp/settings.py @@ -133,6 +133,28 @@ def settings_customise_sources( ), ) + allowed_hosts: list[str] | None = Field( + default=None, + description=( + "Extra Host header values the HTTP transport accepts, on top of " + "loopback and the resource_server_url host (env: " + "PIPEFY_MCP_ALLOWED_HOSTS as a JSON array). Needed only when a proxy " + "forwards a public Host that differs from the resource-server URL; the " + "standard fronted deployment derives its allowlist from " + "resource_server_url and sets none. An entry matches an exact Host or, " + "as 'host:*', any port on that host." + ), + ) + + allowed_origins: list[str] | None = Field( + default=None, + description=( + "Origin header values the HTTP transport accepts (env: " + "PIPEFY_MCP_ALLOWED_ORIGINS as a JSON array). Overrides the derived " + "scheme://host Origin allowlist for a stricter or custom posture." + ), + ) + @field_validator("log_level", mode="before") @classmethod def _normalize_log_level(cls, value: object) -> object: @@ -195,6 +217,24 @@ def _enforce_bind_safety(self) -> Self: ) return self + @model_validator(mode="after") + def _normalize_allowlists(self) -> Self: + """Strip whitespace and drop empty entries from the transport allowlists. + + Light normalization only: an entry is a Host or Origin the operator + vouches for, so this does not run the internal-host SSRF gate + (``localhost`` is a wanted loopback entry). ``None`` stays ``None``. + """ + self.allowed_hosts = _stripped_nonempty(self.allowed_hosts) + self.allowed_origins = _stripped_nonempty(self.allowed_origins) + return self + + +def _stripped_nonempty(values: list[str] | None) -> list[str] | None: + if values is None: + return None + return [stripped for value in values if (stripped := value.strip())] + class ResourceServerSettings(BaseSettings): """This MCP server's identity as an OAuth protected resource (HTTP profile). diff --git a/packages/mcp/tests/core/test_transport_security.py b/packages/mcp/tests/core/test_transport_security.py new file mode 100644 index 00000000..6fb5cbf0 --- /dev/null +++ b/packages/mcp/tests/core/test_transport_security.py @@ -0,0 +1,101 @@ +"""Tests for ``build_transport_security`` (the DNS-rebinding allowlist resolver).""" + +import pytest + +from pipefy_mcp.core.transport_security import build_transport_security +from pipefy_mcp.settings import McpSettings, ResourceServerSettings, Settings + +_LOOPBACK_FORMS = { + "127.0.0.1", + "127.0.0.1:*", + "localhost", + "localhost:*", + "[::1]", + "[::1]:*", +} + + +def _settings(*, resource_server_url=None, allowed_hosts=None, allowed_origins=None): + return Settings( + mcp=McpSettings(allowed_hosts=allowed_hosts, allowed_origins=allowed_origins), + rs=ResourceServerSettings(resource_server_url=resource_server_url), + ) + + +@pytest.mark.unit +def test_unset_returns_none_to_keep_fastmcp_default(): + """No resource URL and no explicit allowlist -> FastMCP's own default stands.""" + assert build_transport_security(_settings()) is None + + +@pytest.mark.unit +def test_resource_server_url_host_is_derived_and_protection_enabled(): + """The public host is added (bare and any-port) with loopback retained.""" + security = build_transport_security( + _settings(resource_server_url="https://mcp.pipefy.com/mcp") + ) + assert security is not None + assert security.enable_dns_rebinding_protection is True + assert "mcp.pipefy.com" in security.allowed_hosts + assert "mcp.pipefy.com:*" in security.allowed_hosts + assert _LOOPBACK_FORMS.issubset(set(security.allowed_hosts)) + + +@pytest.mark.unit +def test_resource_server_url_with_explicit_port_keeps_host_and_hostport(): + """A URL carrying a port contributes both the bare host and host:port.""" + security = build_transport_security( + _settings(resource_server_url="https://mcp.pipefy.com:8443/mcp") + ) + assert security is not None + assert "mcp.pipefy.com" in security.allowed_hosts + assert "mcp.pipefy.com:8443" in security.allowed_hosts + + +@pytest.mark.unit +def test_explicit_allowed_hosts_extend_the_derived_set(): + """PIPEFY_MCP_ALLOWED_HOSTS adds to loopback and the resource host.""" + security = build_transport_security( + _settings( + resource_server_url="https://mcp.pipefy.com/mcp", + allowed_hosts=["proxy.internal"], + ) + ) + assert security is not None + assert "mcp.pipefy.com" in security.allowed_hosts + assert "proxy.internal" in security.allowed_hosts + assert "proxy.internal:*" in security.allowed_hosts + + +@pytest.mark.unit +def test_explicit_allowed_hosts_alone_enable_protection(): + """An override with no resource URL still produces an enabled allowlist.""" + security = build_transport_security(_settings(allowed_hosts=["proxy.internal"])) + assert security is not None + assert security.enable_dns_rebinding_protection is True + assert "proxy.internal" in security.allowed_hosts + + +@pytest.mark.unit +def test_derived_origins_cover_each_host_in_both_schemes(): + """Without an override, origins are http+https for every allowed host.""" + security = build_transport_security( + _settings(resource_server_url="https://mcp.pipefy.com/mcp") + ) + assert security is not None + assert "http://mcp.pipefy.com" in security.allowed_origins + assert "https://mcp.pipefy.com" in security.allowed_origins + assert "http://localhost:*" in security.allowed_origins + + +@pytest.mark.unit +def test_explicit_allowed_origins_replace_the_derived_origins(): + """An explicit origin allowlist is used verbatim, not merged with derived.""" + security = build_transport_security( + _settings( + resource_server_url="https://mcp.pipefy.com/mcp", + allowed_origins=["https://mcp.pipefy.com"], + ) + ) + assert security is not None + assert security.allowed_origins == ["https://mcp.pipefy.com"] diff --git a/packages/mcp/tests/test_server.py b/packages/mcp/tests/test_server.py index bd0673cd..1d0f2583 100644 --- a/packages/mcp/tests/test_server.py +++ b/packages/mcp/tests/test_server.py @@ -1,4 +1,5 @@ import json +from contextlib import asynccontextmanager from datetime import timedelta from unittest.mock import MagicMock, patch @@ -463,6 +464,25 @@ def _asgi_client(app): return httpx.AsyncClient(transport=transport, base_url="http://testserver") +@asynccontextmanager +async def _serving_asgi_client(app): + """An ASGI client with the app's lifespan running. + + The transport's DNS-rebinding Host check sits behind the streamable-HTTP + session manager, whose task group is started by the ASGI lifespan. httpx's + ASGITransport does not run lifespan events, so drive Starlette's lifespan + context explicitly (the 401/metadata tests do not need this because they + short-circuit before the session manager). + """ + asgi = app.streamable_http_app() + async with asgi.router.lifespan_context(asgi): + transport = httpx.ASGITransport(app=asgi) + async with httpx.AsyncClient( + transport=transport, base_url="http://testserver" + ) as client: + yield client + + @pytest.mark.unit async def test_http_unauthenticated_request_gets_401_challenge(): """A request with no bearer is rejected with a 401 + WWW-Authenticate challenge. @@ -490,3 +510,62 @@ async def test_http_serves_protected_resource_metadata(): assert body["resource"].rstrip("/") == RS_RESOURCE advertised = [s.rstrip("/") for s in body["authorization_servers"]] assert RS_ISSUER in advertised + + +# --- transport allowlist (DNS-rebinding) reaches FastMCP and the transport ---- + +_PUBLIC_HOST_PING = { + "json": {"jsonrpc": "2.0", "method": "ping", "id": 1}, + "headers": {"host": "mcp.pipefy.com"}, +} + + +@pytest.mark.unit +def test_build_passes_none_transport_security_for_a_plain_local_build(mocked_runtime): + """With no resource URL or allowlist, FastMCP keeps its own loopback default.""" + with patch("pipefy_mcp.server.FastMCP") as mock_fastmcp: + build_pipefy_mcp_server(_MINIMAL_PIPEFY_SETTINGS) + assert mock_fastmcp.call_args.kwargs["transport_security"] is None + + +@pytest.mark.unit +def test_build_passes_the_derived_allowlist_to_fastmcp(mocked_runtime): + """A configured allowlist reaches the FastMCP constructor as transport_security.""" + settings = _MINIMAL_PIPEFY_SETTINGS.model_copy( + update={"mcp": McpSettings(allowed_hosts=["mcp.pipefy.com"])} + ) + with patch("pipefy_mcp.server.FastMCP") as mock_fastmcp: + build_pipefy_mcp_server(settings) + security = mock_fastmcp.call_args.kwargs["transport_security"] + assert security is not None + assert "mcp.pipefy.com" in security.allowed_hosts + + +@pytest.mark.unit +async def test_http_loopback_default_rejects_a_public_host(mocked_runtime): + """The default (loopback-only) allowlist answers 421 to a public Host header. + + This is the behavior a fronted deployment hits before the allowlist is + configured: FastMCP auto-enables the loopback allowlist on the 127.0.0.1 + construction host, so a proxied public Host is a DNS-rebinding rejection. + """ + app = build_pipefy_mcp_server(_MINIMAL_PIPEFY_SETTINGS) + async with _serving_asgi_client(app) as client: + resp = await client.post("/mcp", **_PUBLIC_HOST_PING) + assert resp.status_code == 421 + + +@pytest.mark.unit +async def test_http_configured_allowlist_accepts_a_public_host(mocked_runtime): + """With the public host allowlisted, the same request clears the Host gate. + + It no longer 421s; it reaches the transport handler (which then fails on the + missing session, not on DNS-rebinding), proving the allowlist widened the gate. + """ + settings = _MINIMAL_PIPEFY_SETTINGS.model_copy( + update={"mcp": McpSettings(allowed_hosts=["mcp.pipefy.com"])} + ) + app = build_pipefy_mcp_server(settings) + async with _serving_asgi_client(app) as client: + resp = await client.post("/mcp", **_PUBLIC_HOST_PING) + assert resp.status_code != 421 diff --git a/packages/mcp/tests/test_settings.py b/packages/mcp/tests/test_settings.py index ec3d410d..e9e7bc46 100644 --- a/packages/mcp/tests/test_settings.py +++ b/packages/mcp/tests/test_settings.py @@ -271,3 +271,38 @@ def test_mcp_remote_binds_any_host_without_the_escape_hatch(): settings = McpSettings(profile="remote", transport="http", host="0.0.0.0") assert settings.host == "0.0.0.0" assert settings.allow_insecure_http_bind is False + + +# --- transport allowlist fields (DNS-rebinding host/Origin allowlist) --------- + + +@pytest.mark.unit +def test_mcp_allowlists_default_to_none(): + """Unset allowlists stay None so the builder can preserve FastMCP's default.""" + mcp = McpSettings() + assert mcp.allowed_hosts is None + assert mcp.allowed_origins is None + + +@pytest.mark.unit +def test_mcp_allowed_hosts_from_env_parses_json(monkeypatch): + """PIPEFY_MCP_ALLOWED_HOSTS is a JSON array, like RS required_scopes.""" + monkeypatch.setenv("PIPEFY_MCP_ALLOWED_HOSTS", '["mcp.pipefy.com", "mcp:8000"]') + assert Settings().mcp.allowed_hosts == ["mcp.pipefy.com", "mcp:8000"] + + +@pytest.mark.unit +def test_mcp_allowed_origins_from_env_parses_json(monkeypatch): + monkeypatch.setenv("PIPEFY_MCP_ALLOWED_ORIGINS", '["https://mcp.pipefy.com"]') + assert Settings().mcp.allowed_origins == ["https://mcp.pipefy.com"] + + +@pytest.mark.unit +def test_mcp_allowlists_strip_and_drop_empty_entries(): + """Normalization trims each entry and drops blanks; localhost is kept.""" + mcp = McpSettings( + allowed_hosts=[" mcp.pipefy.com ", "", " ", "localhost"], + allowed_origins=[" https://mcp.pipefy.com ", " "], + ) + assert mcp.allowed_hosts == ["mcp.pipefy.com", "localhost"] + assert mcp.allowed_origins == ["https://mcp.pipefy.com"] From f87f3d7d8e201f384c19469fd018e137279110ab Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 12 Jul 2026 00:49:04 -0300 Subject: [PATCH 02/23] docs(mcp): document the transport host/Origin allowlist Add PIPEFY_MCP_ALLOWED_HOSTS / PIPEFY_MCP_ALLOWED_ORIGINS to the config reference (env table and TOML sample) and turn the AGENTS.md "separate follow-up work" note into a "Transport allowlist" section describing the derive-from-resource_server_url default and the explicit override. --- docs/config.md | 6 ++++++ packages/mcp/AGENTS.md | 22 +++++++++++++++++----- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/docs/config.md b/docs/config.md index e5b20528..f5eb3896 100644 --- a/docs/config.md +++ b/docs/config.md @@ -43,6 +43,10 @@ remote_mode = false host = "127.0.0.1" port = 8000 log_level = "INFO" +# allowed_hosts / allowed_origins are optional; unset derives the allowlist from +# the resource-server URL. Set them only for extra proxied hostnames. +allowed_hosts = ["mcp.pipefy.com"] +allowed_origins = ["https://mcp.pipefy.com"] ``` Keys use **bare pydantic field names**, not the upper-case `PIPEFY_` environment variable names. The env-only aliases (`PIPEFY_TOKEN`, `PIPEFY_OAUTH_CLIENT`, ...) exist to refuse unprefixed environment leakage and do not double as TOML keys. @@ -128,5 +132,7 @@ These variables load into `pipefy_mcp.McpSettings` (`settings.mcp`). TOML keys u | `PIPEFY_MCP_HOST` | `127.0.0.1` | HTTP bind host. The unauthenticated `local` profile refuses a non-loopback bind unless `PIPEFY_MCP_ALLOW_INSECURE_HTTP_BIND` is set; the authenticated `remote` profile binds any host. | | `PIPEFY_MCP_PORT` | `8000` | HTTP bind port. | | `PIPEFY_MCP_ALLOW_INSECURE_HTTP_BIND` | `false` | Escape hatch: lets the unauthenticated `local` profile serve HTTP on a non-loopback host, exposing the full tool surface with no inbound bearer. The `remote` profile never needs it. | +| `PIPEFY_MCP_ALLOWED_HOSTS` | unset | JSON array of extra `Host` header values the HTTP transport accepts for DNS-rebinding protection, on top of loopback and the `PIPEFY_MCP_RS_RESOURCE_SERVER_URL` host. Unset derives the allowlist from the resource-server URL, so a proxied deployment usually needs none; set it only when a proxy forwards a public `Host` that differs. An entry matches an exact `Host` or, as `host:*`, any port. Empty preserves FastMCP's loopback-only default. | +| `PIPEFY_MCP_ALLOWED_ORIGINS` | unset | JSON array overriding the derived `scheme://host` `Origin` allowlist for a stricter or custom posture. Unset derives `http`/`https` origins from the allowed hosts. | | `PIPEFY_MCP_UNIFIED_ENVELOPE` | `true` | When true, migrated tools return `{success, data, ...}`. | | `PIPEFY_MCP_LOG_LEVEL` | `INFO` | Governs **both** hosted structured JSON lines on **stdout** and the FastMCP root logger (RichHandler text on **stderr**). Accepts `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL` (case-insensitive). The server passes the resolved level to `FastMCP(log_level=...)` at construction, so this is the single operator knob for MCP logging. Some MCP tooling documents a separate `FASTMCP_LOG_LEVEL` env var; when using `pipefy-mcp-server`, prefer `PIPEFY_MCP_LOG_LEVEL` — it configures the root logger explicitly and keeps structured events on the same level. | diff --git a/packages/mcp/AGENTS.md b/packages/mcp/AGENTS.md index d8a35555..e5b14ecd 100644 --- a/packages/mcp/AGENTS.md +++ b/packages/mcp/AGENTS.md @@ -85,11 +85,23 @@ validates a per-request bearer, so its bind host is irrelevant and is not checke `pipefy_infra.security.is_loopback_host`, which covers all of `127.0.0.0/8` and `::1`. This replaced an earlier bind-interface guard (`_assert_safe_http_bind`) that false-positived on the entire hosted profile and lived in the run path where the -ASGI-app path bypassed it. DNS-rebinding protection (the configurable host / Origin -allowlist for a proxied deployment) is separate follow-up work; see -`experiments/hosted-obo/RFC-OUTLINE.md`. The attachment tools' local `file_path` -inputs also still assume a loopback peer that shares the client's disk (remote-safe -file inputs are separate follow-up work). +ASGI-app path bypassed it. The attachment tools' local `file_path` inputs also +still assume a loopback peer that shares the client's disk (remote-safe file inputs +are separate follow-up work). + +**Transport allowlist.** DNS-rebinding protection is a separate axis from the +bind-safety interlock: it checks the inbound request's `Host` / `Origin`, not the +bind interface. FastMCP auto-enables a loopback-only allowlist on the `127.0.0.1` +construction host, so behind a proxy that forwards the public `Host` it answers +`421 Misdirected Request`. `core/transport_security.py:build_transport_security` +widens it by deriving the allowed host from `resource_server_url` (the public origin +the `remote` profile already declares) plus loopback, and `build_pipefy_mcp_server` +passes the result to FastMCP. `PIPEFY_MCP_ALLOWED_HOSTS` / `PIPEFY_MCP_ALLOWED_ORIGINS` +(JSON) extend it for extra hostnames or a stricter Origin posture. Unset (no +resource-server URL and no override) leaves FastMCP's loopback-only default in force, +so the local subprocess case is unaffected. Being configuration derived at +composition (mirroring `build_resource_server_auth`), it lives in the composition +tier, not in `settings.py`, which keeps the mcp SDK out of the config boundary. ## Tool registration From 371cb7ed8973cbfdd0a76c3c75f5dfe8918498f9 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 13 Jul 2026 17:34:16 -0300 Subject: [PATCH 03/23] refactor(mcp): reuse optional_str in the allowlist normalizer Replace the ad-hoc strip-and-truthiness filter in _stripped_nonempty with pipefy_infra.coerce.optional_str, the shared empty-to-None coercion, so the allowlist normalizer drops blank entries through the same helper the rest of the config boundary uses. --- packages/mcp/src/pipefy_mcp/settings.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/mcp/src/pipefy_mcp/settings.py b/packages/mcp/src/pipefy_mcp/settings.py index eee87a55..5ab4817a 100644 --- a/packages/mcp/src/pipefy_mcp/settings.py +++ b/packages/mcp/src/pipefy_mcp/settings.py @@ -4,6 +4,7 @@ from pipefy_auth import AuthSettings, JwtValidationSettings from pipefy_infra import security +from pipefy_infra.coerce import optional_str from pipefy_infra.config import PipefyTomlConfigSource from pipefy_sdk import PipefySettings from pydantic import ( @@ -233,7 +234,7 @@ def _normalize_allowlists(self) -> Self: def _stripped_nonempty(values: list[str] | None) -> list[str] | None: if values is None: return None - return [stripped for value in values if (stripped := value.strip())] + return [s for value in values if (s := optional_str(value)) is not None] class ResourceServerSettings(BaseSettings): From d5cb651bf8413682f3af43fa16933fa280941763 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 13 Jul 2026 17:34:31 -0300 Subject: [PATCH 04/23] refactor(mcp): move ResourceServer into the auth module The ResourceServer value object (verbatim resource URL plus its parsed Host wire forms) modeled an OAuth RFC 9728 concept that auth/resource_server.py already owns, but lived in core/transport_security.py, which merely consumed it. Move the type and its from_url parser next to build_resource_server_auth so the resource-server vocabulary sits in one module; core/transport_security.py now imports it. The new core > auth import keeps the inward-only layering contract. --- packages/mcp/src/pipefy_mcp/auth/__init__.py | 2 ++ .../src/pipefy_mcp/auth/resource_server.py | 34 +++++++++++++++++++ .../src/pipefy_mcp/core/transport_security.py | 29 +++++----------- .../mcp/tests/auth/test_resource_server.py | 29 +++++++++++++++- .../mcp/tests/core/test_transport_security.py | 26 ++++++++++++++ 5 files changed, 98 insertions(+), 22 deletions(-) diff --git a/packages/mcp/src/pipefy_mcp/auth/__init__.py b/packages/mcp/src/pipefy_mcp/auth/__init__.py index e440506e..1a96e38d 100644 --- a/packages/mcp/src/pipefy_mcp/auth/__init__.py +++ b/packages/mcp/src/pipefy_mcp/auth/__init__.py @@ -3,12 +3,14 @@ from pipefy_mcp.auth.request_identity import require_request_bearer from pipefy_mcp.auth.resource_server import ( JwtTokenVerifier, + ResourceServer, ResourceServerAuth, build_resource_server_auth, ) __all__ = [ "JwtTokenVerifier", + "ResourceServer", "ResourceServerAuth", "build_resource_server_auth", "require_request_bearer", diff --git a/packages/mcp/src/pipefy_mcp/auth/resource_server.py b/packages/mcp/src/pipefy_mcp/auth/resource_server.py index 14c307ca..65dd7218 100644 --- a/packages/mcp/src/pipefy_mcp/auth/resource_server.py +++ b/packages/mcp/src/pipefy_mcp/auth/resource_server.py @@ -22,7 +22,9 @@ import asyncio import logging +from dataclasses import dataclass from typing import Any +from urllib.parse import urlparse from mcp.server.auth.provider import AccessToken, TokenVerifier from mcp.server.auth.settings import AuthSettings as FastMcpAuthSettings @@ -39,6 +41,38 @@ logger = logging.getLogger(__name__) +@dataclass(frozen=True) +class ResourceServer: + """A validated resource-server URL paired with its parsed Host authorities. + + Built by :meth:`from_url` at composition, so holding one is proof its + ``host_forms`` are resolved before any read. ``url`` is the verbatim RFC 9728 + resource identifier (kept exact, as clients compare against it); ``host_forms`` + are the Host-header wire forms it presents, which the transport allowlist widens. + """ + + url: str + host_forms: tuple[str, ...] + + @classmethod + def from_url(cls, url: str) -> ResourceServer: + """Parse an already-validated URL into its wire-form Host authorities. + + An IPv6 literal is bracketed, as the wire Host carries it (``urlparse`` + reports it unbracketed); a URL that names a port also contributes the + ``host:port`` form. + """ + parsed = urlparse(url) + hostname = parsed.hostname + forms: list[str] = [] + if hostname: + host = f"[{hostname}]" if ":" in hostname else hostname + forms.append(host) + if parsed.port: + forms.append(f"{host}:{parsed.port}") + return cls(url=url, host_forms=tuple(forms)) + + class PipefyAccessToken(AccessToken): """AccessToken with the JWT ``sub`` claim preserved for request logging.""" diff --git a/packages/mcp/src/pipefy_mcp/core/transport_security.py b/packages/mcp/src/pipefy_mcp/core/transport_security.py index 4aede43c..91e8015c 100644 --- a/packages/mcp/src/pipefy_mcp/core/transport_security.py +++ b/packages/mcp/src/pipefy_mcp/core/transport_security.py @@ -16,17 +16,17 @@ ``settings.py`` so the config boundary stays framework-free. The public host is derived from ``resource_server_url`` (which the remote profile -already requires), so the standard fronted deployment needs no allowlist config; -``allowed_hosts`` / ``allowed_origins`` extend it. When nothing is configured the -function returns ``None``, leaving FastMCP's own loopback default in force. +already requires) via :class:`pipefy_mcp.auth.ResourceServer`, so the standard fronted deployment +needs no allowlist config; ``allowed_hosts`` / ``allowed_origins`` extend it. When +nothing is configured the function returns ``None``, leaving FastMCP's own loopback +default in force. """ from __future__ import annotations -from urllib.parse import urlparse - from mcp.server.transport_security import TransportSecuritySettings +from pipefy_mcp.auth import ResourceServer from pipefy_mcp.settings import Settings # Loopback entries kept in every explicit allowlist so widening for a proxy does @@ -45,21 +45,6 @@ def _host_forms(host: str) -> tuple[str, str]: return host, f"{host}:*" -def _resource_hosts(resource_server_url: str | None) -> list[str]: - """The public Host forms carried by ``resource_server_url``, if set.""" - if not resource_server_url: - return [] - parsed = urlparse(resource_server_url) - hosts: list[str] = [] - if parsed.hostname: - hosts.append(parsed.hostname) - # netloc carries host:port; include it when a port is present so a proxy that - # forwards "host:port" as the Host still matches the exact form. - if parsed.port and parsed.netloc not in hosts: - hosts.append(parsed.netloc) - return hosts - - def build_transport_security(settings: Settings) -> TransportSecuritySettings | None: """Resolve the transport allowlist, or ``None`` to keep FastMCP's default. @@ -69,7 +54,9 @@ def build_transport_security(settings: Settings) -> TransportSecuritySettings | preserved. Otherwise it enables DNS-rebinding protection over loopback plus the derived public host and any explicit entries. """ - public_hosts = _resource_hosts(settings.rs.resource_server_url) + url = settings.rs.resource_server_url + resource = ResourceServer.from_url(url) if url else None + public_hosts = list(resource.host_forms) if resource else [] public_hosts += settings.mcp.allowed_hosts or [] explicit_origins = settings.mcp.allowed_origins diff --git a/packages/mcp/tests/auth/test_resource_server.py b/packages/mcp/tests/auth/test_resource_server.py index 23ba1e23..5f1d7441 100644 --- a/packages/mcp/tests/auth/test_resource_server.py +++ b/packages/mcp/tests/auth/test_resource_server.py @@ -15,7 +15,11 @@ import pytest from pipefy_auth import JwtValidationSettings, TokenValidationError -from pipefy_mcp.auth import JwtTokenVerifier, build_resource_server_auth +from pipefy_mcp.auth import ( + JwtTokenVerifier, + ResourceServer, + build_resource_server_auth, +) from pipefy_mcp.settings import ResourceServerSettings _RESOURCE = "https://mcp.example.com/mcp" @@ -274,3 +278,26 @@ def test_build_without_resolvable_issuer_raises() -> None: JwtValidationSettings(), default_issuer_url=None, ) + + +# --- ResourceServer.from_url (host-authority parsing) ------------------------- + + +@pytest.mark.unit +def test_resource_server_keeps_the_verbatim_url_and_derives_bare_host() -> None: + resource = ResourceServer.from_url("https://mcp.pipefy.com/mcp") + assert resource.url == "https://mcp.pipefy.com/mcp" + assert resource.host_forms == ("mcp.pipefy.com",) + + +@pytest.mark.unit +def test_resource_server_adds_host_port_when_url_names_a_port() -> None: + resource = ResourceServer.from_url("https://mcp.pipefy.com:8443/mcp") + assert resource.host_forms == ("mcp.pipefy.com", "mcp.pipefy.com:8443") + + +@pytest.mark.unit +def test_resource_server_brackets_an_ipv6_literal() -> None: + """urlparse reports the host unbracketed; the wire Host is bracketed.""" + resource = ResourceServer.from_url("https://[2001:db8::1]:8443/mcp") + assert resource.host_forms == ("[2001:db8::1]", "[2001:db8::1]:8443") diff --git a/packages/mcp/tests/core/test_transport_security.py b/packages/mcp/tests/core/test_transport_security.py index 6fb5cbf0..09b7184b 100644 --- a/packages/mcp/tests/core/test_transport_security.py +++ b/packages/mcp/tests/core/test_transport_security.py @@ -52,6 +52,32 @@ def test_resource_server_url_with_explicit_port_keeps_host_and_hostport(): assert "mcp.pipefy.com:8443" in security.allowed_hosts +@pytest.mark.unit +def test_ipv6_literal_resource_url_is_bracketed_to_match_the_wire_host(): + """An IPv6-literal host is bracketed, as the Host header carries it. + + urlparse reports the hostname unbracketed ('2606:4700:4700::1111'), but a + client sends the bracketed form; without re-bracketing every request 421s. + """ + security = build_transport_security( + _settings(resource_server_url="https://[2606:4700:4700::1111]/mcp") + ) + assert security is not None + assert "[2606:4700:4700::1111]" in security.allowed_hosts + assert "[2606:4700:4700::1111]:*" in security.allowed_hosts + + +@pytest.mark.unit +def test_ipv6_literal_with_port_keeps_bracketed_host_and_hostport(): + """A ported IPv6 URL contributes both the bracketed host and host:port.""" + security = build_transport_security( + _settings(resource_server_url="https://[2606:4700:4700::1111]:8443/mcp") + ) + assert security is not None + assert "[2606:4700:4700::1111]" in security.allowed_hosts + assert "[2606:4700:4700::1111]:8443" in security.allowed_hosts + + @pytest.mark.unit def test_explicit_allowed_hosts_extend_the_derived_set(): """PIPEFY_MCP_ALLOWED_HOSTS adds to loopback and the resource host.""" From c5cf6e0dbda8c87e87ca23077556b9b961cbcd3f Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 13 Jul 2026 17:39:56 -0300 Subject: [PATCH 05/23] refactor(mcp): build_resource_server_auth consumes ResourceServer auth published ResourceServer but never used it: the builder read the raw resource_server_url string while only transport security exercised the type. Parse the RFC 9728 resource identity into a ResourceServer at the auth boundary and derive the token's stamped resource and the advertised AuthSettings metadata from that one carrier, so the concern that owns and publishes the type also exercises it. --- packages/mcp/src/pipefy_mcp/auth/resource_server.py | 8 ++++++-- packages/mcp/tests/auth/test_resource_server.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/mcp/src/pipefy_mcp/auth/resource_server.py b/packages/mcp/src/pipefy_mcp/auth/resource_server.py index 65dd7218..34be48ac 100644 --- a/packages/mcp/src/pipefy_mcp/auth/resource_server.py +++ b/packages/mcp/src/pipefy_mcp/auth/resource_server.py @@ -188,6 +188,10 @@ def build_resource_server_auth( """ if rs.resource_server_url is None: return None + # Parse the RFC 9728 resource identity into the ResourceServer this module owns, + # so the token's stamped resource and the advertised metadata both read from the + # one carrier rather than the raw setting. + resource = ResourceServer.from_url(rs.resource_server_url) issuer_url = jwt_validation.resolve_issuer_url(default_issuer_url) if issuer_url is None: raise RuntimeError( @@ -211,11 +215,11 @@ def build_resource_server_auth( allow_insecure_urls=jwt_validation.allow_insecure_urls, jwks_uri=jwt_validation.jwks_uri, ), - resource=rs.resource_server_url, + resource=resource.url, ) auth = FastMcpAuthSettings( issuer_url=issuer_url, - resource_server_url=rs.resource_server_url, + resource_server_url=resource.url, required_scopes=rs.required_scopes, ) return verifier, auth diff --git a/packages/mcp/tests/auth/test_resource_server.py b/packages/mcp/tests/auth/test_resource_server.py index 5f1d7441..2eabe19e 100644 --- a/packages/mcp/tests/auth/test_resource_server.py +++ b/packages/mcp/tests/auth/test_resource_server.py @@ -203,6 +203,17 @@ def test_build_stamps_resource_server_url_not_audience() -> None: assert verifier._resource == _RESOURCE +@pytest.mark.unit +def test_build_advertises_the_parsed_resource_url_in_the_metadata() -> None: + """AuthSettings' resource_server_url comes from the parsed ResourceServer carrier.""" + _, auth = build_resource_server_auth( + ResourceServerSettings(resource_server_url=_RESOURCE), + JwtValidationSettings(jwks_uri="https://idp.example.com/jwks"), + default_issuer_url=_ISSUER, + ) + assert str(auth.resource_server_url).rstrip("/") == _RESOURCE + + @pytest.mark.unit def test_build_skips_audience_by_default() -> None: """No audience config folds to SkipAudience: the validator does not check aud.""" From 30cd841a83657df6ff39ce622050faff566794a0 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 13 Jul 2026 17:52:46 -0300 Subject: [PATCH 06/23] refactor(mcp): wire the resource server once in the runtime The inbound-auth builder and the transport-allowlist builder each parsed resource_server_url into their own ResourceServer, so the one URL was parsed twice by two consumers that could in principle disagree on the host. Make McpRuntime.for_profile the single wiring point: it parses the URL into one ResourceServer and feeds that instance to both build_resource_server_auth and build_transport_security, holding the results (inbound_auth, transport_security) for the server to read. The builders stay standalone functions and now receive the parsed resource rather than a raw setting; the remote-without-resource fail-fast moves to the runtime's resource gate. server.py drops its direct build_transport_security call and reads runtime.transport_security. --- .../src/pipefy_mcp/auth/resource_server.py | 30 +++------ packages/mcp/src/pipefy_mcp/core/runtime.py | 67 ++++++++++++++----- .../src/pipefy_mcp/core/transport_security.py | 41 ++++++------ packages/mcp/src/pipefy_mcp/server.py | 12 ++-- .../mcp/tests/auth/test_resource_server.py | 28 ++------ packages/mcp/tests/core/test_runtime.py | 39 ++++++++++- .../mcp/tests/core/test_transport_security.py | 53 ++++++--------- packages/mcp/tests/test_server.py | 29 ++++---- 8 files changed, 168 insertions(+), 131 deletions(-) diff --git a/packages/mcp/src/pipefy_mcp/auth/resource_server.py b/packages/mcp/src/pipefy_mcp/auth/resource_server.py index 34be48ac..a56567eb 100644 --- a/packages/mcp/src/pipefy_mcp/auth/resource_server.py +++ b/packages/mcp/src/pipefy_mcp/auth/resource_server.py @@ -36,8 +36,6 @@ SkipAudience, ) -from pipefy_mcp.settings import ResourceServerSettings - logger = logging.getLogger(__name__) @@ -168,30 +166,24 @@ def _to_access_token(self, token: str, claims: dict[str, Any]) -> AccessToken: def build_resource_server_auth( - rs: ResourceServerSettings, + resource: ResourceServer, jwt_validation: JwtValidationSettings, *, + required_scopes: list[str] | None = None, default_issuer_url: str | None, -) -> ResourceServerAuth | None: - """Build the inbound bearer verifier and FastMCP auth config, or ``None``. +) -> ResourceServerAuth: + """Build the inbound bearer verifier and FastMCP auth config for ``resource``. - The resource-server profile has no enable flag: it is active when this - server's ``resource_server_url`` is configured. Absent it, this returns - ``None`` and the unauthenticated foundation profile constructs ``FastMCP`` - exactly as before. + Called only when the resource-server profile is active: the composition root + parses the configured ``resource_server_url`` into ``resource`` and gates on its + presence, so this receives an already-parsed identity and never a ``None``. The inbound issuer is ``jwt_validation.issuer_url`` if set, else ``default_issuer_url`` (see :class:`JwtValidationSettings` for why the login - issuer is the fallback). With ``resource_server_url`` set but no issuer - resolvable (the stored-session login is disabled and no override is given), - validation is impossible, so this raises rather than serve an open endpoint. + issuer is the fallback). With ``resource`` set but no issuer resolvable (the + stored-session login is disabled and no override is given), validation is + impossible, so this raises rather than serve an open endpoint. """ - if rs.resource_server_url is None: - return None - # Parse the RFC 9728 resource identity into the ResourceServer this module owns, - # so the token's stamped resource and the advertised metadata both read from the - # one carrier rather than the raw setting. - resource = ResourceServer.from_url(rs.resource_server_url) issuer_url = jwt_validation.resolve_issuer_url(default_issuer_url) if issuer_url is None: raise RuntimeError( @@ -220,6 +212,6 @@ def build_resource_server_auth( auth = FastMcpAuthSettings( issuer_url=issuer_url, resource_server_url=resource.url, - required_scopes=rs.required_scopes, + required_scopes=required_scopes, ) return verifier, auth diff --git a/packages/mcp/src/pipefy_mcp/core/runtime.py b/packages/mcp/src/pipefy_mcp/core/runtime.py index bdb9d193..72e22fe4 100644 --- a/packages/mcp/src/pipefy_mcp/core/runtime.py +++ b/packages/mcp/src/pipefy_mcp/core/runtime.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from httpx import Auth +from mcp.server.transport_security import TransportSecuritySettings from pipefy_auth import ( StaticBearerAuth, build_httpx_auth, @@ -16,10 +17,12 @@ from pipefy_mcp._docs import DOCS_SETUP_REF from pipefy_mcp.auth.request_identity import require_request_bearer from pipefy_mcp.auth.resource_server import ( + ResourceServer, ResourceServerAuth, build_resource_server_auth, ) -from pipefy_mcp.settings import Settings +from pipefy_mcp.core.transport_security import build_transport_security +from pipefy_mcp.settings import ResourceServerSettings, Settings @dataclass(frozen=True) @@ -91,6 +94,17 @@ def resolve(self, request: Request | None) -> Auth: AuthSource = StartupIdentity | RequestScopedIdentity +def _resource_server(rs: ResourceServerSettings) -> ResourceServer | None: + """Parse the configured resource-server URL into its value object, or ``None``. + + The one place the resource identity is parsed: the composition root feeds this + single :class:`ResourceServer` to both consumers (inbound auth and the transport + allowlist), so neither re-parses the URL and they cannot disagree on the host. + """ + url = rs.resource_server_url + return ResourceServer.from_url(url) if url else None + + def _login_issuer_url(settings: Settings) -> str | None: """The issuer this process logs into, the fallback for the inbound issuer. @@ -107,11 +121,14 @@ class McpRuntime: Built once at server startup via :meth:`for_profile`, which turns the resolved settings into wired resources: the outbound identity (whose :meth:`resolve` - backs each request's session) and, under the ``remote`` profile, the inbound - resource-server ``(verifier, auth)`` pair FastMCP uses to validate each caller's - bearer. It owns the process-scoped :class:`PipefyEngine` (the shared endpoints - and GraphQL schema cache, built auth-agnostic with no network I/O) and opens a - cheap per-request session bound to the caller's identity. + backs each request's session), the HTTP transport's DNS-rebinding allowlist, and, + under the ``remote`` profile, the inbound resource-server ``(verifier, auth)`` pair + FastMCP uses to validate each caller's bearer. It parses the ``resource_server_url`` + into one :class:`ResourceServer` and feeds it to both the inbound-auth and the + allowlist builders, so they cannot disagree on the resource host. It owns the + process-scoped :class:`PipefyEngine` (the shared endpoints and GraphQL schema cache, + built auth-agnostic with no network I/O) and opens a cheap per-request session bound + to the caller's identity. Building the engine here is safe off the event loop: it does no network I/O and binds nothing to a running loop (its endpoints open a fresh per-request @@ -119,7 +136,7 @@ class McpRuntime: later handles requests. This is a stepping stone toward a single per-app runtime; today it owns the - shared engine and the inbound-auth pair. + shared engine, the inbound-auth pair, and the transport allowlist. """ def __init__( @@ -128,18 +145,22 @@ def __init__( identity: AuthSource, *, inbound_auth: ResourceServerAuth | None = None, + transport_security: TransportSecuritySettings | None = None, ) -> None: self._settings = settings self._identity = identity self.inbound_auth = inbound_auth + self.transport_security = transport_security self._engine = PipefyEngine.build(settings.pipefy, surface="mcp") @classmethod def for_profile(cls, settings: Settings) -> McpRuntime: """Build the runtime for the resolved profile, wiring inbound and outbound auth. - The composition root's one build step: ``settings.mcp.profile`` selects the - outbound identity and, for ``remote``, the inbound resource-server pair. + The composition root's one build step: it parses the ``resource_server_url`` + once, builds the transport allowlist from it, and ``settings.mcp.profile`` + selects the outbound identity and, for ``remote``, the inbound resource-server + pair (fed the same parsed resource). ``remote`` acts on behalf of each caller: it validates a per-request bearer (the inbound ``(verifier, auth)`` pair) and each session replays that @@ -152,20 +173,32 @@ def for_profile(cls, settings: Settings) -> McpRuntime: identity: the one startup credential is resolved from settings (and fails fast when none is configured) and every session acts as it. """ + resource = _resource_server(settings.rs) + transport_security = build_transport_security(settings.mcp, resource) if settings.mcp.profile == "remote": - inbound_auth = build_resource_server_auth( - settings.rs, - settings.jwt, - default_issuer_url=_login_issuer_url(settings), - ) - if inbound_auth is None: + if resource is None: raise RuntimeError( "the 'remote' profile requires a resource server: set " "PIPEFY_MCP_RS_RESOURCE_SERVER_URL so the server validates a " "per-request bearer and acts on behalf of the caller." ) - return cls(settings, RequestScopedIdentity(), inbound_auth=inbound_auth) - return cls(settings, StartupIdentity.from_configured_credential(settings)) + inbound_auth = build_resource_server_auth( + resource, + settings.jwt, + required_scopes=settings.rs.required_scopes, + default_issuer_url=_login_issuer_url(settings), + ) + return cls( + settings, + RequestScopedIdentity(), + inbound_auth=inbound_auth, + transport_security=transport_security, + ) + return cls( + settings, + StartupIdentity.from_configured_credential(settings), + transport_security=transport_security, + ) def session_for_request(self, request: Request | None) -> PipefyClient: """Open a session bound to the current request's identity. diff --git a/packages/mcp/src/pipefy_mcp/core/transport_security.py b/packages/mcp/src/pipefy_mcp/core/transport_security.py index 91e8015c..07d1e87c 100644 --- a/packages/mcp/src/pipefy_mcp/core/transport_security.py +++ b/packages/mcp/src/pipefy_mcp/core/transport_security.py @@ -1,4 +1,4 @@ -"""Derive the HTTP transport's DNS-rebinding allowlist from settings. +"""Derive the HTTP transport's DNS-rebinding allowlist. FastMCP auto-enables a loopback-only Host/Origin allowlist whenever it is built with a loopback ``host`` and no explicit ``transport_security``. The builder always @@ -11,15 +11,14 @@ This is configuration resolution, not an MCP extension: it produces a static config value the SDK reads, mirroring :func:`pipefy_mcp.auth.resource_server.build_resource_server_auth` (settings to the -SDK's ``AuthSettings``). It lives in the composition tier because transport security -owns no concern folder of its own, and the mcp-SDK type is kept out of -``settings.py`` so the config boundary stays framework-free. - -The public host is derived from ``resource_server_url`` (which the remote profile -already requires) via :class:`pipefy_mcp.auth.ResourceServer`, so the standard fronted deployment -needs no allowlist config; ``allowed_hosts`` / ``allowed_origins`` extend it. When -nothing is configured the function returns ``None``, leaving FastMCP's own loopback -default in force. +SDK's ``AuthSettings``). The mcp-SDK type is kept out of ``settings.py`` so the +config boundary stays framework-free. + +The public host comes from the :class:`pipefy_mcp.auth.ResourceServer` the runtime +parses once and feeds in (which the remote profile already requires), so the standard +fronted deployment needs no allowlist config; ``allowed_hosts`` / ``allowed_origins`` +extend it. When neither a resource nor an explicit allowlist is given the function +returns ``None``, leaving FastMCP's own loopback default in force. """ from __future__ import annotations @@ -27,7 +26,7 @@ from mcp.server.transport_security import TransportSecuritySettings from pipefy_mcp.auth import ResourceServer -from pipefy_mcp.settings import Settings +from pipefy_mcp.settings import McpSettings # Loopback entries kept in every explicit allowlist so widening for a proxy does # not lock out local tooling on the box (a proxy on the same host still reaches the @@ -45,20 +44,20 @@ def _host_forms(host: str) -> tuple[str, str]: return host, f"{host}:*" -def build_transport_security(settings: Settings) -> TransportSecuritySettings | None: +def build_transport_security( + mcp: McpSettings, resource: ResourceServer | None +) -> TransportSecuritySettings | None: """Resolve the transport allowlist, or ``None`` to keep FastMCP's default. - Returns ``None`` when there is nothing to add beyond loopback (no - ``resource_server_url`` and no explicit ``allowed_hosts`` / ``allowed_origins``), - so FastMCP applies its own loopback auto-enable and current behavior is - preserved. Otherwise it enables DNS-rebinding protection over loopback plus the - derived public host and any explicit entries. + Returns ``None`` when there is nothing to add beyond loopback (no ``resource`` + and no explicit ``allowed_hosts`` / ``allowed_origins``), so FastMCP applies its + own loopback auto-enable and current behavior is preserved. Otherwise it enables + DNS-rebinding protection over loopback plus the resource's public host and any + explicit entries. """ - url = settings.rs.resource_server_url - resource = ResourceServer.from_url(url) if url else None public_hosts = list(resource.host_forms) if resource else [] - public_hosts += settings.mcp.allowed_hosts or [] - explicit_origins = settings.mcp.allowed_origins + public_hosts += mcp.allowed_hosts or [] + explicit_origins = mcp.allowed_origins if not public_hosts and not explicit_origins: return None diff --git a/packages/mcp/src/pipefy_mcp/server.py b/packages/mcp/src/pipefy_mcp/server.py index 3769d052..a54442b7 100644 --- a/packages/mcp/src/pipefy_mcp/server.py +++ b/packages/mcp/src/pipefy_mcp/server.py @@ -12,7 +12,6 @@ ToolCallMiddleware, install_tool_call_middleware, ) -from pipefy_mcp.core.transport_security import build_transport_security from pipefy_mcp.observability.json_logging import configure_observability_logging from pipefy_mcp.observability.tool_log_middleware import tool_log_middleware from pipefy_mcp.settings import Settings @@ -101,11 +100,10 @@ def build_pipefy_mcp_server( remote-safe tool surface, and ``settings.mcp.host`` / ``settings.mcp.port`` give the HTTP bind (they matter only for the HTTP transport; stdio ignores them). - The DNS-rebinding allowlist for the HTTP transport is derived here by - :func:`pipefy_mcp.core.transport_security.build_transport_security` (from the - ``resource_server_url`` host plus any ``allowed_hosts`` / ``allowed_origins``) - and passed to FastMCP; it is ``None`` (FastMCP's own loopback default) when - nothing is configured, and irrelevant for stdio. + The DNS-rebinding allowlist for the HTTP transport is built by the runtime (from + the ``resource_server_url`` host plus any ``allowed_hosts`` / ``allowed_origins``) + and read off it here as ``runtime.transport_security``; it is ``None`` (FastMCP's + own loopback default) when nothing is configured, and irrelevant for stdio. ``extra_tool_middlewares`` is the public registration seam for a consumer of this builder (a hosted serving layer that wants per-tool metrics, say): the chain @@ -133,7 +131,7 @@ def build_pipefy_mcp_server( log_level=settings.mcp.log_level, token_verifier=verifier, auth=auth, - transport_security=build_transport_security(settings), + transport_security=runtime.transport_security, ) _register_pipefy_tools(app, remote_mode=settings.mcp.profile == "remote") # Wrap the tool-call handler with the built-in chain plus any consumer middleware. diff --git a/packages/mcp/tests/auth/test_resource_server.py b/packages/mcp/tests/auth/test_resource_server.py index 2eabe19e..13f9eb5f 100644 --- a/packages/mcp/tests/auth/test_resource_server.py +++ b/packages/mcp/tests/auth/test_resource_server.py @@ -20,7 +20,6 @@ ResourceServer, build_resource_server_auth, ) -from pipefy_mcp.settings import ResourceServerSettings _RESOURCE = "https://mcp.example.com/mcp" _ISSUER = "https://idp.example.com/realms/x" @@ -191,7 +190,7 @@ def test_build_stamps_resource_server_url_not_audience() -> None: token's stamped resource must match it, not the (often unset) audience. """ verifier, _ = build_resource_server_auth( - ResourceServerSettings(resource_server_url=_RESOURCE), + ResourceServer.from_url(_RESOURCE), # audience set and distinct from resource_server_url: the stamped resource # must follow resource_server_url, not audience. JwtValidationSettings( @@ -207,7 +206,7 @@ def test_build_stamps_resource_server_url_not_audience() -> None: def test_build_advertises_the_parsed_resource_url_in_the_metadata() -> None: """AuthSettings' resource_server_url comes from the parsed ResourceServer carrier.""" _, auth = build_resource_server_auth( - ResourceServerSettings(resource_server_url=_RESOURCE), + ResourceServer.from_url(_RESOURCE), JwtValidationSettings(jwks_uri="https://idp.example.com/jwks"), default_issuer_url=_ISSUER, ) @@ -218,7 +217,7 @@ def test_build_advertises_the_parsed_resource_url_in_the_metadata() -> None: def test_build_skips_audience_by_default() -> None: """No audience config folds to SkipAudience: the validator does not check aud.""" verifier, _ = build_resource_server_auth( - ResourceServerSettings(resource_server_url=_RESOURCE), + ResourceServer.from_url(_RESOURCE), JwtValidationSettings(jwks_uri="https://idp.example.com/jwks"), default_issuer_url=_ISSUER, ) @@ -230,7 +229,7 @@ def test_build_skips_audience_by_default() -> None: def test_build_requires_audience_when_verifying() -> None: """verify_audience with an audience folds to RequireAudience(audience).""" verifier, _ = build_resource_server_auth( - ResourceServerSettings(resource_server_url=_RESOURCE), + ResourceServer.from_url(_RESOURCE), JwtValidationSettings( audience="api://x", verify_audience=True, @@ -242,24 +241,11 @@ def test_build_requires_audience_when_verifying() -> None: assert verifier._validator._audience == "api://x" -@pytest.mark.unit -def test_build_inactive_when_unconfigured() -> None: - """No resource_server_url means no auth: the profile is off, not an error.""" - assert ( - build_resource_server_auth( - ResourceServerSettings(), - JwtValidationSettings(), - default_issuer_url=_ISSUER, - ) - is None - ) - - @pytest.mark.unit def test_build_issuer_defaults_to_login_issuer() -> None: """With no inbound override, the inbound issuer is the login issuer.""" _, auth = build_resource_server_auth( - ResourceServerSettings(resource_server_url=_RESOURCE), + ResourceServer.from_url(_RESOURCE), JwtValidationSettings(jwks_uri="https://idp.example.com/jwks"), default_issuer_url=_ISSUER, ) @@ -271,7 +257,7 @@ def test_build_inbound_issuer_overrides_login_issuer() -> None: """An explicit inbound issuer wins over the login issuer.""" override = "https://other-idp.example.com/realms/y" _, auth = build_resource_server_auth( - ResourceServerSettings(resource_server_url=_RESOURCE), + ResourceServer.from_url(_RESOURCE), JwtValidationSettings( issuer_url=override, jwks_uri="https://other-idp.example.com/jwks" ), @@ -285,7 +271,7 @@ def test_build_without_resolvable_issuer_raises() -> None: """resource_server_url set but no issuer (override or login) is a misconfiguration.""" with pytest.raises(RuntimeError, match="no inbound issuer"): build_resource_server_auth( - ResourceServerSettings(resource_server_url=_RESOURCE), + ResourceServer.from_url(_RESOURCE), JwtValidationSettings(), default_issuer_url=None, ) diff --git a/packages/mcp/tests/core/test_runtime.py b/packages/mcp/tests/core/test_runtime.py index 045d9ac7..17944153 100644 --- a/packages/mcp/tests/core/test_runtime.py +++ b/packages/mcp/tests/core/test_runtime.py @@ -3,7 +3,12 @@ import httpx import pytest -from _rs_fixtures import authenticated_user, remote_rs_settings, request_with_user +from _rs_fixtures import ( + RS_RESOURCE, + authenticated_user, + remote_rs_settings, + request_with_user, +) from pipefy_auth import ( AuthSettings, RefreshableBearerAuth, @@ -127,6 +132,38 @@ def test_remote_selects_request_scoped_identity_and_builds_inbound_auth(self): assert runtime.inbound_auth is not None assert isinstance(runtime._identity, RequestScopedIdentity) + @pytest.mark.unit + def test_remote_feeds_one_resource_to_the_allowlist_and_the_inbound_metadata(self): + """The parsed resource host reaches the transport allowlist and the RS metadata. + + The composition root parses ``resource_server_url`` once and feeds that one + :class:`ResourceServer` to both builders, so the allowlist's public host and + the advertised metadata resource cannot disagree. + """ + runtime = McpRuntime.for_profile(remote_rs_settings()) + + assert runtime.transport_security is not None + assert "mcp.example.com" in runtime.transport_security.allowed_hosts + _, auth = runtime.inbound_auth + assert str(auth.resource_server_url).rstrip("/") == RS_RESOURCE + + @pytest.mark.unit + def test_local_builds_the_transport_allowlist_from_explicit_hosts( + self, clear_auth_env + ): + """A local profile still builds the allowlist from PIPEFY_MCP_ALLOWED_HOSTS.""" + settings = Settings( + pipefy=PipefySettings(base_url="https://api.pipefy.com"), + auth=AuthSettings(static_token="env-bearer"), + mcp=McpSettings(allowed_hosts=["proxy.internal"]), + ) + + runtime = McpRuntime.for_profile(settings) + + assert runtime.inbound_auth is None + assert runtime.transport_security is not None + assert "proxy.internal" in runtime.transport_security.allowed_hosts + @pytest.mark.unit def test_remote_snapshots_the_callers_bearer_into_its_session(self): """A session opened under the remote profile carries the request's validated bearer.""" diff --git a/packages/mcp/tests/core/test_transport_security.py b/packages/mcp/tests/core/test_transport_security.py index 09b7184b..e1dbf068 100644 --- a/packages/mcp/tests/core/test_transport_security.py +++ b/packages/mcp/tests/core/test_transport_security.py @@ -2,8 +2,9 @@ import pytest +from pipefy_mcp.auth import ResourceServer from pipefy_mcp.core.transport_security import build_transport_security -from pipefy_mcp.settings import McpSettings, ResourceServerSettings, Settings +from pipefy_mcp.settings import McpSettings _LOOPBACK_FORMS = { "127.0.0.1", @@ -15,25 +16,25 @@ } -def _settings(*, resource_server_url=None, allowed_hosts=None, allowed_origins=None): - return Settings( - mcp=McpSettings(allowed_hosts=allowed_hosts, allowed_origins=allowed_origins), - rs=ResourceServerSettings(resource_server_url=resource_server_url), +def _build(*, resource_server_url=None, allowed_hosts=None, allowed_origins=None): + """Build the allowlist the way the runtime does: parse the URL once, feed it in.""" + resource = ( + ResourceServer.from_url(resource_server_url) if resource_server_url else None ) + mcp = McpSettings(allowed_hosts=allowed_hosts, allowed_origins=allowed_origins) + return build_transport_security(mcp, resource) @pytest.mark.unit def test_unset_returns_none_to_keep_fastmcp_default(): """No resource URL and no explicit allowlist -> FastMCP's own default stands.""" - assert build_transport_security(_settings()) is None + assert _build() is None @pytest.mark.unit def test_resource_server_url_host_is_derived_and_protection_enabled(): """The public host is added (bare and any-port) with loopback retained.""" - security = build_transport_security( - _settings(resource_server_url="https://mcp.pipefy.com/mcp") - ) + security = _build(resource_server_url="https://mcp.pipefy.com/mcp") assert security is not None assert security.enable_dns_rebinding_protection is True assert "mcp.pipefy.com" in security.allowed_hosts @@ -44,9 +45,7 @@ def test_resource_server_url_host_is_derived_and_protection_enabled(): @pytest.mark.unit def test_resource_server_url_with_explicit_port_keeps_host_and_hostport(): """A URL carrying a port contributes both the bare host and host:port.""" - security = build_transport_security( - _settings(resource_server_url="https://mcp.pipefy.com:8443/mcp") - ) + security = _build(resource_server_url="https://mcp.pipefy.com:8443/mcp") assert security is not None assert "mcp.pipefy.com" in security.allowed_hosts assert "mcp.pipefy.com:8443" in security.allowed_hosts @@ -59,9 +58,7 @@ def test_ipv6_literal_resource_url_is_bracketed_to_match_the_wire_host(): urlparse reports the hostname unbracketed ('2606:4700:4700::1111'), but a client sends the bracketed form; without re-bracketing every request 421s. """ - security = build_transport_security( - _settings(resource_server_url="https://[2606:4700:4700::1111]/mcp") - ) + security = _build(resource_server_url="https://[2606:4700:4700::1111]/mcp") assert security is not None assert "[2606:4700:4700::1111]" in security.allowed_hosts assert "[2606:4700:4700::1111]:*" in security.allowed_hosts @@ -70,9 +67,7 @@ def test_ipv6_literal_resource_url_is_bracketed_to_match_the_wire_host(): @pytest.mark.unit def test_ipv6_literal_with_port_keeps_bracketed_host_and_hostport(): """A ported IPv6 URL contributes both the bracketed host and host:port.""" - security = build_transport_security( - _settings(resource_server_url="https://[2606:4700:4700::1111]:8443/mcp") - ) + security = _build(resource_server_url="https://[2606:4700:4700::1111]:8443/mcp") assert security is not None assert "[2606:4700:4700::1111]" in security.allowed_hosts assert "[2606:4700:4700::1111]:8443" in security.allowed_hosts @@ -81,11 +76,9 @@ def test_ipv6_literal_with_port_keeps_bracketed_host_and_hostport(): @pytest.mark.unit def test_explicit_allowed_hosts_extend_the_derived_set(): """PIPEFY_MCP_ALLOWED_HOSTS adds to loopback and the resource host.""" - security = build_transport_security( - _settings( - resource_server_url="https://mcp.pipefy.com/mcp", - allowed_hosts=["proxy.internal"], - ) + security = _build( + resource_server_url="https://mcp.pipefy.com/mcp", + allowed_hosts=["proxy.internal"], ) assert security is not None assert "mcp.pipefy.com" in security.allowed_hosts @@ -96,7 +89,7 @@ def test_explicit_allowed_hosts_extend_the_derived_set(): @pytest.mark.unit def test_explicit_allowed_hosts_alone_enable_protection(): """An override with no resource URL still produces an enabled allowlist.""" - security = build_transport_security(_settings(allowed_hosts=["proxy.internal"])) + security = _build(allowed_hosts=["proxy.internal"]) assert security is not None assert security.enable_dns_rebinding_protection is True assert "proxy.internal" in security.allowed_hosts @@ -105,9 +98,7 @@ def test_explicit_allowed_hosts_alone_enable_protection(): @pytest.mark.unit def test_derived_origins_cover_each_host_in_both_schemes(): """Without an override, origins are http+https for every allowed host.""" - security = build_transport_security( - _settings(resource_server_url="https://mcp.pipefy.com/mcp") - ) + security = _build(resource_server_url="https://mcp.pipefy.com/mcp") assert security is not None assert "http://mcp.pipefy.com" in security.allowed_origins assert "https://mcp.pipefy.com" in security.allowed_origins @@ -117,11 +108,9 @@ def test_derived_origins_cover_each_host_in_both_schemes(): @pytest.mark.unit def test_explicit_allowed_origins_replace_the_derived_origins(): """An explicit origin allowlist is used verbatim, not merged with derived.""" - security = build_transport_security( - _settings( - resource_server_url="https://mcp.pipefy.com/mcp", - allowed_origins=["https://mcp.pipefy.com"], - ) + security = _build( + resource_server_url="https://mcp.pipefy.com/mcp", + allowed_origins=["https://mcp.pipefy.com"], ) assert security is not None assert security.allowed_origins == ["https://mcp.pipefy.com"] diff --git a/packages/mcp/tests/test_server.py b/packages/mcp/tests/test_server.py index 1d0f2583..aeeb1c00 100644 --- a/packages/mcp/tests/test_server.py +++ b/packages/mcp/tests/test_server.py @@ -14,6 +14,7 @@ from pipefy_sdk import PipefySettings from pipefy_mcp.core.tool_middleware import ToolCallContext, short_circuit_error +from pipefy_mcp.core.transport_security import build_transport_security from pipefy_mcp.observability.tool_log_middleware import tool_log_middleware from pipefy_mcp.server import ( _make_lifespan, @@ -70,6 +71,7 @@ def mocked_runtime(): runtime = MagicMock() runtime.session_for_request.return_value = MagicMock() runtime.inbound_auth = None + runtime.transport_security = None with patch("pipefy_mcp.server.McpRuntime.for_profile", return_value=runtime): yield runtime @@ -521,24 +523,25 @@ async def test_http_serves_protected_resource_metadata(): @pytest.mark.unit -def test_build_passes_none_transport_security_for_a_plain_local_build(mocked_runtime): - """With no resource URL or allowlist, FastMCP keeps its own loopback default.""" +def test_build_passes_the_runtimes_none_transport_security_to_fastmcp(mocked_runtime): + """When the runtime built no allowlist, FastMCP keeps its own loopback default.""" with patch("pipefy_mcp.server.FastMCP") as mock_fastmcp: build_pipefy_mcp_server(_MINIMAL_PIPEFY_SETTINGS) assert mock_fastmcp.call_args.kwargs["transport_security"] is None @pytest.mark.unit -def test_build_passes_the_derived_allowlist_to_fastmcp(mocked_runtime): - """A configured allowlist reaches the FastMCP constructor as transport_security.""" - settings = _MINIMAL_PIPEFY_SETTINGS.model_copy( - update={"mcp": McpSettings(allowed_hosts=["mcp.pipefy.com"])} +def test_build_passes_the_runtimes_allowlist_to_fastmcp(mocked_runtime): + """The allowlist the runtime built reaches the FastMCP constructor verbatim.""" + mocked_runtime.transport_security = build_transport_security( + McpSettings(allowed_hosts=["mcp.pipefy.com"]), None ) with patch("pipefy_mcp.server.FastMCP") as mock_fastmcp: - build_pipefy_mcp_server(settings) - security = mock_fastmcp.call_args.kwargs["transport_security"] - assert security is not None - assert "mcp.pipefy.com" in security.allowed_hosts + build_pipefy_mcp_server(_MINIMAL_PIPEFY_SETTINGS) + assert ( + mock_fastmcp.call_args.kwargs["transport_security"] + is mocked_runtime.transport_security + ) @pytest.mark.unit @@ -562,10 +565,10 @@ async def test_http_configured_allowlist_accepts_a_public_host(mocked_runtime): It no longer 421s; it reaches the transport handler (which then fails on the missing session, not on DNS-rebinding), proving the allowlist widened the gate. """ - settings = _MINIMAL_PIPEFY_SETTINGS.model_copy( - update={"mcp": McpSettings(allowed_hosts=["mcp.pipefy.com"])} + mocked_runtime.transport_security = build_transport_security( + McpSettings(allowed_hosts=["mcp.pipefy.com"]), None ) - app = build_pipefy_mcp_server(settings) + app = build_pipefy_mcp_server(_MINIMAL_PIPEFY_SETTINGS) async with _serving_asgi_client(app) as client: resp = await client.post("/mcp", **_PUBLIC_HOST_PING) assert resp.status_code != 421 From e64072af8f58b8fb4059d058771a92fd8c93222e Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 13 Jul 2026 17:57:56 -0300 Subject: [PATCH 07/23] refactor(mcp): order required builder kwarg before the defaulted one In build_resource_server_auth the defaulted required_scopes sat before the required default_issuer_url, reading as if default_issuer_url were optional. Both are keyword-only and every caller passes by keyword, so reordering is behavior-neutral. --- packages/mcp/src/pipefy_mcp/auth/resource_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mcp/src/pipefy_mcp/auth/resource_server.py b/packages/mcp/src/pipefy_mcp/auth/resource_server.py index a56567eb..3cad7cda 100644 --- a/packages/mcp/src/pipefy_mcp/auth/resource_server.py +++ b/packages/mcp/src/pipefy_mcp/auth/resource_server.py @@ -169,8 +169,8 @@ def build_resource_server_auth( resource: ResourceServer, jwt_validation: JwtValidationSettings, *, - required_scopes: list[str] | None = None, default_issuer_url: str | None, + required_scopes: list[str] | None = None, ) -> ResourceServerAuth: """Build the inbound bearer verifier and FastMCP auth config for ``resource``. From 6edcb07997cfaffd64510510e17bdede98029fbc Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 13 Jul 2026 20:14:29 -0300 Subject: [PATCH 08/23] fix(pr-review): honor an explicit empty origin allowlist build_transport_security gated origins on `if explicit_origins:`, so an explicit allowed_origins=[] was falsy and silently replaced by the derived origins, despite the field advertising it as a strict override. Gate on `is not None` (and the early-return guard likewise) so [] is kept verbatim: it rejects any request that sends an Origin, while a request with no Origin still passes. Hosts stay additive (empty behaves like unset, since entries are extra on top of the derived set); the config.md wording is corrected to match. --- docs/config.md | 4 ++-- .../src/pipefy_mcp/core/transport_security.py | 20 +++++++++++------ packages/mcp/src/pipefy_mcp/settings.py | 6 +++-- .../mcp/tests/core/test_transport_security.py | 22 +++++++++++++++++++ 4 files changed, 41 insertions(+), 11 deletions(-) diff --git a/docs/config.md b/docs/config.md index f5eb3896..15d20348 100644 --- a/docs/config.md +++ b/docs/config.md @@ -132,7 +132,7 @@ These variables load into `pipefy_mcp.McpSettings` (`settings.mcp`). TOML keys u | `PIPEFY_MCP_HOST` | `127.0.0.1` | HTTP bind host. The unauthenticated `local` profile refuses a non-loopback bind unless `PIPEFY_MCP_ALLOW_INSECURE_HTTP_BIND` is set; the authenticated `remote` profile binds any host. | | `PIPEFY_MCP_PORT` | `8000` | HTTP bind port. | | `PIPEFY_MCP_ALLOW_INSECURE_HTTP_BIND` | `false` | Escape hatch: lets the unauthenticated `local` profile serve HTTP on a non-loopback host, exposing the full tool surface with no inbound bearer. The `remote` profile never needs it. | -| `PIPEFY_MCP_ALLOWED_HOSTS` | unset | JSON array of extra `Host` header values the HTTP transport accepts for DNS-rebinding protection, on top of loopback and the `PIPEFY_MCP_RS_RESOURCE_SERVER_URL` host. Unset derives the allowlist from the resource-server URL, so a proxied deployment usually needs none; set it only when a proxy forwards a public `Host` that differs. An entry matches an exact `Host` or, as `host:*`, any port. Empty preserves FastMCP's loopback-only default. | -| `PIPEFY_MCP_ALLOWED_ORIGINS` | unset | JSON array overriding the derived `scheme://host` `Origin` allowlist for a stricter or custom posture. Unset derives `http`/`https` origins from the allowed hosts. | +| `PIPEFY_MCP_ALLOWED_HOSTS` | unset | JSON array of extra `Host` header values the HTTP transport accepts for DNS-rebinding protection, on top of loopback and the `PIPEFY_MCP_RS_RESOURCE_SERVER_URL` host. Unset derives the allowlist from the resource-server URL, so a proxied deployment usually needs none; set it only when a proxy forwards a public `Host` that differs. An entry matches an exact `Host` or, as `host:*`, any port. These are extra entries, so an empty array behaves like unset (the resource-server host is still derived). | +| `PIPEFY_MCP_ALLOWED_ORIGINS` | unset | JSON array overriding the derived `scheme://host` `Origin` allowlist. Unset derives `http`/`https` origins from the allowed hosts; a non-empty array replaces them with a custom set (e.g. `https`-only); an empty array is the strictest override, rejecting any request that sends an `Origin` header (a request with no `Origin` still passes). | | `PIPEFY_MCP_UNIFIED_ENVELOPE` | `true` | When true, migrated tools return `{success, data, ...}`. | | `PIPEFY_MCP_LOG_LEVEL` | `INFO` | Governs **both** hosted structured JSON lines on **stdout** and the FastMCP root logger (RichHandler text on **stderr**). Accepts `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL` (case-insensitive). The server passes the resolved level to `FastMCP(log_level=...)` at construction, so this is the single operator knob for MCP logging. Some MCP tooling documents a separate `FASTMCP_LOG_LEVEL` env var; when using `pipefy-mcp-server`, prefer `PIPEFY_MCP_LOG_LEVEL` — it configures the root logger explicitly and keeps structured events on the same level. | diff --git a/packages/mcp/src/pipefy_mcp/core/transport_security.py b/packages/mcp/src/pipefy_mcp/core/transport_security.py index 07d1e87c..97fefdd8 100644 --- a/packages/mcp/src/pipefy_mcp/core/transport_security.py +++ b/packages/mcp/src/pipefy_mcp/core/transport_security.py @@ -49,17 +49,23 @@ def build_transport_security( ) -> TransportSecuritySettings | None: """Resolve the transport allowlist, or ``None`` to keep FastMCP's default. - Returns ``None`` when there is nothing to add beyond loopback (no ``resource`` - and no explicit ``allowed_hosts`` / ``allowed_origins``), so FastMCP applies its - own loopback auto-enable and current behavior is preserved. Otherwise it enables - DNS-rebinding protection over loopback plus the resource's public host and any - explicit entries. + Returns ``None`` only when there is nothing to configure: no ``resource``, no + ``allowed_hosts``, and an unset (``None``) ``allowed_origins``, so FastMCP applies + its own loopback auto-enable and current behavior is preserved. Otherwise it + enables DNS-rebinding protection over loopback plus the resource's public host and + any explicit entries. An explicit ``allowed_origins`` (including an empty list, + which rejects any request that sends an Origin) is honored verbatim; only an unset + list falls back to the origins derived from the allowed hosts. """ public_hosts = list(resource.host_forms) if resource else [] public_hosts += mcp.allowed_hosts or [] explicit_origins = mcp.allowed_origins - if not public_hosts and not explicit_origins: + # An explicit origin allowlist (including an empty one) is an override worth + # honoring, so it keeps protection on even with no host to add: ``allowed_origins=[]`` + # is the strictest posture, rejecting any request that sends an Origin. Only an + # unset origin list plus no host leaves nothing to configure. + if not public_hosts and explicit_origins is None: return None allowed_hosts: list[str] = [] @@ -68,7 +74,7 @@ def build_transport_security( if form not in allowed_hosts: allowed_hosts.append(form) - if explicit_origins: + if explicit_origins is not None: allowed_origins = list(explicit_origins) else: allowed_origins = [] diff --git a/packages/mcp/src/pipefy_mcp/settings.py b/packages/mcp/src/pipefy_mcp/settings.py index 5ab4817a..751169e7 100644 --- a/packages/mcp/src/pipefy_mcp/settings.py +++ b/packages/mcp/src/pipefy_mcp/settings.py @@ -151,8 +151,10 @@ def settings_customise_sources( default=None, description=( "Origin header values the HTTP transport accepts (env: " - "PIPEFY_MCP_ALLOWED_ORIGINS as a JSON array). Overrides the derived " - "scheme://host Origin allowlist for a stricter or custom posture." + "PIPEFY_MCP_ALLOWED_ORIGINS as a JSON array). Unset derives the " + "scheme://host origins from the allowed hosts; a non-empty array replaces " + "them with a custom set, and an empty array is the strictest override, " + "rejecting any request that sends an Origin header." ), ) diff --git a/packages/mcp/tests/core/test_transport_security.py b/packages/mcp/tests/core/test_transport_security.py index e1dbf068..a093c3a3 100644 --- a/packages/mcp/tests/core/test_transport_security.py +++ b/packages/mcp/tests/core/test_transport_security.py @@ -114,3 +114,25 @@ def test_explicit_allowed_origins_replace_the_derived_origins(): ) assert security is not None assert security.allowed_origins == ["https://mcp.pipefy.com"] + + +@pytest.mark.unit +def test_explicit_empty_origins_are_honored_as_the_strictest_override(): + """An explicit empty origin list is kept verbatim (reject any Origin), not derived.""" + security = _build( + resource_server_url="https://mcp.pipefy.com/mcp", allowed_origins=[] + ) + assert security is not None + assert security.allowed_origins == [] + # The host allowlist is still derived; only origins are the strict override. + assert "mcp.pipefy.com" in security.allowed_hosts + + +@pytest.mark.unit +def test_explicit_empty_origins_alone_still_enable_protection(): + """Empty origins with no host still builds an allowlist (not FastMCP's default).""" + security = _build(allowed_origins=[]) + assert security is not None + assert security.enable_dns_rebinding_protection is True + assert security.allowed_origins == [] + assert _LOOPBACK_FORMS.issubset(set(security.allowed_hosts)) From 5e173e1b0279ed3d9651924dfb56f5c81296a55a Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 13 Jul 2026 20:18:16 -0300 Subject: [PATCH 09/23] refactor(mcp): split for_profile into per-profile builders for_profile parsed the resource and built the allowlist, then inlined both profile branches. Keep the single parse in for_profile and dispatch to _for_remote_profile and _for_local_profile, each carrying its own profile-specific rationale. Both receive the one parsed resource/allowlist, so the single-parse invariant holds. --- packages/mcp/src/pipefy_mcp/core/runtime.py | 82 +++++++++++++-------- 1 file changed, 51 insertions(+), 31 deletions(-) diff --git a/packages/mcp/src/pipefy_mcp/core/runtime.py b/packages/mcp/src/pipefy_mcp/core/runtime.py index 72e22fe4..13705f68 100644 --- a/packages/mcp/src/pipefy_mcp/core/runtime.py +++ b/packages/mcp/src/pipefy_mcp/core/runtime.py @@ -158,42 +158,62 @@ def for_profile(cls, settings: Settings) -> McpRuntime: """Build the runtime for the resolved profile, wiring inbound and outbound auth. The composition root's one build step: it parses the ``resource_server_url`` - once, builds the transport allowlist from it, and ``settings.mcp.profile`` - selects the outbound identity and, for ``remote``, the inbound resource-server - pair (fed the same parsed resource). - - ``remote`` acts on behalf of each caller: it validates a per-request bearer - (the inbound ``(verifier, auth)`` pair) and each session replays that - caller's snapshotted bearer (:class:`RequestScopedIdentity`). A configured - resource server is mandatory there, so this fails fast when none resolves, - rather than serve an open endpoint or silently fall back to a startup - credential. - - Every other profile (stdio, or ``local`` over loopback HTTP) has no inbound - identity: the one startup credential is resolved from settings (and fails - fast when none is configured) and every session acts as it. + once, builds the transport allowlist from that one parsed resource, and hands + both to the per-profile builder ``settings.mcp.profile`` selects. Parsing here + keeps the resource a single value both the allowlist and (for ``remote``) the + inbound-auth pair are derived from, so they cannot disagree on the host. """ resource = _resource_server(settings.rs) transport_security = build_transport_security(settings.mcp, resource) if settings.mcp.profile == "remote": - if resource is None: - raise RuntimeError( - "the 'remote' profile requires a resource server: set " - "PIPEFY_MCP_RS_RESOURCE_SERVER_URL so the server validates a " - "per-request bearer and acts on behalf of the caller." - ) - inbound_auth = build_resource_server_auth( - resource, - settings.jwt, - required_scopes=settings.rs.required_scopes, - default_issuer_url=_login_issuer_url(settings), - ) - return cls( - settings, - RequestScopedIdentity(), - inbound_auth=inbound_auth, - transport_security=transport_security, + return cls._for_remote_profile(settings, resource, transport_security) + return cls._for_local_profile(settings, transport_security) + + @classmethod + def _for_remote_profile( + cls, + settings: Settings, + resource: ResourceServer | None, + transport_security: TransportSecuritySettings | None, + ) -> McpRuntime: + """The ``remote`` profile: a per-request identity and an inbound RS pair. + + ``remote`` acts on behalf of each caller: it validates a per-request bearer + (the inbound ``(verifier, auth)`` pair) and each session replays that caller's + snapshotted bearer (:class:`RequestScopedIdentity`). A configured resource + server is mandatory here, so this fails fast when none resolves rather than + serve an open endpoint or silently fall back to a startup credential. + """ + if resource is None: + raise RuntimeError( + "the 'remote' profile requires a resource server: set " + "PIPEFY_MCP_RS_RESOURCE_SERVER_URL so the server validates a " + "per-request bearer and acts on behalf of the caller." ) + inbound_auth = build_resource_server_auth( + resource, + settings.jwt, + required_scopes=settings.rs.required_scopes, + default_issuer_url=_login_issuer_url(settings), + ) + return cls( + settings, + RequestScopedIdentity(), + inbound_auth=inbound_auth, + transport_security=transport_security, + ) + + @classmethod + def _for_local_profile( + cls, + settings: Settings, + transport_security: TransportSecuritySettings | None, + ) -> McpRuntime: + """The ``local`` profile (stdio, or loopback HTTP): one startup credential. + + No inbound identity: the one startup credential is resolved from settings (and + fails fast when none is configured) and every session acts as it. + """ return cls( settings, StartupIdentity.from_configured_credential(settings), From 1e2bf8a5f8eebabe907a279f1e0efe363aa4ab54 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 13 Jul 2026 20:33:19 -0300 Subject: [PATCH 10/23] refactor(mcp): resolve the inbound issuer at the composition root build_resource_server_auth resolved the issuer itself (jwt_validation.resolve_issuer_url + a default_issuer_url fallback) and raised when it was unresolvable, mixing a cross-settings resolution and its fail-fast into a builder that should just wire. Move both to McpRuntime._for_remote_profile, where they sit beside the existing resource fail-fast, and pass a resolved issuer_url into the builder. The resolution rule stays owned by JwtValidationSettings.resolve_issuer_url (tested in pipefy_auth), so the builder's two resolution tests were redundant; the unresolvable-issuer fail-fast is now a runtime test. --- .../src/pipefy_mcp/auth/resource_server.py | 22 +++------- packages/mcp/src/pipefy_mcp/core/runtime.py | 19 ++++++-- .../mcp/tests/auth/test_resource_server.py | 43 +++++-------------- packages/mcp/tests/core/test_runtime.py | 21 +++++++++ 4 files changed, 52 insertions(+), 53 deletions(-) diff --git a/packages/mcp/src/pipefy_mcp/auth/resource_server.py b/packages/mcp/src/pipefy_mcp/auth/resource_server.py index 3cad7cda..e00a01f6 100644 --- a/packages/mcp/src/pipefy_mcp/auth/resource_server.py +++ b/packages/mcp/src/pipefy_mcp/auth/resource_server.py @@ -169,29 +169,17 @@ def build_resource_server_auth( resource: ResourceServer, jwt_validation: JwtValidationSettings, *, - default_issuer_url: str | None, + issuer_url: str, required_scopes: list[str] | None = None, ) -> ResourceServerAuth: """Build the inbound bearer verifier and FastMCP auth config for ``resource``. Called only when the resource-server profile is active: the composition root - parses the configured ``resource_server_url`` into ``resource`` and gates on its - presence, so this receives an already-parsed identity and never a ``None``. - - The inbound issuer is ``jwt_validation.issuer_url`` if set, else - ``default_issuer_url`` (see :class:`JwtValidationSettings` for why the login - issuer is the fallback). With ``resource`` set but no issuer resolvable (the - stored-session login is disabled and no override is given), validation is - impossible, so this raises rather than serve an open endpoint. + parses the configured ``resource_server_url`` into ``resource`` and resolves the + inbound ``issuer_url`` (gating on both, so an unresolvable issuer fails fast at + the root). This receives an already-parsed identity and a resolved issuer and just + wires them onto the validator and FastMCP's ``AuthSettings``. """ - issuer_url = jwt_validation.resolve_issuer_url(default_issuer_url) - if issuer_url is None: - raise RuntimeError( - "The resource-server profile is active " - "(PIPEFY_MCP_RS_RESOURCE_SERVER_URL is set) but no inbound issuer is " - "resolvable: set PIPEFY_JWT_ISSUER_URL, or leave the stored-session " - "login enabled so its issuer can be reused." - ) # Fold the loose audience pair into the AudiencePolicy sum type. A # verify-without-audience is already rejected at JwtValidationSettings # construction, so `is not None` only narrows the Optional for the type diff --git a/packages/mcp/src/pipefy_mcp/core/runtime.py b/packages/mcp/src/pipefy_mcp/core/runtime.py index 13705f68..908d7149 100644 --- a/packages/mcp/src/pipefy_mcp/core/runtime.py +++ b/packages/mcp/src/pipefy_mcp/core/runtime.py @@ -180,9 +180,10 @@ def _for_remote_profile( ``remote`` acts on behalf of each caller: it validates a per-request bearer (the inbound ``(verifier, auth)`` pair) and each session replays that caller's - snapshotted bearer (:class:`RequestScopedIdentity`). A configured resource - server is mandatory here, so this fails fast when none resolves rather than - serve an open endpoint or silently fall back to a startup credential. + snapshotted bearer (:class:`RequestScopedIdentity`). A resource server and a + resolvable inbound issuer are both mandatory here, so this gates on each and + fails fast rather than serve an open endpoint or silently fall back to a + startup credential. """ if resource is None: raise RuntimeError( @@ -190,11 +191,21 @@ def _for_remote_profile( "PIPEFY_MCP_RS_RESOURCE_SERVER_URL so the server validates a " "per-request bearer and acts on behalf of the caller." ) + # The inbound issuer is the explicit PIPEFY_JWT_ISSUER_URL override, else the + # login issuer this process authenticates against; with neither, the bearers + # cannot be validated, so refuse to build rather than serve an open endpoint. + issuer_url = settings.jwt.resolve_issuer_url(_login_issuer_url(settings)) + if issuer_url is None: + raise RuntimeError( + "the 'remote' profile requires an inbound issuer: set " + "PIPEFY_JWT_ISSUER_URL, or leave the stored-session login enabled so " + "its issuer can be reused." + ) inbound_auth = build_resource_server_auth( resource, settings.jwt, + issuer_url=issuer_url, required_scopes=settings.rs.required_scopes, - default_issuer_url=_login_issuer_url(settings), ) return cls( settings, diff --git a/packages/mcp/tests/auth/test_resource_server.py b/packages/mcp/tests/auth/test_resource_server.py index 13f9eb5f..1e1c31b6 100644 --- a/packages/mcp/tests/auth/test_resource_server.py +++ b/packages/mcp/tests/auth/test_resource_server.py @@ -197,7 +197,7 @@ def test_build_stamps_resource_server_url_not_audience() -> None: audience="urn:some-other-audience", jwks_uri="https://idp.example.com/jwks", ), - default_issuer_url=_ISSUER, + issuer_url=_ISSUER, ) assert verifier._resource == _RESOURCE @@ -208,7 +208,7 @@ def test_build_advertises_the_parsed_resource_url_in_the_metadata() -> None: _, auth = build_resource_server_auth( ResourceServer.from_url(_RESOURCE), JwtValidationSettings(jwks_uri="https://idp.example.com/jwks"), - default_issuer_url=_ISSUER, + issuer_url=_ISSUER, ) assert str(auth.resource_server_url).rstrip("/") == _RESOURCE @@ -219,7 +219,7 @@ def test_build_skips_audience_by_default() -> None: verifier, _ = build_resource_server_auth( ResourceServer.from_url(_RESOURCE), JwtValidationSettings(jwks_uri="https://idp.example.com/jwks"), - default_issuer_url=_ISSUER, + issuer_url=_ISSUER, ) assert verifier._validator._verify_aud is False assert verifier._validator._audience is None @@ -235,48 +235,27 @@ def test_build_requires_audience_when_verifying() -> None: verify_audience=True, jwks_uri="https://idp.example.com/jwks", ), - default_issuer_url=_ISSUER, + issuer_url=_ISSUER, ) assert verifier._validator._verify_aud is True assert verifier._validator._audience == "api://x" @pytest.mark.unit -def test_build_issuer_defaults_to_login_issuer() -> None: - """With no inbound override, the inbound issuer is the login issuer.""" +def test_build_advertises_the_given_issuer() -> None: + """The resolved issuer the composition root passes is advertised verbatim. + + Resolving the issuer (override vs login fallback, and the unresolvable fail-fast) + is the runtime's job; this pins only that the builder wires it onto AuthSettings. + """ _, auth = build_resource_server_auth( ResourceServer.from_url(_RESOURCE), JwtValidationSettings(jwks_uri="https://idp.example.com/jwks"), - default_issuer_url=_ISSUER, + issuer_url=_ISSUER, ) assert str(auth.issuer_url).rstrip("/") == _ISSUER -@pytest.mark.unit -def test_build_inbound_issuer_overrides_login_issuer() -> None: - """An explicit inbound issuer wins over the login issuer.""" - override = "https://other-idp.example.com/realms/y" - _, auth = build_resource_server_auth( - ResourceServer.from_url(_RESOURCE), - JwtValidationSettings( - issuer_url=override, jwks_uri="https://other-idp.example.com/jwks" - ), - default_issuer_url=_ISSUER, - ) - assert str(auth.issuer_url).rstrip("/") == override - - -@pytest.mark.unit -def test_build_without_resolvable_issuer_raises() -> None: - """resource_server_url set but no issuer (override or login) is a misconfiguration.""" - with pytest.raises(RuntimeError, match="no inbound issuer"): - build_resource_server_auth( - ResourceServer.from_url(_RESOURCE), - JwtValidationSettings(), - default_issuer_url=None, - ) - - # --- ResourceServer.from_url (host-authority parsing) ------------------------- diff --git a/packages/mcp/tests/core/test_runtime.py b/packages/mcp/tests/core/test_runtime.py index 17944153..5fce4070 100644 --- a/packages/mcp/tests/core/test_runtime.py +++ b/packages/mcp/tests/core/test_runtime.py @@ -4,6 +4,7 @@ import httpx import pytest from _rs_fixtures import ( + RS_JWKS_URI, RS_RESOURCE, authenticated_user, remote_rs_settings, @@ -11,6 +12,7 @@ ) from pipefy_auth import ( AuthSettings, + JwtValidationSettings, RefreshableBearerAuth, StaticBearerAuth, TokenResponse, @@ -189,6 +191,25 @@ def test_remote_without_resource_server_fails_fast( with pytest.raises(RuntimeError, match="requires a resource server"): McpRuntime.for_profile(settings) + @pytest.mark.unit + def test_remote_without_resolvable_issuer_fails_fast(self, monkeypatch): + """Remote with a resource but no inbound issuer (override or login) refuses to build. + + The composition root resolves the inbound issuer and gates on it, so a + resource server with no issuer to validate its bearers fails fast here rather + than in the auth builder. Disabling the stored-session login drops the login + issuer and the delenv drops the explicit override, so neither source resolves. + """ + monkeypatch.delenv("PIPEFY_JWT_ISSUER_URL", raising=False) + settings = remote_rs_settings().model_copy( + update={ + "auth": AuthSettings(disable_stored_session=True), + "jwt": JwtValidationSettings(jwks_uri=RS_JWKS_URI), + } + ) + with pytest.raises(RuntimeError, match="requires an inbound issuer"): + McpRuntime.for_profile(settings) + @pytest.mark.unit def test_local_static_token_binds_the_static_bearer(self, clear_auth_env): """``PIPEFY_TOKEN`` resolves to a static-bearer startup identity, no inbound auth.""" From bef48cd24c1f24d26b34b8191c7c20461fbe715b Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 13 Jul 2026 20:39:38 -0300 Subject: [PATCH 11/23] refactor(mcp): rename ResourceServer.host_forms to host_authorities The field holds the host[:port] values the resource presents in the Host header, which is the URL authority (RFC 3986 / RFC 9110). 'forms' was vague filler and read as a near-synonym of transport_security._host_forms (the allowlist match forms); 'host_authorities' names what it holds, matches the docstring's own 'Host authorities' wording, and clears the collision. --- .../src/pipefy_mcp/auth/resource_server.py | 21 ++++++++++--------- .../src/pipefy_mcp/core/transport_security.py | 2 +- .../mcp/tests/auth/test_resource_server.py | 6 +++--- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/mcp/src/pipefy_mcp/auth/resource_server.py b/packages/mcp/src/pipefy_mcp/auth/resource_server.py index e00a01f6..aab1a081 100644 --- a/packages/mcp/src/pipefy_mcp/auth/resource_server.py +++ b/packages/mcp/src/pipefy_mcp/auth/resource_server.py @@ -44,31 +44,32 @@ class ResourceServer: """A validated resource-server URL paired with its parsed Host authorities. Built by :meth:`from_url` at composition, so holding one is proof its - ``host_forms`` are resolved before any read. ``url`` is the verbatim RFC 9728 - resource identifier (kept exact, as clients compare against it); ``host_forms`` - are the Host-header wire forms it presents, which the transport allowlist widens. + ``host_authorities`` are resolved before any read. ``url`` is the verbatim RFC 9728 + resource identifier (kept exact, as clients compare against it); ``host_authorities`` + are the ``host[:port]`` values it presents in the ``Host`` header, which the + transport allowlist widens. """ url: str - host_forms: tuple[str, ...] + host_authorities: tuple[str, ...] @classmethod def from_url(cls, url: str) -> ResourceServer: - """Parse an already-validated URL into its wire-form Host authorities. + """Parse an already-validated URL into its ``Host`` header authorities. An IPv6 literal is bracketed, as the wire Host carries it (``urlparse`` reports it unbracketed); a URL that names a port also contributes the - ``host:port`` form. + ``host:port`` authority. """ parsed = urlparse(url) hostname = parsed.hostname - forms: list[str] = [] + authorities: list[str] = [] if hostname: host = f"[{hostname}]" if ":" in hostname else hostname - forms.append(host) + authorities.append(host) if parsed.port: - forms.append(f"{host}:{parsed.port}") - return cls(url=url, host_forms=tuple(forms)) + authorities.append(f"{host}:{parsed.port}") + return cls(url=url, host_authorities=tuple(authorities)) class PipefyAccessToken(AccessToken): diff --git a/packages/mcp/src/pipefy_mcp/core/transport_security.py b/packages/mcp/src/pipefy_mcp/core/transport_security.py index 97fefdd8..9b4247f7 100644 --- a/packages/mcp/src/pipefy_mcp/core/transport_security.py +++ b/packages/mcp/src/pipefy_mcp/core/transport_security.py @@ -57,7 +57,7 @@ def build_transport_security( which rejects any request that sends an Origin) is honored verbatim; only an unset list falls back to the origins derived from the allowed hosts. """ - public_hosts = list(resource.host_forms) if resource else [] + public_hosts = list(resource.host_authorities) if resource else [] public_hosts += mcp.allowed_hosts or [] explicit_origins = mcp.allowed_origins diff --git a/packages/mcp/tests/auth/test_resource_server.py b/packages/mcp/tests/auth/test_resource_server.py index 1e1c31b6..85abe249 100644 --- a/packages/mcp/tests/auth/test_resource_server.py +++ b/packages/mcp/tests/auth/test_resource_server.py @@ -263,17 +263,17 @@ def test_build_advertises_the_given_issuer() -> None: def test_resource_server_keeps_the_verbatim_url_and_derives_bare_host() -> None: resource = ResourceServer.from_url("https://mcp.pipefy.com/mcp") assert resource.url == "https://mcp.pipefy.com/mcp" - assert resource.host_forms == ("mcp.pipefy.com",) + assert resource.host_authorities == ("mcp.pipefy.com",) @pytest.mark.unit def test_resource_server_adds_host_port_when_url_names_a_port() -> None: resource = ResourceServer.from_url("https://mcp.pipefy.com:8443/mcp") - assert resource.host_forms == ("mcp.pipefy.com", "mcp.pipefy.com:8443") + assert resource.host_authorities == ("mcp.pipefy.com", "mcp.pipefy.com:8443") @pytest.mark.unit def test_resource_server_brackets_an_ipv6_literal() -> None: """urlparse reports the host unbracketed; the wire Host is bracketed.""" resource = ResourceServer.from_url("https://[2001:db8::1]:8443/mcp") - assert resource.host_forms == ("[2001:db8::1]", "[2001:db8::1]:8443") + assert resource.host_authorities == ("[2001:db8::1]", "[2001:db8::1]:8443") From 5e2c5390220c8152676c854bb59bfb1d00441d52 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 13 Jul 2026 20:46:50 -0300 Subject: [PATCH 12/23] refactor(mcp): move outbound session identity into auth StartupIdentity, RequestScopedIdentity, and the AuthSource union resolve the outbound httpx.Auth a session acts as: StartupIdentity via pipefy_auth credential resolution, RequestScopedIdentity via auth's own require_request_bearer. They lived in core/runtime.py only because the runtime consumes them. Move them to auth/session_identity.py (the outbound counterpart to request_identity.py's inbound bearer) and publish them from auth's contract, so runtime.py is just the composition root: own the engine, wire resources per profile, open sessions. core imports the AuthSource contract inward from auth; the layering contract holds. --- packages/mcp/src/pipefy_mcp/auth/__init__.py | 10 +- .../src/pipefy_mcp/auth/request_identity.py | 2 +- .../src/pipefy_mcp/auth/session_identity.py | 98 +++++++++++++++++++ packages/mcp/src/pipefy_mcp/core/runtime.py | 86 +--------------- packages/mcp/tests/core/test_runtime.py | 7 +- packages/mcp/tests/tools/conftest.py | 3 +- packages/mcp/tests/tools/test_pipe_tools.py | 3 +- packages/mcp/tests/tools/test_tool_context.py | 3 +- 8 files changed, 121 insertions(+), 91 deletions(-) create mode 100644 packages/mcp/src/pipefy_mcp/auth/session_identity.py diff --git a/packages/mcp/src/pipefy_mcp/auth/__init__.py b/packages/mcp/src/pipefy_mcp/auth/__init__.py index 1a96e38d..7e75491e 100644 --- a/packages/mcp/src/pipefy_mcp/auth/__init__.py +++ b/packages/mcp/src/pipefy_mcp/auth/__init__.py @@ -1,4 +1,4 @@ -"""Resource-server auth for the HTTP transport: inbound bearer validation.""" +"""Identity for the HTTP transport: inbound bearer validation and session identity.""" from pipefy_mcp.auth.request_identity import require_request_bearer from pipefy_mcp.auth.resource_server import ( @@ -7,11 +7,19 @@ ResourceServerAuth, build_resource_server_auth, ) +from pipefy_mcp.auth.session_identity import ( + AuthSource, + RequestScopedIdentity, + StartupIdentity, +) __all__ = [ + "AuthSource", "JwtTokenVerifier", + "RequestScopedIdentity", "ResourceServer", "ResourceServerAuth", + "StartupIdentity", "build_resource_server_auth", "require_request_bearer", ] diff --git a/packages/mcp/src/pipefy_mcp/auth/request_identity.py b/packages/mcp/src/pipefy_mcp/auth/request_identity.py index 5570ac58..a8cc1c7e 100644 --- a/packages/mcp/src/pipefy_mcp/auth/request_identity.py +++ b/packages/mcp/src/pipefy_mcp/auth/request_identity.py @@ -5,7 +5,7 @@ that caller rather than as one identity resolved at startup. This module reads the caller's validated bearer off the request the tool handler received, so the runtime can snapshot it into a per-session credential (see -:meth:`pipefy_mcp.core.runtime.RequestScopedIdentity.resolve`). +:meth:`pipefy_mcp.auth.session_identity.RequestScopedIdentity.resolve`). The bearer comes from the request the handler passes in (``ctx.request_context.request``), not from ``AuthContextMiddleware``'s diff --git a/packages/mcp/src/pipefy_mcp/auth/session_identity.py b/packages/mcp/src/pipefy_mcp/auth/session_identity.py new file mode 100644 index 00000000..21504d08 --- /dev/null +++ b/packages/mcp/src/pipefy_mcp/auth/session_identity.py @@ -0,0 +1,98 @@ +"""Outbound identity for a request's SDK session: who each session acts as. + +The counterpart to :mod:`pipefy_mcp.auth.request_identity` (which extracts the +*inbound* bearer a caller presents): these types resolve the *outbound* +``httpx.Auth`` the per-request SDK session binds. The two profiles pick a +different variant at the composition root, and both speak one ``resolve`` contract +so the runtime opens every session uniformly. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from httpx import Auth +from pipefy_auth import ( + StaticBearerAuth, + build_httpx_auth, + configure_keychain_backend, + missing_auth_message, + resolve_pipefy_auth, +) +from starlette.requests import Request + +from pipefy_mcp._docs import DOCS_SETUP_REF +from pipefy_mcp.auth.request_identity import require_request_bearer +from pipefy_mcp.settings import Settings + + +@dataclass(frozen=True) +class StartupIdentity: + """One credential resolved from settings at startup; every request runs as it. + + The stdio/local profile: with no inbound bearer, the composition root resolves + the highest-precedence configured credential once (via + :meth:`from_configured_credential`); :meth:`resolve` returns that same + credential for every session. + """ + + auth: Auth + + @classmethod + def from_configured_credential(cls, settings: Settings) -> StartupIdentity: + """Resolve the one startup credential from settings, or fail fast. + + Swaps the keyring backend (no-op when ``auto``), resolves the + highest-precedence configured credential (the keychain read behind + :func:`resolve_pipefy_auth`), and raises when none is configured so a + missing credential surfaces at startup rather than on the first tool call. + + The resolved auth refreshes lazily (a stored session wires + :class:`pipefy_auth.RefreshableBearerAuth`): the token is fetched and + refreshed on the first request that needs it, not eagerly here. + """ + configure_keychain_backend(settings.auth.keychain_backend) + resolved = resolve_pipefy_auth( + static_token=settings.auth.static_token, + service_account=settings.auth.to_service_account(), + oidc_client=settings.auth.to_oidc_client(), + ) + if resolved is None: + raise RuntimeError( + f"{missing_auth_message()} " + f"See {DOCS_SETUP_REF} for host-specific install steps." + ) + return cls(build_httpx_auth(resolved)) + + def resolve(self, request: Request | None) -> Auth: + # The startup credential is request-independent; the request the runtime + # threads through for the hosted profile has nothing to resolve here. + return self.auth + + +@dataclass(frozen=True) +class RequestScopedIdentity: + """The calling user's identity, resolved per request (hosted profile). + + :meth:`resolve` snapshots the validated bearer off the ``request`` the tool + handler passes in into a static credential for that one session, so concurrent + callers never share identity. Reading the request the handler received (rather + than ``auth_context_var``, which stateful Streamable HTTP freezes at the + session's first bearer) is what keeps the snapshot on the current caller. A + future auth transform (OBO exchange, a distinct downstream audience) is a change + to what this method returns, nothing else. + """ + + def resolve(self, request: Request | None) -> Auth: + return StaticBearerAuth(require_request_bearer(request)) + + +# The identity source for a request's session, chosen by profile at the +# composition root (:meth:`pipefy_mcp.core.runtime.McpRuntime.for_profile`): each +# variant's :meth:`resolve` takes the in-flight request and returns the +# ``httpx.Auth`` the per-request session binds. Both arms speak that one contract, +# so the runtime opens every session uniformly with no per-variant branching. +AuthSource = StartupIdentity | RequestScopedIdentity + + +__all__ = ["AuthSource", "RequestScopedIdentity", "StartupIdentity"] diff --git a/packages/mcp/src/pipefy_mcp/core/runtime.py b/packages/mcp/src/pipefy_mcp/core/runtime.py index 908d7149..af2d3a4e 100644 --- a/packages/mcp/src/pipefy_mcp/core/runtime.py +++ b/packages/mcp/src/pipefy_mcp/core/runtime.py @@ -1,99 +1,23 @@ from __future__ import annotations -from dataclasses import dataclass - -from httpx import Auth from mcp.server.transport_security import TransportSecuritySettings -from pipefy_auth import ( - StaticBearerAuth, - build_httpx_auth, - configure_keychain_backend, - missing_auth_message, - resolve_pipefy_auth, -) from pipefy_sdk import PipefyClient, PipefyEngine from starlette.requests import Request -from pipefy_mcp._docs import DOCS_SETUP_REF -from pipefy_mcp.auth.request_identity import require_request_bearer from pipefy_mcp.auth.resource_server import ( ResourceServer, ResourceServerAuth, build_resource_server_auth, ) +from pipefy_mcp.auth.session_identity import ( + AuthSource, + RequestScopedIdentity, + StartupIdentity, +) from pipefy_mcp.core.transport_security import build_transport_security from pipefy_mcp.settings import ResourceServerSettings, Settings -@dataclass(frozen=True) -class StartupIdentity: - """One credential resolved from settings at startup; every request runs as it. - - The stdio/local profile: with no inbound bearer, the composition root resolves - the highest-precedence configured credential once (via - :meth:`from_configured_credential`); :meth:`resolve` returns that same - credential for every session. - """ - - auth: Auth - - @classmethod - def from_configured_credential(cls, settings: Settings) -> StartupIdentity: - """Resolve the one startup credential from settings, or fail fast. - - Swaps the keyring backend (no-op when ``auto``), resolves the - highest-precedence configured credential (the keychain read behind - :func:`resolve_pipefy_auth`), and raises when none is configured so a - missing credential surfaces at startup rather than on the first tool call. - - The resolved auth refreshes lazily (a stored session wires - :class:`pipefy_auth.RefreshableBearerAuth`): the token is fetched and - refreshed on the first request that needs it, not eagerly here. - """ - configure_keychain_backend(settings.auth.keychain_backend) - resolved = resolve_pipefy_auth( - static_token=settings.auth.static_token, - service_account=settings.auth.to_service_account(), - oidc_client=settings.auth.to_oidc_client(), - ) - if resolved is None: - raise RuntimeError( - f"{missing_auth_message()} " - f"See {DOCS_SETUP_REF} for host-specific install steps." - ) - return cls(build_httpx_auth(resolved)) - - def resolve(self, request: Request | None) -> Auth: - # The startup credential is request-independent; the request the runtime - # threads through for the hosted profile has nothing to resolve here. - return self.auth - - -@dataclass(frozen=True) -class RequestScopedIdentity: - """The calling user's identity, resolved per request (hosted profile). - - :meth:`resolve` snapshots the validated bearer off the ``request`` the tool - handler passes in into a static credential for that one session, so concurrent - callers never share identity. Reading the request the handler received (rather - than ``auth_context_var``, which stateful Streamable HTTP freezes at the - session's first bearer) is what keeps the snapshot on the current caller. A - future auth transform (OBO exchange, a distinct downstream audience) is a change - to what this method returns, nothing else. - """ - - def resolve(self, request: Request | None) -> Auth: - return StaticBearerAuth(require_request_bearer(request)) - - -# The identity source for a request's session, chosen by profile at the -# composition root (:meth:`McpRuntime.for_profile`): each variant's :meth:`resolve` -# takes the in-flight request and returns the ``httpx.Auth`` the per-request session -# binds. Both arms speak that one contract, so the runtime opens every session -# uniformly with no per-variant branching. -AuthSource = StartupIdentity | RequestScopedIdentity - - def _resource_server(rs: ResourceServerSettings) -> ResourceServer | None: """Parse the configured resource-server URL into its value object, or ``None``. diff --git a/packages/mcp/tests/core/test_runtime.py b/packages/mcp/tests/core/test_runtime.py index 5fce4070..0d30a64b 100644 --- a/packages/mcp/tests/core/test_runtime.py +++ b/packages/mcp/tests/core/test_runtime.py @@ -21,11 +21,8 @@ from pipefy_sdk import PipefyClient, PipefySettings from pipefy_mcp._docs import DOCS_SETUP_REF -from pipefy_mcp.core.runtime import ( - McpRuntime, - RequestScopedIdentity, - StartupIdentity, -) +from pipefy_mcp.auth import RequestScopedIdentity, StartupIdentity +from pipefy_mcp.core.runtime import McpRuntime from pipefy_mcp.settings import McpSettings, Settings diff --git a/packages/mcp/tests/tools/conftest.py b/packages/mcp/tests/tools/conftest.py index 3ef5f791..e99f26f8 100644 --- a/packages/mcp/tests/tools/conftest.py +++ b/packages/mcp/tests/tools/conftest.py @@ -6,7 +6,8 @@ import pytest from mcp.server.fastmcp import FastMCP -from pipefy_mcp.core.runtime import McpRuntime, RequestScopedIdentity +from pipefy_mcp.auth import RequestScopedIdentity +from pipefy_mcp.core.runtime import McpRuntime from pipefy_mcp.settings import settings diff --git a/packages/mcp/tests/tools/test_pipe_tools.py b/packages/mcp/tests/tools/test_pipe_tools.py index 37728dfe..85d8c293 100644 --- a/packages/mcp/tests/tools/test_pipe_tools.py +++ b/packages/mcp/tests/tools/test_pipe_tools.py @@ -17,7 +17,8 @@ ) from pipefy_sdk import PipefyClient -from pipefy_mcp.core.runtime import McpRuntime, RequestScopedIdentity +from pipefy_mcp.auth import RequestScopedIdentity +from pipefy_mcp.core.runtime import McpRuntime from pipefy_mcp.core.tool_error_envelope import tool_error, tool_error_message from pipefy_mcp.settings import settings from pipefy_mcp.tools.pipe_tool_helpers import ( diff --git a/packages/mcp/tests/tools/test_tool_context.py b/packages/mcp/tests/tools/test_tool_context.py index bf4d07e1..769b74b8 100644 --- a/packages/mcp/tests/tools/test_tool_context.py +++ b/packages/mcp/tests/tools/test_tool_context.py @@ -5,7 +5,8 @@ import pytest -from pipefy_mcp.core.runtime import McpRuntime, RequestScopedIdentity +from pipefy_mcp.auth import RequestScopedIdentity +from pipefy_mcp.core.runtime import McpRuntime from pipefy_mcp.settings import settings from pipefy_mcp.tools.tool_context import get_pipefy_client From 1b9479b89e33562436fb1a3377ee0fc69d49682d Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 13 Jul 2026 20:50:50 -0300 Subject: [PATCH 13/23] refactor(mcp): import auth contracts through the package facade runtime.py reached into pipefy_mcp.auth.resource_server and pipefy_mcp.auth.session_identity submodules while the tests consumed the same symbols through the pipefy_mcp.auth facade. Import through the facade so the composition root integrates with the auth concern via its published contract, matching the tests and collapsing two import blocks into one. --- packages/mcp/src/pipefy_mcp/core/runtime.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/mcp/src/pipefy_mcp/core/runtime.py b/packages/mcp/src/pipefy_mcp/core/runtime.py index af2d3a4e..14012576 100644 --- a/packages/mcp/src/pipefy_mcp/core/runtime.py +++ b/packages/mcp/src/pipefy_mcp/core/runtime.py @@ -4,15 +4,13 @@ from pipefy_sdk import PipefyClient, PipefyEngine from starlette.requests import Request -from pipefy_mcp.auth.resource_server import ( - ResourceServer, - ResourceServerAuth, - build_resource_server_auth, -) -from pipefy_mcp.auth.session_identity import ( +from pipefy_mcp.auth import ( AuthSource, RequestScopedIdentity, + ResourceServer, + ResourceServerAuth, StartupIdentity, + build_resource_server_auth, ) from pipefy_mcp.core.transport_security import build_transport_security from pipefy_mcp.settings import ResourceServerSettings, Settings From bf3e198dc51701b94f0bc5f3448cb94669be2369 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 13 Jul 2026 23:05:30 -0300 Subject: [PATCH 14/23] refactor(infra): add nonempty_strs list coercion helper The list counterpart to optional_str: strip each entry and drop blanks. Gives the strip-and-drop-empty pattern used across the CLI and MCP settings one shared home beside its scalar sibling. --- packages/infra/src/pipefy_infra/coerce.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/infra/src/pipefy_infra/coerce.py b/packages/infra/src/pipefy_infra/coerce.py index 14c41d9b..e96d370f 100644 --- a/packages/infra/src/pipefy_infra/coerce.py +++ b/packages/infra/src/pipefy_infra/coerce.py @@ -22,6 +22,7 @@ from __future__ import annotations import math +from collections.abc import Iterable from typing import TypeVar T = TypeVar("T") @@ -75,6 +76,16 @@ def optional_float(value: object) -> float | None: return result +def nonempty_strs(values: Iterable[object]) -> list[str]: + """Strip each value via :func:`optional_str` and drop the ones that vanish. + + The list counterpart to :func:`optional_str`: ``None``, empty, whitespace-only, + and misshapen (bool/bytes) entries are dropped, so the result holds only real, + trimmed strings. Order is preserved and duplicates are kept. + """ + return [s for value in values if (s := optional_str(value)) is not None] + + def try_int(value: T) -> T | int: """Return ``int(value)``, or ``value`` unchanged if conversion fails. @@ -92,4 +103,10 @@ def try_int(value: T) -> T | int: return value -__all__ = ["optional_float", "optional_int", "optional_str", "try_int"] +__all__ = [ + "nonempty_strs", + "optional_float", + "optional_int", + "optional_str", + "try_int", +] From a3bad56d24aaf88b286e507cfe1b07a20810d784 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 13 Jul 2026 23:05:31 -0300 Subject: [PATCH 15/23] fix(mcp): treat an all-blank transport allowlist as unset An all-blank allowed_hosts/allowed_origins (e.g. an unfilled template value) now normalizes to None rather than []. An empty allowed_origins is the strict reject-all-Origin posture, so a blank value must not silently select it; an explicit [] is still preserved. Folds the two normalizers into one field validator. --- packages/mcp/src/pipefy_mcp/settings.py | 31 ++++++++++++++----------- packages/mcp/tests/test_settings.py | 15 ++++++++++++ 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/packages/mcp/src/pipefy_mcp/settings.py b/packages/mcp/src/pipefy_mcp/settings.py index 751169e7..d08858f3 100644 --- a/packages/mcp/src/pipefy_mcp/settings.py +++ b/packages/mcp/src/pipefy_mcp/settings.py @@ -4,7 +4,7 @@ from pipefy_auth import AuthSettings, JwtValidationSettings from pipefy_infra import security -from pipefy_infra.coerce import optional_str +from pipefy_infra.coerce import nonempty_strs from pipefy_infra.config import PipefyTomlConfigSource from pipefy_sdk import PipefySettings from pydantic import ( @@ -220,23 +220,26 @@ def _enforce_bind_safety(self) -> Self: ) return self - @model_validator(mode="after") - def _normalize_allowlists(self) -> Self: - """Strip whitespace and drop empty entries from the transport allowlists. + @field_validator("allowed_hosts", "allowed_origins", mode="after") + @classmethod + def _normalize_allowlist(cls, values: list[str] | None) -> list[str] | None: + """Trim entries and drop blanks from a transport allowlist; None stays None. Light normalization only: an entry is a Host or Origin the operator vouches for, so this does not run the internal-host SSRF gate - (``localhost`` is a wanted loopback entry). ``None`` stays ``None``. - """ - self.allowed_hosts = _stripped_nonempty(self.allowed_hosts) - self.allowed_origins = _stripped_nonempty(self.allowed_origins) - return self - + (``localhost`` is a wanted loopback entry). -def _stripped_nonempty(values: list[str] | None) -> list[str] | None: - if values is None: - return None - return [s for value in values if (s := optional_str(value)) is not None] + A list whose entries are all blank collapses to ``None`` (unset), not to + an empty list: an empty ``allowed_origins`` is the strict reject-all-Origin + posture, so a fat-fingered blank value (``[""]``, an unfilled template) + must not silently select it. An explicitly empty list is left as-is. + """ + if values is None: + return None + cleaned = nonempty_strs(values) + if values and not cleaned: + return None + return cleaned class ResourceServerSettings(BaseSettings): diff --git a/packages/mcp/tests/test_settings.py b/packages/mcp/tests/test_settings.py index e9e7bc46..4b58ee09 100644 --- a/packages/mcp/tests/test_settings.py +++ b/packages/mcp/tests/test_settings.py @@ -306,3 +306,18 @@ def test_mcp_allowlists_strip_and_drop_empty_entries(): ) assert mcp.allowed_hosts == ["mcp.pipefy.com", "localhost"] assert mcp.allowed_origins == ["https://mcp.pipefy.com"] + + +@pytest.mark.unit +def test_mcp_all_blank_allowlist_collapses_to_none_not_empty(): + """An all-blank list normalizes to None (unset), not the strict empty override. + + ``allowed_origins=[]`` is the reject-all-Origin posture; a fat-fingered blank + value (an unfilled template) must not silently select it, so an all-blank list + reads as unset. An explicitly empty list is preserved. + """ + mcp = McpSettings(allowed_hosts=[" ", ""], allowed_origins=[" "]) + assert mcp.allowed_hosts is None + assert mcp.allowed_origins is None + + assert McpSettings(allowed_origins=[]).allowed_origins == [] From 6ea67c09974e708dca9ab6e710cbe73ba90478bc Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 13 Jul 2026 23:05:42 -0300 Subject: [PATCH 16/23] refactor(mcp): simplify the transport allowlist derivation Dedupe host forms with dict.fromkeys and drop the _host_forms helper; derive origins directly, since the deduped hosts make a duplicate impossible and the membership guard was unreachable. --- .../src/pipefy_mcp/core/transport_security.py | 39 +++++++++---------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/packages/mcp/src/pipefy_mcp/core/transport_security.py b/packages/mcp/src/pipefy_mcp/core/transport_security.py index 9b4247f7..eb29b76d 100644 --- a/packages/mcp/src/pipefy_mcp/core/transport_security.py +++ b/packages/mcp/src/pipefy_mcp/core/transport_security.py @@ -34,16 +34,6 @@ _LOOPBACK_HOSTS = ("127.0.0.1", "localhost", "[::1]") -def _host_forms(host: str) -> tuple[str, str]: - """The two match forms for a Host entry: exact, and any-port (``host:*``). - - The SDK matches a Host header by exact string or a trailing ``:*`` port - wildcard, and ``localhost:*`` does not match a portless ``localhost``, so both - forms are listed to cover a Host sent with or without a port. - """ - return host, f"{host}:*" - - def build_transport_security( mcp: McpSettings, resource: ResourceServer | None ) -> TransportSecuritySettings | None: @@ -68,21 +58,28 @@ def build_transport_security( if not public_hosts and explicit_origins is None: return None - allowed_hosts: list[str] = [] - for host in (*_LOOPBACK_HOSTS, *public_hosts): - for form in _host_forms(host): - if form not in allowed_hosts: - allowed_hosts.append(form) + # Each host contributes an exact form and a ``host:*`` any-port form: the SDK + # matches a Host header by exact string or trailing ``:*`` wildcard, and + # ``localhost:*`` does not match a portless ``localhost``, so both cover a Host + # sent with or without a port. ``dict.fromkeys`` dedupes while keeping order. + allowed_hosts = list( + dict.fromkeys( + form + for host in (*_LOOPBACK_HOSTS, *public_hosts) + for form in (host, f"{host}:*") + ) + ) if explicit_origins is not None: allowed_origins = list(explicit_origins) else: - allowed_origins = [] - for host in allowed_hosts: - for scheme in ("http", "https"): - origin = f"{scheme}://{host}" - if origin not in allowed_origins: - allowed_origins.append(origin) + # allowed_hosts is already deduped and (host, scheme) is injective, so no + # duplicate origin can arise; derive directly with no dedup pass. + allowed_origins = [ + f"{scheme}://{host}" + for host in allowed_hosts + for scheme in ("http", "https") + ] return TransportSecuritySettings( enable_dns_rebinding_protection=True, From 0f7e8ac04b4d9d821703d72fc9d6753b05c66821 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 13 Jul 2026 23:05:42 -0300 Subject: [PATCH 17/23] refactor(mcp): assemble the runtime once per profile The per-profile builders return the identity and inbound-auth pair; for_profile makes the single constructor call, so the transport allowlist is no longer threaded through each builder only to forward it unchanged. --- packages/mcp/src/pipefy_mcp/core/runtime.py | 56 +++++++++------------ 1 file changed, 24 insertions(+), 32 deletions(-) diff --git a/packages/mcp/src/pipefy_mcp/core/runtime.py b/packages/mcp/src/pipefy_mcp/core/runtime.py index 14012576..ef76971d 100644 --- a/packages/mcp/src/pipefy_mcp/core/runtime.py +++ b/packages/mcp/src/pipefy_mcp/core/runtime.py @@ -80,25 +80,30 @@ def for_profile(cls, settings: Settings) -> McpRuntime: """Build the runtime for the resolved profile, wiring inbound and outbound auth. The composition root's one build step: it parses the ``resource_server_url`` - once, builds the transport allowlist from that one parsed resource, and hands - both to the per-profile builder ``settings.mcp.profile`` selects. Parsing here - keeps the resource a single value both the allowlist and (for ``remote``) the - inbound-auth pair are derived from, so they cannot disagree on the host. + once, builds the transport allowlist from that one parsed resource, and + selects the per-profile identity (and, for ``remote``, the inbound-auth pair). + Parsing here keeps the resource a single value both the allowlist and the + inbound-auth pair are derived from, so they cannot disagree on the host; the + one ``cls(...)`` call then wires the fields common to both profiles. """ resource = _resource_server(settings.rs) transport_security = build_transport_security(settings.mcp, resource) if settings.mcp.profile == "remote": - return cls._for_remote_profile(settings, resource, transport_security) - return cls._for_local_profile(settings, transport_security) + identity, inbound_auth = cls._remote_identity(settings, resource) + else: + identity, inbound_auth = cls._local_identity(settings), None + return cls( + settings, + identity, + inbound_auth=inbound_auth, + transport_security=transport_security, + ) - @classmethod - def _for_remote_profile( - cls, - settings: Settings, - resource: ResourceServer | None, - transport_security: TransportSecuritySettings | None, - ) -> McpRuntime: - """The ``remote`` profile: a per-request identity and an inbound RS pair. + @staticmethod + def _remote_identity( + settings: Settings, resource: ResourceServer | None + ) -> tuple[AuthSource, ResourceServerAuth]: + """The ``remote`` profile's identity: a per-request bearer and an inbound RS pair. ``remote`` acts on behalf of each caller: it validates a per-request bearer (the inbound ``(verifier, auth)`` pair) and each session replays that caller's @@ -129,29 +134,16 @@ def _for_remote_profile( issuer_url=issuer_url, required_scopes=settings.rs.required_scopes, ) - return cls( - settings, - RequestScopedIdentity(), - inbound_auth=inbound_auth, - transport_security=transport_security, - ) + return RequestScopedIdentity(), inbound_auth - @classmethod - def _for_local_profile( - cls, - settings: Settings, - transport_security: TransportSecuritySettings | None, - ) -> McpRuntime: - """The ``local`` profile (stdio, or loopback HTTP): one startup credential. + @staticmethod + def _local_identity(settings: Settings) -> AuthSource: + """The ``local`` profile's identity (stdio, or loopback HTTP): one startup credential. No inbound identity: the one startup credential is resolved from settings (and fails fast when none is configured) and every session acts as it. """ - return cls( - settings, - StartupIdentity.from_configured_credential(settings), - transport_security=transport_security, - ) + return StartupIdentity.from_configured_credential(settings) def session_for_request(self, request: Request | None) -> PipefyClient: """Open a session bound to the current request's identity. From 607b9cacf75455b774b356a372e51e6f704af64a Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 13 Jul 2026 23:05:43 -0300 Subject: [PATCH 18/23] test(mcp): cover the remote login-issuer fallback The existing remote tests pin an explicit PIPEFY_JWT_ISSUER_URL, masking the override-absent path where the inbound issuer defaults to the login issuer this process authenticates against. --- packages/mcp/tests/core/test_runtime.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/mcp/tests/core/test_runtime.py b/packages/mcp/tests/core/test_runtime.py index 0d30a64b..cac7a426 100644 --- a/packages/mcp/tests/core/test_runtime.py +++ b/packages/mcp/tests/core/test_runtime.py @@ -207,6 +207,30 @@ def test_remote_without_resolvable_issuer_fails_fast(self, monkeypatch): with pytest.raises(RuntimeError, match="requires an inbound issuer"): McpRuntime.for_profile(settings) + @pytest.mark.unit + def test_remote_inbound_issuer_defaults_to_the_login_issuer(self, monkeypatch): + """With no PIPEFY_JWT_ISSUER_URL override, the inbound issuer is the login issuer. + + A single-realm deployment reuses the IdP the server logs into as the issuer + that mints the inbound bearers it validates. ``remote_rs_settings`` pins an + explicit override, so drop it and configure only ``auth_url`` (the login + issuer) to pin the override-absent fallback the composition root owns + (``_login_issuer_url`` -> ``resolve_issuer_url``). + """ + monkeypatch.delenv("PIPEFY_JWT_ISSUER_URL", raising=False) + login_issuer = "https://signin.pipefy.com/realms/pipefy" + settings = remote_rs_settings().model_copy( + update={ + "auth": AuthSettings(auth_url=login_issuer), + "jwt": JwtValidationSettings(jwks_uri=RS_JWKS_URI), + } + ) + + runtime = McpRuntime.for_profile(settings) + + _, auth = runtime.inbound_auth + assert str(auth.issuer_url).rstrip("/") == login_issuer + @pytest.mark.unit def test_local_static_token_binds_the_static_bearer(self, clear_auth_env): """``PIPEFY_TOKEN`` resolves to a static-bearer startup identity, no inbound auth.""" From 409f6d6eebf35323b477558e765fed2104d946ca Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 13 Jul 2026 23:05:43 -0300 Subject: [PATCH 19/23] docs(mcp): attribute inbound-issuer resolution to the composition root build_resource_server_auth stopped resolving the inbound issuer earlier in this stack, but the module docstring, package AGENTS.md, and the builder-test docstring still credited it with that job. --- packages/mcp/AGENTS.md | 7 ++++--- packages/mcp/src/pipefy_mcp/auth/resource_server.py | 8 +++++--- packages/mcp/tests/auth/test_resource_server.py | 6 ++++-- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/mcp/AGENTS.md b/packages/mcp/AGENTS.md index e5b14ecd..384131ed 100644 --- a/packages/mcp/AGENTS.md +++ b/packages/mcp/AGENTS.md @@ -69,9 +69,10 @@ The JWKS/RS256 validation lives in `pipefy_auth` (`JwtValidator`); the MCP adapt `auth/resource_server.py` (`JwtTokenVerifier`) maps validated claims onto the SDK's `AccessToken`. FastMCP serves the RFC 9728 protected-resource metadata and the `401` + `WWW-Authenticate` challenge; `build_resource_server_auth` (same -module) resolves the inbound issuer and pairs the verifier with `AuthSettings`. -The runtime (`McpRuntime.for_profile`) calls it for the `remote` profile and holds -the pair as `inbound_auth`, which `server.py` wires into the app. +module) pairs the verifier with `AuthSettings` from an already-resolved issuer. +The runtime (`McpRuntime.for_profile`) resolves the inbound issuer, gates on it, +and calls the builder for the `remote` profile, holding the pair as `inbound_auth`, +which `server.py` wires into the app. **Bind-safety interlock.** The property protected is auth posture, not bind interface: an unauthenticated profile must not be reachable by untrusted callers. diff --git a/packages/mcp/src/pipefy_mcp/auth/resource_server.py b/packages/mcp/src/pipefy_mcp/auth/resource_server.py index aab1a081..99356706 100644 --- a/packages/mcp/src/pipefy_mcp/auth/resource_server.py +++ b/packages/mcp/src/pipefy_mcp/auth/resource_server.py @@ -13,9 +13,11 @@ the SDK's ``AccessToken`` and turns a validation failure into the ``None`` the protocol reads as "reject". -:func:`build_resource_server_auth` is the composition root for this profile: it -resolves the inbound issuer, constructs the verifier over a ``JwtValidator``, -and pairs it with FastMCP's ``AuthSettings`` for the server to wire in. +:func:`build_resource_server_auth` constructs the verifier over a ``JwtValidator`` +and pairs it with FastMCP's ``AuthSettings`` for the server to wire in. It takes an +already-resolved issuer: the composition root +(:meth:`pipefy_mcp.core.runtime.McpRuntime.for_profile`) resolves the inbound issuer +and gates on it, so this builder only wires the resolved values in. """ from __future__ import annotations diff --git a/packages/mcp/tests/auth/test_resource_server.py b/packages/mcp/tests/auth/test_resource_server.py index 85abe249..78d416ae 100644 --- a/packages/mcp/tests/auth/test_resource_server.py +++ b/packages/mcp/tests/auth/test_resource_server.py @@ -4,8 +4,10 @@ pin :class:`JwtTokenVerifier`'s two jobs: mapping validated claims onto the SDK ``AccessToken`` and turning a validation failure into ``None`` (which FastMCP renders as a 401). The JWT/JWKS validation itself is covered in ``pipefy_auth``'s -``test_verification.py``. The builder tests pin -:func:`build_resource_server_auth`'s issuer resolution and active/inactive gating. +``test_verification.py``. The builder tests pin that +:func:`build_resource_server_auth` wires an already-resolved issuer onto the verifier +and ``AuthSettings``; the issuer resolution and active/inactive gating it used to own +now live at the composition root and are covered in ``core/test_runtime.py``. """ from __future__ import annotations From beb776a401a6bb63ac21dfaa07b11321a5b5e293 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 13 Jul 2026 23:18:41 -0300 Subject: [PATCH 20/23] fix(mcp): reject a blank transport allowlist entry instead of dropping it A blank or whitespace-only entry in allowed_hosts/allowed_origins now fails at the settings boundary rather than being trimmed away. A silent drop hides the operator's typo, and for allowed_origins it could collapse the list to the strict reject-all-Origin posture. Real entries are still trimmed and an explicitly empty list is still accepted. --- packages/mcp/src/pipefy_mcp/settings.py | 39 +++++++++++++++---------- packages/mcp/tests/test_settings.py | 31 +++++++++++--------- 2 files changed, 42 insertions(+), 28 deletions(-) diff --git a/packages/mcp/src/pipefy_mcp/settings.py b/packages/mcp/src/pipefy_mcp/settings.py index d08858f3..f8033a6b 100644 --- a/packages/mcp/src/pipefy_mcp/settings.py +++ b/packages/mcp/src/pipefy_mcp/settings.py @@ -4,13 +4,13 @@ from pipefy_auth import AuthSettings, JwtValidationSettings from pipefy_infra import security -from pipefy_infra.coerce import nonempty_strs from pipefy_infra.config import PipefyTomlConfigSource from pipefy_sdk import PipefySettings from pydantic import ( AliasChoices, Field, ValidationError, + ValidationInfo, field_validator, model_validator, ) @@ -222,23 +222,32 @@ def _enforce_bind_safety(self) -> Self: @field_validator("allowed_hosts", "allowed_origins", mode="after") @classmethod - def _normalize_allowlist(cls, values: list[str] | None) -> list[str] | None: - """Trim entries and drop blanks from a transport allowlist; None stays None. - - Light normalization only: an entry is a Host or Origin the operator - vouches for, so this does not run the internal-host SSRF gate - (``localhost`` is a wanted loopback entry). - - A list whose entries are all blank collapses to ``None`` (unset), not to - an empty list: an empty ``allowed_origins`` is the strict reject-all-Origin - posture, so a fat-fingered blank value (``[""]``, an unfilled template) - must not silently select it. An explicitly empty list is left as-is. + def _normalize_allowlist( + cls, values: list[str] | None, info: ValidationInfo + ) -> list[str] | None: + """Trim entries and reject a blank one; ``None`` stays ``None``. + + Each entry is a Host or Origin the operator vouches for, so surrounding + whitespace is trimmed (a stray space would break the transport's exact + match), but a blank entry (an empty string, or an unfilled template value) + raises rather than being silently dropped: a dropped entry hides the typo, + and for ``allowed_origins`` it could collapse the list to the strict + reject-all-Origin posture. An explicitly empty list is a deliberate value + and is kept. This does not run the internal-host SSRF gate (``localhost`` + is a wanted loopback entry). """ if values is None: return None - cleaned = nonempty_strs(values) - if values and not cleaned: - return None + cleaned: list[str] = [] + for value in values: + entry = value.strip() + if not entry: + raise ValueError( + f"{info.field_name} contains a blank entry; remove it or give " + "a real Host/Origin value (an empty list is accepted, a blank " + "entry is not)." + ) + cleaned.append(entry) return cleaned diff --git a/packages/mcp/tests/test_settings.py b/packages/mcp/tests/test_settings.py index 4b58ee09..e3a75715 100644 --- a/packages/mcp/tests/test_settings.py +++ b/packages/mcp/tests/test_settings.py @@ -298,26 +298,31 @@ def test_mcp_allowed_origins_from_env_parses_json(monkeypatch): @pytest.mark.unit -def test_mcp_allowlists_strip_and_drop_empty_entries(): - """Normalization trims each entry and drops blanks; localhost is kept.""" +def test_mcp_allowlist_trims_surrounding_whitespace(): + """Each entry is trimmed; localhost is kept (no SSRF gate on operator entries).""" mcp = McpSettings( - allowed_hosts=[" mcp.pipefy.com ", "", " ", "localhost"], - allowed_origins=[" https://mcp.pipefy.com ", " "], + allowed_hosts=[" mcp.pipefy.com ", "localhost"], + allowed_origins=[" https://mcp.pipefy.com "], ) assert mcp.allowed_hosts == ["mcp.pipefy.com", "localhost"] assert mcp.allowed_origins == ["https://mcp.pipefy.com"] @pytest.mark.unit -def test_mcp_all_blank_allowlist_collapses_to_none_not_empty(): - """An all-blank list normalizes to None (unset), not the strict empty override. +@pytest.mark.parametrize("field", ["allowed_hosts", "allowed_origins"]) +def test_mcp_allowlist_rejects_a_blank_entry(field): + """A blank/whitespace entry is a config error, not a silently-dropped value. - ``allowed_origins=[]`` is the reject-all-Origin posture; a fat-fingered blank - value (an unfilled template) must not silently select it, so an all-blank list - reads as unset. An explicitly empty list is preserved. + Dropping it would hide the typo, and for allowed_origins could collapse the + list to the strict reject-all-Origin posture, so the settings boundary refuses. """ - mcp = McpSettings(allowed_hosts=[" ", ""], allowed_origins=[" "]) - assert mcp.allowed_hosts is None - assert mcp.allowed_origins is None + with pytest.raises(ValidationError, match="blank entry"): + McpSettings(**{field: ["ok", " "]}) - assert McpSettings(allowed_origins=[]).allowed_origins == [] + +@pytest.mark.unit +def test_mcp_explicit_empty_allowlist_is_preserved(): + """An explicit [] is a deliberate value (reject-all-Origin), not a blank entry.""" + mcp = McpSettings(allowed_hosts=[], allowed_origins=[]) + assert mcp.allowed_hosts == [] + assert mcp.allowed_origins == [] From 50c468c26739d6ecda901bc217d72195f8a3832c Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 13 Jul 2026 23:18:41 -0300 Subject: [PATCH 21/23] refactor(infra): drop the now-unused nonempty_strs helper The transport allowlist normalizer now rejects blank entries rather than dropping them, so nonempty_strs has no remaining consumer. The CLI split sites that duplicate the pattern can reintroduce it when they actually adopt it. --- packages/infra/src/pipefy_infra/coerce.py | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/packages/infra/src/pipefy_infra/coerce.py b/packages/infra/src/pipefy_infra/coerce.py index e96d370f..14c41d9b 100644 --- a/packages/infra/src/pipefy_infra/coerce.py +++ b/packages/infra/src/pipefy_infra/coerce.py @@ -22,7 +22,6 @@ from __future__ import annotations import math -from collections.abc import Iterable from typing import TypeVar T = TypeVar("T") @@ -76,16 +75,6 @@ def optional_float(value: object) -> float | None: return result -def nonempty_strs(values: Iterable[object]) -> list[str]: - """Strip each value via :func:`optional_str` and drop the ones that vanish. - - The list counterpart to :func:`optional_str`: ``None``, empty, whitespace-only, - and misshapen (bool/bytes) entries are dropped, so the result holds only real, - trimmed strings. Order is preserved and duplicates are kept. - """ - return [s for value in values if (s := optional_str(value)) is not None] - - def try_int(value: T) -> T | int: """Return ``int(value)``, or ``value`` unchanged if conversion fails. @@ -103,10 +92,4 @@ def try_int(value: T) -> T | int: return value -__all__ = [ - "nonempty_strs", - "optional_float", - "optional_int", - "optional_str", - "try_int", -] +__all__ = ["optional_float", "optional_int", "optional_str", "try_int"] From 0c5d09b379697a11adcd274a839c43bdb3163c96 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 13 Jul 2026 23:26:24 -0300 Subject: [PATCH 22/23] refactor(mcp): inline the single-use resource-server parse _resource_server was a one-call helper whose docstring restated for_profile's own parsed-once rationale; fold it into for_profile and drop the now-unused ResourceServerSettings import. --- packages/mcp/src/pipefy_mcp/core/runtime.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/packages/mcp/src/pipefy_mcp/core/runtime.py b/packages/mcp/src/pipefy_mcp/core/runtime.py index ef76971d..e90382e9 100644 --- a/packages/mcp/src/pipefy_mcp/core/runtime.py +++ b/packages/mcp/src/pipefy_mcp/core/runtime.py @@ -13,18 +13,7 @@ build_resource_server_auth, ) from pipefy_mcp.core.transport_security import build_transport_security -from pipefy_mcp.settings import ResourceServerSettings, Settings - - -def _resource_server(rs: ResourceServerSettings) -> ResourceServer | None: - """Parse the configured resource-server URL into its value object, or ``None``. - - The one place the resource identity is parsed: the composition root feeds this - single :class:`ResourceServer` to both consumers (inbound auth and the transport - allowlist), so neither re-parses the URL and they cannot disagree on the host. - """ - url = rs.resource_server_url - return ResourceServer.from_url(url) if url else None +from pipefy_mcp.settings import Settings def _login_issuer_url(settings: Settings) -> str | None: @@ -86,7 +75,8 @@ def for_profile(cls, settings: Settings) -> McpRuntime: inbound-auth pair are derived from, so they cannot disagree on the host; the one ``cls(...)`` call then wires the fields common to both profiles. """ - resource = _resource_server(settings.rs) + url = settings.rs.resource_server_url + resource = ResourceServer.from_url(url) if url else None transport_security = build_transport_security(settings.mcp, resource) if settings.mcp.profile == "remote": identity, inbound_auth = cls._remote_identity(settings, resource) From b149c4f53ee4e5f610a9d80b107219824765715f Mon Sep 17 00:00:00 2001 From: Gabriel Date: Tue, 14 Jul 2026 16:50:01 -0300 Subject: [PATCH 23/23] docs(changelog): note the transport allowlist and bind-safety interlock Add Unreleased entries for the configurable DNS-rebinding Host/Origin allowlist (PIPEFY_MCP_ALLOWED_HOSTS / PIPEFY_MCP_ALLOWED_ORIGINS and the protection-on-when-resource-server-URL-set flip, #303) and the auth-posture bind-safety interlock that #396 left undocumented (#379). --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dbf8e52..9227c66e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **CLI**: `pipefy pipe start-form ` mirrors MCP `get_start_form_fields` (optional `--required-only`). - **CLI**: `pipefy card fill --phase --fields '{"…"}'` fills phase fields non-interactively; filters to editable phase field IDs before `update_card`. Optional `--required-only` limits the phase field lookup to required fields. JSON responses may include `skipped_field_ids` when `--fields` keys are dropped by the filter. - **SDK**: `pipefy_sdk.field_filters` exports `filter_editable_field_definitions`, `filter_fields_by_definitions`, and `skipped_field_ids` for shared MCP/CLI field filtering. +- **MCP**: the HTTP transport derives a configurable DNS-rebinding Host/Origin allowlist. FastMCP auto-enables a loopback-only allowlist on its `127.0.0.1` construction host, so behind a proxy that forwards the public Host the server answered `421 Misdirected Request` before any handler ran; setting `resource_server_url` now widens the allowlist to that public host (plus loopback) and keeps DNS-rebinding protection on. `PIPEFY_MCP_ALLOWED_HOSTS` and `PIPEFY_MCP_ALLOWED_ORIGINS` (JSON) extend it for extra hostnames or a stricter Origin posture; an empty `PIPEFY_MCP_ALLOWED_ORIGINS` is the strictest form, rejecting any request that sends an Origin. Unset (no resource-server URL and no override) leaves FastMCP's loopback-only default in force, so the installed local subprocess case is unchanged. Closes #303. ### Changed +- **MCP**: the HTTP bind-safety guard now keys on auth posture, not bind interface. The old `_assert_safe_http_bind` refused any non-loopback HTTP bind, which false-positived on the entire hosted deployment (a container binds `0.0.0.0` and is still private) and lived in the run path, so a caller building the ASGI app directly bypassed it. It is replaced by `McpSettings._enforce_bind_safety`, a cross-field validator at the settings boundary that every serving path inherits: the unauthenticated `local` profile still refuses a non-loopback HTTP bind unless `PIPEFY_MCP_ALLOW_INSECURE_HTTP_BIND` is set, while the `remote` profile (which always carries inbound auth) binds any host without a bind-host check. Loopback detection moves to `pipefy_infra.security.is_loopback_host`, covering all of `127.0.0.0/8` and `::1` rather than three hardcoded string literals. Closes #379. - **CLI / Plugin (Claude Code)**: the CLI install path now installs `pipefy-cli` from PyPI instead of a `git+…#subdirectory=packages/cli` reference with `--with` sibling pins. The `/pipefy:install` slash command runs `uv tool install --force pipefy-cli`, and the documented snippets (root `README.md`, `packages/cli/README.md`, `docs/MIGRATION.md`) use `uvx --from pipefy-cli pipefy …` / `uv tool install pipefy-cli`. PyPI resolves `pipefy` and `pipefy-auth` transitively, so the explicit `--with` flags are gone and installs no longer pay a git clone plus build. This matches the MCP server's PyPI install (#368); do not pass a global `--prerelease allow`, which would let transitive dependencies jump to their own pre-releases. `RELEASE.md` verification snippets move to PyPI accordingly, and the now-unused `latest` moving-tag release step is removed. Closes #234. ## [0.3.0-alpha.1] - 2026-07-04