fastmcp 2.12.5__py3-none-any.whl → 2.13.0rc2__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/cli/cli.py +6 -6
- fastmcp/cli/install/claude_code.py +3 -3
- fastmcp/cli/install/claude_desktop.py +3 -3
- fastmcp/cli/install/cursor.py +7 -7
- fastmcp/cli/install/gemini_cli.py +3 -3
- fastmcp/cli/install/mcp_json.py +3 -3
- fastmcp/cli/run.py +13 -8
- fastmcp/client/auth/oauth.py +100 -208
- fastmcp/client/client.py +11 -11
- fastmcp/client/logging.py +18 -14
- fastmcp/client/oauth_callback.py +81 -171
- fastmcp/client/transports.py +76 -22
- fastmcp/contrib/component_manager/component_service.py +6 -6
- fastmcp/contrib/mcp_mixin/README.md +32 -1
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -2
- fastmcp/experimental/utilities/openapi/json_schema_converter.py +4 -0
- fastmcp/experimental/utilities/openapi/parser.py +23 -3
- fastmcp/prompts/prompt.py +13 -6
- fastmcp/prompts/prompt_manager.py +16 -101
- fastmcp/resources/resource.py +13 -6
- fastmcp/resources/resource_manager.py +5 -164
- fastmcp/resources/template.py +107 -17
- fastmcp/server/auth/auth.py +40 -32
- fastmcp/server/auth/jwt_issuer.py +289 -0
- fastmcp/server/auth/oauth_proxy.py +1228 -233
- fastmcp/server/auth/oidc_proxy.py +8 -6
- fastmcp/server/auth/providers/auth0.py +13 -7
- fastmcp/server/auth/providers/aws.py +14 -3
- fastmcp/server/auth/providers/azure.py +137 -124
- fastmcp/server/auth/providers/descope.py +4 -6
- fastmcp/server/auth/providers/github.py +14 -8
- fastmcp/server/auth/providers/google.py +15 -9
- fastmcp/server/auth/providers/introspection.py +281 -0
- fastmcp/server/auth/providers/jwt.py +8 -2
- fastmcp/server/auth/providers/scalekit.py +179 -0
- fastmcp/server/auth/providers/supabase.py +172 -0
- fastmcp/server/auth/providers/workos.py +17 -14
- fastmcp/server/context.py +89 -34
- fastmcp/server/http.py +57 -17
- fastmcp/server/low_level.py +121 -2
- fastmcp/server/middleware/caching.py +469 -0
- fastmcp/server/middleware/error_handling.py +6 -2
- fastmcp/server/middleware/logging.py +48 -37
- fastmcp/server/middleware/middleware.py +28 -15
- fastmcp/server/middleware/rate_limiting.py +3 -3
- fastmcp/server/proxy.py +6 -6
- fastmcp/server/server.py +638 -183
- fastmcp/settings.py +22 -9
- fastmcp/tools/tool.py +7 -3
- fastmcp/tools/tool_manager.py +22 -108
- fastmcp/tools/tool_transform.py +3 -3
- fastmcp/utilities/cli.py +32 -22
- fastmcp/utilities/components.py +5 -0
- fastmcp/utilities/inspect.py +77 -21
- fastmcp/utilities/logging.py +118 -8
- fastmcp/utilities/mcp_server_config/v1/environments/uv.py +6 -6
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +3 -3
- fastmcp/utilities/mcp_server_config/v1/schema.json +3 -0
- fastmcp/utilities/tests.py +87 -4
- fastmcp/utilities/types.py +1 -1
- fastmcp/utilities/ui.py +497 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0rc2.dist-info}/METADATA +8 -4
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0rc2.dist-info}/RECORD +66 -62
- fastmcp/cli/claude.py +0 -135
- fastmcp/utilities/storage.py +0 -204
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0rc2.dist-info}/WHEEL +0 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0rc2.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.0rc2.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Supabase authentication provider for FastMCP.
|
|
2
|
+
|
|
3
|
+
This module provides SupabaseProvider - a complete authentication solution that integrates
|
|
4
|
+
with Supabase Auth's JWT verification, supporting Dynamic Client Registration (DCR)
|
|
5
|
+
for seamless MCP client authentication.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from pydantic import AnyHttpUrl, field_validator
|
|
12
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
13
|
+
from starlette.responses import JSONResponse
|
|
14
|
+
from starlette.routing import Route
|
|
15
|
+
|
|
16
|
+
from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier
|
|
17
|
+
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
|
18
|
+
from fastmcp.settings import ENV_FILE
|
|
19
|
+
from fastmcp.utilities.auth import parse_scopes
|
|
20
|
+
from fastmcp.utilities.logging import get_logger
|
|
21
|
+
from fastmcp.utilities.types import NotSet, NotSetT
|
|
22
|
+
|
|
23
|
+
logger = get_logger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SupabaseProviderSettings(BaseSettings):
|
|
27
|
+
model_config = SettingsConfigDict(
|
|
28
|
+
env_prefix="FASTMCP_SERVER_AUTH_SUPABASE_",
|
|
29
|
+
env_file=ENV_FILE,
|
|
30
|
+
extra="ignore",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
project_url: AnyHttpUrl
|
|
34
|
+
base_url: AnyHttpUrl
|
|
35
|
+
required_scopes: list[str] | None = None
|
|
36
|
+
|
|
37
|
+
@field_validator("required_scopes", mode="before")
|
|
38
|
+
@classmethod
|
|
39
|
+
def _parse_scopes(cls, v):
|
|
40
|
+
return parse_scopes(v)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SupabaseProvider(RemoteAuthProvider):
|
|
44
|
+
"""Supabase metadata provider for DCR (Dynamic Client Registration).
|
|
45
|
+
|
|
46
|
+
This provider implements Supabase Auth integration using metadata forwarding.
|
|
47
|
+
This approach allows Supabase to handle the OAuth flow directly while FastMCP acts
|
|
48
|
+
as a resource server, verifying JWTs issued by Supabase Auth.
|
|
49
|
+
|
|
50
|
+
IMPORTANT SETUP REQUIREMENTS:
|
|
51
|
+
|
|
52
|
+
1. Supabase Project Setup:
|
|
53
|
+
- Create a Supabase project at https://supabase.com
|
|
54
|
+
- Note your project URL (e.g., "https://abc123.supabase.co")
|
|
55
|
+
- For projects created after May 1st, 2025, asymmetric RS256 keys are used by default
|
|
56
|
+
- For older projects, consider migrating to asymmetric keys for better security
|
|
57
|
+
|
|
58
|
+
2. JWT Verification:
|
|
59
|
+
- FastMCP verifies JWTs using the JWKS endpoint at {project_url}/auth/v1/.well-known/jwks.json
|
|
60
|
+
- JWTs are issued by {project_url}/auth/v1
|
|
61
|
+
- Tokens are cached for up to 10 minutes by Supabase's edge servers
|
|
62
|
+
|
|
63
|
+
For detailed setup instructions, see:
|
|
64
|
+
https://supabase.com/docs/guides/auth/jwts
|
|
65
|
+
|
|
66
|
+
Example:
|
|
67
|
+
```python
|
|
68
|
+
from fastmcp.server.auth.providers.supabase import SupabaseProvider
|
|
69
|
+
|
|
70
|
+
# Create Supabase metadata provider (JWT verifier created automatically)
|
|
71
|
+
supabase_auth = SupabaseProvider(
|
|
72
|
+
project_url="https://abc123.supabase.co",
|
|
73
|
+
base_url="https://your-fastmcp-server.com",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Use with FastMCP
|
|
77
|
+
mcp = FastMCP("My App", auth=supabase_auth)
|
|
78
|
+
```
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
*,
|
|
84
|
+
project_url: AnyHttpUrl | str | NotSetT = NotSet,
|
|
85
|
+
base_url: AnyHttpUrl | str | NotSetT = NotSet,
|
|
86
|
+
required_scopes: list[str] | None | NotSetT = NotSet,
|
|
87
|
+
token_verifier: TokenVerifier | None = None,
|
|
88
|
+
):
|
|
89
|
+
"""Initialize Supabase metadata provider.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
project_url: Your Supabase project URL (e.g., "https://abc123.supabase.co")
|
|
93
|
+
base_url: Public URL of this FastMCP server
|
|
94
|
+
required_scopes: Optional list of scopes to require for all requests
|
|
95
|
+
token_verifier: Optional token verifier. If None, creates JWT verifier for Supabase
|
|
96
|
+
"""
|
|
97
|
+
settings = SupabaseProviderSettings.model_validate(
|
|
98
|
+
{
|
|
99
|
+
k: v
|
|
100
|
+
for k, v in {
|
|
101
|
+
"project_url": project_url,
|
|
102
|
+
"base_url": base_url,
|
|
103
|
+
"required_scopes": required_scopes,
|
|
104
|
+
}.items()
|
|
105
|
+
if v is not NotSet
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
self.project_url = str(settings.project_url).rstrip("/")
|
|
110
|
+
self.base_url = str(settings.base_url).rstrip("/")
|
|
111
|
+
|
|
112
|
+
# Create default JWT verifier if none provided
|
|
113
|
+
if token_verifier is None:
|
|
114
|
+
token_verifier = JWTVerifier(
|
|
115
|
+
jwks_uri=f"{self.project_url}/auth/v1/.well-known/jwks.json",
|
|
116
|
+
issuer=f"{self.project_url}/auth/v1",
|
|
117
|
+
algorithm="ES256", # Supabase uses ES256 for asymmetric keys
|
|
118
|
+
required_scopes=settings.required_scopes,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Initialize RemoteAuthProvider with Supabase as the authorization server
|
|
122
|
+
super().__init__(
|
|
123
|
+
token_verifier=token_verifier,
|
|
124
|
+
authorization_servers=[AnyHttpUrl(f"{self.project_url}/auth/v1")],
|
|
125
|
+
base_url=self.base_url,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def get_routes(
|
|
129
|
+
self,
|
|
130
|
+
mcp_path: str | None = None,
|
|
131
|
+
) -> list[Route]:
|
|
132
|
+
"""Get OAuth routes including Supabase authorization server metadata forwarding.
|
|
133
|
+
|
|
134
|
+
This returns the standard protected resource routes plus an authorization server
|
|
135
|
+
metadata endpoint that forwards Supabase's OAuth metadata to clients.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
|
|
139
|
+
This is used to advertise the resource URL in metadata.
|
|
140
|
+
"""
|
|
141
|
+
# Get the standard protected resource routes from RemoteAuthProvider
|
|
142
|
+
routes = super().get_routes(mcp_path)
|
|
143
|
+
|
|
144
|
+
async def oauth_authorization_server_metadata(request):
|
|
145
|
+
"""Forward Supabase OAuth authorization server metadata with FastMCP customizations."""
|
|
146
|
+
try:
|
|
147
|
+
async with httpx.AsyncClient() as client:
|
|
148
|
+
response = await client.get(
|
|
149
|
+
f"{self.project_url}/auth/v1/.well-known/oauth-authorization-server"
|
|
150
|
+
)
|
|
151
|
+
response.raise_for_status()
|
|
152
|
+
metadata = response.json()
|
|
153
|
+
return JSONResponse(metadata)
|
|
154
|
+
except Exception as e:
|
|
155
|
+
return JSONResponse(
|
|
156
|
+
{
|
|
157
|
+
"error": "server_error",
|
|
158
|
+
"error_description": f"Failed to fetch Supabase metadata: {e}",
|
|
159
|
+
},
|
|
160
|
+
status_code=500,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Add Supabase authorization server metadata forwarding
|
|
164
|
+
routes.append(
|
|
165
|
+
Route(
|
|
166
|
+
"/.well-known/oauth-authorization-server",
|
|
167
|
+
endpoint=oauth_authorization_server_metadata,
|
|
168
|
+
methods=["GET"],
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
return routes
|
|
@@ -10,9 +10,8 @@ Choose based on your WorkOS setup and authentication requirements.
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
-
from typing import Any
|
|
14
|
-
|
|
15
13
|
import httpx
|
|
14
|
+
from key_value.aio.protocols import AsyncKeyValue
|
|
16
15
|
from pydantic import AnyHttpUrl, SecretStr, field_validator
|
|
17
16
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
18
17
|
from starlette.responses import JSONResponse
|
|
@@ -21,9 +20,9 @@ from starlette.routing import Route
|
|
|
21
20
|
from fastmcp.server.auth import AccessToken, RemoteAuthProvider, TokenVerifier
|
|
22
21
|
from fastmcp.server.auth.oauth_proxy import OAuthProxy
|
|
23
22
|
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
|
23
|
+
from fastmcp.settings import ENV_FILE
|
|
24
24
|
from fastmcp.utilities.auth import parse_scopes
|
|
25
25
|
from fastmcp.utilities.logging import get_logger
|
|
26
|
-
from fastmcp.utilities.storage import KVStorage
|
|
27
26
|
from fastmcp.utilities.types import NotSet, NotSetT
|
|
28
27
|
|
|
29
28
|
logger = get_logger(__name__)
|
|
@@ -34,7 +33,7 @@ class WorkOSProviderSettings(BaseSettings):
|
|
|
34
33
|
|
|
35
34
|
model_config = SettingsConfigDict(
|
|
36
35
|
env_prefix="FASTMCP_SERVER_AUTH_WORKOS_",
|
|
37
|
-
env_file=
|
|
36
|
+
env_file=ENV_FILE,
|
|
38
37
|
extra="ignore",
|
|
39
38
|
)
|
|
40
39
|
|
|
@@ -42,6 +41,7 @@ class WorkOSProviderSettings(BaseSettings):
|
|
|
42
41
|
client_secret: SecretStr | None = None
|
|
43
42
|
authkit_domain: str | None = None # e.g., "https://your-app.authkit.app"
|
|
44
43
|
base_url: AnyHttpUrl | str | None = None
|
|
44
|
+
issuer_url: AnyHttpUrl | str | None = None
|
|
45
45
|
redirect_path: str | None = None
|
|
46
46
|
required_scopes: list[str] | None = None
|
|
47
47
|
timeout_seconds: int | None = None
|
|
@@ -166,11 +166,12 @@ class WorkOSProvider(OAuthProxy):
|
|
|
166
166
|
client_secret: str | NotSetT = NotSet,
|
|
167
167
|
authkit_domain: str | NotSetT = NotSet,
|
|
168
168
|
base_url: AnyHttpUrl | str | NotSetT = NotSet,
|
|
169
|
+
issuer_url: AnyHttpUrl | str | NotSetT = NotSet,
|
|
169
170
|
redirect_path: str | NotSetT = NotSet,
|
|
170
171
|
required_scopes: list[str] | None | NotSetT = NotSet,
|
|
171
172
|
timeout_seconds: int | NotSetT = NotSet,
|
|
172
173
|
allowed_client_redirect_uris: list[str] | NotSetT = NotSet,
|
|
173
|
-
client_storage:
|
|
174
|
+
client_storage: AsyncKeyValue | None = None,
|
|
174
175
|
):
|
|
175
176
|
"""Initialize WorkOS OAuth provider.
|
|
176
177
|
|
|
@@ -178,14 +179,15 @@ class WorkOSProvider(OAuthProxy):
|
|
|
178
179
|
client_id: WorkOS client ID
|
|
179
180
|
client_secret: WorkOS client secret
|
|
180
181
|
authkit_domain: Your WorkOS AuthKit domain (e.g., "https://your-app.authkit.app")
|
|
181
|
-
base_url: Public URL
|
|
182
|
+
base_url: Public URL where OAuth endpoints will be accessible (includes any mount path)
|
|
183
|
+
issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL
|
|
184
|
+
to avoid 404s during discovery when mounting under a path.
|
|
182
185
|
redirect_path: Redirect path configured in WorkOS (defaults to "/auth/callback")
|
|
183
186
|
required_scopes: Required OAuth scopes (no default)
|
|
184
187
|
timeout_seconds: HTTP request timeout for WorkOS API calls
|
|
185
188
|
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
|
|
186
189
|
If None (default), all URIs are allowed. If empty list, no URIs are allowed.
|
|
187
|
-
client_storage:
|
|
188
|
-
Defaults to file-based storage if not specified.
|
|
190
|
+
client_storage: An AsyncKeyValue-compatible store for client registrations, registrations are stored in memory if not provided
|
|
189
191
|
"""
|
|
190
192
|
|
|
191
193
|
settings = WorkOSProviderSettings.model_validate(
|
|
@@ -196,6 +198,7 @@ class WorkOSProvider(OAuthProxy):
|
|
|
196
198
|
"client_secret": client_secret,
|
|
197
199
|
"authkit_domain": authkit_domain,
|
|
198
200
|
"base_url": base_url,
|
|
201
|
+
"issuer_url": issuer_url,
|
|
199
202
|
"redirect_path": redirect_path,
|
|
200
203
|
"required_scopes": required_scopes,
|
|
201
204
|
"timeout_seconds": timeout_seconds,
|
|
@@ -249,12 +252,13 @@ class WorkOSProvider(OAuthProxy):
|
|
|
249
252
|
token_verifier=token_verifier,
|
|
250
253
|
base_url=settings.base_url,
|
|
251
254
|
redirect_path=settings.redirect_path,
|
|
252
|
-
issuer_url=settings.
|
|
255
|
+
issuer_url=settings.issuer_url
|
|
256
|
+
or settings.base_url, # Default to base_url if not specified
|
|
253
257
|
allowed_client_redirect_uris=allowed_client_redirect_uris_final,
|
|
254
258
|
client_storage=client_storage,
|
|
255
259
|
)
|
|
256
260
|
|
|
257
|
-
logger.
|
|
261
|
+
logger.debug(
|
|
258
262
|
"Initialized WorkOS OAuth provider for client %s with AuthKit domain %s",
|
|
259
263
|
settings.client_id,
|
|
260
264
|
authkit_domain_final,
|
|
@@ -264,7 +268,7 @@ class WorkOSProvider(OAuthProxy):
|
|
|
264
268
|
class AuthKitProviderSettings(BaseSettings):
|
|
265
269
|
model_config = SettingsConfigDict(
|
|
266
270
|
env_prefix="FASTMCP_SERVER_AUTH_AUTHKITPROVIDER_",
|
|
267
|
-
env_file=
|
|
271
|
+
env_file=ENV_FILE,
|
|
268
272
|
extra="ignore",
|
|
269
273
|
)
|
|
270
274
|
|
|
@@ -364,7 +368,6 @@ class AuthKitProvider(RemoteAuthProvider):
|
|
|
364
368
|
def get_routes(
|
|
365
369
|
self,
|
|
366
370
|
mcp_path: str | None = None,
|
|
367
|
-
mcp_endpoint: Any | None = None,
|
|
368
371
|
) -> list[Route]:
|
|
369
372
|
"""Get OAuth routes including AuthKit authorization server metadata forwarding.
|
|
370
373
|
|
|
@@ -373,10 +376,10 @@ class AuthKitProvider(RemoteAuthProvider):
|
|
|
373
376
|
|
|
374
377
|
Args:
|
|
375
378
|
mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
|
|
376
|
-
|
|
379
|
+
This is used to advertise the resource URL in metadata.
|
|
377
380
|
"""
|
|
378
381
|
# Get the standard protected resource routes from RemoteAuthProvider
|
|
379
|
-
routes = super().get_routes(mcp_path
|
|
382
|
+
routes = super().get_routes(mcp_path)
|
|
380
383
|
|
|
381
384
|
async def oauth_authorization_server_metadata(request):
|
|
382
385
|
"""Forward AuthKit OAuth authorization server metadata with FastMCP customizations."""
|
fastmcp/server/context.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import asyncio
|
|
4
3
|
import copy
|
|
5
4
|
import inspect
|
|
5
|
+
import logging
|
|
6
6
|
import warnings
|
|
7
7
|
import weakref
|
|
8
8
|
from collections.abc import Generator, Mapping, Sequence
|
|
@@ -10,8 +10,10 @@ from contextlib import contextmanager
|
|
|
10
10
|
from contextvars import ContextVar, Token
|
|
11
11
|
from dataclasses import dataclass
|
|
12
12
|
from enum import Enum
|
|
13
|
+
from logging import Logger
|
|
13
14
|
from typing import Any, Literal, cast, get_origin, overload
|
|
14
15
|
|
|
16
|
+
import anyio
|
|
15
17
|
from mcp import LoggingLevel, ServerSession
|
|
16
18
|
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
|
17
19
|
from mcp.server.lowlevel.server import request_ctx
|
|
@@ -44,14 +46,21 @@ from fastmcp.server.elicitation import (
|
|
|
44
46
|
get_elicitation_schema,
|
|
45
47
|
)
|
|
46
48
|
from fastmcp.server.server import FastMCP
|
|
47
|
-
from fastmcp.utilities.logging import get_logger
|
|
49
|
+
from fastmcp.utilities.logging import _clamp_logger, get_logger
|
|
48
50
|
from fastmcp.utilities.types import get_cached_typeadapter
|
|
49
51
|
|
|
50
|
-
logger = get_logger(__name__)
|
|
52
|
+
logger: Logger = get_logger(name=__name__)
|
|
53
|
+
to_client_logger: Logger = logger.getChild(suffix="to_client")
|
|
54
|
+
|
|
55
|
+
# Convert all levels of server -> client messages to debug level
|
|
56
|
+
# This clamp can be undone at runtime by calling `_unclamp_logger` or calling
|
|
57
|
+
# `_clamp_logger` with a different max level.
|
|
58
|
+
_clamp_logger(logger=to_client_logger, max_level="DEBUG")
|
|
59
|
+
|
|
51
60
|
|
|
52
61
|
T = TypeVar("T", default=Any)
|
|
53
62
|
_current_context: ContextVar[Context | None] = ContextVar("context", default=None) # type: ignore[assignment]
|
|
54
|
-
_flush_lock =
|
|
63
|
+
_flush_lock = anyio.Lock()
|
|
55
64
|
|
|
56
65
|
|
|
57
66
|
@dataclass
|
|
@@ -66,6 +75,18 @@ class LogData:
|
|
|
66
75
|
extra: Mapping[str, Any] | None = None
|
|
67
76
|
|
|
68
77
|
|
|
78
|
+
_mcp_level_to_python_level = {
|
|
79
|
+
"debug": logging.DEBUG,
|
|
80
|
+
"info": logging.INFO,
|
|
81
|
+
"notice": logging.INFO,
|
|
82
|
+
"warning": logging.WARNING,
|
|
83
|
+
"error": logging.ERROR,
|
|
84
|
+
"critical": logging.CRITICAL,
|
|
85
|
+
"alert": logging.CRITICAL,
|
|
86
|
+
"emergency": logging.CRITICAL,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
69
90
|
@contextmanager
|
|
70
91
|
def set_context(context: Context) -> Generator[Context, None, None]:
|
|
71
92
|
token = _current_context.set(context)
|
|
@@ -205,7 +226,7 @@ class Context:
|
|
|
205
226
|
"""
|
|
206
227
|
if self.fastmcp is None:
|
|
207
228
|
raise ValueError("Context is not available outside of a request")
|
|
208
|
-
return await self.fastmcp.
|
|
229
|
+
return await self.fastmcp._read_resource_mcp(uri)
|
|
209
230
|
|
|
210
231
|
async def log(
|
|
211
232
|
self,
|
|
@@ -216,6 +237,8 @@ class Context:
|
|
|
216
237
|
) -> None:
|
|
217
238
|
"""Send a log message to the client.
|
|
218
239
|
|
|
240
|
+
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.
|
|
241
|
+
|
|
219
242
|
Args:
|
|
220
243
|
message: Log message
|
|
221
244
|
level: Optional log level. One of "debug", "info", "notice", "warning", "error", "critical",
|
|
@@ -223,13 +246,13 @@ class Context:
|
|
|
223
246
|
logger_name: Optional logger name
|
|
224
247
|
extra: Optional mapping for additional arguments
|
|
225
248
|
"""
|
|
226
|
-
if level is None:
|
|
227
|
-
level = "info"
|
|
228
249
|
data = LogData(msg=message, extra=extra)
|
|
229
|
-
|
|
230
|
-
|
|
250
|
+
|
|
251
|
+
await _log_to_server_and_client(
|
|
231
252
|
data=data,
|
|
232
|
-
|
|
253
|
+
session=self.session,
|
|
254
|
+
level=level or "info",
|
|
255
|
+
logger_name=logger_name,
|
|
233
256
|
related_request_id=self.request_id,
|
|
234
257
|
)
|
|
235
258
|
|
|
@@ -303,9 +326,14 @@ class Context:
|
|
|
303
326
|
logger_name: str | None = None,
|
|
304
327
|
extra: Mapping[str, Any] | None = None,
|
|
305
328
|
) -> None:
|
|
306
|
-
"""Send a
|
|
329
|
+
"""Send a `DEBUG`-level message to the connected MCP Client.
|
|
330
|
+
|
|
331
|
+
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`."""
|
|
307
332
|
await self.log(
|
|
308
|
-
level="debug",
|
|
333
|
+
level="debug",
|
|
334
|
+
message=message,
|
|
335
|
+
logger_name=logger_name,
|
|
336
|
+
extra=extra,
|
|
309
337
|
)
|
|
310
338
|
|
|
311
339
|
async def info(
|
|
@@ -314,9 +342,14 @@ class Context:
|
|
|
314
342
|
logger_name: str | None = None,
|
|
315
343
|
extra: Mapping[str, Any] | None = None,
|
|
316
344
|
) -> None:
|
|
317
|
-
"""Send
|
|
345
|
+
"""Send a `INFO`-level message to the connected MCP Client.
|
|
346
|
+
|
|
347
|
+
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`."""
|
|
318
348
|
await self.log(
|
|
319
|
-
level="info",
|
|
349
|
+
level="info",
|
|
350
|
+
message=message,
|
|
351
|
+
logger_name=logger_name,
|
|
352
|
+
extra=extra,
|
|
320
353
|
)
|
|
321
354
|
|
|
322
355
|
async def warning(
|
|
@@ -325,9 +358,14 @@ class Context:
|
|
|
325
358
|
logger_name: str | None = None,
|
|
326
359
|
extra: Mapping[str, Any] | None = None,
|
|
327
360
|
) -> None:
|
|
328
|
-
"""Send a
|
|
361
|
+
"""Send a `WARNING`-level message to the connected MCP Client.
|
|
362
|
+
|
|
363
|
+
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`."""
|
|
329
364
|
await self.log(
|
|
330
|
-
level="warning",
|
|
365
|
+
level="warning",
|
|
366
|
+
message=message,
|
|
367
|
+
logger_name=logger_name,
|
|
368
|
+
extra=extra,
|
|
331
369
|
)
|
|
332
370
|
|
|
333
371
|
async def error(
|
|
@@ -336,9 +374,14 @@ class Context:
|
|
|
336
374
|
logger_name: str | None = None,
|
|
337
375
|
extra: Mapping[str, Any] | None = None,
|
|
338
376
|
) -> None:
|
|
339
|
-
"""Send
|
|
377
|
+
"""Send a `ERROR`-level message to the connected MCP Client.
|
|
378
|
+
|
|
379
|
+
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`."""
|
|
340
380
|
await self.log(
|
|
341
|
-
level="error",
|
|
381
|
+
level="error",
|
|
382
|
+
message=message,
|
|
383
|
+
logger_name=logger_name,
|
|
384
|
+
extra=extra,
|
|
342
385
|
)
|
|
343
386
|
|
|
344
387
|
async def list_roots(self) -> list[Root]:
|
|
@@ -592,30 +635,14 @@ class Context:
|
|
|
592
635
|
def _queue_tool_list_changed(self) -> None:
|
|
593
636
|
"""Queue a tool list changed notification."""
|
|
594
637
|
self._notification_queue.add("notifications/tools/list_changed")
|
|
595
|
-
self._try_flush_notifications()
|
|
596
638
|
|
|
597
639
|
def _queue_resource_list_changed(self) -> None:
|
|
598
640
|
"""Queue a resource list changed notification."""
|
|
599
641
|
self._notification_queue.add("notifications/resources/list_changed")
|
|
600
|
-
self._try_flush_notifications()
|
|
601
642
|
|
|
602
643
|
def _queue_prompt_list_changed(self) -> None:
|
|
603
644
|
"""Queue a prompt list changed notification."""
|
|
604
645
|
self._notification_queue.add("notifications/prompts/list_changed")
|
|
605
|
-
self._try_flush_notifications()
|
|
606
|
-
|
|
607
|
-
def _try_flush_notifications(self) -> None:
|
|
608
|
-
"""Synchronous method that attempts to flush notifications if we're in an async context."""
|
|
609
|
-
try:
|
|
610
|
-
# Check if we're in an async context
|
|
611
|
-
loop = asyncio.get_running_loop()
|
|
612
|
-
if loop and not loop.is_running():
|
|
613
|
-
return
|
|
614
|
-
# Schedule flush as a task (fire-and-forget)
|
|
615
|
-
asyncio.create_task(self._flush_notifications())
|
|
616
|
-
except RuntimeError:
|
|
617
|
-
# No event loop - will flush later
|
|
618
|
-
pass
|
|
619
646
|
|
|
620
647
|
async def _flush_notifications(self) -> None:
|
|
621
648
|
"""Send all queued notifications."""
|
|
@@ -675,3 +702,31 @@ def _parse_model_preferences(
|
|
|
675
702
|
raise ValueError(
|
|
676
703
|
"model_preferences must be one of: ModelPreferences, str, list[str], or None."
|
|
677
704
|
)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
async def _log_to_server_and_client(
|
|
708
|
+
data: LogData,
|
|
709
|
+
session: ServerSession,
|
|
710
|
+
level: LoggingLevel,
|
|
711
|
+
logger_name: str | None = None,
|
|
712
|
+
related_request_id: str | None = None,
|
|
713
|
+
) -> None:
|
|
714
|
+
"""Log a message to the server and client."""
|
|
715
|
+
|
|
716
|
+
msg_prefix = f"Sending {level.upper()} to client"
|
|
717
|
+
|
|
718
|
+
if logger_name:
|
|
719
|
+
msg_prefix += f" ({logger_name})"
|
|
720
|
+
|
|
721
|
+
to_client_logger.log(
|
|
722
|
+
level=_mcp_level_to_python_level[level],
|
|
723
|
+
msg=f"{msg_prefix}: {data.msg}",
|
|
724
|
+
extra=data.extra,
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
await session.send_log_message(
|
|
728
|
+
level=level,
|
|
729
|
+
data=data,
|
|
730
|
+
logger=logger_name,
|
|
731
|
+
related_request_id=related_request_id,
|
|
732
|
+
)
|
fastmcp/server/http.py
CHANGED
|
@@ -6,9 +6,12 @@ from contextvars import ContextVar
|
|
|
6
6
|
from typing import TYPE_CHECKING
|
|
7
7
|
|
|
8
8
|
from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware
|
|
9
|
+
from mcp.server.auth.routes import build_resource_metadata_url
|
|
9
10
|
from mcp.server.lowlevel.server import LifespanResultT
|
|
10
11
|
from mcp.server.sse import SseServerTransport
|
|
11
|
-
from mcp.server.streamable_http import
|
|
12
|
+
from mcp.server.streamable_http import (
|
|
13
|
+
EventStore,
|
|
14
|
+
)
|
|
12
15
|
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
|
13
16
|
from starlette.applications import Starlette
|
|
14
17
|
from starlette.middleware import Middleware
|
|
@@ -167,23 +170,38 @@ def create_sse_app(
|
|
|
167
170
|
# Get auth middleware from the provider
|
|
168
171
|
auth_middleware = auth.get_middleware()
|
|
169
172
|
|
|
170
|
-
# Get auth routes
|
|
171
|
-
auth_routes = auth.get_routes(
|
|
172
|
-
mcp_path=sse_path,
|
|
173
|
-
mcp_endpoint=handle_sse,
|
|
174
|
-
)
|
|
175
|
-
|
|
173
|
+
# Get auth provider's own routes (OAuth endpoints, metadata, etc)
|
|
174
|
+
auth_routes = auth.get_routes(mcp_path=sse_path)
|
|
176
175
|
server_routes.extend(auth_routes)
|
|
177
176
|
server_middleware.extend(auth_middleware)
|
|
178
177
|
|
|
179
|
-
#
|
|
178
|
+
# Build RFC 9728-compliant metadata URL
|
|
179
|
+
resource_url = auth._get_resource_url(sse_path)
|
|
180
|
+
resource_metadata_url = (
|
|
181
|
+
build_resource_metadata_url(resource_url) if resource_url else None
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# Create protected SSE endpoint route
|
|
185
|
+
server_routes.append(
|
|
186
|
+
Route(
|
|
187
|
+
sse_path,
|
|
188
|
+
endpoint=RequireAuthMiddleware(
|
|
189
|
+
handle_sse,
|
|
190
|
+
auth.required_scopes,
|
|
191
|
+
resource_metadata_url,
|
|
192
|
+
),
|
|
193
|
+
methods=["GET"],
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Wrap the SSE message endpoint with RequireAuthMiddleware
|
|
180
198
|
server_routes.append(
|
|
181
199
|
Mount(
|
|
182
200
|
message_path,
|
|
183
201
|
app=RequireAuthMiddleware(
|
|
184
202
|
sse.handle_post_message,
|
|
185
203
|
auth.required_scopes,
|
|
186
|
-
|
|
204
|
+
resource_metadata_url,
|
|
187
205
|
),
|
|
188
206
|
)
|
|
189
207
|
)
|
|
@@ -215,11 +233,17 @@ def create_sse_app(
|
|
|
215
233
|
if middleware:
|
|
216
234
|
server_middleware.extend(middleware)
|
|
217
235
|
|
|
236
|
+
@asynccontextmanager
|
|
237
|
+
async def lifespan(app: Starlette) -> AsyncGenerator[None, None]:
|
|
238
|
+
async with server._lifespan_manager():
|
|
239
|
+
yield
|
|
240
|
+
|
|
218
241
|
# Create and return the app
|
|
219
242
|
app = create_base_app(
|
|
220
243
|
routes=server_routes,
|
|
221
244
|
middleware=server_middleware,
|
|
222
245
|
debug=debug,
|
|
246
|
+
lifespan=lifespan,
|
|
223
247
|
)
|
|
224
248
|
# Store the FastMCP server instance on the Starlette app state
|
|
225
249
|
app.state.fastmcp_server = server
|
|
@@ -274,14 +298,29 @@ def create_streamable_http_app(
|
|
|
274
298
|
# Get auth middleware from the provider
|
|
275
299
|
auth_middleware = auth.get_middleware()
|
|
276
300
|
|
|
277
|
-
# Get auth routes
|
|
278
|
-
auth_routes = auth.get_routes(
|
|
279
|
-
mcp_path=streamable_http_path,
|
|
280
|
-
mcp_endpoint=streamable_http_app,
|
|
281
|
-
)
|
|
282
|
-
|
|
301
|
+
# Get auth provider's own routes (OAuth endpoints, metadata, etc)
|
|
302
|
+
auth_routes = auth.get_routes(mcp_path=streamable_http_path)
|
|
283
303
|
server_routes.extend(auth_routes)
|
|
284
304
|
server_middleware.extend(auth_middleware)
|
|
305
|
+
|
|
306
|
+
# Build RFC 9728-compliant metadata URL
|
|
307
|
+
resource_url = auth._get_resource_url(streamable_http_path)
|
|
308
|
+
resource_metadata_url = (
|
|
309
|
+
build_resource_metadata_url(resource_url) if resource_url else None
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# Create protected HTTP endpoint route
|
|
313
|
+
server_routes.append(
|
|
314
|
+
Route(
|
|
315
|
+
streamable_http_path,
|
|
316
|
+
endpoint=RequireAuthMiddleware(
|
|
317
|
+
streamable_http_app,
|
|
318
|
+
auth.required_scopes,
|
|
319
|
+
resource_metadata_url,
|
|
320
|
+
),
|
|
321
|
+
methods=["GET", "POST", "DELETE"],
|
|
322
|
+
)
|
|
323
|
+
)
|
|
285
324
|
else:
|
|
286
325
|
# No auth required
|
|
287
326
|
server_routes.append(
|
|
@@ -303,8 +342,9 @@ def create_streamable_http_app(
|
|
|
303
342
|
# Create a lifespan manager to start and stop the session manager
|
|
304
343
|
@asynccontextmanager
|
|
305
344
|
async def lifespan(app: Starlette) -> AsyncGenerator[None, None]:
|
|
306
|
-
async with
|
|
307
|
-
|
|
345
|
+
async with server._lifespan_manager():
|
|
346
|
+
async with session_manager.run():
|
|
347
|
+
yield
|
|
308
348
|
|
|
309
349
|
# Create and return the app with lifespan
|
|
310
350
|
app = create_base_app(
|