authplane-mcp 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_mcp-0.1.0/.gitignore +7 -0
- authplane_mcp-0.1.0/.python-version +1 -0
- authplane_mcp-0.1.0/PKG-INFO +69 -0
- authplane_mcp-0.1.0/README.md +46 -0
- authplane_mcp-0.1.0/authplane_mcp/__init__.py +25 -0
- authplane_mcp-0.1.0/authplane_mcp/auth.py +327 -0
- authplane_mcp-0.1.0/authplane_mcp/py.typed +0 -0
- authplane_mcp-0.1.0/authplane_mcp/url_elicitation.py +42 -0
- authplane_mcp-0.1.0/authplane_mcp/verifier.py +71 -0
- authplane_mcp-0.1.0/demo/README.md +65 -0
- authplane_mcp-0.1.0/demo/mcpserver.py +117 -0
- authplane_mcp-0.1.0/demo/run.sh +28 -0
- authplane_mcp-0.1.0/docs/user-guide.md +517 -0
- authplane_mcp-0.1.0/pyproject.toml +91 -0
- authplane_mcp-0.1.0/pyrightconfig.json +16 -0
- authplane_mcp-0.1.0/tests/test_auth.py +332 -0
- authplane_mcp-0.1.0/tests/test_url_elicitation.py +139 -0
- authplane_mcp-0.1.0/tests/test_verifier.py +78 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12.8
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: authplane-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Authplane JWT validation adapter for the official MCP Python SDK
|
|
5
|
+
Project-URL: Homepage, https://github.com/AuthPlane/python-sdk/tree/main/authplane-mcp
|
|
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,jwt,mcp,oauth
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Requires-Dist: authplane-sdk==0.1.0
|
|
12
|
+
Requires-Dist: mcp<1.28.0,>=1.23.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-mcp
|
|
25
|
+
|
|
26
|
+
[](https://pypi.org/project/authplane-mcp/)
|
|
27
|
+
[](https://opensource.org/licenses/Apache-2.0)
|
|
28
|
+
|
|
29
|
+
Authplane JWT validation for servers built on the [official MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk).
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install authplane-mcp
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Compatibility
|
|
38
|
+
|
|
39
|
+
Supported `mcp` range: **`>=1.23.0, <1.28.0`**. MCP 1.28 renamed the elicitation field from `elicitationId` (camelCase) to `elicitation_id` (snake_case), which breaks this adapter's current wire handling. If your project needs MCP 1.28+, please open an issue — the adapter update is straightforward, we just haven't cut it yet.
|
|
40
|
+
|
|
41
|
+
## Quickstart
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import asyncio
|
|
45
|
+
from authplane_mcp import authplane_mcp_auth
|
|
46
|
+
from mcp.server.fastmcp import FastMCP
|
|
47
|
+
|
|
48
|
+
auth_result = asyncio.run(
|
|
49
|
+
authplane_mcp_auth(
|
|
50
|
+
issuer="https://auth.company.com",
|
|
51
|
+
resource="https://mcp.company.com",
|
|
52
|
+
scopes=["tools/query", "tools/write"],
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
mcp = FastMCP("My MCP Server", json_response=True, **auth_result)
|
|
57
|
+
|
|
58
|
+
@mcp.tool()
|
|
59
|
+
async def query_database(query: str) -> str:
|
|
60
|
+
return f"Result for: {query}"
|
|
61
|
+
|
|
62
|
+
mcp.run(transport="streamable-http")
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`mcp.run()` starts its own event loop, so the auth setup runs synchronously via `asyncio.run(...)` first. `auth_result` holds background JWKS and metadata refresh tasks — call `await auth_result.aclose()` during server shutdown.
|
|
66
|
+
|
|
67
|
+
## Documentation
|
|
68
|
+
|
|
69
|
+
PRM behavior, dev mode, revocation checking, manual setup, the full `authplane_mcp_auth` / `AuthplaneTokenVerifier` API, and error handling: **[User Guide](docs/user-guide.md)**.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# authplane-mcp
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/authplane-mcp/)
|
|
4
|
+
[](https://opensource.org/licenses/Apache-2.0)
|
|
5
|
+
|
|
6
|
+
Authplane JWT validation for servers built on the [official MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk).
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install authplane-mcp
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Compatibility
|
|
15
|
+
|
|
16
|
+
Supported `mcp` range: **`>=1.23.0, <1.28.0`**. MCP 1.28 renamed the elicitation field from `elicitationId` (camelCase) to `elicitation_id` (snake_case), which breaks this adapter's current wire handling. If your project needs MCP 1.28+, please open an issue — the adapter update is straightforward, we just haven't cut it yet.
|
|
17
|
+
|
|
18
|
+
## Quickstart
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import asyncio
|
|
22
|
+
from authplane_mcp import authplane_mcp_auth
|
|
23
|
+
from mcp.server.fastmcp import FastMCP
|
|
24
|
+
|
|
25
|
+
auth_result = asyncio.run(
|
|
26
|
+
authplane_mcp_auth(
|
|
27
|
+
issuer="https://auth.company.com",
|
|
28
|
+
resource="https://mcp.company.com",
|
|
29
|
+
scopes=["tools/query", "tools/write"],
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
mcp = FastMCP("My MCP Server", json_response=True, **auth_result)
|
|
34
|
+
|
|
35
|
+
@mcp.tool()
|
|
36
|
+
async def query_database(query: str) -> str:
|
|
37
|
+
return f"Result for: {query}"
|
|
38
|
+
|
|
39
|
+
mcp.run(transport="streamable-http")
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`mcp.run()` starts its own event loop, so the auth setup runs synchronously via `asyncio.run(...)` first. `auth_result` holds background JWKS and metadata refresh tasks — call `await auth_result.aclose()` during server shutdown.
|
|
43
|
+
|
|
44
|
+
## Documentation
|
|
45
|
+
|
|
46
|
+
PRM behavior, dev mode, revocation checking, manual setup, the full `authplane_mcp_auth` / `AuthplaneTokenVerifier` API, and error handling: **[User Guide](docs/user-guide.md)**.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Authplane Python SDK - MCP adapter.
|
|
2
|
+
|
|
3
|
+
This package provides a thin adapter between the Authplane Python SDK and the
|
|
4
|
+
official Model Context Protocol (MCP) Python SDK, enabling MCP servers to
|
|
5
|
+
validate Authplane-issued JWT access tokens.
|
|
6
|
+
|
|
7
|
+
Core SDK types (``ASCredentials``, ``FetchSettings``, ``IntrospectionRevocation``,
|
|
8
|
+
DPoP types, errors, etc.) are imported from ``authplane`` directly. This package
|
|
9
|
+
exports only the adapter-owned glue.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
from .auth import AuthplaneAuthResult, authplane_mcp_auth, require_scope
|
|
15
|
+
from .url_elicitation import to_url_elicitation_required_error
|
|
16
|
+
from .verifier import AuthplaneTokenVerifier
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AuthplaneAuthResult",
|
|
20
|
+
"AuthplaneTokenVerifier",
|
|
21
|
+
"__version__",
|
|
22
|
+
"authplane_mcp_auth",
|
|
23
|
+
"require_scope",
|
|
24
|
+
"to_url_elicitation_required_error",
|
|
25
|
+
]
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Convenience factory for enabling Authplane auth on MCP servers.
|
|
2
|
+
|
|
3
|
+
Provides ``authplane_mcp_auth()``, an async factory function that creates
|
|
4
|
+
and configures all the components needed to add Authplane JWT validation
|
|
5
|
+
to an official MCP Python SDK 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 mcp.server.auth.middleware.auth_context import get_access_token as _get_access_token
|
|
22
|
+
from mcp.server.auth.settings import AuthSettings
|
|
23
|
+
from pydantic import AnyHttpUrl
|
|
24
|
+
|
|
25
|
+
from .url_elicitation import to_url_elicitation_required_error
|
|
26
|
+
from .verifier import AuthplaneTokenVerifier
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _wrap_client_for_elicitation(client: AuthplaneClient) -> AuthplaneClient:
|
|
30
|
+
"""Translate ``client.exchange`` consent errors into MCP ``-32042``.
|
|
31
|
+
|
|
32
|
+
Wrapping ``exchange()`` (the only method that realistically surfaces
|
|
33
|
+
``ConsentRequiredError`` from the AS) means user tool code can call
|
|
34
|
+
``result.client.exchange(...)`` without any try/except: a consent error
|
|
35
|
+
becomes a ``UrlElicitationRequiredError`` transparently, and FastMCP /
|
|
36
|
+
the MCP server forwards it as a ``-32042`` JSON-RPC error.
|
|
37
|
+
"""
|
|
38
|
+
original_exchange = client.exchange
|
|
39
|
+
|
|
40
|
+
async def exchange(options: TokenExchangeOptions) -> TokenResponse:
|
|
41
|
+
try:
|
|
42
|
+
return await original_exchange(options)
|
|
43
|
+
except Exception as error:
|
|
44
|
+
mapped = to_url_elicitation_required_error(error)
|
|
45
|
+
if mapped is not None:
|
|
46
|
+
raise mapped from error
|
|
47
|
+
raise
|
|
48
|
+
|
|
49
|
+
client.exchange = exchange
|
|
50
|
+
return client
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def require_scope(scope: str) -> None:
|
|
54
|
+
"""Raise PermissionError if the current request token is missing a required scope.
|
|
55
|
+
|
|
56
|
+
Call this at the top of a tool handler to enforce per-tool scope requirements::
|
|
57
|
+
|
|
58
|
+
@mcp.tool()
|
|
59
|
+
async def add(a: float, b: float) -> float:
|
|
60
|
+
require_scope("tools/add")
|
|
61
|
+
return a + b
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
scope: The scope string that must be present in the token.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
PermissionError: If the token is absent or does not contain ``scope``.
|
|
68
|
+
"""
|
|
69
|
+
token = _get_access_token()
|
|
70
|
+
if token is None or scope not in token.scopes:
|
|
71
|
+
raise PermissionError(f"Missing required scope: {scope}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class AuthplaneAuthResult:
|
|
75
|
+
"""Return value of ``authplane_mcp_auth()``.
|
|
76
|
+
|
|
77
|
+
Supports ``**`` unpacking into ``FastMCP()`` — only ``token_verifier``
|
|
78
|
+
and ``auth`` keys are included in the mapping view so FastMCP receives
|
|
79
|
+
exactly what it expects. ``client`` is exposed as a plain attribute for
|
|
80
|
+
advanced use cases such as RFC 8693 token exchange::
|
|
81
|
+
|
|
82
|
+
result = await authplane_mcp_auth(issuer=..., resource=..., ...)
|
|
83
|
+
mcp = FastMCP("My Server", **result)
|
|
84
|
+
|
|
85
|
+
# Inside a tool handler — exchange a user token for a downstream token:
|
|
86
|
+
downstream = await result.client.exchange(TokenExchangeOptions(
|
|
87
|
+
subject_token=user_token,
|
|
88
|
+
))
|
|
89
|
+
|
|
90
|
+
Call ``await result.aclose()`` on server shutdown to release background
|
|
91
|
+
tasks and HTTP connections held by the underlying ``AuthplaneClient``.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
token_verifier: AuthplaneTokenVerifier,
|
|
97
|
+
auth: AuthSettings,
|
|
98
|
+
client: AuthplaneClient,
|
|
99
|
+
) -> None:
|
|
100
|
+
self.token_verifier = token_verifier
|
|
101
|
+
self.auth = auth
|
|
102
|
+
self.client = client
|
|
103
|
+
|
|
104
|
+
async def aclose(self) -> None:
|
|
105
|
+
"""Release resources held by the underlying ``AuthplaneClient``.
|
|
106
|
+
|
|
107
|
+
Cancels the background JWKS refresh task and closes the HTTP
|
|
108
|
+
connection pool. Safe to call multiple times.
|
|
109
|
+
"""
|
|
110
|
+
await self.client.aclose()
|
|
111
|
+
|
|
112
|
+
# Mapping protocol — yields only what FastMCP expects.
|
|
113
|
+
def keys(self) -> list[str]:
|
|
114
|
+
return ["token_verifier", "auth"]
|
|
115
|
+
|
|
116
|
+
def __getitem__(self, key: str) -> Any:
|
|
117
|
+
if key == "token_verifier":
|
|
118
|
+
return self.token_verifier
|
|
119
|
+
if key == "auth":
|
|
120
|
+
return self.auth
|
|
121
|
+
raise KeyError(key)
|
|
122
|
+
|
|
123
|
+
def __iter__(self) -> Iterator[str]:
|
|
124
|
+
return iter(self.keys())
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def authplane_mcp_auth(
|
|
128
|
+
issuer: str,
|
|
129
|
+
resource: str,
|
|
130
|
+
scopes: list[str] | None = None,
|
|
131
|
+
*,
|
|
132
|
+
enforce_scopes_on_all_requests: bool = False,
|
|
133
|
+
as_credentials: ASCredentials | None = None,
|
|
134
|
+
dpop: DPoPProvider | None = None,
|
|
135
|
+
allowed_algorithms: list[str] | None = None,
|
|
136
|
+
jwks_refresh_seconds: int | None = None,
|
|
137
|
+
metadata_refresh_seconds: int | None = None,
|
|
138
|
+
cache_ttl_buffer_seconds: float | None = None,
|
|
139
|
+
default_ttl_seconds: float | None = None,
|
|
140
|
+
circuit_breaker_threshold: int | None = None,
|
|
141
|
+
circuit_breaker_cooldown_seconds: float | None = None,
|
|
142
|
+
clock_skew_seconds: int | None = None,
|
|
143
|
+
dev_mode: bool | None = None,
|
|
144
|
+
fetch_settings: FetchSettings | None = None,
|
|
145
|
+
inbound_dpop: InboundDPoPOptions | None = None,
|
|
146
|
+
revocation_checker: IntrospectionRevocation | RevocationChecker | None = None,
|
|
147
|
+
) -> AuthplaneAuthResult:
|
|
148
|
+
"""Build the kwargs to enable Authplane auth on a FastMCP server.
|
|
149
|
+
|
|
150
|
+
This async factory performs RFC 8414 metadata discovery, fetches the
|
|
151
|
+
JWKS, and wires up all components (client, resource, token verifier,
|
|
152
|
+
auth settings) in a single awaitable call.
|
|
153
|
+
|
|
154
|
+
Usage::
|
|
155
|
+
|
|
156
|
+
mcp = FastMCP(
|
|
157
|
+
"My Server",
|
|
158
|
+
**await authplane_mcp_auth(
|
|
159
|
+
issuer="https://auth.company.com",
|
|
160
|
+
resource="https://mcp.company.com",
|
|
161
|
+
scopes=["tools/query", "tools/write"],
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
issuer: Authplane authorization server URL
|
|
167
|
+
(e.g., ``"https://auth.company.com"``).
|
|
168
|
+
resource: URL of this MCP server / resource identifier
|
|
169
|
+
(e.g., ``"https://mcp.company.com"``). This is used as the
|
|
170
|
+
JWT audience (``aud`` claim) and ``resource_server_url`` in
|
|
171
|
+
AuthSettings.
|
|
172
|
+
scopes: All scopes this server supports. Defaults to an empty
|
|
173
|
+
list.
|
|
174
|
+
enforce_scopes_on_all_requests: When ``True``, ``scopes`` are passed
|
|
175
|
+
to the MCP SDK as ``AuthSettings.required_scopes``. This causes
|
|
176
|
+
two things:
|
|
177
|
+
|
|
178
|
+
1. The Protected Resource Metadata (PRM) endpoint advertises
|
|
179
|
+
them as ``scopes_supported`` — required for OAuth-discovery
|
|
180
|
+
clients (e.g. Claude Code) to know which scopes to request
|
|
181
|
+
on a fresh token mint, otherwise they fall back to the AS
|
|
182
|
+
metadata's global ``scopes_supported`` (every scope across
|
|
183
|
+
every resource) and the AS rejects with ``invalid_scope``.
|
|
184
|
+
2. ``RequireAuthMiddleware`` rejects any request whose token
|
|
185
|
+
does not carry **all** listed scopes — coarse-grained
|
|
186
|
+
enforcement at the request layer.
|
|
187
|
+
|
|
188
|
+
This is a workaround for an MCP reference SDK limitation:
|
|
189
|
+
``AuthSettings`` has no separate "supported" field, so the SDK
|
|
190
|
+
uses ``required_scopes`` for both purposes (see
|
|
191
|
+
``mcp/server/fastmcp/server.py`` ``create_protected_resource_routes``
|
|
192
|
+
calls). Per-tool ``require_scope()`` is the intended granular
|
|
193
|
+
pattern; keep those calls in place even when this flag is
|
|
194
|
+
``True`` — they remain correct, are simply redundant under
|
|
195
|
+
request-level enforcement, and continue to work unchanged
|
|
196
|
+
once the upstream SDK gains a separate "supported" field and
|
|
197
|
+
this flag becomes unnecessary.
|
|
198
|
+
|
|
199
|
+
Defaults to ``False``: PRM advertises no scopes, per-tool
|
|
200
|
+
``require_scope()`` is the only enforcement.
|
|
201
|
+
dpop: Optional DPoP provider used for outbound calls from the
|
|
202
|
+
underlying SDK client to the authorization server.
|
|
203
|
+
allowed_algorithms: Algorithms allowed for signature verification.
|
|
204
|
+
Defaults to SDK defaults (``["RS256", "ES256"]``).
|
|
205
|
+
jwks_refresh_seconds: JWKS cache TTL in seconds (default ``300``).
|
|
206
|
+
cache_ttl_buffer_seconds: Buffer subtracted from token TTLs
|
|
207
|
+
before cache expiry (default ``30.0``).
|
|
208
|
+
default_ttl_seconds: Fallback token cache TTL used when token
|
|
209
|
+
responses do not include expiry metadata (default ``3600.0``).
|
|
210
|
+
circuit_breaker_threshold: Number of transient failures before
|
|
211
|
+
opening the AS circuit breaker (default ``5``).
|
|
212
|
+
circuit_breaker_cooldown_seconds: Cooldown before allowing a
|
|
213
|
+
half-open probe request after the circuit opens
|
|
214
|
+
(default ``30.0``).
|
|
215
|
+
clock_skew_seconds: Leeway in seconds for exp/nbf/iat validation
|
|
216
|
+
(default ``30``).
|
|
217
|
+
dev_mode: Enable development mode. Relaxes SSRF checks to allow
|
|
218
|
+
HTTP, localhost, and private networks. Can also be set via
|
|
219
|
+
``AUTHPLANE_DEV_MODE=true`` environment variable.
|
|
220
|
+
metadata_refresh_seconds: AS metadata cache TTL in seconds
|
|
221
|
+
(default ``3600``).
|
|
222
|
+
fetch_settings: Full ``FetchSettings`` object applied to both
|
|
223
|
+
metadata and JWKS fetches. When provided, overrides
|
|
224
|
+
``dev_mode`` for those fetches.
|
|
225
|
+
inbound_dpop: Per-resource inbound DPoP policy
|
|
226
|
+
(:class:`InboundDPoPOptions`). Bundles ``required``,
|
|
227
|
+
``signing_algs``, ``max_proof_age_seconds``,
|
|
228
|
+
``clock_skew_seconds`` and ``replay_store``; see RFC 9728 §2 +
|
|
229
|
+
RFC 9449 §7.1 for the field semantics.
|
|
230
|
+
as_credentials: Client credentials for authenticating to the AS.
|
|
231
|
+
Shared by introspection (RFC 7662) and token exchange (RFC 8693).
|
|
232
|
+
Required when using ``IntrospectionRevocation`` for authenticated
|
|
233
|
+
introspection, or when calling ``client.exchange()``.
|
|
234
|
+
revocation_checker: Controls token revocation checking after
|
|
235
|
+
signature validation passes.
|
|
236
|
+
|
|
237
|
+
- ``None`` (default): disables revocation checking (offline
|
|
238
|
+
validation only).
|
|
239
|
+
- ``IntrospectionRevocation()``: calls the AS
|
|
240
|
+
``introspection_endpoint`` (RFC 7662) discovered from AS
|
|
241
|
+
metadata. Raises ``TokenRevokedError`` if ``active=false``.
|
|
242
|
+
Pass ``as_credentials`` for authenticated introspection.
|
|
243
|
+
Fails open if the endpoint is unavailable.
|
|
244
|
+
- async callable: custom checker called with
|
|
245
|
+
``(VerifiedClaims, raw_token)``; return ``True`` to reject
|
|
246
|
+
the token (raises ``TokenRevokedError``).
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
``AuthplaneAuthResult`` with ``token_verifier`` (``AuthplaneTokenVerifier``),
|
|
250
|
+
``auth`` (``AuthSettings``), and ``client`` (``AuthplaneClient``) attributes.
|
|
251
|
+
Supports ``**`` unpacking into ``FastMCP()`` — only ``token_verifier``
|
|
252
|
+
and ``auth`` are included in the mapping view. Access ``client`` directly
|
|
253
|
+
for RFC 8693 token exchange via ``result.client.exchange()``.
|
|
254
|
+
|
|
255
|
+
Raises:
|
|
256
|
+
ValueError: If configuration is invalid (bad algorithms, etc.).
|
|
257
|
+
JWKSFetchError: If metadata discovery or JWKS fetching fails.
|
|
258
|
+
"""
|
|
259
|
+
resolved_scopes = scopes or []
|
|
260
|
+
|
|
261
|
+
# Prepare client-level kwargs, filtering out None to use SDK defaults
|
|
262
|
+
client_kwargs_raw: dict[str, Any] = {
|
|
263
|
+
"dpop": dpop,
|
|
264
|
+
"dev_mode": dev_mode,
|
|
265
|
+
"fetch_settings": fetch_settings,
|
|
266
|
+
"jwks_refresh_seconds": jwks_refresh_seconds,
|
|
267
|
+
"metadata_refresh_seconds": metadata_refresh_seconds,
|
|
268
|
+
"cache_ttl_buffer_seconds": cache_ttl_buffer_seconds,
|
|
269
|
+
"default_ttl_seconds": default_ttl_seconds,
|
|
270
|
+
"circuit_breaker_threshold": circuit_breaker_threshold,
|
|
271
|
+
"circuit_breaker_cooldown_seconds": circuit_breaker_cooldown_seconds,
|
|
272
|
+
}
|
|
273
|
+
client_kwargs: dict[str, Any] = {k: v for k, v in client_kwargs_raw.items() if v is not None}
|
|
274
|
+
|
|
275
|
+
# Prepare resource-level kwargs, filtering out None to use SDK defaults
|
|
276
|
+
verifier_kwargs_raw: dict[str, Any] = {
|
|
277
|
+
"allowed_algorithms": allowed_algorithms,
|
|
278
|
+
"clock_skew_seconds": clock_skew_seconds,
|
|
279
|
+
"inbound_dpop": inbound_dpop,
|
|
280
|
+
}
|
|
281
|
+
verifier_kwargs: dict[str, Any] = {
|
|
282
|
+
k: v for k, v in verifier_kwargs_raw.items() if v is not None
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
# Create the AuthplaneClient (handles metadata discovery, JWKS fetching, caching)
|
|
286
|
+
client = await AuthplaneClient.create(
|
|
287
|
+
issuer=issuer,
|
|
288
|
+
auth=as_credentials,
|
|
289
|
+
**client_kwargs,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# Translate ConsentRequiredError → MCP UrlElicitationRequiredError at the
|
|
293
|
+
# client boundary, before user tool code sees it. Tool authors don't need
|
|
294
|
+
# to wrap handlers or import elicitation primitives — the MCP wire-format
|
|
295
|
+
# mapping is owned by the adapter that constructs the client.
|
|
296
|
+
client = _wrap_client_for_elicitation(client)
|
|
297
|
+
|
|
298
|
+
# Create the resource from the client
|
|
299
|
+
verifier = client.resource(
|
|
300
|
+
resource=resource,
|
|
301
|
+
scopes=resolved_scopes,
|
|
302
|
+
revocation_checker=revocation_checker,
|
|
303
|
+
**verifier_kwargs,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Wrap in AuthplaneTokenVerifier
|
|
307
|
+
token_verifier = AuthplaneTokenVerifier(verifier)
|
|
308
|
+
|
|
309
|
+
# Create AuthSettings for FastMCP.
|
|
310
|
+
#
|
|
311
|
+
# The MCP SDK's AuthSettings has no separate "supported" field — it uses
|
|
312
|
+
# ``required_scopes`` for both PRM ``scopes_supported`` advertisement
|
|
313
|
+
# AND RequireAuthMiddleware enforcement. See the docstring on
|
|
314
|
+
# ``enforce_scopes_on_all_requests`` above for the trade-off and why
|
|
315
|
+
# this flag exists. Per-tool ``require_scope()`` is the intended
|
|
316
|
+
# granular pattern in either mode.
|
|
317
|
+
auth_settings = AuthSettings(
|
|
318
|
+
issuer_url=AnyHttpUrl(issuer),
|
|
319
|
+
resource_server_url=AnyHttpUrl(resource),
|
|
320
|
+
required_scopes=resolved_scopes if enforce_scopes_on_all_requests else None,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
return AuthplaneAuthResult(
|
|
324
|
+
token_verifier=token_verifier,
|
|
325
|
+
auth=auth_settings,
|
|
326
|
+
client=client,
|
|
327
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""URL elicitation primitive for the MCP adapter.
|
|
2
|
+
|
|
3
|
+
The factory function :func:`authplane_mcp.authplane_mcp_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,71 @@
|
|
|
1
|
+
"""AuthplaneTokenVerifier - MCP SDK TokenVerifier backed by AuthplaneResource.
|
|
2
|
+
|
|
3
|
+
Bridges the Authplane core SDK with the official MCP Python SDK's
|
|
4
|
+
``TokenVerifier`` interface, delegating all JWT validation to
|
|
5
|
+
``AuthplaneResource`` and mapping results to MCP's ``AccessToken``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from authplane import AuthplaneError, AuthplaneResource
|
|
9
|
+
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthplaneTokenVerifier(TokenVerifier):
|
|
13
|
+
"""MCP SDK TokenVerifier backed by AuthplaneResource.
|
|
14
|
+
|
|
15
|
+
Validates JWTs once per request via the core Authplane SDK and returns
|
|
16
|
+
an MCP ``AccessToken`` populated with standard OAuth 2.1 claims
|
|
17
|
+
(``client_id``, ``scopes``, ``expires_at``, ``resource``).
|
|
18
|
+
|
|
19
|
+
All security-critical logic (signature verification, claim validation,
|
|
20
|
+
JWKS caching, SSRF protection) is handled by the core SDK. This class
|
|
21
|
+
is a thin adapter that maps between the two interfaces.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, verifier: AuthplaneResource) -> None:
|
|
25
|
+
"""Initialize the token verifier.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
verifier: A fully initialized ``AuthplaneResource`` instance,
|
|
29
|
+
typically created via ``AuthplaneClient.create()`` and
|
|
30
|
+
``client.resource()``.
|
|
31
|
+
"""
|
|
32
|
+
self._verifier = verifier
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def verifier(self) -> AuthplaneResource:
|
|
36
|
+
"""The underlying ``AuthplaneResource`` instance."""
|
|
37
|
+
return self._verifier
|
|
38
|
+
|
|
39
|
+
async def verify_token(self, token: str) -> AccessToken | None:
|
|
40
|
+
"""Validate a JWT and return an MCP AccessToken.
|
|
41
|
+
|
|
42
|
+
Called by the MCP server once per authenticated request. Delegates
|
|
43
|
+
to ``AuthplaneResource.verify()`` for all validation, then maps the
|
|
44
|
+
resulting ``VerifiedClaims`` to an MCP ``AccessToken``.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
token: The raw JWT string (the server strips ``'Bearer '``
|
|
48
|
+
before calling this method).
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
``AccessToken`` on successful validation with ``token``,
|
|
52
|
+
``client_id``, ``scopes``, ``expires_at``, and ``resource``
|
|
53
|
+
fields populated. Returns ``None`` on any validation failure
|
|
54
|
+
(the MCP server responds with 401).
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
claims = await self._verifier.verify(token)
|
|
58
|
+
except AuthplaneError:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
# AccessToken.resource must be a string. Since audience is a list,
|
|
62
|
+
# take the first one (standard JWT behavior when multiple audiences are present).
|
|
63
|
+
resource = claims.audience[0]
|
|
64
|
+
|
|
65
|
+
return AccessToken(
|
|
66
|
+
token=token,
|
|
67
|
+
client_id=claims.client_id,
|
|
68
|
+
scopes=list(claims.scopes),
|
|
69
|
+
expires_at=claims.expires_at,
|
|
70
|
+
resource=str(resource),
|
|
71
|
+
)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Calculator Service Example
|
|
2
|
+
|
|
3
|
+
A minimal MCP server demonstrating Authplane JWT authentication with per-tool scope enforcement and RFC 8693 token exchange surfaced via MCP URL elicitation.
|
|
4
|
+
|
|
5
|
+
The server exposes three tools:
|
|
6
|
+
|
|
7
|
+
| Tool | Per-tool scope |
|
|
8
|
+
|------|---------------|
|
|
9
|
+
| `add` | `tools/add` |
|
|
10
|
+
| `multiply` | `tools/multiply` |
|
|
11
|
+
| `consent_demo` | `tools/consent_demo` |
|
|
12
|
+
|
|
13
|
+
`consent_demo` calls `client.exchange(...)` to swap the inbound user token for a Google Calendar token. The local authserver responds with `consent_required` plus a `consent_url`; the wrapped client translates that into `UrlElicitationRequiredError` (JSON-RPC `-32042`), and the MCP client surfaces the URL elicitation prompt to the user.
|
|
14
|
+
|
|
15
|
+
## Scope advertisement and consent prompt
|
|
16
|
+
|
|
17
|
+
This demo passes `enforce_scopes_on_all_requests=True` to `authplane_mcp_auth`. With that flag set, the three demo scopes are listed in the Protected Resource Metadata's `scopes_supported` and your MCP client requests all of them when minting a token — so the **OAuth consent prompt asks you to grant all three scopes at once**. Approve all of them; declining any one will cause the AS to reject the token.
|
|
18
|
+
|
|
19
|
+
This is a workaround for an MCP reference SDK limitation: `AuthSettings` has no separate "supported" field, so the SDK uses `required_scopes` for both PRM advertisement and request-layer enforcement. The flag is opt-in; production deployments that don't need OAuth-discovery clients to know the scope list can leave it off and rely solely on per-tool `require_scope()`.
|
|
20
|
+
|
|
21
|
+
The per-tool `require_scope()` calls remain in the demo on purpose — they are the granular enforcement pattern, become no-ops under request-level enforcement, and stay correct once the upstream SDK gains a separate "supported" field and the flag goes away.
|
|
22
|
+
|
|
23
|
+
## Prerequisites
|
|
24
|
+
|
|
25
|
+
- Python 3.11+
|
|
26
|
+
- The **Authplane authserver** running locally — from a checkout of the `authserver` repo, run:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
bash demo/mcp-demo-server-start.sh
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
This starts the auth server on `http://localhost:9000`, registers the calculator client and scopes, and creates a demo user.
|
|
33
|
+
|
|
34
|
+
## Run
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
cd authplane-mcp
|
|
38
|
+
./demo/run.sh
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`run.sh` creates a virtualenv, installs dependencies, and starts the server on port `8080`. All demo credentials are pre-configured — no additional setup needed.
|
|
42
|
+
|
|
43
|
+
## How it works
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
MCP Client ──Bearer JWT──► mcpserver.py (port 8080)
|
|
47
|
+
│
|
|
48
|
+
├─ authplane_mcp_auth()
|
|
49
|
+
│ • Discovers JWKS from ISSUER_URL
|
|
50
|
+
│ • Validates JWT signature, aud, exp
|
|
51
|
+
│ • Introspects token (revocation check)
|
|
52
|
+
│
|
|
53
|
+
└─ require_scope("tools/add")
|
|
54
|
+
• Reads token from request context
|
|
55
|
+
• Raises PermissionError if scope missing
|
|
56
|
+
→ MCP returns isError=true to client
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Key patterns shown
|
|
60
|
+
|
|
61
|
+
**`authplane_mcp_auth()`** — wires up the verifier and auth settings in one call. By default the `scopes` list is **not** propagated to the PRM — see "Scope advertisement and consent prompt" above for why this demo opts in via `enforce_scopes_on_all_requests=True` and what that means.
|
|
62
|
+
|
|
63
|
+
**`require_scope(scope)`** — call at the top of any tool handler to enforce per-tool scope. If the token is missing the scope the tool returns an error result (`isError: true`) to the MCP client.
|
|
64
|
+
|
|
65
|
+
**`client.exchange(...)`** — performs RFC 8693 token exchange. If the AS responds with `consent_required` and a `consent_url`, the wrapped client raises `UrlElicitationRequiredError` (JSON-RPC `-32042`) instead of `ConsentRequiredError`, and the MCP client surfaces it as a URL elicitation prompt — no try/except needed in the tool handler. See `consent_demo` in `mcpserver.py`.
|