Skip to content

Commit ca0c774

Browse files
committed
Use token introspection instead of hardcoded token validation
Replace the hardcoded token prefix validation with OAuth 2.0 Token Introspection (RFC 7662). The server now: - Discovers the introspection endpoint from AS metadata - Calls the introspection endpoint to validate each token - Extracts client_id, scopes, and expiry from the response This properly integrates with the authorization server rather than relying on hardcoded token patterns.
1 parent a5c7e7c commit ca0c774

File tree

4 files changed

+71
-16
lines changed

4 files changed

+71
-16
lines changed

examples/servers/conformance-auth-server/README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,9 @@ npx @modelcontextprotocol/conformance server --suite auth \
4545

4646
## Token Validation
4747

48-
The server accepts Bearer tokens that start with:
49-
- `test-token` - Standard test tokens
50-
- `cc-token` - Client credentials tokens
48+
The server validates Bearer tokens using OAuth 2.0 Token Introspection (RFC 7662).
49+
It discovers the introspection endpoint from the authorization server's metadata
50+
and calls it to validate each token.
5151

52-
These are the token formats issued by the conformance test framework's fake authorization server.
52+
This approach ensures the server properly integrates with the authorization server
53+
rather than relying on hardcoded token patterns.

examples/servers/conformance-auth-server/mcp_conformance_auth_server/server.py

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import sys
1818

1919
import click
20+
import httpx
2021
from mcp.server.auth.provider import AccessToken, TokenVerifier
2122
from mcp.server.auth.settings import AuthSettings
2223
from mcp.server.fastmcp import FastMCP
@@ -25,24 +26,75 @@
2526
logger = logging.getLogger(__name__)
2627

2728

28-
class ConformanceTokenVerifier(TokenVerifier):
29+
class IntrospectionTokenVerifier(TokenVerifier):
2930
"""
30-
Token verifier for conformance testing.
31+
Token verifier that uses OAuth 2.0 Token Introspection (RFC 7662).
3132
32-
Validates Bearer tokens that start with 'test-token' or 'cc-token'
33-
(as issued by the fake auth server).
33+
Validates Bearer tokens by calling the authorization server's
34+
introspection endpoint.
3435
"""
3536

37+
def __init__(self, auth_server_url: str):
38+
self._auth_server_url = auth_server_url.rstrip("/")
39+
self._introspection_endpoint: str | None = None
40+
self._http_client = httpx.AsyncClient()
41+
42+
async def _get_introspection_endpoint(self) -> str:
43+
"""Discover the introspection endpoint from AS metadata."""
44+
if self._introspection_endpoint is not None:
45+
return self._introspection_endpoint
46+
47+
# Fetch AS metadata
48+
metadata_url = f"{self._auth_server_url}/.well-known/oauth-authorization-server"
49+
logger.debug(f"Fetching AS metadata from {metadata_url}")
50+
51+
response = await self._http_client.get(metadata_url)
52+
response.raise_for_status()
53+
metadata = response.json()
54+
55+
introspection_endpoint = metadata.get("introspection_endpoint")
56+
if not introspection_endpoint:
57+
raise ValueError("Authorization server does not advertise introspection_endpoint")
58+
59+
self._introspection_endpoint = introspection_endpoint
60+
logger.debug(f"Discovered introspection endpoint: {introspection_endpoint}")
61+
return introspection_endpoint
62+
3663
async def verify_token(self, token: str) -> AccessToken | None:
37-
"""Verify a bearer token and return access info if valid."""
38-
# Accept tokens that start with 'test-token' or 'cc-token'
39-
if token.startswith("test-token") or token.startswith("cc-token"):
64+
"""Verify a bearer token using introspection and return access info if valid."""
65+
try:
66+
introspection_endpoint = await self._get_introspection_endpoint()
67+
68+
# Call introspection endpoint (RFC 7662)
69+
response = await self._http_client.post(
70+
introspection_endpoint,
71+
data={"token": token},
72+
headers={"Content-Type": "application/x-www-form-urlencoded"},
73+
)
74+
response.raise_for_status()
75+
result = response.json()
76+
77+
# Check if token is active
78+
if not result.get("active", False):
79+
logger.debug("Token introspection returned active=false")
80+
return None
81+
82+
# Extract token info from introspection response
83+
client_id: str = result.get("client_id", "unknown")
84+
scope_str: str = result.get("scope", "")
85+
scopes: list[str] = scope_str.split() if scope_str else []
86+
expires_at: int | None = result.get("exp")
87+
88+
logger.debug(f"Token verified for client {client_id} with scopes {scopes}")
4089
return AccessToken(
4190
token=token,
42-
client_id="conformance-test-client",
43-
scopes=["mcp:read", "mcp:write"],
91+
client_id=client_id,
92+
scopes=scopes,
93+
expires_at=expires_at,
4494
)
45-
return None
95+
except Exception:
96+
logger.exception("Token introspection failed")
97+
return None
4698

4799

48100
def create_server(auth_server_url: str, port: int) -> FastMCP:
@@ -51,7 +103,7 @@ def create_server(auth_server_url: str, port: int) -> FastMCP:
51103

52104
mcp = FastMCP(
53105
name="mcp-auth-test-server",
54-
token_verifier=ConformanceTokenVerifier(),
106+
token_verifier=IntrospectionTokenVerifier(auth_server_url),
55107
auth=AuthSettings(
56108
issuer_url=AnyHttpUrl(auth_server_url),
57109
resource_server_url=AnyHttpUrl(base_url),

examples/servers/conformance-auth-server/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ requires-python = ">=3.10"
77
authors = [{ name = "Anthropic, PBC." }]
88
keywords = ["mcp", "llm", "oauth", "conformance", "testing"]
99
license = { text = "MIT" }
10-
dependencies = ["click>=8.2.0", "mcp", "starlette", "uvicorn"]
10+
dependencies = ["click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"]
1111

1212
[project.scripts]
1313
mcp-conformance-auth-server = "mcp_conformance_auth_server.server:main"

uv.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)