fastmcp 2.12.5__py3-none-any.whl → 2.13.2__py3-none-any.whl

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.
Files changed (108) hide show
  1. fastmcp/__init__.py +2 -2
  2. fastmcp/cli/cli.py +11 -11
  3. fastmcp/cli/install/claude_code.py +6 -6
  4. fastmcp/cli/install/claude_desktop.py +3 -3
  5. fastmcp/cli/install/cursor.py +18 -12
  6. fastmcp/cli/install/gemini_cli.py +3 -3
  7. fastmcp/cli/install/mcp_json.py +3 -3
  8. fastmcp/cli/run.py +13 -8
  9. fastmcp/client/__init__.py +9 -9
  10. fastmcp/client/auth/oauth.py +115 -217
  11. fastmcp/client/client.py +105 -39
  12. fastmcp/client/logging.py +18 -14
  13. fastmcp/client/oauth_callback.py +85 -171
  14. fastmcp/client/sampling.py +1 -1
  15. fastmcp/client/transports.py +80 -25
  16. fastmcp/contrib/component_manager/__init__.py +1 -1
  17. fastmcp/contrib/component_manager/component_manager.py +2 -2
  18. fastmcp/contrib/component_manager/component_service.py +6 -6
  19. fastmcp/contrib/mcp_mixin/README.md +32 -1
  20. fastmcp/contrib/mcp_mixin/__init__.py +2 -2
  21. fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -2
  22. fastmcp/experimental/sampling/handlers/openai.py +2 -2
  23. fastmcp/experimental/server/openapi/__init__.py +5 -8
  24. fastmcp/experimental/server/openapi/components.py +11 -7
  25. fastmcp/experimental/server/openapi/routing.py +2 -2
  26. fastmcp/experimental/utilities/openapi/__init__.py +10 -15
  27. fastmcp/experimental/utilities/openapi/director.py +14 -15
  28. fastmcp/experimental/utilities/openapi/json_schema_converter.py +6 -2
  29. fastmcp/experimental/utilities/openapi/models.py +3 -3
  30. fastmcp/experimental/utilities/openapi/parser.py +37 -16
  31. fastmcp/experimental/utilities/openapi/schemas.py +2 -2
  32. fastmcp/mcp_config.py +3 -4
  33. fastmcp/prompts/__init__.py +1 -1
  34. fastmcp/prompts/prompt.py +22 -19
  35. fastmcp/prompts/prompt_manager.py +16 -101
  36. fastmcp/resources/__init__.py +5 -5
  37. fastmcp/resources/resource.py +14 -9
  38. fastmcp/resources/resource_manager.py +9 -168
  39. fastmcp/resources/template.py +107 -17
  40. fastmcp/resources/types.py +30 -24
  41. fastmcp/server/__init__.py +1 -1
  42. fastmcp/server/auth/__init__.py +9 -5
  43. fastmcp/server/auth/auth.py +70 -43
  44. fastmcp/server/auth/handlers/authorize.py +326 -0
  45. fastmcp/server/auth/jwt_issuer.py +236 -0
  46. fastmcp/server/auth/middleware.py +96 -0
  47. fastmcp/server/auth/oauth_proxy.py +1510 -289
  48. fastmcp/server/auth/oidc_proxy.py +84 -20
  49. fastmcp/server/auth/providers/auth0.py +40 -21
  50. fastmcp/server/auth/providers/aws.py +29 -3
  51. fastmcp/server/auth/providers/azure.py +312 -131
  52. fastmcp/server/auth/providers/bearer.py +1 -1
  53. fastmcp/server/auth/providers/debug.py +114 -0
  54. fastmcp/server/auth/providers/descope.py +86 -29
  55. fastmcp/server/auth/providers/discord.py +308 -0
  56. fastmcp/server/auth/providers/github.py +29 -8
  57. fastmcp/server/auth/providers/google.py +48 -9
  58. fastmcp/server/auth/providers/in_memory.py +27 -3
  59. fastmcp/server/auth/providers/introspection.py +281 -0
  60. fastmcp/server/auth/providers/jwt.py +48 -31
  61. fastmcp/server/auth/providers/oci.py +233 -0
  62. fastmcp/server/auth/providers/scalekit.py +238 -0
  63. fastmcp/server/auth/providers/supabase.py +188 -0
  64. fastmcp/server/auth/providers/workos.py +35 -17
  65. fastmcp/server/context.py +177 -51
  66. fastmcp/server/dependencies.py +39 -12
  67. fastmcp/server/elicitation.py +1 -1
  68. fastmcp/server/http.py +56 -17
  69. fastmcp/server/low_level.py +121 -2
  70. fastmcp/server/middleware/__init__.py +1 -1
  71. fastmcp/server/middleware/caching.py +476 -0
  72. fastmcp/server/middleware/error_handling.py +14 -10
  73. fastmcp/server/middleware/logging.py +50 -39
  74. fastmcp/server/middleware/middleware.py +29 -16
  75. fastmcp/server/middleware/rate_limiting.py +3 -3
  76. fastmcp/server/middleware/tool_injection.py +116 -0
  77. fastmcp/server/openapi.py +10 -6
  78. fastmcp/server/proxy.py +22 -11
  79. fastmcp/server/server.py +725 -242
  80. fastmcp/settings.py +24 -10
  81. fastmcp/tools/__init__.py +1 -1
  82. fastmcp/tools/tool.py +70 -23
  83. fastmcp/tools/tool_manager.py +30 -112
  84. fastmcp/tools/tool_transform.py +12 -10
  85. fastmcp/utilities/cli.py +67 -28
  86. fastmcp/utilities/components.py +7 -2
  87. fastmcp/utilities/inspect.py +79 -23
  88. fastmcp/utilities/json_schema.py +4 -4
  89. fastmcp/utilities/json_schema_type.py +4 -4
  90. fastmcp/utilities/logging.py +118 -8
  91. fastmcp/utilities/mcp_server_config/__init__.py +3 -3
  92. fastmcp/utilities/mcp_server_config/v1/environments/base.py +1 -2
  93. fastmcp/utilities/mcp_server_config/v1/environments/uv.py +6 -6
  94. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +4 -4
  95. fastmcp/utilities/mcp_server_config/v1/schema.json +3 -0
  96. fastmcp/utilities/mcp_server_config/v1/sources/base.py +0 -1
  97. fastmcp/utilities/openapi.py +11 -11
  98. fastmcp/utilities/tests.py +85 -4
  99. fastmcp/utilities/types.py +78 -16
  100. fastmcp/utilities/ui.py +626 -0
  101. {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/METADATA +22 -14
  102. fastmcp-2.13.2.dist-info/RECORD +144 -0
  103. {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/WHEEL +1 -1
  104. fastmcp/cli/claude.py +0 -135
  105. fastmcp/utilities/storage.py +0 -204
  106. fastmcp-2.12.5.dist-info/RECORD +0 -134
  107. {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/entry_points.txt +0 -0
  108. {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/licenses/LICENSE +0 -0
@@ -6,113 +6,62 @@ using the OAuth Proxy pattern for non-DCR OAuth flows.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- import httpx
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from key_value.aio.protocols import AsyncKeyValue
10
12
  from pydantic import SecretStr, field_validator
11
13
  from pydantic_settings import BaseSettings, SettingsConfigDict
12
14
 
13
- from fastmcp.server.auth import AccessToken, TokenVerifier
14
15
  from fastmcp.server.auth.oauth_proxy import OAuthProxy
16
+ from fastmcp.server.auth.providers.jwt import JWTVerifier
17
+ from fastmcp.settings import ENV_FILE
15
18
  from fastmcp.utilities.auth import parse_scopes
16
19
  from fastmcp.utilities.logging import get_logger
17
- from fastmcp.utilities.storage import KVStorage
18
20
  from fastmcp.utilities.types import NotSet, NotSetT
19
21
 
22
+ if TYPE_CHECKING:
23
+ from mcp.server.auth.provider import AuthorizationParams
24
+ from mcp.shared.auth import OAuthClientInformationFull
25
+
20
26
  logger = get_logger(__name__)
21
27
 
28
+ # Standard OIDC scopes that should never be prefixed with identifier_uri.
29
+ # Per Microsoft docs: https://learn.microsoft.com/en-us/entra/identity-platform/scopes-oidc
30
+ # "OIDC scopes are requested as simple string identifiers without resource prefixes"
31
+ OIDC_SCOPES = frozenset({"openid", "profile", "email", "offline_access"})
32
+
22
33
 
23
34
  class AzureProviderSettings(BaseSettings):
24
35
  """Settings for Azure OAuth provider."""
25
36
 
26
37
  model_config = SettingsConfigDict(
27
38
  env_prefix="FASTMCP_SERVER_AUTH_AZURE_",
28
- env_file=".env",
39
+ env_file=ENV_FILE,
29
40
  extra="ignore",
30
41
  )
31
42
 
32
43
  client_id: str | None = None
33
44
  client_secret: SecretStr | None = None
34
45
  tenant_id: str | None = None
46
+ identifier_uri: str | None = None
35
47
  base_url: str | None = None
48
+ issuer_url: str | None = None
36
49
  redirect_path: str | None = None
37
50
  required_scopes: list[str] | None = None
38
- timeout_seconds: int | None = None
51
+ additional_authorize_scopes: list[str] | None = None
39
52
  allowed_client_redirect_uris: list[str] | None = None
53
+ jwt_signing_key: str | None = None
54
+ base_authority: str = "login.microsoftonline.com"
40
55
 
41
56
  @field_validator("required_scopes", mode="before")
42
57
  @classmethod
43
- def _parse_scopes(cls, v):
58
+ def _parse_scopes(cls, v: object) -> list[str] | None:
44
59
  return parse_scopes(v)
45
60
 
46
-
47
- class AzureTokenVerifier(TokenVerifier):
48
- """Token verifier for Azure OAuth tokens.
49
-
50
- Azure tokens are JWTs, but we verify them by calling the Microsoft Graph API
51
- to get user information and validate the token.
52
- """
53
-
54
- def __init__(
55
- self,
56
- *,
57
- required_scopes: list[str] | None = None,
58
- timeout_seconds: int = 10,
59
- ):
60
- """Initialize the Azure token verifier.
61
-
62
- Args:
63
- required_scopes: Required OAuth scopes
64
- timeout_seconds: HTTP request timeout
65
- """
66
- super().__init__(required_scopes=required_scopes)
67
- self.timeout_seconds = timeout_seconds
68
-
69
- async def verify_token(self, token: str) -> AccessToken | None:
70
- """Verify Azure OAuth token by calling Microsoft Graph API."""
71
- try:
72
- async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
73
- # Use Microsoft Graph API to validate token and get user info
74
- response = await client.get(
75
- "https://graph.microsoft.com/v1.0/me",
76
- headers={
77
- "Authorization": f"Bearer {token}",
78
- "User-Agent": "FastMCP-Azure-OAuth",
79
- },
80
- )
81
-
82
- if response.status_code != 200:
83
- logger.debug(
84
- "Azure token verification failed: %d - %s",
85
- response.status_code,
86
- response.text[:200],
87
- )
88
- return None
89
-
90
- user_data = response.json()
91
-
92
- # Create AccessToken with Azure user info
93
- return AccessToken(
94
- token=token,
95
- client_id=str(user_data.get("id", "unknown")),
96
- scopes=self.required_scopes or [],
97
- expires_at=None,
98
- claims={
99
- "sub": user_data.get("id"),
100
- "email": user_data.get("mail")
101
- or user_data.get("userPrincipalName"),
102
- "name": user_data.get("displayName"),
103
- "given_name": user_data.get("givenName"),
104
- "family_name": user_data.get("surname"),
105
- "job_title": user_data.get("jobTitle"),
106
- "office_location": user_data.get("officeLocation"),
107
- },
108
- )
109
-
110
- except httpx.RequestError as e:
111
- logger.debug("Failed to verify Azure token: %s", e)
112
- return None
113
- except Exception as e:
114
- logger.debug("Azure token verification error: %s", e)
115
- return None
61
+ @field_validator("additional_authorize_scopes", mode="before")
62
+ @classmethod
63
+ def _parse_additional_authorize_scopes(cls, v: object) -> list[str] | None:
64
+ return parse_scopes(v)
116
65
 
117
66
 
118
67
  class AzureProvider(OAuthProxy):
@@ -122,28 +71,53 @@ class AzureProvider(OAuthProxy):
122
71
  OAuth Proxy pattern. It supports both organizational accounts and personal
123
72
  Microsoft accounts depending on the tenant configuration.
124
73
 
125
- Features:
126
- - Transparent OAuth proxy to Azure/Microsoft identity platform
127
- - Automatic token validation via Microsoft Graph API
128
- - User information extraction
129
- - Support for different tenant configurations (common, organizations, consumers)
74
+ Scope Handling:
75
+ - required_scopes: Provide unprefixed scope names (e.g., ["read", "write"])
76
+ Automatically prefixed with identifier_uri during initialization
77
+ Validated on all tokens and advertised to MCP clients
78
+ - additional_authorize_scopes: Provide full format (e.g., ["User.Read"])
79
+ → NOT prefixed, NOT validated, NOT advertised to clients
80
+ → Used to request Microsoft Graph or other upstream API permissions
130
81
 
131
- Setup Requirements:
132
- 1. Register an application in Azure Portal (portal.azure.com)
133
- 2. Configure redirect URI as: http://localhost:8000/auth/callback
134
- 3. Note your Application (client) ID and create a client secret
135
- 4. Optionally note your Directory (tenant) ID for single-tenant apps
82
+ Features:
83
+ - OAuth proxy to Azure/Microsoft identity platform
84
+ - JWT validation using tenant issuer and JWKS
85
+ - Supports tenant configurations: specific tenant ID, "organizations", or "consumers"
86
+ - Custom API scopes and Microsoft Graph scopes in a single provider
87
+
88
+ Setup:
89
+ 1. Create an App registration in Azure Portal
90
+ 2. Configure Web platform redirect URI: http://localhost:8000/auth/callback (or your custom path)
91
+ 3. Add an Application ID URI under "Expose an API" (defaults to api://{client_id})
92
+ 4. Add custom scopes (e.g., "read", "write") under "Expose an API"
93
+ 5. Set access token version to 2 in the App manifest: "requestedAccessTokenVersion": 2
94
+ 6. Create a client secret
95
+ 7. Get Application (client) ID, Directory (tenant) ID, and client secret
136
96
 
137
97
  Example:
138
98
  ```python
139
99
  from fastmcp import FastMCP
140
100
  from fastmcp.server.auth.providers.azure import AzureProvider
141
101
 
102
+ # Standard Azure (Public Cloud)
142
103
  auth = AzureProvider(
143
104
  client_id="your-client-id",
144
105
  client_secret="your-client-secret",
145
- tenant_id="your-tenant-id", # Required: your Azure tenant ID from Azure Portal
146
- base_url="http://localhost:8000"
106
+ tenant_id="your-tenant-id",
107
+ required_scopes=["read", "write"], # Unprefixed scope names
108
+ additional_authorize_scopes=["User.Read", "Mail.Read"], # Optional Graph scopes
109
+ base_url="http://localhost:8000",
110
+ # identifier_uri defaults to api://{client_id}
111
+ )
112
+
113
+ # Azure Government
114
+ auth_gov = AzureProvider(
115
+ client_id="your-client-id",
116
+ client_secret="your-client-secret",
117
+ tenant_id="your-tenant-id",
118
+ required_scopes=["read", "write"],
119
+ base_authority="login.microsoftonline.us", # Override for Azure Gov
120
+ base_url="http://localhost:8000",
147
121
  )
148
122
 
149
123
  mcp = FastMCP("My App", auth=auth)
@@ -156,27 +130,60 @@ class AzureProvider(OAuthProxy):
156
130
  client_id: str | NotSetT = NotSet,
157
131
  client_secret: str | NotSetT = NotSet,
158
132
  tenant_id: str | NotSetT = NotSet,
133
+ identifier_uri: str | NotSetT | None = NotSet,
159
134
  base_url: str | NotSetT = NotSet,
135
+ issuer_url: str | NotSetT = NotSet,
160
136
  redirect_path: str | NotSetT = NotSet,
161
- required_scopes: list[str] | None | NotSetT = NotSet,
162
- timeout_seconds: int | NotSetT = NotSet,
137
+ required_scopes: list[str] | NotSetT | None = NotSet,
138
+ additional_authorize_scopes: list[str] | NotSetT | None = NotSet,
163
139
  allowed_client_redirect_uris: list[str] | NotSetT = NotSet,
164
- client_storage: KVStorage | None = None,
165
- ):
140
+ client_storage: AsyncKeyValue | None = None,
141
+ jwt_signing_key: str | bytes | NotSetT = NotSet,
142
+ require_authorization_consent: bool = True,
143
+ base_authority: str | NotSetT = NotSet,
144
+ ) -> None:
166
145
  """Initialize Azure OAuth provider.
167
146
 
168
147
  Args:
169
- client_id: Azure application (client) ID
170
- client_secret: Azure client secret
171
- tenant_id: Azure tenant ID (your specific tenant ID, "organizations", or "consumers")
172
- base_url: Public URL of your FastMCP server (for OAuth callbacks)
173
- redirect_path: Redirect path configured in Azure (defaults to "/auth/callback")
174
- required_scopes: Required scopes (defaults to ["User.Read", "email", "openid", "profile"])
175
- timeout_seconds: HTTP request timeout for Azure API calls
148
+ client_id: Azure application (client) ID from your App registration
149
+ client_secret: Azure client secret from your App registration
150
+ tenant_id: Azure tenant ID (specific tenant GUID, "organizations", or "consumers")
151
+ identifier_uri: Optional Application ID URI for your custom API (defaults to api://{client_id}).
152
+ This URI is automatically prefixed to all required_scopes during initialization.
153
+ Example: identifier_uri="api://my-api" + required_scopes=["read"]
154
+ tokens validated for "api://my-api/read"
155
+ base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
156
+ issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
157
+ to avoid 404s during discovery when mounting under a path.
158
+ redirect_path: Redirect path configured in Azure App registration (defaults to "/auth/callback")
159
+ base_authority: Azure authority base URL (defaults to "login.microsoftonline.com").
160
+ For Azure Government, use "login.microsoftonline.us".
161
+ required_scopes: Custom API scope names WITHOUT prefix (e.g., ["read", "write"]).
162
+ - Automatically prefixed with identifier_uri during initialization
163
+ - Validated on all tokens
164
+ - Advertised in Protected Resource Metadata
165
+ - Must match scope names defined in Azure Portal under "Expose an API"
166
+ Example: ["read", "write"] → validates tokens containing ["api://xxx/read", "api://xxx/write"]
167
+ additional_authorize_scopes: Microsoft Graph or other upstream scopes in full format.
168
+ - NOT prefixed with identifier_uri
169
+ - NOT validated on tokens
170
+ - NOT advertised to MCP clients
171
+ - Used to request additional permissions from Azure (e.g., Graph API access)
172
+ Example: ["User.Read", "Mail.Read", "offline_access"]
173
+ These scopes allow your FastMCP server to call Microsoft Graph APIs using the
174
+ upstream Azure token, but MCP clients are unaware of them.
176
175
  allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
177
176
  If None (default), all URIs are allowed. If empty list, no URIs are allowed.
178
- client_storage: Storage implementation for OAuth client registrations.
179
- Defaults to file-based storage if not specified.
177
+ client_storage: Storage backend for OAuth state (client registrations, encrypted tokens).
178
+ If None, a DiskStore will be created in the data directory (derived from `platformdirs`). The
179
+ disk store will be encrypted using a key derived from the JWT Signing Key.
180
+ jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided,
181
+ they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not
182
+ provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2.
183
+ require_authorization_consent: Whether to require user consent before authorizing clients (default True).
184
+ When True, users see a consent screen before being redirected to Azure.
185
+ When False, authorization proceeds directly without user confirmation.
186
+ SECURITY WARNING: Only disable for local development or testing environments.
180
187
  """
181
188
  settings = AzureProviderSettings.model_validate(
182
189
  {
@@ -185,11 +192,15 @@ class AzureProvider(OAuthProxy):
185
192
  "client_id": client_id,
186
193
  "client_secret": client_secret,
187
194
  "tenant_id": tenant_id,
195
+ "identifier_uri": identifier_uri,
188
196
  "base_url": base_url,
197
+ "issuer_url": issuer_url,
189
198
  "redirect_path": redirect_path,
190
199
  "required_scopes": required_scopes,
191
- "timeout_seconds": timeout_seconds,
200
+ "additional_authorize_scopes": additional_authorize_scopes,
192
201
  "allowed_client_redirect_uris": allowed_client_redirect_uris,
202
+ "jwt_signing_key": jwt_signing_key,
203
+ "base_authority": base_authority,
193
204
  }.items()
194
205
  if v is not NotSet
195
206
  }
@@ -197,51 +208,75 @@ class AzureProvider(OAuthProxy):
197
208
 
198
209
  # Validate required settings
199
210
  if not settings.client_id:
200
- raise ValueError(
201
- "client_id is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_CLIENT_ID"
202
- )
211
+ msg = "client_id is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_CLIENT_ID"
212
+ raise ValueError(msg)
203
213
  if not settings.client_secret:
204
- raise ValueError(
205
- "client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_CLIENT_SECRET"
206
- )
214
+ msg = "client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_CLIENT_SECRET"
215
+ raise ValueError(msg)
207
216
 
208
217
  # Validate tenant_id is provided
209
218
  if not settings.tenant_id:
210
- raise ValueError(
211
- "tenant_id is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_TENANT_ID. "
212
- "Use your Azure tenant ID (found in Azure Portal), 'organizations', or 'consumers'"
219
+ msg = (
220
+ "tenant_id is required - set via parameter or "
221
+ "FASTMCP_SERVER_AUTH_AZURE_TENANT_ID. Use your Azure tenant ID "
222
+ "(found in Azure Portal), 'organizations', or 'consumers'"
213
223
  )
224
+ raise ValueError(msg)
225
+
226
+ # Validate required_scopes has at least one scope
227
+ if not settings.required_scopes:
228
+ msg = (
229
+ "required_scopes must include at least one scope - set via parameter or "
230
+ "FASTMCP_SERVER_AUTH_AZURE_REQUIRED_SCOPES. Azure's OAuth API requires "
231
+ "the 'scope' parameter in authorization requests. Use the unprefixed scope "
232
+ "names from your Azure App registration (e.g., ['read', 'write'])"
233
+ )
234
+ raise ValueError(msg)
214
235
 
215
236
  # Apply defaults
237
+ self.identifier_uri = settings.identifier_uri or f"api://{settings.client_id}"
238
+ self.additional_authorize_scopes = settings.additional_authorize_scopes or []
216
239
  tenant_id_final = settings.tenant_id
217
240
 
218
- timeout_seconds_final = settings.timeout_seconds or 10
219
- # Default scopes for Azure - User.Read gives us access to user info via Graph API
220
- scopes_final = settings.required_scopes or [
221
- "User.Read",
222
- "email",
223
- "openid",
224
- "profile",
225
- ]
226
- allowed_client_redirect_uris_final = settings.allowed_client_redirect_uris
241
+ # Always validate tokens against the app's API client ID using JWT
242
+ base_authority_final = settings.base_authority
243
+ issuer = f"https://{base_authority_final}/{tenant_id_final}/v2.0"
244
+ jwks_uri = (
245
+ f"https://{base_authority_final}/{tenant_id_final}/discovery/v2.0/keys"
246
+ )
247
+
248
+ # Azure access tokens only include custom API scopes in the `scp` claim,
249
+ # NOT standard OIDC scopes (openid, profile, email, offline_access).
250
+ # Filter out OIDC scopes from validation - they'll still be sent to Azure
251
+ # during authorization (handled by _prefix_scopes_for_azure).
252
+ validation_scopes = None
253
+ if settings.required_scopes:
254
+ validation_scopes = [
255
+ s for s in settings.required_scopes if s not in OIDC_SCOPES
256
+ ]
257
+ # If all scopes were OIDC scopes, use None (no scope validation)
258
+ if not validation_scopes:
259
+ validation_scopes = None
260
+
261
+ token_verifier = JWTVerifier(
262
+ jwks_uri=jwks_uri,
263
+ issuer=issuer,
264
+ audience=settings.client_id,
265
+ algorithm="RS256",
266
+ required_scopes=validation_scopes, # Only validate non-OIDC scopes
267
+ )
227
268
 
228
269
  # Extract secret string from SecretStr
229
270
  client_secret_str = (
230
271
  settings.client_secret.get_secret_value() if settings.client_secret else ""
231
272
  )
232
273
 
233
- # Create Azure token verifier
234
- token_verifier = AzureTokenVerifier(
235
- required_scopes=scopes_final,
236
- timeout_seconds=timeout_seconds_final,
237
- )
238
-
239
274
  # Build Azure OAuth endpoints with tenant
240
275
  authorization_endpoint = (
241
- f"https://login.microsoftonline.com/{tenant_id_final}/oauth2/v2.0/authorize"
276
+ f"https://{base_authority_final}/{tenant_id_final}/oauth2/v2.0/authorize"
242
277
  )
243
278
  token_endpoint = (
244
- f"https://login.microsoftonline.com/{tenant_id_final}/oauth2/v2.0/token"
279
+ f"https://{base_authority_final}/{tenant_id_final}/oauth2/v2.0/token"
245
280
  )
246
281
 
247
282
  # Initialize OAuth proxy with Azure endpoints
@@ -253,13 +288,159 @@ class AzureProvider(OAuthProxy):
253
288
  token_verifier=token_verifier,
254
289
  base_url=settings.base_url,
255
290
  redirect_path=settings.redirect_path,
256
- issuer_url=settings.base_url,
257
- allowed_client_redirect_uris=allowed_client_redirect_uris_final,
291
+ issuer_url=settings.issuer_url
292
+ or settings.base_url, # Default to base_url if not specified
293
+ allowed_client_redirect_uris=settings.allowed_client_redirect_uris,
258
294
  client_storage=client_storage,
295
+ jwt_signing_key=settings.jwt_signing_key,
296
+ require_authorization_consent=require_authorization_consent,
297
+ # Advertise full scopes including OIDC (even though we only validate non-OIDC)
298
+ valid_scopes=settings.required_scopes,
259
299
  )
260
300
 
301
+ authority_info = ""
302
+ if base_authority_final != "login.microsoftonline.com":
303
+ authority_info = f" using authority {base_authority_final}"
261
304
  logger.info(
262
- "Initialized Azure OAuth provider for client %s with tenant %s",
305
+ "Initialized Azure OAuth provider for client %s with tenant %s%s%s",
263
306
  settings.client_id,
264
307
  tenant_id_final,
308
+ f" and identifier_uri {self.identifier_uri}" if self.identifier_uri else "",
309
+ authority_info,
265
310
  )
311
+
312
+ async def authorize(
313
+ self,
314
+ client: OAuthClientInformationFull,
315
+ params: AuthorizationParams,
316
+ ) -> str:
317
+ """Start OAuth transaction and redirect to Azure AD.
318
+
319
+ Override parent's authorize method to filter out the 'resource' parameter
320
+ which is not supported by Azure AD v2.0 endpoints. The v2.0 endpoints use
321
+ scopes to determine the resource/audience instead of a separate parameter.
322
+
323
+ Args:
324
+ client: OAuth client information
325
+ params: Authorization parameters from the client
326
+
327
+ Returns:
328
+ Authorization URL to redirect the user to Azure AD
329
+ """
330
+ # Clear the resource parameter that Azure AD v2.0 doesn't support
331
+ # This parameter comes from RFC 8707 (OAuth 2.0 Resource Indicators)
332
+ # but Azure AD v2.0 uses scopes instead to determine the audience
333
+ params_to_use = params
334
+ if hasattr(params, "resource"):
335
+ original_resource = getattr(params, "resource", None)
336
+ if original_resource is not None:
337
+ params_to_use = params.model_copy(update={"resource": None})
338
+ if original_resource:
339
+ logger.debug(
340
+ "Filtering out 'resource' parameter '%s' for Azure AD v2.0 (use scopes instead)",
341
+ original_resource,
342
+ )
343
+ # Don't modify the scopes in params - they stay unprefixed for MCP clients
344
+ # We'll prefix them when building the Azure authorization URL (in _build_upstream_authorize_url)
345
+ auth_url = await super().authorize(client, params_to_use)
346
+ separator = "&" if "?" in auth_url else "?"
347
+ return f"{auth_url}{separator}prompt=select_account"
348
+
349
+ def _prefix_scopes_for_azure(self, scopes: list[str]) -> list[str]:
350
+ """Prefix unprefixed custom API scopes with identifier_uri for Azure.
351
+
352
+ This helper centralizes the scope prefixing logic used in both
353
+ authorization and token refresh flows.
354
+
355
+ Scopes that are NOT prefixed:
356
+ - Standard OIDC scopes (openid, profile, email, offline_access)
357
+ - Fully-qualified URIs (contain "://")
358
+ - Scopes with path component (contain "/")
359
+
360
+ Note: Microsoft Graph scopes (e.g., User.Read) should be passed via
361
+ `additional_authorize_scopes` or use fully-qualified format
362
+ (e.g., https://graph.microsoft.com/User.Read).
363
+
364
+ Args:
365
+ scopes: List of scopes, may be prefixed or unprefixed
366
+
367
+ Returns:
368
+ List of scopes with identifier_uri prefix applied where needed
369
+ """
370
+ prefixed = []
371
+ for scope in scopes:
372
+ if scope in OIDC_SCOPES:
373
+ # Standard OIDC scopes - never prefix
374
+ prefixed.append(scope)
375
+ elif "://" in scope or "/" in scope:
376
+ # Already fully-qualified (e.g., "api://xxx/read" or
377
+ # "https://graph.microsoft.com/User.Read")
378
+ prefixed.append(scope)
379
+ else:
380
+ # Unprefixed custom API scope - prefix with identifier_uri
381
+ prefixed.append(f"{self.identifier_uri}/{scope}")
382
+ return prefixed
383
+
384
+ def _build_upstream_authorize_url(
385
+ self, txn_id: str, transaction: dict[str, Any]
386
+ ) -> str:
387
+ """Build Azure authorization URL with prefixed scopes.
388
+
389
+ Overrides parent to prefix scopes with identifier_uri before sending to Azure,
390
+ while keeping unprefixed scopes in the transaction for MCP clients.
391
+ """
392
+ # Get unprefixed scopes from transaction
393
+ unprefixed_scopes = transaction.get("scopes") or self.required_scopes or []
394
+
395
+ # Prefix scopes for Azure authorization request
396
+ prefixed_scopes = self._prefix_scopes_for_azure(unprefixed_scopes)
397
+
398
+ # Add Microsoft Graph scopes (not validated, not prefixed)
399
+ if self.additional_authorize_scopes:
400
+ prefixed_scopes.extend(self.additional_authorize_scopes)
401
+
402
+ # Temporarily modify transaction dict for parent's URL building
403
+ modified_transaction = transaction.copy()
404
+ modified_transaction["scopes"] = prefixed_scopes
405
+
406
+ # Let parent build the URL with prefixed scopes
407
+ return super()._build_upstream_authorize_url(txn_id, modified_transaction)
408
+
409
+ def _prepare_scopes_for_upstream_refresh(self, scopes: list[str]) -> list[str]:
410
+ """Prepare scopes for Azure token refresh.
411
+
412
+ Azure requires:
413
+ 1. Fully-qualified custom scopes (e.g., "api://xxx/read" not "read")
414
+ 2. Microsoft Graph scopes (e.g., "User.Read", "openid") sent as-is
415
+ 3. Additional scopes from provider config (additional_authorize_scopes)
416
+
417
+ This method transforms base client scopes for Azure while keeping them
418
+ unprefixed in storage to prevent accumulation.
419
+
420
+ Args:
421
+ scopes: Base scopes from RefreshToken (unprefixed, e.g., ["read"])
422
+
423
+ Returns:
424
+ Deduplicated list of scopes formatted for Azure token endpoint
425
+ """
426
+ logger.debug("Base scopes from storage: %s", scopes)
427
+
428
+ # Filter out any additional_authorize_scopes that may have been stored
429
+ # (they shouldn't be in storage, but clean them up if they are)
430
+ additional_scopes_set = set(self.additional_authorize_scopes or [])
431
+ base_scopes = [s for s in scopes if s not in additional_scopes_set]
432
+
433
+ # Prefix base scopes with identifier_uri for Azure using shared helper
434
+ prefixed_scopes = self._prefix_scopes_for_azure(base_scopes)
435
+
436
+ # Add additional scopes (Graph + OIDC) for the Azure request
437
+ # These are NOT stored in RefreshToken, only sent to Azure
438
+ if self.additional_authorize_scopes:
439
+ prefixed_scopes.extend(self.additional_authorize_scopes)
440
+
441
+ # Deduplicate while preserving order (in case older tokens have duplicates)
442
+ # Use dict.fromkeys() for O(n) deduplication with order preservation
443
+ deduplicated_scopes = list(dict.fromkeys(prefixed_scopes))
444
+
445
+ logger.debug("Scopes for Azure token endpoint: %s", deduplicated_scopes)
446
+ return deduplicated_scopes
@@ -11,7 +11,7 @@ from fastmcp.server.auth.providers.jwt import JWKData, JWKSData, RSAKeyPair
11
11
  from fastmcp.server.auth.providers.jwt import JWTVerifier as BearerAuthProvider
12
12
 
13
13
  # Re-export for backwards compatibility
14
- __all__ = ["BearerAuthProvider", "RSAKeyPair", "JWKData", "JWKSData"]
14
+ __all__ = ["BearerAuthProvider", "JWKData", "JWKSData", "RSAKeyPair"]
15
15
 
16
16
  # Deprecated in 2.11
17
17
  if fastmcp.settings.deprecation_warnings: