authplane-fastmcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- authplane_fastmcp-0.1.0/.gitignore +79 -0
- authplane_fastmcp-0.1.0/PKG-INFO +74 -0
- authplane_fastmcp-0.1.0/README.md +51 -0
- authplane_fastmcp-0.1.0/authplane_fastmcp/__init__.py +23 -0
- authplane_fastmcp-0.1.0/authplane_fastmcp/auth.py +283 -0
- authplane_fastmcp-0.1.0/authplane_fastmcp/py.typed +0 -0
- authplane_fastmcp-0.1.0/authplane_fastmcp/url_elicitation.py +42 -0
- authplane_fastmcp-0.1.0/authplane_fastmcp/verifier.py +95 -0
- authplane_fastmcp-0.1.0/demo/README.md +55 -0
- authplane_fastmcp-0.1.0/demo/mcpserver.py +70 -0
- authplane_fastmcp-0.1.0/demo/run.sh +35 -0
- authplane_fastmcp-0.1.0/docs/user-guide.md +490 -0
- authplane_fastmcp-0.1.0/pyproject.toml +89 -0
- authplane_fastmcp-0.1.0/pyrightconfig.json +16 -0
- authplane_fastmcp-0.1.0/tests/conftest.py +141 -0
- authplane_fastmcp-0.1.0/tests/test_auth_factory.py +312 -0
- authplane_fastmcp-0.1.0/tests/test_integration.py +34 -0
- authplane_fastmcp-0.1.0/tests/test_url_elicitation.py +141 -0
- authplane_fastmcp-0.1.0/tests/test_verifier.py +105 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.venv/
|
|
11
|
+
.Python
|
|
12
|
+
build/
|
|
13
|
+
develop-eggs/
|
|
14
|
+
dist/
|
|
15
|
+
downloads/
|
|
16
|
+
eggs/
|
|
17
|
+
.eggs/
|
|
18
|
+
lib/
|
|
19
|
+
lib64/
|
|
20
|
+
parts/
|
|
21
|
+
sdist/
|
|
22
|
+
var/
|
|
23
|
+
wheels/
|
|
24
|
+
share/python-wheels/
|
|
25
|
+
*.egg-info/
|
|
26
|
+
.installed.cfg
|
|
27
|
+
*.egg
|
|
28
|
+
MANIFEST
|
|
29
|
+
|
|
30
|
+
# PyInstaller
|
|
31
|
+
*.manifest
|
|
32
|
+
*.spec
|
|
33
|
+
|
|
34
|
+
# Unit test / coverage reports
|
|
35
|
+
htmlcov/
|
|
36
|
+
.tox/
|
|
37
|
+
.nox/
|
|
38
|
+
.coverage
|
|
39
|
+
.coverage.*
|
|
40
|
+
.cache
|
|
41
|
+
nosetests.xml
|
|
42
|
+
coverage.xml
|
|
43
|
+
*.cover
|
|
44
|
+
*.py,cover
|
|
45
|
+
.hypothesis/
|
|
46
|
+
.pytest_cache/
|
|
47
|
+
cover/
|
|
48
|
+
|
|
49
|
+
# Virtual environments
|
|
50
|
+
venv/
|
|
51
|
+
env/
|
|
52
|
+
ENV/
|
|
53
|
+
env.bak/
|
|
54
|
+
venv.bak/
|
|
55
|
+
|
|
56
|
+
# IDEs
|
|
57
|
+
.vscode/
|
|
58
|
+
.idea/
|
|
59
|
+
*.swp
|
|
60
|
+
*.swo
|
|
61
|
+
*~
|
|
62
|
+
.DS_Store
|
|
63
|
+
|
|
64
|
+
# mypy
|
|
65
|
+
.mypy_cache/
|
|
66
|
+
.dmypy.json
|
|
67
|
+
dmypy.json
|
|
68
|
+
|
|
69
|
+
# Pyre type checker
|
|
70
|
+
.pyre/
|
|
71
|
+
|
|
72
|
+
# pytype static type analyzer
|
|
73
|
+
.pytype/
|
|
74
|
+
|
|
75
|
+
# Cython debug symbols
|
|
76
|
+
cython_debug/
|
|
77
|
+
|
|
78
|
+
# Python version file
|
|
79
|
+
.python-version
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: authplane-fastmcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Authplane JWT validation adapter for FastMCP
|
|
5
|
+
Project-URL: Homepage, https://github.com/AuthPlane/python-sdk/tree/main/authplane-fastmcp
|
|
6
|
+
Project-URL: Issues, https://github.com/AuthPlane/python-sdk/issues
|
|
7
|
+
Author: Authplane Team
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Keywords: adapter,authplane,fastmcp,jwt,oauth
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Requires-Dist: authplane-sdk==0.1.0
|
|
12
|
+
Requires-Dist: fastmcp>=2.0
|
|
13
|
+
Requires-Dist: pydantic>=2.0
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: coverage>=7; extra == 'dev'
|
|
16
|
+
Requires-Dist: cryptography>=42; extra == 'dev'
|
|
17
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
20
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
21
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# authplane-fastmcp
|
|
25
|
+
|
|
26
|
+
[](https://pypi.org/project/authplane-fastmcp/)
|
|
27
|
+
[](https://opensource.org/licenses/Apache-2.0)
|
|
28
|
+
|
|
29
|
+
Authplane JWT validation for servers built on [FastMCP](https://github.com/PrefectHQ/fastmcp).
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install authplane-fastmcp
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quickstart
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import asyncio
|
|
41
|
+
from authplane_fastmcp import authplane_auth
|
|
42
|
+
from fastmcp import FastMCP
|
|
43
|
+
from fastmcp.server.auth import AccessToken, require_scopes
|
|
44
|
+
from fastmcp.dependencies import CurrentAccessToken
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def main():
|
|
48
|
+
mcp = FastMCP(
|
|
49
|
+
"My MCP Server",
|
|
50
|
+
**await authplane_auth(
|
|
51
|
+
issuer="https://auth.company.com",
|
|
52
|
+
base_url="https://mcp.company.com",
|
|
53
|
+
scopes=["tools/query", "tools/write"],
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@mcp.tool(auth=require_scopes("tools/query"))
|
|
58
|
+
async def query_database(
|
|
59
|
+
query: str, token: AccessToken = CurrentAccessToken()
|
|
60
|
+
) -> str:
|
|
61
|
+
user_id = token.claims.get("sub")
|
|
62
|
+
return f"Query: {query}, User: {user_id}"
|
|
63
|
+
|
|
64
|
+
await mcp.run_async(transport="http", host="0.0.0.0", port=8080)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
asyncio.run(main())
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`authplane_auth()` holds background JWKS and metadata refresh tasks; call `aclose()` on the returned `client` during server shutdown.
|
|
71
|
+
|
|
72
|
+
## Documentation
|
|
73
|
+
|
|
74
|
+
PRM behavior, dev mode, revocation checking, manual setup, scope enforcement semantics, claim access, the full `authplane_auth` / `AuthplaneTokenVerifier` API, and error handling: **[User Guide](docs/user-guide.md)**.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# authplane-fastmcp
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/authplane-fastmcp/)
|
|
4
|
+
[](https://opensource.org/licenses/Apache-2.0)
|
|
5
|
+
|
|
6
|
+
Authplane JWT validation for servers built on [FastMCP](https://github.com/PrefectHQ/fastmcp).
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install authplane-fastmcp
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quickstart
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
import asyncio
|
|
18
|
+
from authplane_fastmcp import authplane_auth
|
|
19
|
+
from fastmcp import FastMCP
|
|
20
|
+
from fastmcp.server.auth import AccessToken, require_scopes
|
|
21
|
+
from fastmcp.dependencies import CurrentAccessToken
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def main():
|
|
25
|
+
mcp = FastMCP(
|
|
26
|
+
"My MCP Server",
|
|
27
|
+
**await authplane_auth(
|
|
28
|
+
issuer="https://auth.company.com",
|
|
29
|
+
base_url="https://mcp.company.com",
|
|
30
|
+
scopes=["tools/query", "tools/write"],
|
|
31
|
+
),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
@mcp.tool(auth=require_scopes("tools/query"))
|
|
35
|
+
async def query_database(
|
|
36
|
+
query: str, token: AccessToken = CurrentAccessToken()
|
|
37
|
+
) -> str:
|
|
38
|
+
user_id = token.claims.get("sub")
|
|
39
|
+
return f"Query: {query}, User: {user_id}"
|
|
40
|
+
|
|
41
|
+
await mcp.run_async(transport="http", host="0.0.0.0", port=8080)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
asyncio.run(main())
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`authplane_auth()` holds background JWKS and metadata refresh tasks; call `aclose()` on the returned `client` during server shutdown.
|
|
48
|
+
|
|
49
|
+
## Documentation
|
|
50
|
+
|
|
51
|
+
PRM behavior, dev mode, revocation checking, manual setup, scope enforcement semantics, claim access, the full `authplane_auth` / `AuthplaneTokenVerifier` API, and error handling: **[User Guide](docs/user-guide.md)**.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Authplane Python SDK - FastMCP adapter.
|
|
2
|
+
|
|
3
|
+
This package provides a thin adapter between the Authplane Python SDK and FastMCP,
|
|
4
|
+
enabling FastMCP servers to validate Authplane-issued JWT access tokens.
|
|
5
|
+
|
|
6
|
+
Core SDK types (``ASCredentials``, ``FetchSettings``, ``IntrospectionRevocation``,
|
|
7
|
+
DPoP types, errors, etc.) are imported from ``authplane`` directly. This package
|
|
8
|
+
exports only the adapter-owned glue.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
from .auth import AuthplaneAuthResult, authplane_auth
|
|
14
|
+
from .url_elicitation import to_url_elicitation_required_error
|
|
15
|
+
from .verifier import AuthplaneTokenVerifier
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"AuthplaneAuthResult",
|
|
19
|
+
"AuthplaneTokenVerifier",
|
|
20
|
+
"__version__",
|
|
21
|
+
"authplane_auth",
|
|
22
|
+
"to_url_elicitation_required_error",
|
|
23
|
+
]
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Convenience factory for enabling Authplane auth on FastMCP servers.
|
|
2
|
+
|
|
3
|
+
Provides ``authplane_auth()``, an async factory function that creates and
|
|
4
|
+
configures all the components needed to add Authplane JWT validation to a
|
|
5
|
+
FastMCP server in a single call.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from authplane import (
|
|
12
|
+
ASCredentials,
|
|
13
|
+
AuthplaneClient,
|
|
14
|
+
DPoPProvider,
|
|
15
|
+
FetchSettings,
|
|
16
|
+
InboundDPoPOptions,
|
|
17
|
+
IntrospectionRevocation,
|
|
18
|
+
RevocationChecker,
|
|
19
|
+
)
|
|
20
|
+
from authplane.oauth import TokenExchangeOptions, TokenResponse
|
|
21
|
+
from fastmcp.server.auth import RemoteAuthProvider
|
|
22
|
+
from pydantic import AnyHttpUrl
|
|
23
|
+
|
|
24
|
+
from .url_elicitation import to_url_elicitation_required_error
|
|
25
|
+
from .verifier import AuthplaneTokenVerifier
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _wrap_client_for_elicitation(client: AuthplaneClient) -> AuthplaneClient:
|
|
29
|
+
"""Translate ``client.exchange`` consent errors into MCP ``-32042``.
|
|
30
|
+
|
|
31
|
+
Wrapping ``exchange()`` (the only method that realistically surfaces
|
|
32
|
+
``ConsentRequiredError`` from the AS) means user tool code can call
|
|
33
|
+
``result.client.exchange(...)`` without any try/except: a consent error
|
|
34
|
+
becomes a ``UrlElicitationRequiredError`` transparently, and FastMCP
|
|
35
|
+
forwards it as a ``-32042`` JSON-RPC error.
|
|
36
|
+
"""
|
|
37
|
+
original_exchange = client.exchange
|
|
38
|
+
|
|
39
|
+
async def exchange(options: TokenExchangeOptions) -> TokenResponse:
|
|
40
|
+
try:
|
|
41
|
+
return await original_exchange(options)
|
|
42
|
+
except Exception as error:
|
|
43
|
+
mapped = to_url_elicitation_required_error(error)
|
|
44
|
+
if mapped is not None:
|
|
45
|
+
raise mapped from error
|
|
46
|
+
raise
|
|
47
|
+
|
|
48
|
+
client.exchange = exchange
|
|
49
|
+
return client
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AuthplaneAuthResult:
|
|
53
|
+
"""Return value of ``authplane_auth()``.
|
|
54
|
+
|
|
55
|
+
Supports ``**`` unpacking into ``FastMCP()`` — only the ``auth`` key
|
|
56
|
+
is included in the mapping view so FastMCP receives exactly what it
|
|
57
|
+
expects. ``token_verifier`` and ``client`` are exposed as plain
|
|
58
|
+
attributes for advanced use cases such as RFC 8693 token exchange::
|
|
59
|
+
|
|
60
|
+
result = await authplane_auth(issuer=..., base_url=..., ...)
|
|
61
|
+
mcp = FastMCP("My Server", **result)
|
|
62
|
+
|
|
63
|
+
# Inside a tool handler — exchange a user token for a downstream token:
|
|
64
|
+
downstream = await result.client.exchange(TokenExchangeOptions(
|
|
65
|
+
subject_token=user_token,
|
|
66
|
+
))
|
|
67
|
+
|
|
68
|
+
Call ``await result.aclose()`` on server shutdown to release background
|
|
69
|
+
tasks and HTTP connections held by the underlying ``AuthplaneClient``.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
auth: RemoteAuthProvider,
|
|
75
|
+
token_verifier: AuthplaneTokenVerifier,
|
|
76
|
+
client: AuthplaneClient,
|
|
77
|
+
) -> None:
|
|
78
|
+
self.auth = auth
|
|
79
|
+
self.token_verifier = token_verifier
|
|
80
|
+
self.client = client
|
|
81
|
+
|
|
82
|
+
async def aclose(self) -> None:
|
|
83
|
+
"""Release resources held by the underlying ``AuthplaneClient``.
|
|
84
|
+
|
|
85
|
+
Cancels the background JWKS refresh task and closes the HTTP
|
|
86
|
+
connection pool. Safe to call multiple times.
|
|
87
|
+
"""
|
|
88
|
+
await self.client.aclose()
|
|
89
|
+
|
|
90
|
+
# Mapping protocol — yields only "auth" so FastMCP(**result) works cleanly.
|
|
91
|
+
def keys(self) -> list[str]:
|
|
92
|
+
return ["auth"]
|
|
93
|
+
|
|
94
|
+
def __getitem__(self, key: str) -> Any:
|
|
95
|
+
if key == "auth":
|
|
96
|
+
return self.auth
|
|
97
|
+
raise KeyError(key)
|
|
98
|
+
|
|
99
|
+
def __iter__(self) -> Iterator[str]:
|
|
100
|
+
return iter(self.keys())
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def authplane_auth(
|
|
104
|
+
issuer: str,
|
|
105
|
+
base_url: str,
|
|
106
|
+
scopes: list[str] | None = None,
|
|
107
|
+
*,
|
|
108
|
+
as_credentials: ASCredentials | None = None,
|
|
109
|
+
dpop: DPoPProvider | None = None,
|
|
110
|
+
allowed_algorithms: list[str] | None = None,
|
|
111
|
+
jwks_refresh_seconds: int | None = None,
|
|
112
|
+
metadata_refresh_seconds: int | None = None,
|
|
113
|
+
cache_ttl_buffer_seconds: float | None = None,
|
|
114
|
+
default_ttl_seconds: float | None = None,
|
|
115
|
+
circuit_breaker_threshold: int | None = None,
|
|
116
|
+
circuit_breaker_cooldown_seconds: float | None = None,
|
|
117
|
+
clock_skew_seconds: int | None = None,
|
|
118
|
+
dev_mode: bool | None = None,
|
|
119
|
+
fetch_settings: FetchSettings | None = None,
|
|
120
|
+
inbound_dpop: InboundDPoPOptions | None = None,
|
|
121
|
+
mcp_path: str = "/mcp",
|
|
122
|
+
revocation_checker: IntrospectionRevocation | RevocationChecker | None = None,
|
|
123
|
+
) -> AuthplaneAuthResult:
|
|
124
|
+
"""Build the kwargs to enable Authplane auth on a FastMCP server.
|
|
125
|
+
|
|
126
|
+
This async factory performs RFC 8414 metadata discovery, fetches the
|
|
127
|
+
JWKS, and wires up all components (client, resource, token verifier,
|
|
128
|
+
auth provider) in a single awaitable call.
|
|
129
|
+
|
|
130
|
+
Usage::
|
|
131
|
+
|
|
132
|
+
mcp = FastMCP("My Server", **await authplane_auth(
|
|
133
|
+
issuer="https://auth.company.com",
|
|
134
|
+
base_url="https://mcp.company.com",
|
|
135
|
+
scopes=["tools/query", "tools/write"],
|
|
136
|
+
))
|
|
137
|
+
|
|
138
|
+
After setup:
|
|
139
|
+
|
|
140
|
+
- Use ``@mcp.tool(auth=require_scopes(...))`` for scope enforcement.
|
|
141
|
+
- Use ``CurrentAccessToken()`` or ``get_access_token()`` to access
|
|
142
|
+
token claims inside tool handlers (both are FastMCP built-ins).
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
issuer: Authplane authorization server URL
|
|
146
|
+
(e.g., ``"https://auth.company.com"``).
|
|
147
|
+
base_url: Root URL of the FastMCP server
|
|
148
|
+
(e.g., ``"https://mcp.company.com"``). Same value you would
|
|
149
|
+
pass to FastMCP for deployment.
|
|
150
|
+
scopes: All scopes this server supports. Used in AuthSettings
|
|
151
|
+
and the PRM document. Defaults to an empty list.
|
|
152
|
+
dpop: Optional DPoP provider used for outbound calls from the
|
|
153
|
+
underlying SDK client to the authorization server.
|
|
154
|
+
allowed_algorithms: Algorithms allowed for signature verification.
|
|
155
|
+
Defaults to SDK defaults (``["RS256", "ES256"]``).
|
|
156
|
+
jwks_refresh_seconds: JWKS cache TTL in seconds (default ``300``).
|
|
157
|
+
cache_ttl_buffer_seconds: Buffer subtracted from token TTLs
|
|
158
|
+
before cache expiry (default ``30.0``).
|
|
159
|
+
default_ttl_seconds: Fallback token cache TTL used when token
|
|
160
|
+
responses do not include expiry metadata (default ``3600.0``).
|
|
161
|
+
circuit_breaker_threshold: Number of transient failures before
|
|
162
|
+
opening the AS circuit breaker (default ``5``).
|
|
163
|
+
circuit_breaker_cooldown_seconds: Cooldown before allowing a
|
|
164
|
+
half-open probe request after the circuit opens
|
|
165
|
+
(default ``30.0``).
|
|
166
|
+
clock_skew_seconds: Leeway in seconds for exp/nbf/iat validation
|
|
167
|
+
(default ``30``).
|
|
168
|
+
dev_mode: Enable development mode. Relaxes SSRF checks to allow
|
|
169
|
+
HTTP, localhost, and private networks. Can also be set via
|
|
170
|
+
``AUTHPLANE_DEV_MODE=true`` environment variable.
|
|
171
|
+
metadata_refresh_seconds: AS metadata cache TTL in seconds
|
|
172
|
+
(default ``3600``).
|
|
173
|
+
fetch_settings: Full ``FetchSettings`` object applied to both
|
|
174
|
+
metadata and JWKS fetches. When provided, overrides
|
|
175
|
+
``dev_mode`` for those fetches.
|
|
176
|
+
inbound_dpop: Per-resource inbound DPoP policy
|
|
177
|
+
(:class:`InboundDPoPOptions`). Bundles ``required``,
|
|
178
|
+
``signing_algs``, ``max_proof_age_seconds``,
|
|
179
|
+
``clock_skew_seconds`` and ``replay_store``; see RFC 9728 §2 +
|
|
180
|
+
RFC 9449 §7.1 for the field semantics.
|
|
181
|
+
mcp_path: Mount path of the MCP endpoint (default ``"/mcp"``).
|
|
182
|
+
The JWT audience (resource) is derived as
|
|
183
|
+
``base_url + mcp_path``. Only set this if you changed
|
|
184
|
+
FastMCP's default HTTP mount path.
|
|
185
|
+
as_credentials: Client credentials for authenticating to the AS.
|
|
186
|
+
Shared by introspection (RFC 7662) and token exchange (RFC 8693).
|
|
187
|
+
Required when using ``IntrospectionRevocation`` for authenticated
|
|
188
|
+
introspection, or when calling ``client.exchange()``.
|
|
189
|
+
revocation_checker: Controls token revocation checking after
|
|
190
|
+
signature validation passes.
|
|
191
|
+
|
|
192
|
+
- ``None`` (default): disables revocation checking (offline
|
|
193
|
+
validation only).
|
|
194
|
+
- ``IntrospectionRevocation()``: calls the AS
|
|
195
|
+
``introspection_endpoint`` (RFC 7662) discovered from AS
|
|
196
|
+
metadata. Raises ``TokenRevokedError`` if ``active=false``.
|
|
197
|
+
Pass ``as_credentials`` for authenticated introspection.
|
|
198
|
+
Fails open if the endpoint is unavailable.
|
|
199
|
+
- async callable: custom checker called with
|
|
200
|
+
``(VerifiedClaims, raw_token)``; return ``True`` to reject
|
|
201
|
+
the token (raises ``TokenRevokedError``).
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
``AuthplaneAuthResult`` with ``auth`` (``RemoteAuthProvider``),
|
|
205
|
+
``token_verifier`` (``AuthplaneTokenVerifier``), and ``client``
|
|
206
|
+
(``AuthplaneClient``) attributes. Supports ``**`` unpacking into
|
|
207
|
+
``FastMCP()`` — only ``auth`` is included in the mapping view.
|
|
208
|
+
Access ``client`` directly for RFC 8693 token exchange via
|
|
209
|
+
``result.client.exchange()``.
|
|
210
|
+
|
|
211
|
+
Raises:
|
|
212
|
+
ValueError: If configuration is invalid (bad algorithms, etc.).
|
|
213
|
+
JWKSFetchError: If metadata discovery or JWKS fetching fails.
|
|
214
|
+
"""
|
|
215
|
+
resolved_scopes = scopes or []
|
|
216
|
+
|
|
217
|
+
# Derive the canonical resource URL (= JWT audience) from base_url + mcp_path.
|
|
218
|
+
# This must match exactly what RemoteAuthProvider advertises in the PRM, which
|
|
219
|
+
# FastMCP computes as base_url + mcp_path via _get_resource_url().
|
|
220
|
+
resource = base_url.rstrip("/") + "/" + mcp_path.lstrip("/")
|
|
221
|
+
|
|
222
|
+
# Prepare client-level kwargs, filtering out None to use SDK defaults
|
|
223
|
+
client_kwargs_raw: dict[str, Any] = {
|
|
224
|
+
"dpop": dpop,
|
|
225
|
+
"dev_mode": dev_mode,
|
|
226
|
+
"fetch_settings": fetch_settings,
|
|
227
|
+
"jwks_refresh_seconds": jwks_refresh_seconds,
|
|
228
|
+
"metadata_refresh_seconds": metadata_refresh_seconds,
|
|
229
|
+
"cache_ttl_buffer_seconds": cache_ttl_buffer_seconds,
|
|
230
|
+
"default_ttl_seconds": default_ttl_seconds,
|
|
231
|
+
"circuit_breaker_threshold": circuit_breaker_threshold,
|
|
232
|
+
"circuit_breaker_cooldown_seconds": circuit_breaker_cooldown_seconds,
|
|
233
|
+
}
|
|
234
|
+
client_kwargs: dict[str, Any] = {k: v for k, v in client_kwargs_raw.items() if v is not None}
|
|
235
|
+
|
|
236
|
+
# Prepare resource-level kwargs, filtering out None to use SDK defaults
|
|
237
|
+
verifier_kwargs_raw: dict[str, Any] = {
|
|
238
|
+
"allowed_algorithms": allowed_algorithms,
|
|
239
|
+
"clock_skew_seconds": clock_skew_seconds,
|
|
240
|
+
"inbound_dpop": inbound_dpop,
|
|
241
|
+
}
|
|
242
|
+
verifier_kwargs: dict[str, Any] = {
|
|
243
|
+
k: v for k, v in verifier_kwargs_raw.items() if v is not None
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
# Create the AuthplaneClient (handles metadata discovery, JWKS fetching, caching)
|
|
247
|
+
client = await AuthplaneClient.create(
|
|
248
|
+
issuer=issuer,
|
|
249
|
+
auth=as_credentials,
|
|
250
|
+
**client_kwargs,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Translate ConsentRequiredError → MCP UrlElicitationRequiredError at the
|
|
254
|
+
# client boundary, before user tool code sees it. Tool authors don't need
|
|
255
|
+
# to wrap handlers or import elicitation primitives — the MCP wire-format
|
|
256
|
+
# mapping is owned by the adapter that constructs the client.
|
|
257
|
+
client = _wrap_client_for_elicitation(client)
|
|
258
|
+
|
|
259
|
+
# Create the resource from the client
|
|
260
|
+
verifier = client.resource(
|
|
261
|
+
resource=resource,
|
|
262
|
+
scopes=resolved_scopes,
|
|
263
|
+
revocation_checker=revocation_checker,
|
|
264
|
+
**verifier_kwargs,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# Wrap in AuthplaneTokenVerifier
|
|
268
|
+
# Note: FastMCP 3.0.0 uses token_verifier.base_url for PRM generation if provided
|
|
269
|
+
token_verifier = AuthplaneTokenVerifier(verifier, base_url=base_url)
|
|
270
|
+
|
|
271
|
+
# Wrap in RemoteAuthProvider to get PRM routes
|
|
272
|
+
auth_provider = RemoteAuthProvider(
|
|
273
|
+
token_verifier=token_verifier,
|
|
274
|
+
authorization_servers=[AnyHttpUrl(issuer)],
|
|
275
|
+
base_url=AnyHttpUrl(base_url),
|
|
276
|
+
scopes_supported=resolved_scopes,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
return AuthplaneAuthResult(
|
|
280
|
+
auth=auth_provider,
|
|
281
|
+
token_verifier=token_verifier,
|
|
282
|
+
client=client,
|
|
283
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""URL elicitation primitive for the FastMCP adapter.
|
|
2
|
+
|
|
3
|
+
The factory function :func:`authplane_fastmcp.authplane_auth` wraps the
|
|
4
|
+
``AuthplaneClient`` it returns so that ``client.exchange()`` consent errors
|
|
5
|
+
auto-translate into MCP ``-32042`` (URL elicitation required) before user
|
|
6
|
+
tool code sees them. This module exposes the underlying conversion as a
|
|
7
|
+
small primitive for unusual flows where users build a consent error outside
|
|
8
|
+
the wrapped client and want to raise the MCP-shaped error themselves.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from uuid import uuid4
|
|
14
|
+
|
|
15
|
+
from authplane.errors import ConsentRequiredError
|
|
16
|
+
from mcp.shared.exceptions import UrlElicitationRequiredError
|
|
17
|
+
from mcp.types import ElicitRequestURLParams
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def to_url_elicitation_required_error(
|
|
21
|
+
error: BaseException,
|
|
22
|
+
) -> UrlElicitationRequiredError | None:
|
|
23
|
+
"""Map a :class:`ConsentRequiredError` with a ``consent_url`` to MCP -32042.
|
|
24
|
+
|
|
25
|
+
Returns ``None`` for any other input — non-consent errors and consent
|
|
26
|
+
errors without a ``consent_url`` are not translatable. Callers then
|
|
27
|
+
re-raise the original exception unchanged.
|
|
28
|
+
"""
|
|
29
|
+
if not isinstance(error, ConsentRequiredError) or not error.consent_url:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
return UrlElicitationRequiredError(
|
|
33
|
+
elicitations=[
|
|
34
|
+
ElicitRequestURLParams(
|
|
35
|
+
mode="url",
|
|
36
|
+
url=error.consent_url,
|
|
37
|
+
elicitationId=str(uuid4()),
|
|
38
|
+
message=error.describe(),
|
|
39
|
+
)
|
|
40
|
+
],
|
|
41
|
+
message=str(error),
|
|
42
|
+
)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""AuthplaneTokenVerifier - FastMCP TokenVerifier backed by AuthplaneResource.
|
|
2
|
+
|
|
3
|
+
Bridges the Authplane core SDK with FastMCP's ``TokenVerifier`` interface,
|
|
4
|
+
delegating all JWT validation to ``AuthplaneResource`` and mapping results
|
|
5
|
+
to FastMCP's ``AccessToken`` with the full JWT payload in ``claims``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
from authplane import AuthplaneError, AuthplaneResource
|
|
11
|
+
from fastmcp.server.auth import AccessToken, TokenVerifier
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AuthplaneTokenVerifier(TokenVerifier):
|
|
15
|
+
"""FastMCP TokenVerifier backed by AuthplaneResource.
|
|
16
|
+
|
|
17
|
+
Validates JWTs once per request via the core Authplane SDK and returns
|
|
18
|
+
a FastMCP ``AccessToken`` with ``claims`` populated from the full JWT
|
|
19
|
+
payload (``VerifiedClaims.raw``). Token claims are then available in
|
|
20
|
+
tool handlers via FastMCP's native ``CurrentAccessToken()`` dependency
|
|
21
|
+
or ``get_access_token()`` function.
|
|
22
|
+
|
|
23
|
+
All security-critical logic (signature verification, claim validation,
|
|
24
|
+
JWKS caching, SSRF protection) is handled by the core SDK. This class
|
|
25
|
+
is a thin adapter that maps between the two interfaces.
|
|
26
|
+
|
|
27
|
+
Scope enforcement is FastMCP's responsibility via
|
|
28
|
+
``@mcp.tool(auth=require_scopes(...))``.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
verifier: AuthplaneResource,
|
|
34
|
+
base_url: str | None = None,
|
|
35
|
+
required_scopes: list[str] | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Initialize the token verifier.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
verifier: A fully initialized ``AuthplaneResource`` instance,
|
|
41
|
+
typically created via ``AuthplaneClient.create()`` and
|
|
42
|
+
``client.resource()``.
|
|
43
|
+
base_url: The base URL of this server. Passed to the parent
|
|
44
|
+
``TokenVerifier`` for PRM generation.
|
|
45
|
+
required_scopes: Scopes required for all requests. Passed to
|
|
46
|
+
the parent ``TokenVerifier``.
|
|
47
|
+
"""
|
|
48
|
+
super().__init__(base_url=base_url, required_scopes=required_scopes)
|
|
49
|
+
self._verifier = verifier
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def verifier(self) -> AuthplaneResource:
|
|
53
|
+
"""The underlying ``AuthplaneResource`` instance."""
|
|
54
|
+
return self._verifier
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def scopes_supported(self) -> list[str]:
|
|
58
|
+
"""Return scopes supported by this verifier.
|
|
59
|
+
|
|
60
|
+
Returns the scopes configured in the ``AuthplaneResource``, which
|
|
61
|
+
are used by FastMCP for PRM metadata generation at
|
|
62
|
+
``/.well-known/oauth-protected-resource``.
|
|
63
|
+
"""
|
|
64
|
+
return list(self._verifier.scopes)
|
|
65
|
+
|
|
66
|
+
async def verify_token(self, token: str) -> AccessToken | None:
|
|
67
|
+
"""Validate a JWT and return a FastMCP AccessToken.
|
|
68
|
+
|
|
69
|
+
Called by FastMCP once per authenticated request. Delegates to
|
|
70
|
+
``AuthplaneResource.verify()`` for all validation, then maps the
|
|
71
|
+
resulting ``VerifiedClaims`` to a FastMCP ``AccessToken`` with the
|
|
72
|
+
full JWT payload in ``claims``.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
token: The raw JWT string (FastMCP strips ``'Bearer '``
|
|
76
|
+
before calling this method).
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
``AccessToken`` on successful validation with ``token``,
|
|
80
|
+
``client_id``, ``scopes``, ``expires_at``, and ``claims``
|
|
81
|
+
(full JWT payload dict) fields populated. Returns ``None``
|
|
82
|
+
on any validation failure (FastMCP responds with 401).
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
claims = await self._verifier.verify(token)
|
|
86
|
+
except AuthplaneError:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
return AccessToken(
|
|
90
|
+
token=token,
|
|
91
|
+
client_id=claims.client_id,
|
|
92
|
+
scopes=list(claims.scopes),
|
|
93
|
+
expires_at=claims.expires_at,
|
|
94
|
+
claims=cast("dict[str, Any]", claims.raw), # full JWT payload
|
|
95
|
+
)
|