fastmcp 2.12.5__py3-none-any.whl → 2.13.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastmcp/__init__.py +2 -2
- fastmcp/cli/cli.py +11 -11
- fastmcp/cli/install/claude_code.py +6 -6
- fastmcp/cli/install/claude_desktop.py +3 -3
- fastmcp/cli/install/cursor.py +18 -12
- fastmcp/cli/install/gemini_cli.py +3 -3
- fastmcp/cli/install/mcp_json.py +3 -3
- fastmcp/cli/run.py +13 -8
- fastmcp/client/__init__.py +9 -9
- fastmcp/client/auth/oauth.py +115 -217
- fastmcp/client/client.py +105 -39
- fastmcp/client/logging.py +18 -14
- fastmcp/client/oauth_callback.py +85 -171
- fastmcp/client/sampling.py +1 -1
- fastmcp/client/transports.py +80 -25
- fastmcp/contrib/component_manager/__init__.py +1 -1
- fastmcp/contrib/component_manager/component_manager.py +2 -2
- fastmcp/contrib/component_manager/component_service.py +6 -6
- fastmcp/contrib/mcp_mixin/README.md +32 -1
- fastmcp/contrib/mcp_mixin/__init__.py +2 -2
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -2
- 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 +14 -15
- 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 +2 -2
- fastmcp/mcp_config.py +3 -4
- fastmcp/prompts/__init__.py +1 -1
- fastmcp/prompts/prompt.py +22 -19
- fastmcp/prompts/prompt_manager.py +16 -101
- fastmcp/resources/__init__.py +5 -5
- fastmcp/resources/resource.py +14 -9
- fastmcp/resources/resource_manager.py +9 -168
- fastmcp/resources/template.py +107 -17
- fastmcp/resources/types.py +30 -24
- fastmcp/server/__init__.py +1 -1
- fastmcp/server/auth/__init__.py +9 -5
- fastmcp/server/auth/auth.py +70 -43
- 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 +1510 -289
- fastmcp/server/auth/oidc_proxy.py +84 -20
- fastmcp/server/auth/providers/auth0.py +40 -21
- fastmcp/server/auth/providers/aws.py +29 -3
- fastmcp/server/auth/providers/azure.py +312 -131
- fastmcp/server/auth/providers/bearer.py +1 -1
- fastmcp/server/auth/providers/debug.py +114 -0
- fastmcp/server/auth/providers/descope.py +86 -29
- fastmcp/server/auth/providers/discord.py +308 -0
- fastmcp/server/auth/providers/github.py +29 -8
- fastmcp/server/auth/providers/google.py +48 -9
- 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 +35 -17
- fastmcp/server/context.py +177 -51
- fastmcp/server/dependencies.py +39 -12
- fastmcp/server/elicitation.py +1 -1
- fastmcp/server/http.py +56 -17
- 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 +50 -39
- fastmcp/server/middleware/middleware.py +29 -16
- fastmcp/server/middleware/rate_limiting.py +3 -3
- fastmcp/server/middleware/tool_injection.py +116 -0
- fastmcp/server/openapi.py +10 -6
- fastmcp/server/proxy.py +22 -11
- fastmcp/server/server.py +725 -242
- fastmcp/settings.py +24 -10
- fastmcp/tools/__init__.py +1 -1
- fastmcp/tools/tool.py +70 -23
- fastmcp/tools/tool_manager.py +30 -112
- fastmcp/tools/tool_transform.py +12 -10
- fastmcp/utilities/cli.py +67 -28
- fastmcp/utilities/components.py +7 -2
- fastmcp/utilities/inspect.py +79 -23
- fastmcp/utilities/json_schema.py +4 -4
- fastmcp/utilities/json_schema_type.py +4 -4
- fastmcp/utilities/logging.py +118 -8
- 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 +6 -6
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +4 -4
- fastmcp/utilities/mcp_server_config/v1/schema.json +3 -0
- fastmcp/utilities/mcp_server_config/v1/sources/base.py +0 -1
- fastmcp/utilities/openapi.py +11 -11
- fastmcp/utilities/tests.py +85 -4
- fastmcp/utilities/types.py +78 -16
- fastmcp/utilities/ui.py +626 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/METADATA +22 -14
- fastmcp-2.13.2.dist-info/RECORD +144 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/WHEEL +1 -1
- fastmcp/cli/claude.py +0 -135
- fastmcp/utilities/storage.py +0 -204
- fastmcp-2.12.5.dist-info/RECORD +0 -134
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/licenses/LICENSE +0 -0
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
|
|
@@ -20,6 +22,7 @@ from mcp.types import (
|
|
|
20
22
|
AudioContent,
|
|
21
23
|
ClientCapabilities,
|
|
22
24
|
CreateMessageResult,
|
|
25
|
+
GetPromptResult,
|
|
23
26
|
ImageContent,
|
|
24
27
|
IncludeContext,
|
|
25
28
|
ModelHint,
|
|
@@ -30,6 +33,8 @@ from mcp.types import (
|
|
|
30
33
|
TextContent,
|
|
31
34
|
)
|
|
32
35
|
from mcp.types import CreateMessageRequestParams as SamplingParams
|
|
36
|
+
from mcp.types import Prompt as MCPPrompt
|
|
37
|
+
from mcp.types import Resource as MCPResource
|
|
33
38
|
from pydantic.networks import AnyUrl
|
|
34
39
|
from starlette.requests import Request
|
|
35
40
|
from typing_extensions import TypeVar
|
|
@@ -44,14 +49,21 @@ from fastmcp.server.elicitation import (
|
|
|
44
49
|
get_elicitation_schema,
|
|
45
50
|
)
|
|
46
51
|
from fastmcp.server.server import FastMCP
|
|
47
|
-
from fastmcp.utilities.logging import get_logger
|
|
52
|
+
from fastmcp.utilities.logging import _clamp_logger, get_logger
|
|
48
53
|
from fastmcp.utilities.types import get_cached_typeadapter
|
|
49
54
|
|
|
50
|
-
logger = get_logger(__name__)
|
|
55
|
+
logger: Logger = get_logger(name=__name__)
|
|
56
|
+
to_client_logger: Logger = logger.getChild(suffix="to_client")
|
|
57
|
+
|
|
58
|
+
# Convert all levels of server -> client messages to debug level
|
|
59
|
+
# This clamp can be undone at runtime by calling `_unclamp_logger` or calling
|
|
60
|
+
# `_clamp_logger` with a different max level.
|
|
61
|
+
_clamp_logger(logger=to_client_logger, max_level="DEBUG")
|
|
62
|
+
|
|
51
63
|
|
|
52
64
|
T = TypeVar("T", default=Any)
|
|
53
65
|
_current_context: ContextVar[Context | None] = ContextVar("context", default=None) # type: ignore[assignment]
|
|
54
|
-
_flush_lock =
|
|
66
|
+
_flush_lock = anyio.Lock()
|
|
55
67
|
|
|
56
68
|
|
|
57
69
|
@dataclass
|
|
@@ -66,6 +78,18 @@ class LogData:
|
|
|
66
78
|
extra: Mapping[str, Any] | None = None
|
|
67
79
|
|
|
68
80
|
|
|
81
|
+
_mcp_level_to_python_level = {
|
|
82
|
+
"debug": logging.DEBUG,
|
|
83
|
+
"info": logging.INFO,
|
|
84
|
+
"notice": logging.INFO,
|
|
85
|
+
"warning": logging.WARNING,
|
|
86
|
+
"error": logging.ERROR,
|
|
87
|
+
"critical": logging.CRITICAL,
|
|
88
|
+
"alert": logging.CRITICAL,
|
|
89
|
+
"emergency": logging.CRITICAL,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
69
93
|
@contextmanager
|
|
70
94
|
def set_context(context: Context) -> Generator[Context, None, None]:
|
|
71
95
|
token = _current_context.set(context)
|
|
@@ -157,15 +181,33 @@ class Context:
|
|
|
157
181
|
_current_context.reset(token)
|
|
158
182
|
|
|
159
183
|
@property
|
|
160
|
-
def request_context(self) -> RequestContext[ServerSession, Any, Request]:
|
|
184
|
+
def request_context(self) -> RequestContext[ServerSession, Any, Request] | None:
|
|
161
185
|
"""Access to the underlying request context.
|
|
162
186
|
|
|
163
|
-
|
|
187
|
+
Returns None when the MCP session has not been established yet.
|
|
188
|
+
Returns the full RequestContext once the MCP session is available.
|
|
189
|
+
|
|
190
|
+
For HTTP request access in middleware, use `get_http_request()` from fastmcp.server.dependencies,
|
|
191
|
+
which works whether or not the MCP session is available.
|
|
192
|
+
|
|
193
|
+
Example in middleware:
|
|
194
|
+
```python
|
|
195
|
+
async def on_request(self, context, call_next):
|
|
196
|
+
ctx = context.fastmcp_context
|
|
197
|
+
if ctx.request_context:
|
|
198
|
+
# MCP session available - can access session_id, request_id, etc.
|
|
199
|
+
session_id = ctx.session_id
|
|
200
|
+
else:
|
|
201
|
+
# MCP session not available yet - use HTTP helpers
|
|
202
|
+
from fastmcp.server.dependencies import get_http_request
|
|
203
|
+
request = get_http_request()
|
|
204
|
+
return await call_next(context)
|
|
205
|
+
```
|
|
164
206
|
"""
|
|
165
207
|
try:
|
|
166
208
|
return request_ctx.get()
|
|
167
209
|
except LookupError:
|
|
168
|
-
|
|
210
|
+
return None
|
|
169
211
|
|
|
170
212
|
async def report_progress(
|
|
171
213
|
self, progress: float, total: float | None = None, message: str | None = None
|
|
@@ -179,7 +221,7 @@ class Context:
|
|
|
179
221
|
|
|
180
222
|
progress_token = (
|
|
181
223
|
self.request_context.meta.progressToken
|
|
182
|
-
if self.request_context.meta
|
|
224
|
+
if self.request_context and self.request_context.meta
|
|
183
225
|
else None
|
|
184
226
|
)
|
|
185
227
|
|
|
@@ -194,6 +236,36 @@ class Context:
|
|
|
194
236
|
related_request_id=self.request_id,
|
|
195
237
|
)
|
|
196
238
|
|
|
239
|
+
async def list_resources(self) -> list[MCPResource]:
|
|
240
|
+
"""List all available resources from the server.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
List of Resource objects available on the server
|
|
244
|
+
"""
|
|
245
|
+
return await self.fastmcp._list_resources_mcp()
|
|
246
|
+
|
|
247
|
+
async def list_prompts(self) -> list[MCPPrompt]:
|
|
248
|
+
"""List all available prompts from the server.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
List of Prompt objects available on the server
|
|
252
|
+
"""
|
|
253
|
+
return await self.fastmcp._list_prompts_mcp()
|
|
254
|
+
|
|
255
|
+
async def get_prompt(
|
|
256
|
+
self, name: str, arguments: dict[str, Any] | None = None
|
|
257
|
+
) -> GetPromptResult:
|
|
258
|
+
"""Get a prompt by name with optional arguments.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
name: The name of the prompt to get
|
|
262
|
+
arguments: Optional arguments to pass to the prompt
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
The prompt result
|
|
266
|
+
"""
|
|
267
|
+
return await self.fastmcp._get_prompt_mcp(name, arguments)
|
|
268
|
+
|
|
197
269
|
async def read_resource(self, uri: str | AnyUrl) -> list[ReadResourceContents]:
|
|
198
270
|
"""Read a resource by URI.
|
|
199
271
|
|
|
@@ -203,9 +275,7 @@ class Context:
|
|
|
203
275
|
Returns:
|
|
204
276
|
The resource content as either text or bytes
|
|
205
277
|
"""
|
|
206
|
-
|
|
207
|
-
raise ValueError("Context is not available outside of a request")
|
|
208
|
-
return await self.fastmcp._mcp_read_resource(uri)
|
|
278
|
+
return await self.fastmcp._read_resource_mcp(uri)
|
|
209
279
|
|
|
210
280
|
async def log(
|
|
211
281
|
self,
|
|
@@ -216,6 +286,8 @@ class Context:
|
|
|
216
286
|
) -> None:
|
|
217
287
|
"""Send a log message to the client.
|
|
218
288
|
|
|
289
|
+
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.
|
|
290
|
+
|
|
219
291
|
Args:
|
|
220
292
|
message: Log message
|
|
221
293
|
level: Optional log level. One of "debug", "info", "notice", "warning", "error", "critical",
|
|
@@ -223,13 +295,13 @@ class Context:
|
|
|
223
295
|
logger_name: Optional logger name
|
|
224
296
|
extra: Optional mapping for additional arguments
|
|
225
297
|
"""
|
|
226
|
-
if level is None:
|
|
227
|
-
level = "info"
|
|
228
298
|
data = LogData(msg=message, extra=extra)
|
|
229
|
-
|
|
230
|
-
|
|
299
|
+
|
|
300
|
+
await _log_to_server_and_client(
|
|
231
301
|
data=data,
|
|
232
|
-
|
|
302
|
+
session=self.session,
|
|
303
|
+
level=level or "info",
|
|
304
|
+
logger_name=logger_name,
|
|
233
305
|
related_request_id=self.request_id,
|
|
234
306
|
)
|
|
235
307
|
|
|
@@ -238,13 +310,21 @@ class Context:
|
|
|
238
310
|
"""Get the client ID if available."""
|
|
239
311
|
return (
|
|
240
312
|
getattr(self.request_context.meta, "client_id", None)
|
|
241
|
-
if self.request_context.meta
|
|
313
|
+
if self.request_context and self.request_context.meta
|
|
242
314
|
else None
|
|
243
315
|
)
|
|
244
316
|
|
|
245
317
|
@property
|
|
246
318
|
def request_id(self) -> str:
|
|
247
|
-
"""Get the unique ID for this request.
|
|
319
|
+
"""Get the unique ID for this request.
|
|
320
|
+
|
|
321
|
+
Raises RuntimeError if MCP request context is not available.
|
|
322
|
+
"""
|
|
323
|
+
if self.request_context is None:
|
|
324
|
+
raise RuntimeError(
|
|
325
|
+
"request_id is not available because the MCP session has not been established yet. "
|
|
326
|
+
"Check `context.request_context` for None before accessing this attribute."
|
|
327
|
+
)
|
|
248
328
|
return str(self.request_context.request_id)
|
|
249
329
|
|
|
250
330
|
@property
|
|
@@ -259,6 +339,9 @@ class Context:
|
|
|
259
339
|
The session ID for StreamableHTTP transports, or a generated ID
|
|
260
340
|
for other transports.
|
|
261
341
|
|
|
342
|
+
Raises:
|
|
343
|
+
RuntimeError if MCP request context is not available.
|
|
344
|
+
|
|
262
345
|
Example:
|
|
263
346
|
```python
|
|
264
347
|
@server.tool
|
|
@@ -269,6 +352,11 @@ class Context:
|
|
|
269
352
|
```
|
|
270
353
|
"""
|
|
271
354
|
request_ctx = self.request_context
|
|
355
|
+
if request_ctx is None:
|
|
356
|
+
raise RuntimeError(
|
|
357
|
+
"session_id is not available because the MCP session has not been established yet. "
|
|
358
|
+
"Check `context.request_context` for None before accessing this attribute."
|
|
359
|
+
)
|
|
272
360
|
session = request_ctx.session
|
|
273
361
|
|
|
274
362
|
# Try to get the session ID from the session attributes
|
|
@@ -288,12 +376,20 @@ class Context:
|
|
|
288
376
|
session_id = str(uuid4())
|
|
289
377
|
|
|
290
378
|
# Save the session id to the session attributes
|
|
291
|
-
|
|
379
|
+
session._fastmcp_id = session_id
|
|
292
380
|
return session_id
|
|
293
381
|
|
|
294
382
|
@property
|
|
295
383
|
def session(self) -> ServerSession:
|
|
296
|
-
"""Access to the underlying session for advanced usage.
|
|
384
|
+
"""Access to the underlying session for advanced usage.
|
|
385
|
+
|
|
386
|
+
Raises RuntimeError if MCP request context is not available.
|
|
387
|
+
"""
|
|
388
|
+
if self.request_context is None:
|
|
389
|
+
raise RuntimeError(
|
|
390
|
+
"session is not available because the MCP session has not been established yet. "
|
|
391
|
+
"Check `context.request_context` for None before accessing this attribute."
|
|
392
|
+
)
|
|
297
393
|
return self.request_context.session
|
|
298
394
|
|
|
299
395
|
# Convenience methods for common log levels
|
|
@@ -303,9 +399,14 @@ class Context:
|
|
|
303
399
|
logger_name: str | None = None,
|
|
304
400
|
extra: Mapping[str, Any] | None = None,
|
|
305
401
|
) -> None:
|
|
306
|
-
"""Send a
|
|
402
|
+
"""Send a `DEBUG`-level message to the connected MCP Client.
|
|
403
|
+
|
|
404
|
+
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`."""
|
|
307
405
|
await self.log(
|
|
308
|
-
level="debug",
|
|
406
|
+
level="debug",
|
|
407
|
+
message=message,
|
|
408
|
+
logger_name=logger_name,
|
|
409
|
+
extra=extra,
|
|
309
410
|
)
|
|
310
411
|
|
|
311
412
|
async def info(
|
|
@@ -314,9 +415,14 @@ class Context:
|
|
|
314
415
|
logger_name: str | None = None,
|
|
315
416
|
extra: Mapping[str, Any] | None = None,
|
|
316
417
|
) -> None:
|
|
317
|
-
"""Send
|
|
418
|
+
"""Send a `INFO`-level message to the connected MCP Client.
|
|
419
|
+
|
|
420
|
+
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`."""
|
|
318
421
|
await self.log(
|
|
319
|
-
level="info",
|
|
422
|
+
level="info",
|
|
423
|
+
message=message,
|
|
424
|
+
logger_name=logger_name,
|
|
425
|
+
extra=extra,
|
|
320
426
|
)
|
|
321
427
|
|
|
322
428
|
async def warning(
|
|
@@ -325,9 +431,14 @@ class Context:
|
|
|
325
431
|
logger_name: str | None = None,
|
|
326
432
|
extra: Mapping[str, Any] | None = None,
|
|
327
433
|
) -> None:
|
|
328
|
-
"""Send a
|
|
434
|
+
"""Send a `WARNING`-level message to the connected MCP Client.
|
|
435
|
+
|
|
436
|
+
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`."""
|
|
329
437
|
await self.log(
|
|
330
|
-
level="warning",
|
|
438
|
+
level="warning",
|
|
439
|
+
message=message,
|
|
440
|
+
logger_name=logger_name,
|
|
441
|
+
extra=extra,
|
|
331
442
|
)
|
|
332
443
|
|
|
333
444
|
async def error(
|
|
@@ -336,9 +447,14 @@ class Context:
|
|
|
336
447
|
logger_name: str | None = None,
|
|
337
448
|
extra: Mapping[str, Any] | None = None,
|
|
338
449
|
) -> None:
|
|
339
|
-
"""Send
|
|
450
|
+
"""Send a `ERROR`-level message to the connected MCP Client.
|
|
451
|
+
|
|
452
|
+
Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`."""
|
|
340
453
|
await self.log(
|
|
341
|
-
level="error",
|
|
454
|
+
level="error",
|
|
455
|
+
message=message,
|
|
456
|
+
logger_name=logger_name,
|
|
457
|
+
extra=extra,
|
|
342
458
|
)
|
|
343
459
|
|
|
344
460
|
async def list_roots(self) -> list[Root]:
|
|
@@ -521,13 +637,11 @@ class Context:
|
|
|
521
637
|
choice_literal = Literal[tuple(response_type)] # type: ignore
|
|
522
638
|
response_type = ScalarElicitationType[choice_literal] # type: ignore
|
|
523
639
|
# if the user provided a primitive scalar, wrap it in an object schema
|
|
524
|
-
elif
|
|
525
|
-
response_type
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
# if the user provided an Enum type, wrap it in an object schema
|
|
530
|
-
elif isinstance(response_type, type) and issubclass(response_type, Enum):
|
|
640
|
+
elif (
|
|
641
|
+
response_type in {bool, int, float, str}
|
|
642
|
+
or get_origin(response_type) is Literal
|
|
643
|
+
or (isinstance(response_type, type) and issubclass(response_type, Enum))
|
|
644
|
+
):
|
|
531
645
|
response_type = ScalarElicitationType[response_type] # type: ignore
|
|
532
646
|
|
|
533
647
|
response_type = cast(type[T], response_type)
|
|
@@ -592,30 +706,14 @@ class Context:
|
|
|
592
706
|
def _queue_tool_list_changed(self) -> None:
|
|
593
707
|
"""Queue a tool list changed notification."""
|
|
594
708
|
self._notification_queue.add("notifications/tools/list_changed")
|
|
595
|
-
self._try_flush_notifications()
|
|
596
709
|
|
|
597
710
|
def _queue_resource_list_changed(self) -> None:
|
|
598
711
|
"""Queue a resource list changed notification."""
|
|
599
712
|
self._notification_queue.add("notifications/resources/list_changed")
|
|
600
|
-
self._try_flush_notifications()
|
|
601
713
|
|
|
602
714
|
def _queue_prompt_list_changed(self) -> None:
|
|
603
715
|
"""Queue a prompt list changed notification."""
|
|
604
716
|
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
717
|
|
|
620
718
|
async def _flush_notifications(self) -> None:
|
|
621
719
|
"""Send all queued notifications."""
|
|
@@ -675,3 +773,31 @@ def _parse_model_preferences(
|
|
|
675
773
|
raise ValueError(
|
|
676
774
|
"model_preferences must be one of: ModelPreferences, str, list[str], or None."
|
|
677
775
|
)
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
async def _log_to_server_and_client(
|
|
779
|
+
data: LogData,
|
|
780
|
+
session: ServerSession,
|
|
781
|
+
level: LoggingLevel,
|
|
782
|
+
logger_name: str | None = None,
|
|
783
|
+
related_request_id: str | None = None,
|
|
784
|
+
) -> None:
|
|
785
|
+
"""Log a message to the server and client."""
|
|
786
|
+
|
|
787
|
+
msg_prefix = f"Sending {level.upper()} to client"
|
|
788
|
+
|
|
789
|
+
if logger_name:
|
|
790
|
+
msg_prefix += f" ({logger_name})"
|
|
791
|
+
|
|
792
|
+
to_client_logger.log(
|
|
793
|
+
level=_mcp_level_to_python_level[level],
|
|
794
|
+
msg=f"{msg_prefix}: {data.msg}",
|
|
795
|
+
extra=data.extra,
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
await session.send_log_message(
|
|
799
|
+
level=level,
|
|
800
|
+
data=data,
|
|
801
|
+
logger=logger_name,
|
|
802
|
+
related_request_id=related_request_id,
|
|
803
|
+
)
|
fastmcp/server/dependencies.py
CHANGED
|
@@ -1,26 +1,30 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import contextlib
|
|
3
4
|
from typing import TYPE_CHECKING
|
|
4
5
|
|
|
5
6
|
from mcp.server.auth.middleware.auth_context import (
|
|
6
7
|
get_access_token as _sdk_get_access_token,
|
|
7
8
|
)
|
|
9
|
+
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
|
|
8
10
|
from mcp.server.auth.provider import (
|
|
9
11
|
AccessToken as _SDKAccessToken,
|
|
10
12
|
)
|
|
13
|
+
from mcp.server.lowlevel.server import request_ctx
|
|
11
14
|
from starlette.requests import Request
|
|
12
15
|
|
|
13
16
|
from fastmcp.server.auth import AccessToken
|
|
17
|
+
from fastmcp.server.http import _current_http_request
|
|
14
18
|
|
|
15
19
|
if TYPE_CHECKING:
|
|
16
20
|
from fastmcp.server.context import Context
|
|
17
21
|
|
|
18
22
|
__all__ = [
|
|
23
|
+
"AccessToken",
|
|
24
|
+
"get_access_token",
|
|
19
25
|
"get_context",
|
|
20
|
-
"get_http_request",
|
|
21
26
|
"get_http_headers",
|
|
22
|
-
"
|
|
23
|
-
"AccessToken",
|
|
27
|
+
"get_http_request",
|
|
24
28
|
]
|
|
25
29
|
|
|
26
30
|
|
|
@@ -40,13 +44,15 @@ def get_context() -> Context:
|
|
|
40
44
|
|
|
41
45
|
|
|
42
46
|
def get_http_request() -> Request:
|
|
43
|
-
|
|
44
|
-
|
|
47
|
+
# Try MCP SDK's request_ctx first (set during normal MCP request handling)
|
|
45
48
|
request = None
|
|
46
|
-
|
|
49
|
+
with contextlib.suppress(LookupError):
|
|
47
50
|
request = request_ctx.get().request
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
|
|
52
|
+
# Fallback to FastMCP's HTTP context variable
|
|
53
|
+
# This is needed during `on_initialize` middleware where request_ctx isn't set yet
|
|
54
|
+
if request is None:
|
|
55
|
+
request = _current_http_request.get()
|
|
50
56
|
|
|
51
57
|
if request is None:
|
|
52
58
|
raise RuntimeError("No active HTTP request found.")
|
|
@@ -106,17 +112,38 @@ def get_access_token() -> AccessToken | None:
|
|
|
106
112
|
"""
|
|
107
113
|
Get the FastMCP access token from the current context.
|
|
108
114
|
|
|
115
|
+
This function first tries to get the token from the current HTTP request's scope,
|
|
116
|
+
which is more reliable for long-lived connections where the SDK's auth_context_var
|
|
117
|
+
may become stale after token refresh. Falls back to the SDK's context var if no
|
|
118
|
+
request is available.
|
|
119
|
+
|
|
109
120
|
Returns:
|
|
110
121
|
The access token if an authenticated user is available, None otherwise.
|
|
111
122
|
"""
|
|
112
|
-
|
|
113
|
-
|
|
123
|
+
access_token: _SDKAccessToken | None = None
|
|
124
|
+
|
|
125
|
+
# First, try to get from current HTTP request's scope (issue #1863)
|
|
126
|
+
# This is more reliable than auth_context_var for Streamable HTTP sessions
|
|
127
|
+
# where tokens may be refreshed between MCP messages
|
|
128
|
+
try:
|
|
129
|
+
request = get_http_request()
|
|
130
|
+
user = request.scope.get("user")
|
|
131
|
+
if isinstance(user, AuthenticatedUser):
|
|
132
|
+
access_token = user.access_token
|
|
133
|
+
except RuntimeError:
|
|
134
|
+
# No HTTP request available, fall back to context var
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
# Fall back to SDK's context var if we didn't get a token from the request
|
|
138
|
+
if access_token is None:
|
|
139
|
+
access_token = _sdk_get_access_token()
|
|
114
140
|
|
|
115
141
|
if access_token is None or isinstance(access_token, AccessToken):
|
|
116
142
|
return access_token
|
|
117
143
|
|
|
118
|
-
# If the object is not a FastMCP AccessToken, convert it to one if the
|
|
119
|
-
#
|
|
144
|
+
# If the object is not a FastMCP AccessToken, convert it to one if the
|
|
145
|
+
# fields are compatible (e.g. `claims` is not present in the SDK's AccessToken).
|
|
146
|
+
# This is a workaround for the case where the SDK or auth provider returns a different type
|
|
120
147
|
# If it fails, it will raise a TypeError
|
|
121
148
|
try:
|
|
122
149
|
access_token_as_dict = access_token.model_dump()
|
fastmcp/server/elicitation.py
CHANGED
fastmcp/server/http.py
CHANGED
|
@@ -5,10 +5,12 @@ from contextlib import asynccontextmanager, contextmanager
|
|
|
5
5
|
from contextvars import ContextVar
|
|
6
6
|
from typing import TYPE_CHECKING
|
|
7
7
|
|
|
8
|
-
from mcp.server.auth.
|
|
8
|
+
from mcp.server.auth.routes import build_resource_metadata_url
|
|
9
9
|
from mcp.server.lowlevel.server import LifespanResultT
|
|
10
10
|
from mcp.server.sse import SseServerTransport
|
|
11
|
-
from mcp.server.streamable_http import
|
|
11
|
+
from mcp.server.streamable_http import (
|
|
12
|
+
EventStore,
|
|
13
|
+
)
|
|
12
14
|
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
|
13
15
|
from starlette.applications import Starlette
|
|
14
16
|
from starlette.middleware import Middleware
|
|
@@ -18,6 +20,7 @@ from starlette.routing import BaseRoute, Mount, Route
|
|
|
18
20
|
from starlette.types import Lifespan, Receive, Scope, Send
|
|
19
21
|
|
|
20
22
|
from fastmcp.server.auth import AuthProvider
|
|
23
|
+
from fastmcp.server.auth.middleware import RequireAuthMiddleware
|
|
21
24
|
from fastmcp.utilities.logging import get_logger
|
|
22
25
|
|
|
23
26
|
if TYPE_CHECKING:
|
|
@@ -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,7 +342,7 @@ 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 session_manager.run():
|
|
345
|
+
async with server._lifespan_manager(), session_manager.run():
|
|
307
346
|
yield
|
|
308
347
|
|
|
309
348
|
# Create and return the app with lifespan
|