fastmcp 2.12.1__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 (109) hide show
  1. fastmcp/__init__.py +2 -2
  2. fastmcp/cli/cli.py +56 -36
  3. fastmcp/cli/install/__init__.py +2 -0
  4. fastmcp/cli/install/claude_code.py +7 -16
  5. fastmcp/cli/install/claude_desktop.py +4 -12
  6. fastmcp/cli/install/cursor.py +20 -30
  7. fastmcp/cli/install/gemini_cli.py +241 -0
  8. fastmcp/cli/install/mcp_json.py +4 -12
  9. fastmcp/cli/run.py +15 -94
  10. fastmcp/client/__init__.py +9 -9
  11. fastmcp/client/auth/oauth.py +117 -206
  12. fastmcp/client/client.py +123 -47
  13. fastmcp/client/elicitation.py +6 -1
  14. fastmcp/client/logging.py +18 -14
  15. fastmcp/client/oauth_callback.py +85 -171
  16. fastmcp/client/sampling.py +1 -1
  17. fastmcp/client/transports.py +81 -26
  18. fastmcp/contrib/component_manager/__init__.py +1 -1
  19. fastmcp/contrib/component_manager/component_manager.py +2 -2
  20. fastmcp/contrib/component_manager/component_service.py +7 -7
  21. fastmcp/contrib/mcp_mixin/README.md +35 -4
  22. fastmcp/contrib/mcp_mixin/__init__.py +2 -2
  23. fastmcp/contrib/mcp_mixin/mcp_mixin.py +54 -7
  24. fastmcp/experimental/sampling/handlers/openai.py +2 -2
  25. fastmcp/experimental/server/openapi/__init__.py +5 -8
  26. fastmcp/experimental/server/openapi/components.py +11 -7
  27. fastmcp/experimental/server/openapi/routing.py +2 -2
  28. fastmcp/experimental/utilities/openapi/__init__.py +10 -15
  29. fastmcp/experimental/utilities/openapi/director.py +16 -10
  30. fastmcp/experimental/utilities/openapi/json_schema_converter.py +6 -2
  31. fastmcp/experimental/utilities/openapi/models.py +3 -3
  32. fastmcp/experimental/utilities/openapi/parser.py +37 -16
  33. fastmcp/experimental/utilities/openapi/schemas.py +33 -7
  34. fastmcp/mcp_config.py +3 -4
  35. fastmcp/prompts/__init__.py +1 -1
  36. fastmcp/prompts/prompt.py +32 -27
  37. fastmcp/prompts/prompt_manager.py +16 -101
  38. fastmcp/resources/__init__.py +5 -5
  39. fastmcp/resources/resource.py +28 -20
  40. fastmcp/resources/resource_manager.py +9 -168
  41. fastmcp/resources/template.py +119 -27
  42. fastmcp/resources/types.py +30 -24
  43. fastmcp/server/__init__.py +1 -1
  44. fastmcp/server/auth/__init__.py +9 -5
  45. fastmcp/server/auth/auth.py +80 -47
  46. fastmcp/server/auth/handlers/authorize.py +326 -0
  47. fastmcp/server/auth/jwt_issuer.py +236 -0
  48. fastmcp/server/auth/middleware.py +96 -0
  49. fastmcp/server/auth/oauth_proxy.py +1556 -265
  50. fastmcp/server/auth/oidc_proxy.py +412 -0
  51. fastmcp/server/auth/providers/auth0.py +193 -0
  52. fastmcp/server/auth/providers/aws.py +263 -0
  53. fastmcp/server/auth/providers/azure.py +314 -129
  54. fastmcp/server/auth/providers/bearer.py +1 -1
  55. fastmcp/server/auth/providers/debug.py +114 -0
  56. fastmcp/server/auth/providers/descope.py +229 -0
  57. fastmcp/server/auth/providers/discord.py +308 -0
  58. fastmcp/server/auth/providers/github.py +31 -6
  59. fastmcp/server/auth/providers/google.py +50 -7
  60. fastmcp/server/auth/providers/in_memory.py +27 -3
  61. fastmcp/server/auth/providers/introspection.py +281 -0
  62. fastmcp/server/auth/providers/jwt.py +48 -31
  63. fastmcp/server/auth/providers/oci.py +233 -0
  64. fastmcp/server/auth/providers/scalekit.py +238 -0
  65. fastmcp/server/auth/providers/supabase.py +188 -0
  66. fastmcp/server/auth/providers/workos.py +37 -15
  67. fastmcp/server/context.py +194 -67
  68. fastmcp/server/dependencies.py +56 -16
  69. fastmcp/server/elicitation.py +1 -1
  70. fastmcp/server/http.py +57 -18
  71. fastmcp/server/low_level.py +121 -2
  72. fastmcp/server/middleware/__init__.py +1 -1
  73. fastmcp/server/middleware/caching.py +476 -0
  74. fastmcp/server/middleware/error_handling.py +14 -10
  75. fastmcp/server/middleware/logging.py +158 -116
  76. fastmcp/server/middleware/middleware.py +30 -16
  77. fastmcp/server/middleware/rate_limiting.py +3 -3
  78. fastmcp/server/middleware/tool_injection.py +116 -0
  79. fastmcp/server/openapi.py +15 -7
  80. fastmcp/server/proxy.py +22 -11
  81. fastmcp/server/server.py +744 -254
  82. fastmcp/settings.py +65 -15
  83. fastmcp/tools/__init__.py +1 -1
  84. fastmcp/tools/tool.py +173 -108
  85. fastmcp/tools/tool_manager.py +30 -112
  86. fastmcp/tools/tool_transform.py +13 -11
  87. fastmcp/utilities/cli.py +67 -28
  88. fastmcp/utilities/components.py +7 -2
  89. fastmcp/utilities/inspect.py +79 -23
  90. fastmcp/utilities/json_schema.py +21 -4
  91. fastmcp/utilities/json_schema_type.py +4 -4
  92. fastmcp/utilities/logging.py +182 -10
  93. fastmcp/utilities/mcp_server_config/__init__.py +3 -3
  94. fastmcp/utilities/mcp_server_config/v1/environments/base.py +1 -2
  95. fastmcp/utilities/mcp_server_config/v1/environments/uv.py +10 -45
  96. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +8 -7
  97. fastmcp/utilities/mcp_server_config/v1/schema.json +5 -1
  98. fastmcp/utilities/mcp_server_config/v1/sources/base.py +0 -1
  99. fastmcp/utilities/openapi.py +11 -11
  100. fastmcp/utilities/tests.py +93 -10
  101. fastmcp/utilities/types.py +87 -21
  102. fastmcp/utilities/ui.py +626 -0
  103. {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/METADATA +141 -60
  104. fastmcp-2.13.2.dist-info/RECORD +144 -0
  105. {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/WHEEL +1 -1
  106. fastmcp/cli/claude.py +0 -144
  107. fastmcp-2.12.1.dist-info/RECORD +0 -128
  108. {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/entry_points.txt +0 -0
  109. {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/licenses/LICENSE +0 -0
@@ -6,112 +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
20
  from fastmcp.utilities.types import NotSet, NotSetT
18
21
 
22
+ if TYPE_CHECKING:
23
+ from mcp.server.auth.provider import AuthorizationParams
24
+ from mcp.shared.auth import OAuthClientInformationFull
25
+
19
26
  logger = get_logger(__name__)
20
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
+
21
33
 
22
34
  class AzureProviderSettings(BaseSettings):
23
35
  """Settings for Azure OAuth provider."""
24
36
 
25
37
  model_config = SettingsConfigDict(
26
38
  env_prefix="FASTMCP_SERVER_AUTH_AZURE_",
27
- env_file=".env",
39
+ env_file=ENV_FILE,
28
40
  extra="ignore",
29
41
  )
30
42
 
31
43
  client_id: str | None = None
32
44
  client_secret: SecretStr | None = None
33
45
  tenant_id: str | None = None
46
+ identifier_uri: str | None = None
34
47
  base_url: str | None = None
48
+ issuer_url: str | None = None
35
49
  redirect_path: str | None = None
36
50
  required_scopes: list[str] | None = None
37
- timeout_seconds: int | None = None
51
+ additional_authorize_scopes: list[str] | None = None
38
52
  allowed_client_redirect_uris: list[str] | None = None
53
+ jwt_signing_key: str | None = None
54
+ base_authority: str = "login.microsoftonline.com"
39
55
 
40
56
  @field_validator("required_scopes", mode="before")
41
57
  @classmethod
42
- def _parse_scopes(cls, v):
58
+ def _parse_scopes(cls, v: object) -> list[str] | None:
43
59
  return parse_scopes(v)
44
60
 
45
-
46
- class AzureTokenVerifier(TokenVerifier):
47
- """Token verifier for Azure OAuth tokens.
48
-
49
- Azure tokens are JWTs, but we verify them by calling the Microsoft Graph API
50
- to get user information and validate the token.
51
- """
52
-
53
- def __init__(
54
- self,
55
- *,
56
- required_scopes: list[str] | None = None,
57
- timeout_seconds: int = 10,
58
- ):
59
- """Initialize the Azure token verifier.
60
-
61
- Args:
62
- required_scopes: Required OAuth scopes
63
- timeout_seconds: HTTP request timeout
64
- """
65
- super().__init__(required_scopes=required_scopes)
66
- self.timeout_seconds = timeout_seconds
67
-
68
- async def verify_token(self, token: str) -> AccessToken | None:
69
- """Verify Azure OAuth token by calling Microsoft Graph API."""
70
- try:
71
- async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
72
- # Use Microsoft Graph API to validate token and get user info
73
- response = await client.get(
74
- "https://graph.microsoft.com/v1.0/me",
75
- headers={
76
- "Authorization": f"Bearer {token}",
77
- "User-Agent": "FastMCP-Azure-OAuth",
78
- },
79
- )
80
-
81
- if response.status_code != 200:
82
- logger.debug(
83
- "Azure token verification failed: %d - %s",
84
- response.status_code,
85
- response.text[:200],
86
- )
87
- return None
88
-
89
- user_data = response.json()
90
-
91
- # Create AccessToken with Azure user info
92
- return AccessToken(
93
- token=token,
94
- client_id=str(user_data.get("id", "unknown")),
95
- scopes=self.required_scopes or [],
96
- expires_at=None,
97
- claims={
98
- "sub": user_data.get("id"),
99
- "email": user_data.get("mail")
100
- or user_data.get("userPrincipalName"),
101
- "name": user_data.get("displayName"),
102
- "given_name": user_data.get("givenName"),
103
- "family_name": user_data.get("surname"),
104
- "job_title": user_data.get("jobTitle"),
105
- "office_location": user_data.get("officeLocation"),
106
- },
107
- )
108
-
109
- except httpx.RequestError as e:
110
- logger.debug("Failed to verify Azure token: %s", e)
111
- return None
112
- except Exception as e:
113
- logger.debug("Azure token verification error: %s", e)
114
- 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)
115
65
 
116
66
 
117
67
  class AzureProvider(OAuthProxy):
@@ -121,28 +71,53 @@ class AzureProvider(OAuthProxy):
121
71
  OAuth Proxy pattern. It supports both organizational accounts and personal
122
72
  Microsoft accounts depending on the tenant configuration.
123
73
 
124
- Features:
125
- - Transparent OAuth proxy to Azure/Microsoft identity platform
126
- - Automatic token validation via Microsoft Graph API
127
- - User information extraction
128
- - 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
129
81
 
130
- Setup Requirements:
131
- 1. Register an application in Azure Portal (portal.azure.com)
132
- 2. Configure redirect URI as: http://localhost:8000/auth/callback
133
- 3. Note your Application (client) ID and create a client secret
134
- 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
135
96
 
136
97
  Example:
137
98
  ```python
138
99
  from fastmcp import FastMCP
139
100
  from fastmcp.server.auth.providers.azure import AzureProvider
140
101
 
102
+ # Standard Azure (Public Cloud)
141
103
  auth = AzureProvider(
142
104
  client_id="your-client-id",
143
105
  client_secret="your-client-secret",
144
- tenant_id="your-tenant-id", # Required: your Azure tenant ID from Azure Portal
145
- 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",
146
121
  )
147
122
 
148
123
  mcp = FastMCP("My App", auth=auth)
@@ -155,24 +130,60 @@ class AzureProvider(OAuthProxy):
155
130
  client_id: str | NotSetT = NotSet,
156
131
  client_secret: str | NotSetT = NotSet,
157
132
  tenant_id: str | NotSetT = NotSet,
133
+ identifier_uri: str | NotSetT | None = NotSet,
158
134
  base_url: str | NotSetT = NotSet,
135
+ issuer_url: str | NotSetT = NotSet,
159
136
  redirect_path: str | NotSetT = NotSet,
160
- required_scopes: list[str] | None | NotSetT = NotSet,
161
- timeout_seconds: int | NotSetT = NotSet,
137
+ required_scopes: list[str] | NotSetT | None = NotSet,
138
+ additional_authorize_scopes: list[str] | NotSetT | None = NotSet,
162
139
  allowed_client_redirect_uris: list[str] | NotSetT = NotSet,
163
- ):
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:
164
145
  """Initialize Azure OAuth provider.
165
146
 
166
147
  Args:
167
- client_id: Azure application (client) ID
168
- client_secret: Azure client secret
169
- tenant_id: Azure tenant ID (your specific tenant ID, "organizations", or "consumers")
170
- base_url: Public URL of your FastMCP server (for OAuth callbacks)
171
- redirect_path: Redirect path configured in Azure (defaults to "/auth/callback")
172
- required_scopes: Required scopes (defaults to ["User.Read", "email", "openid", "profile"])
173
- 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.
174
175
  allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
175
176
  If None (default), all URIs are allowed. If empty list, no URIs are allowed.
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.
176
187
  """
177
188
  settings = AzureProviderSettings.model_validate(
178
189
  {
@@ -181,11 +192,15 @@ class AzureProvider(OAuthProxy):
181
192
  "client_id": client_id,
182
193
  "client_secret": client_secret,
183
194
  "tenant_id": tenant_id,
195
+ "identifier_uri": identifier_uri,
184
196
  "base_url": base_url,
197
+ "issuer_url": issuer_url,
185
198
  "redirect_path": redirect_path,
186
199
  "required_scopes": required_scopes,
187
- "timeout_seconds": timeout_seconds,
200
+ "additional_authorize_scopes": additional_authorize_scopes,
188
201
  "allowed_client_redirect_uris": allowed_client_redirect_uris,
202
+ "jwt_signing_key": jwt_signing_key,
203
+ "base_authority": base_authority,
189
204
  }.items()
190
205
  if v is not NotSet
191
206
  }
@@ -193,52 +208,75 @@ class AzureProvider(OAuthProxy):
193
208
 
194
209
  # Validate required settings
195
210
  if not settings.client_id:
196
- raise ValueError(
197
- "client_id is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_CLIENT_ID"
198
- )
211
+ msg = "client_id is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_CLIENT_ID"
212
+ raise ValueError(msg)
199
213
  if not settings.client_secret:
200
- raise ValueError(
201
- "client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_CLIENT_SECRET"
202
- )
214
+ msg = "client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_CLIENT_SECRET"
215
+ raise ValueError(msg)
203
216
 
204
217
  # Validate tenant_id is provided
205
218
  if not settings.tenant_id:
206
- raise ValueError(
207
- "tenant_id is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_TENANT_ID. "
208
- "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'"
209
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)
210
235
 
211
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 []
212
239
  tenant_id_final = settings.tenant_id
213
240
 
214
- redirect_path_final = settings.redirect_path or "/auth/callback"
215
- timeout_seconds_final = settings.timeout_seconds or 10
216
- # Default scopes for Azure - User.Read gives us access to user info via Graph API
217
- scopes_final = settings.required_scopes or [
218
- "User.Read",
219
- "email",
220
- "openid",
221
- "profile",
222
- ]
223
- 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
+ )
224
268
 
225
269
  # Extract secret string from SecretStr
226
270
  client_secret_str = (
227
271
  settings.client_secret.get_secret_value() if settings.client_secret else ""
228
272
  )
229
273
 
230
- # Create Azure token verifier
231
- token_verifier = AzureTokenVerifier(
232
- required_scopes=scopes_final,
233
- timeout_seconds=timeout_seconds_final,
234
- )
235
-
236
274
  # Build Azure OAuth endpoints with tenant
237
275
  authorization_endpoint = (
238
- 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"
239
277
  )
240
278
  token_endpoint = (
241
- 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"
242
280
  )
243
281
 
244
282
  # Initialize OAuth proxy with Azure endpoints
@@ -249,13 +287,160 @@ class AzureProvider(OAuthProxy):
249
287
  upstream_client_secret=client_secret_str,
250
288
  token_verifier=token_verifier,
251
289
  base_url=settings.base_url,
252
- redirect_path=redirect_path_final,
253
- issuer_url=settings.base_url,
254
- allowed_client_redirect_uris=allowed_client_redirect_uris_final,
290
+ redirect_path=settings.redirect_path,
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,
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,
255
299
  )
256
300
 
301
+ authority_info = ""
302
+ if base_authority_final != "login.microsoftonline.com":
303
+ authority_info = f" using authority {base_authority_final}"
257
304
  logger.info(
258
- "Initialized Azure OAuth provider for client %s with tenant %s",
305
+ "Initialized Azure OAuth provider for client %s with tenant %s%s%s",
259
306
  settings.client_id,
260
307
  tenant_id_final,
308
+ f" and identifier_uri {self.identifier_uri}" if self.identifier_uri else "",
309
+ authority_info,
261
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: