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.
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ .mypy_cache/
6
+ .coverage
7
+ htmlcov/
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/authplane-mcp?style=flat-square&label=authplane-mcp)](https://pypi.org/project/authplane-mcp/)
27
+ [![License](https://img.shields.io/badge/License-Apache_2.0-blue?style=flat-square)](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
+ [![PyPI](https://img.shields.io/pypi/v/authplane-mcp?style=flat-square&label=authplane-mcp)](https://pypi.org/project/authplane-mcp/)
4
+ [![License](https://img.shields.io/badge/License-Apache_2.0-blue?style=flat-square)](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`.