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.
- fastmcp/__init__.py +2 -2
- fastmcp/cli/cli.py +56 -36
- fastmcp/cli/install/__init__.py +2 -0
- fastmcp/cli/install/claude_code.py +7 -16
- fastmcp/cli/install/claude_desktop.py +4 -12
- fastmcp/cli/install/cursor.py +20 -30
- fastmcp/cli/install/gemini_cli.py +241 -0
- fastmcp/cli/install/mcp_json.py +4 -12
- fastmcp/cli/run.py +15 -94
- fastmcp/client/__init__.py +9 -9
- fastmcp/client/auth/oauth.py +117 -206
- fastmcp/client/client.py +123 -47
- fastmcp/client/elicitation.py +6 -1
- fastmcp/client/logging.py +18 -14
- fastmcp/client/oauth_callback.py +85 -171
- fastmcp/client/sampling.py +1 -1
- fastmcp/client/transports.py +81 -26
- fastmcp/contrib/component_manager/__init__.py +1 -1
- fastmcp/contrib/component_manager/component_manager.py +2 -2
- fastmcp/contrib/component_manager/component_service.py +7 -7
- fastmcp/contrib/mcp_mixin/README.md +35 -4
- fastmcp/contrib/mcp_mixin/__init__.py +2 -2
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +54 -7
- fastmcp/experimental/sampling/handlers/openai.py +2 -2
- fastmcp/experimental/server/openapi/__init__.py +5 -8
- fastmcp/experimental/server/openapi/components.py +11 -7
- fastmcp/experimental/server/openapi/routing.py +2 -2
- fastmcp/experimental/utilities/openapi/__init__.py +10 -15
- fastmcp/experimental/utilities/openapi/director.py +16 -10
- fastmcp/experimental/utilities/openapi/json_schema_converter.py +6 -2
- fastmcp/experimental/utilities/openapi/models.py +3 -3
- fastmcp/experimental/utilities/openapi/parser.py +37 -16
- fastmcp/experimental/utilities/openapi/schemas.py +33 -7
- fastmcp/mcp_config.py +3 -4
- fastmcp/prompts/__init__.py +1 -1
- fastmcp/prompts/prompt.py +32 -27
- fastmcp/prompts/prompt_manager.py +16 -101
- fastmcp/resources/__init__.py +5 -5
- fastmcp/resources/resource.py +28 -20
- fastmcp/resources/resource_manager.py +9 -168
- fastmcp/resources/template.py +119 -27
- fastmcp/resources/types.py +30 -24
- fastmcp/server/__init__.py +1 -1
- fastmcp/server/auth/__init__.py +9 -5
- fastmcp/server/auth/auth.py +80 -47
- fastmcp/server/auth/handlers/authorize.py +326 -0
- fastmcp/server/auth/jwt_issuer.py +236 -0
- fastmcp/server/auth/middleware.py +96 -0
- fastmcp/server/auth/oauth_proxy.py +1556 -265
- fastmcp/server/auth/oidc_proxy.py +412 -0
- fastmcp/server/auth/providers/auth0.py +193 -0
- fastmcp/server/auth/providers/aws.py +263 -0
- fastmcp/server/auth/providers/azure.py +314 -129
- fastmcp/server/auth/providers/bearer.py +1 -1
- fastmcp/server/auth/providers/debug.py +114 -0
- fastmcp/server/auth/providers/descope.py +229 -0
- fastmcp/server/auth/providers/discord.py +308 -0
- fastmcp/server/auth/providers/github.py +31 -6
- fastmcp/server/auth/providers/google.py +50 -7
- fastmcp/server/auth/providers/in_memory.py +27 -3
- fastmcp/server/auth/providers/introspection.py +281 -0
- fastmcp/server/auth/providers/jwt.py +48 -31
- fastmcp/server/auth/providers/oci.py +233 -0
- fastmcp/server/auth/providers/scalekit.py +238 -0
- fastmcp/server/auth/providers/supabase.py +188 -0
- fastmcp/server/auth/providers/workos.py +37 -15
- fastmcp/server/context.py +194 -67
- fastmcp/server/dependencies.py +56 -16
- fastmcp/server/elicitation.py +1 -1
- fastmcp/server/http.py +57 -18
- fastmcp/server/low_level.py +121 -2
- fastmcp/server/middleware/__init__.py +1 -1
- fastmcp/server/middleware/caching.py +476 -0
- fastmcp/server/middleware/error_handling.py +14 -10
- fastmcp/server/middleware/logging.py +158 -116
- fastmcp/server/middleware/middleware.py +30 -16
- fastmcp/server/middleware/rate_limiting.py +3 -3
- fastmcp/server/middleware/tool_injection.py +116 -0
- fastmcp/server/openapi.py +15 -7
- fastmcp/server/proxy.py +22 -11
- fastmcp/server/server.py +744 -254
- fastmcp/settings.py +65 -15
- fastmcp/tools/__init__.py +1 -1
- fastmcp/tools/tool.py +173 -108
- fastmcp/tools/tool_manager.py +30 -112
- fastmcp/tools/tool_transform.py +13 -11
- fastmcp/utilities/cli.py +67 -28
- fastmcp/utilities/components.py +7 -2
- fastmcp/utilities/inspect.py +79 -23
- fastmcp/utilities/json_schema.py +21 -4
- fastmcp/utilities/json_schema_type.py +4 -4
- fastmcp/utilities/logging.py +182 -10
- fastmcp/utilities/mcp_server_config/__init__.py +3 -3
- fastmcp/utilities/mcp_server_config/v1/environments/base.py +1 -2
- fastmcp/utilities/mcp_server_config/v1/environments/uv.py +10 -45
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +8 -7
- fastmcp/utilities/mcp_server_config/v1/schema.json +5 -1
- fastmcp/utilities/mcp_server_config/v1/sources/base.py +0 -1
- fastmcp/utilities/openapi.py +11 -11
- fastmcp/utilities/tests.py +93 -10
- fastmcp/utilities/types.py +87 -21
- fastmcp/utilities/ui.py +626 -0
- {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/METADATA +141 -60
- fastmcp-2.13.2.dist-info/RECORD +144 -0
- {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/WHEEL +1 -1
- fastmcp/cli/claude.py +0 -144
- fastmcp-2.12.1.dist-info/RECORD +0 -128
- {fastmcp-2.12.1.dist-info → fastmcp-2.13.2.dist-info}/entry_points.txt +0 -0
- {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
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
125
|
-
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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",
|
|
145
|
-
|
|
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] |
|
|
161
|
-
|
|
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 (
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
207
|
-
"tenant_id is required - set via parameter or
|
|
208
|
-
"Use your Azure tenant ID
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
"
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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://
|
|
276
|
+
f"https://{base_authority_final}/{tenant_id_final}/oauth2/v2.0/authorize"
|
|
239
277
|
)
|
|
240
278
|
token_endpoint = (
|
|
241
|
-
f"https://
|
|
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=
|
|
253
|
-
issuer_url=settings.
|
|
254
|
-
|
|
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", "
|
|
14
|
+
__all__ = ["BearerAuthProvider", "JWKData", "JWKSData", "RSAKeyPair"]
|
|
15
15
|
|
|
16
16
|
# Deprecated in 2.11
|
|
17
17
|
if fastmcp.settings.deprecation_warnings:
|