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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. fastmcp/__init__.py +2 -2
  2. fastmcp/cli/cli.py +11 -11
  3. fastmcp/cli/install/claude_code.py +6 -6
  4. fastmcp/cli/install/claude_desktop.py +3 -3
  5. fastmcp/cli/install/cursor.py +18 -12
  6. fastmcp/cli/install/gemini_cli.py +3 -3
  7. fastmcp/cli/install/mcp_json.py +3 -3
  8. fastmcp/cli/run.py +13 -8
  9. fastmcp/client/__init__.py +9 -9
  10. fastmcp/client/auth/oauth.py +115 -217
  11. fastmcp/client/client.py +105 -39
  12. fastmcp/client/logging.py +18 -14
  13. fastmcp/client/oauth_callback.py +85 -171
  14. fastmcp/client/sampling.py +1 -1
  15. fastmcp/client/transports.py +80 -25
  16. fastmcp/contrib/component_manager/__init__.py +1 -1
  17. fastmcp/contrib/component_manager/component_manager.py +2 -2
  18. fastmcp/contrib/component_manager/component_service.py +6 -6
  19. fastmcp/contrib/mcp_mixin/README.md +32 -1
  20. fastmcp/contrib/mcp_mixin/__init__.py +2 -2
  21. fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -2
  22. fastmcp/experimental/sampling/handlers/openai.py +2 -2
  23. fastmcp/experimental/server/openapi/__init__.py +5 -8
  24. fastmcp/experimental/server/openapi/components.py +11 -7
  25. fastmcp/experimental/server/openapi/routing.py +2 -2
  26. fastmcp/experimental/utilities/openapi/__init__.py +10 -15
  27. fastmcp/experimental/utilities/openapi/director.py +14 -15
  28. fastmcp/experimental/utilities/openapi/json_schema_converter.py +6 -2
  29. fastmcp/experimental/utilities/openapi/models.py +3 -3
  30. fastmcp/experimental/utilities/openapi/parser.py +37 -16
  31. fastmcp/experimental/utilities/openapi/schemas.py +2 -2
  32. fastmcp/mcp_config.py +3 -4
  33. fastmcp/prompts/__init__.py +1 -1
  34. fastmcp/prompts/prompt.py +22 -19
  35. fastmcp/prompts/prompt_manager.py +16 -101
  36. fastmcp/resources/__init__.py +5 -5
  37. fastmcp/resources/resource.py +14 -9
  38. fastmcp/resources/resource_manager.py +9 -168
  39. fastmcp/resources/template.py +107 -17
  40. fastmcp/resources/types.py +30 -24
  41. fastmcp/server/__init__.py +1 -1
  42. fastmcp/server/auth/__init__.py +9 -5
  43. fastmcp/server/auth/auth.py +70 -43
  44. fastmcp/server/auth/handlers/authorize.py +326 -0
  45. fastmcp/server/auth/jwt_issuer.py +236 -0
  46. fastmcp/server/auth/middleware.py +96 -0
  47. fastmcp/server/auth/oauth_proxy.py +1510 -289
  48. fastmcp/server/auth/oidc_proxy.py +84 -20
  49. fastmcp/server/auth/providers/auth0.py +40 -21
  50. fastmcp/server/auth/providers/aws.py +29 -3
  51. fastmcp/server/auth/providers/azure.py +312 -131
  52. fastmcp/server/auth/providers/bearer.py +1 -1
  53. fastmcp/server/auth/providers/debug.py +114 -0
  54. fastmcp/server/auth/providers/descope.py +86 -29
  55. fastmcp/server/auth/providers/discord.py +308 -0
  56. fastmcp/server/auth/providers/github.py +29 -8
  57. fastmcp/server/auth/providers/google.py +48 -9
  58. fastmcp/server/auth/providers/in_memory.py +27 -3
  59. fastmcp/server/auth/providers/introspection.py +281 -0
  60. fastmcp/server/auth/providers/jwt.py +48 -31
  61. fastmcp/server/auth/providers/oci.py +233 -0
  62. fastmcp/server/auth/providers/scalekit.py +238 -0
  63. fastmcp/server/auth/providers/supabase.py +188 -0
  64. fastmcp/server/auth/providers/workos.py +35 -17
  65. fastmcp/server/context.py +177 -51
  66. fastmcp/server/dependencies.py +39 -12
  67. fastmcp/server/elicitation.py +1 -1
  68. fastmcp/server/http.py +56 -17
  69. fastmcp/server/low_level.py +121 -2
  70. fastmcp/server/middleware/__init__.py +1 -1
  71. fastmcp/server/middleware/caching.py +476 -0
  72. fastmcp/server/middleware/error_handling.py +14 -10
  73. fastmcp/server/middleware/logging.py +50 -39
  74. fastmcp/server/middleware/middleware.py +29 -16
  75. fastmcp/server/middleware/rate_limiting.py +3 -3
  76. fastmcp/server/middleware/tool_injection.py +116 -0
  77. fastmcp/server/openapi.py +10 -6
  78. fastmcp/server/proxy.py +22 -11
  79. fastmcp/server/server.py +725 -242
  80. fastmcp/settings.py +24 -10
  81. fastmcp/tools/__init__.py +1 -1
  82. fastmcp/tools/tool.py +70 -23
  83. fastmcp/tools/tool_manager.py +30 -112
  84. fastmcp/tools/tool_transform.py +12 -10
  85. fastmcp/utilities/cli.py +67 -28
  86. fastmcp/utilities/components.py +7 -2
  87. fastmcp/utilities/inspect.py +79 -23
  88. fastmcp/utilities/json_schema.py +4 -4
  89. fastmcp/utilities/json_schema_type.py +4 -4
  90. fastmcp/utilities/logging.py +118 -8
  91. fastmcp/utilities/mcp_server_config/__init__.py +3 -3
  92. fastmcp/utilities/mcp_server_config/v1/environments/base.py +1 -2
  93. fastmcp/utilities/mcp_server_config/v1/environments/uv.py +6 -6
  94. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +4 -4
  95. fastmcp/utilities/mcp_server_config/v1/schema.json +3 -0
  96. fastmcp/utilities/mcp_server_config/v1/sources/base.py +0 -1
  97. fastmcp/utilities/openapi.py +11 -11
  98. fastmcp/utilities/tests.py +85 -4
  99. fastmcp/utilities/types.py +78 -16
  100. fastmcp/utilities/ui.py +626 -0
  101. {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/METADATA +22 -14
  102. fastmcp-2.13.2.dist-info/RECORD +144 -0
  103. {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/WHEEL +1 -1
  104. fastmcp/cli/claude.py +0 -135
  105. fastmcp/utilities/storage.py +0 -204
  106. fastmcp-2.12.5.dist-info/RECORD +0 -134
  107. {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/entry_points.txt +0 -0
  108. {fastmcp-2.12.5.dist-info → fastmcp-2.13.2.dist-info}/licenses/LICENSE +0 -0
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 = asyncio.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
- If called outside of a request context, this will raise a ValueError.
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
- raise ValueError("Context is not available outside of a request")
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
- if self.fastmcp is None:
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
- await self.session.send_log_message(
230
- level=level,
299
+
300
+ await _log_to_server_and_client(
231
301
  data=data,
232
- logger=logger_name,
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
- setattr(session, "_fastmcp_id", session_id)
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 debug log message."""
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", message=message, logger_name=logger_name, extra=extra
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 an info log message."""
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", message=message, logger_name=logger_name, extra=extra
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 warning log message."""
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", message=message, logger_name=logger_name, extra=extra
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 an error log message."""
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", message=message, logger_name=logger_name, extra=extra
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 response_type in {bool, int, float, str}:
525
- response_type = ScalarElicitationType[response_type] # type: ignore
526
- # if the user provided a Literal type, wrap it in an object schema
527
- elif get_origin(response_type) is Literal:
528
- response_type = ScalarElicitationType[response_type] # type: ignore
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
+ )
@@ -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
- "get_access_token",
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
- from mcp.server.lowlevel.server import request_ctx
44
-
47
+ # Try MCP SDK's request_ctx first (set during normal MCP request handling)
45
48
  request = None
46
- try:
49
+ with contextlib.suppress(LookupError):
47
50
  request = request_ctx.get().request
48
- except LookupError:
49
- pass
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
- access_token: _SDKAccessToken | None = _sdk_get_access_token()
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 fields are compatible
119
- # This is a workaround for the case where the SDK returns a different type
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()
@@ -20,8 +20,8 @@ __all__ = [
20
20
  "AcceptedElicitation",
21
21
  "CancelledElicitation",
22
22
  "DeclinedElicitation",
23
- "get_elicitation_schema",
24
23
  "ScalarElicitationType",
24
+ "get_elicitation_schema",
25
25
  ]
26
26
 
27
27
  logger = get_logger(__name__)
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.middleware.bearer_auth import RequireAuthMiddleware
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 EventStore
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 including protected MCP endpoint
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
- # Manually wrap the SSE message endpoint with RequireAuthMiddleware
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
- auth._get_resource_url("/.well-known/oauth-protected-resource"),
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 including protected MCP endpoint
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