Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0c136f2
feat(mcp): derive a configurable transport host/Origin allowlist
gbrlcustodio Jul 12, 2026
f87f3d7
docs(mcp): document the transport host/Origin allowlist
gbrlcustodio Jul 12, 2026
371cb7e
refactor(mcp): reuse optional_str in the allowlist normalizer
gbrlcustodio Jul 13, 2026
d5cb651
refactor(mcp): move ResourceServer into the auth module
gbrlcustodio Jul 13, 2026
c5cf6e0
refactor(mcp): build_resource_server_auth consumes ResourceServer
gbrlcustodio Jul 13, 2026
30cd841
refactor(mcp): wire the resource server once in the runtime
gbrlcustodio Jul 13, 2026
e64072a
refactor(mcp): order required builder kwarg before the defaulted one
gbrlcustodio Jul 13, 2026
6edcb07
fix(pr-review): honor an explicit empty origin allowlist
gbrlcustodio Jul 13, 2026
5e173e1
refactor(mcp): split for_profile into per-profile builders
gbrlcustodio Jul 13, 2026
1e2bf8a
refactor(mcp): resolve the inbound issuer at the composition root
gbrlcustodio Jul 13, 2026
bef48cd
refactor(mcp): rename ResourceServer.host_forms to host_authorities
gbrlcustodio Jul 13, 2026
5e2c539
refactor(mcp): move outbound session identity into auth
gbrlcustodio Jul 13, 2026
1b9479b
refactor(mcp): import auth contracts through the package facade
gbrlcustodio Jul 13, 2026
bf3e198
refactor(infra): add nonempty_strs list coercion helper
gbrlcustodio Jul 14, 2026
a3bad56
fix(mcp): treat an all-blank transport allowlist as unset
gbrlcustodio Jul 14, 2026
6ea67c0
refactor(mcp): simplify the transport allowlist derivation
gbrlcustodio Jul 14, 2026
0f7e8ac
refactor(mcp): assemble the runtime once per profile
gbrlcustodio Jul 14, 2026
607b9ca
test(mcp): cover the remote login-issuer fallback
gbrlcustodio Jul 14, 2026
409f6d6
docs(mcp): attribute inbound-issuer resolution to the composition root
gbrlcustodio Jul 14, 2026
beb776a
fix(mcp): reject a blank transport allowlist entry instead of droppin…
gbrlcustodio Jul 14, 2026
50c468c
refactor(infra): drop the now-unused nonempty_strs helper
gbrlcustodio Jul 14, 2026
0c5d09b
refactor(mcp): inline the single-use resource-server parse
gbrlcustodio Jul 14, 2026
b149c4f
docs(changelog): note the transport allowlist and bind-safety interlock
gbrlcustodio Jul 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **CLI**: `pipefy pipe start-form <pipe_id>` mirrors MCP `get_start_form_fields` (optional `--required-only`).
- **CLI**: `pipefy card fill <card_id> --phase <phase_id> --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
Expand Down
6 changes: 6 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<NAME>` 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.
Expand Down Expand Up @@ -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. 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. |
29 changes: 21 additions & 8 deletions packages/mcp/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -85,11 +86,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

Expand Down
12 changes: 11 additions & 1 deletion packages/mcp/src/pipefy_mcp/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
"""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 (
JwtTokenVerifier,
ResourceServer,
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",
]
2 changes: 1 addition & 1 deletion packages/mcp/src/pipefy_mcp/auth/request_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
87 changes: 54 additions & 33 deletions packages/mcp/src/pipefy_mcp/auth/resource_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,20 @@
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

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
Expand All @@ -34,11 +38,42 @@
SkipAudience,
)

from pipefy_mcp.settings import ResourceServerSettings

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_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_authorities: tuple[str, ...]

@classmethod
def from_url(cls, url: str) -> ResourceServer:
"""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`` authority.
"""
parsed = urlparse(url)
hostname = parsed.hostname
authorities: list[str] = []
if hostname:
host = f"[{hostname}]" if ":" in hostname else hostname
authorities.append(host)
if parsed.port:
authorities.append(f"{host}:{parsed.port}")
return cls(url=url, host_authorities=tuple(authorities))


class PipefyAccessToken(AccessToken):
"""AccessToken with the JWT ``sub`` claim preserved for request logging."""

Expand Down Expand Up @@ -134,34 +169,20 @@ def _to_access_token(self, token: str, claims: dict[str, Any]) -> AccessToken:


def build_resource_server_auth(
rs: ResourceServerSettings,
resource: ResourceServer,
jwt_validation: JwtValidationSettings,
*,
default_issuer_url: str | None,
) -> ResourceServerAuth | None:
"""Build the inbound bearer verifier and FastMCP auth config, or ``None``.

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.

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_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 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``.
"""
if rs.resource_server_url is None:
return None
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
Expand All @@ -177,11 +198,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,
required_scopes=rs.required_scopes,
resource_server_url=resource.url,
required_scopes=required_scopes,
)
return verifier, auth
98 changes: 98 additions & 0 deletions packages/mcp/src/pipefy_mcp/auth/session_identity.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading