You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(fastmcp,mcp): expose fail_closed through the adapter auth factories (#21)
* feat(fastmcp,mcp): expose fail_closed through the adapter auth factories
The core SDK already implements fail_closed on
AuthplaneClient.resource(...), but neither authplane_auth() nor
authplane_mcp_auth() forwarded it, so factory users opting into
revocation checking were locked into fail-open behavior on
introspection/revocation-check outages.
Both factories now accept fail_closed: bool = False and pass it through
at the client.resource(...) boundary. The default preserves today's
fail-open behavior.
Also adds the previously missing core coverage for fail_closed=True
(crashing custom checker and introspection HTTP 500 both reject with
TokenRevokedError), forwarding tests for both adapters, and user-guide
docs covering the availability/security trade-off, the
authenticated-introspection credential requirement, and the
circuit-breaker interaction.
Requested in #20.
* style: apply ruff 0.16 formatting to Markdown code blocks
ruff 0.16.0 started formatting Python code blocks inside Markdown
files, so CI (which installs the latest ruff satisfying >=0.8) rejects
the previously formatted docs. Blank-line normalization only, no
content changes.
* fix(client): warn on fail_closed without a checker; pin outage edge cases
- AuthplaneClient.resource() logs a warning when fail_closed=True is
combined with revocation_checker=None. The flag is a security-relevant
no-op in that combination, and the misconfiguration should be
observable in production, not only documented. The warning lives in
the core factory rather than the adapters so direct resource() users
get it too.
- New core tests pin two fail-closed failure modes that were emergent:
AS metadata without introspection_endpoint rejects every token
(permanently, since it is a configuration property rather than an
outage), and an open circuit breaker rejects all traffic without
further HTTP calls (TokenRevokedError with CircuitOpenError as cause).
- Both user guides gain the metadata trade-off bullet and note the new
warning.
* chore: pin ruff to >=0.16,<0.17 in all dev extras
An unpinned ruff means CI silently adopts each new minor's lint and
formatting behavior mid-development (0.16.0 started formatting Markdown
code blocks, which is what forced the docs reformat on this branch).
Pinning to the current minor keeps patch fixes and makes format checks
reproducible; bumping the minor becomes a deliberate change.
* docs: shorten the unreleased changelog entries
* chore(mcp): raise the mcp ceiling to <1.29.0
mcp 1.27.2, the newest release the previous <1.28.0 ceiling allowed,
carries PYSEC-2026-3483; the fix ships in 1.28.1. Adapter tests pass
against 1.28.1.
* fix(mcp): revert mcp ceiling to <1.28.0; document no-checker warning
The <1.29.0 ceiling let mcp 1.28 resolve, but 1.28 renamed the elicitation
field to snake_case elicitation_id, which the adapter's url_elicitation path
does not handle — every consent-driven exchange would raise a pydantic
ValidationError instead of -32042. Keep the ceiling below 1.28 until the
adapter is migrated (README already documents the <1.28.0 range).
Also document the fail_closed-without-revocation_checker warning in the core
user-guide (the direct client.resource() audience); both adapter guides
already cover it.
* ci(security): ignore PYSEC-2026-3483 pending mcp 1.28 adapter migration
The adapter pins mcp <1.28.0 (1.28 renamed elicitationId -> elicitation_id,
breaking url_elicitation.py), so pip-audit resolves the vulnerable mcp 1.27.2.
Suppress the finding with a comment until the adapter is migrated to 1.28.1.
Copy file name to clipboardExpand all lines: CHANGELOG.md
+4Lines changed: 4 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
8
8
## [Unreleased]
9
9
10
+
### Added
11
+
-`authplane-fastmcp`, `authplane-mcp`: `authplane_auth()` and `authplane_mcp_auth()` accept `fail_closed: bool = False` and forward it to `AuthplaneClient.resource(...)`.
12
+
-`AuthplaneClient.resource(...)` logs a warning when `fail_closed=True` is set without a `revocation_checker`.
Copy file name to clipboardExpand all lines: authplane-fastmcp/docs/user-guide.md
+53-13Lines changed: 53 additions & 13 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -37,6 +37,7 @@ import asyncio
37
37
from fastmcp import FastMCP
38
38
from authplane_fastmcp import authplane_auth
39
39
40
+
40
41
asyncdefmain() -> None:
41
42
result =await authplane_auth(
42
43
issuer="https://auth.company.com",
@@ -55,6 +56,7 @@ async def main() -> None:
55
56
finally:
56
57
await result.aclose()
57
58
59
+
58
60
asyncio.run(main())
59
61
```
60
62
@@ -82,6 +84,7 @@ All parameters of `authplane_auth()`:
82
84
|`clock_skew_seconds`|`int`|`30`| Leeway for `exp`/`nbf`/`iat` validation |
83
85
|`dev_mode`|`bool`|`False`| Relaxes SSRF checks for local development |
84
86
|`revocation_checker`| see [below](#token-revocation-checking)|`None`| Token revocation strategy |
87
+
|`fail_closed`|`bool`|`False`| Reject tokens when the revocation check itself fails, instead of accepting them (see [below](#failure-policy-fail-open-vs-fail-closed)) |
85
88
|`fetch_settings`|`FetchSettings`|`None`| Full SSRF / fetch settings applied to both metadata and JWKS fetches (overrides `dev_mode`) |
86
89
|`inbound_dpop`|`InboundDPoPOptions`|`None`| Per-resource inbound DPoP policy (replay store, max proof age, clock skew, accepted proof algorithms, `required`). When set, the resource advertises DPoP support in PRM (RFC 9728 §2). See **Inbound DPoP through the FastMCP adapter** below for current limitations. |
87
90
@@ -98,11 +101,13 @@ Use FastMCP's built-in `require_scopes` decorator to enforce per-tool scope requ
98
101
```python
99
102
from fastmcp.server.auth import require_scopes
100
103
104
+
101
105
@mcp.tool(auth=require_scopes("tools/query"))
102
106
defquery(sql: str) -> str:
103
107
"""Requires the tools/query scope."""
104
108
returnf"Ran: {sql}"# replace with your real handler
@@ -149,6 +155,7 @@ The `claims` dict contains the **full JWT payload** including all standard and c
149
155
```python
150
156
from fastmcp.server.dependencies import get_access_token
151
157
158
+
152
159
@mcp.tool()
153
160
asyncdefmy_tool(data: str) -> str:
154
161
token = get_access_token() # Returns None if unauthenticated
@@ -210,20 +217,49 @@ await authplane_auth(
210
217
211
218
- The introspection endpoint is automatically discovered from AS metadata.
212
219
- If the endpoint returns `active=false`, the token is rejected with `TokenRevokedError`.
213
-
-**Fails open**: if the introspection endpoint is unavailable, the token is accepted (offline validation still applies).
220
+
-**Fails open by default**: if the introspection endpoint is unavailable, the token is accepted (offline validation still applies). Pass `fail_closed=True` to reject instead (see [below](#failure-policy-fail-open-vs-fail-closed)).
214
221
-`as_credentials` enables authenticated introspection (recommended for production).
215
222
223
+
### Failure Policy: Fail-Open vs Fail-Closed
224
+
225
+
`fail_closed` controls what happens when the revocation check itself fails — the introspection endpoint is unreachable, returns an error, or a custom checker raises:
226
+
227
+
```python
228
+
await authplane_auth(
229
+
issuer="https://auth.company.com",
230
+
base_url="https://mcp.company.com",
231
+
revocation_checker=IntrospectionRevocation(),
232
+
as_credentials=ASCredentials(
233
+
client_id="my_resource_server",
234
+
client_secret="secret",
235
+
),
236
+
fail_closed=True,
237
+
)
238
+
```
239
+
240
+
-`False` (default) accepts the token and logs a warning. Signature and claims validation still apply, so this only skips the *revocation* freshness check — it never admits an otherwise-invalid token.
241
+
-`True` rejects the token with `TokenRevokedError`. Choose this for servers exposing mutation-capable or otherwise high-impact tools, where serving a revoked-but-unverifiable token is worse than downtime.
242
+
243
+
Trade-offs to understand before enabling `fail_closed=True`:
244
+
245
+
-**Availability**: an authorization server or introspection outage makes every request fail with 401 until the outage resolves. Once the client's circuit breaker opens, checks fail fast and all tokens are rejected until the cooldown elapses.
246
+
-**Credentials**: authorization servers commonly require authenticated introspection; without valid `as_credentials` the introspection call fails, which under `fail_closed=True` means every token is rejected. Verify credentials as part of deployment, not just at rollout.
247
+
-**Metadata**: an AS whose metadata document does not advertise `introspection_endpoint` fails every introspection attempt. Under the default that check is silently skipped; under `fail_closed=True` every token is rejected — and unlike an outage this never self-recovers, because the missing endpoint is a permanent property of the AS configuration. Confirm the endpoint is present in AS metadata before enabling.
248
+
-`fail_closed` has no effect when `revocation_checker` is `None` — the flag is only consulted when a revocation check actually runs. The SDK logs a warning at resource construction when it detects this misconfiguration.
249
+
216
250
### Custom Revocation Checker
217
251
218
252
Implement your own revocation logic with an async callable:
0 commit comments