fastmcp 2.9.0__py3-none-any.whl → 2.9.1__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/client/client.py CHANGED
@@ -15,10 +15,10 @@ from pydantic import AnyUrl
15
15
  import fastmcp
16
16
  from fastmcp.client.logging import (
17
17
  LogHandler,
18
- MessageHandler,
19
18
  create_log_callback,
20
19
  default_log_handler,
21
20
  )
21
+ from fastmcp.client.messages import MessageHandler, MessageHandlerT
22
22
  from fastmcp.client.progress import ProgressHandler, default_progress_handler
23
23
  from fastmcp.client.roots import (
24
24
  RootsHandler,
@@ -143,7 +143,7 @@ class Client(Generic[ClientTransportT]):
143
143
  roots: RootsList | RootsHandler | None = None,
144
144
  sampling_handler: SamplingHandler | None = None,
145
145
  log_handler: LogHandler | None = None,
146
- message_handler: MessageHandler | None = None,
146
+ message_handler: MessageHandlerT | MessageHandler | None = None,
147
147
  progress_handler: ProgressHandler | None = None,
148
148
  timeout: datetime.timedelta | float | int | None = None,
149
149
  init_timeout: datetime.timedelta | float | int | None = None,
fastmcp/client/logging.py CHANGED
@@ -1,7 +1,7 @@
1
1
  from collections.abc import Awaitable, Callable
2
2
  from typing import TypeAlias
3
3
 
4
- from mcp.client.session import LoggingFnT, MessageHandlerFnT
4
+ from mcp.client.session import LoggingFnT
5
5
  from mcp.types import LoggingMessageNotificationParams
6
6
 
7
7
  from fastmcp.utilities.logging import get_logger
@@ -10,7 +10,6 @@ logger = get_logger(__name__)
10
10
 
11
11
  LogMessage: TypeAlias = LoggingMessageNotificationParams
12
12
  LogHandler: TypeAlias = Callable[[LogMessage], Awaitable[None]]
13
- MessageHandler: TypeAlias = MessageHandlerFnT
14
13
 
15
14
 
16
15
  async def default_log_handler(message: LogMessage) -> None:
@@ -0,0 +1,126 @@
1
+ from typing import TypeAlias
2
+
3
+ import mcp.types
4
+ from mcp.client.session import MessageHandlerFnT
5
+ from mcp.shared.session import RequestResponder
6
+
7
+ Message: TypeAlias = (
8
+ RequestResponder[mcp.types.ServerRequest, mcp.types.ClientResult]
9
+ | mcp.types.ServerNotification
10
+ | Exception
11
+ )
12
+
13
+ MessageHandlerT: TypeAlias = MessageHandlerFnT
14
+
15
+
16
+ class MessageHandler:
17
+ """
18
+ This class is used to handle MCP messages sent to the client. It is used to handle all messages,
19
+ requests, notifications, and exceptions. Users can override any of the hooks
20
+ """
21
+
22
+ async def __call__(
23
+ self,
24
+ message: RequestResponder[mcp.types.ServerRequest, mcp.types.ClientResult]
25
+ | mcp.types.ServerNotification
26
+ | Exception,
27
+ ) -> None:
28
+ return await self.dispatch(message)
29
+
30
+ async def dispatch(self, message: Message) -> None:
31
+ # handle all messages
32
+ await self.on_message(message)
33
+
34
+ match message:
35
+ # requests
36
+ case RequestResponder():
37
+ # handle all requests
38
+ await self.on_request(message)
39
+
40
+ # handle specific requests
41
+ match message.request.root:
42
+ case mcp.types.PingRequest():
43
+ await self.on_ping(message.request.root)
44
+ case mcp.types.ListRootsRequest():
45
+ await self.on_list_roots(message.request.root)
46
+ case mcp.types.CreateMessageRequest():
47
+ await self.on_create_message(message.request.root)
48
+
49
+ # notifications
50
+ case mcp.types.ServerNotification():
51
+ # handle all notifications
52
+ await self.on_notification(message)
53
+
54
+ # handle specific notifications
55
+ match message.root:
56
+ case mcp.types.CancelledNotification():
57
+ await self.on_cancelled(message.root)
58
+ case mcp.types.ProgressNotification():
59
+ await self.on_progress(message.root)
60
+ case mcp.types.LoggingMessageNotification():
61
+ await self.on_logging_message(message.root)
62
+ case mcp.types.ToolListChangedNotification():
63
+ await self.on_tool_list_changed(message.root)
64
+ case mcp.types.ResourceListChangedNotification():
65
+ await self.on_resource_list_changed(message.root)
66
+ case mcp.types.PromptListChangedNotification():
67
+ await self.on_prompt_list_changed(message.root)
68
+ case mcp.types.ResourceUpdatedNotification():
69
+ await self.on_resource_updated(message.root)
70
+
71
+ case Exception():
72
+ await self.on_exception(message)
73
+
74
+ async def on_message(self, message: Message) -> None:
75
+ pass
76
+
77
+ async def on_request(
78
+ self, message: RequestResponder[mcp.types.ServerRequest, mcp.types.ClientResult]
79
+ ) -> None:
80
+ pass
81
+
82
+ async def on_ping(self, message: mcp.types.PingRequest) -> None:
83
+ pass
84
+
85
+ async def on_list_roots(self, message: mcp.types.ListRootsRequest) -> None:
86
+ pass
87
+
88
+ async def on_create_message(self, message: mcp.types.CreateMessageRequest) -> None:
89
+ pass
90
+
91
+ async def on_notification(self, message: mcp.types.ServerNotification) -> None:
92
+ pass
93
+
94
+ async def on_exception(self, message: Exception) -> None:
95
+ pass
96
+
97
+ async def on_progress(self, message: mcp.types.ProgressNotification) -> None:
98
+ pass
99
+
100
+ async def on_logging_message(
101
+ self, message: mcp.types.LoggingMessageNotification
102
+ ) -> None:
103
+ pass
104
+
105
+ async def on_tool_list_changed(
106
+ self, message: mcp.types.ToolListChangedNotification
107
+ ) -> None:
108
+ pass
109
+
110
+ async def on_resource_list_changed(
111
+ self, message: mcp.types.ResourceListChangedNotification
112
+ ) -> None:
113
+ pass
114
+
115
+ async def on_prompt_list_changed(
116
+ self, message: mcp.types.PromptListChangedNotification
117
+ ) -> None:
118
+ pass
119
+
120
+ async def on_resource_updated(
121
+ self, message: mcp.types.ResourceUpdatedNotification
122
+ ) -> None:
123
+ pass
124
+
125
+ async def on_cancelled(self, message: mcp.types.CancelledNotification) -> None:
126
+ pass
fastmcp/prompts/prompt.py CHANGED
@@ -70,6 +70,22 @@ class Prompt(FastMCPComponent, ABC):
70
70
  default=None, description="Arguments that can be passed to the prompt"
71
71
  )
72
72
 
73
+ def enable(self) -> None:
74
+ super().enable()
75
+ try:
76
+ context = get_context()
77
+ context._queue_prompt_list_changed() # type: ignore[private-use]
78
+ except RuntimeError:
79
+ pass # No context available
80
+
81
+ def disable(self) -> None:
82
+ super().disable()
83
+ try:
84
+ context = get_context()
85
+ context._queue_prompt_list_changed() # type: ignore[private-use]
86
+ except RuntimeError:
87
+ pass # No context available
88
+
73
89
  def to_mcp_prompt(self, **overrides: Any) -> MCPPrompt:
74
90
  """Convert the prompt to an MCP prompt."""
75
91
  arguments = [
@@ -339,6 +355,6 @@ class FunctionPrompt(Prompt):
339
355
  raise PromptError("Could not convert prompt result to message.")
340
356
 
341
357
  return messages
342
- except Exception as e:
343
- logger.exception(f"Error rendering prompt {self.name}: {e}")
358
+ except Exception:
359
+ logger.exception(f"Error rendering prompt {self.name}")
344
360
  raise PromptError(f"Error rendering prompt {self.name}.")
@@ -172,12 +172,12 @@ class PromptManager:
172
172
 
173
173
  # Pass through PromptErrors as-is
174
174
  except PromptError as e:
175
- logger.exception(f"Error rendering prompt {name!r}: {e}")
175
+ logger.exception(f"Error rendering prompt {name!r}")
176
176
  raise e
177
177
 
178
178
  # Handle other exceptions
179
179
  except Exception as e:
180
- logger.exception(f"Error rendering prompt {name!r}: {e}")
180
+ logger.exception(f"Error rendering prompt {name!r}")
181
181
  if self.mask_error_details:
182
182
  # Mask internal details
183
183
  raise PromptError(f"Error rendering prompt {name!r}") from e
@@ -44,6 +44,22 @@ class Resource(FastMCPComponent, abc.ABC):
44
44
  pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$",
45
45
  )
46
46
 
47
+ def enable(self) -> None:
48
+ super().enable()
49
+ try:
50
+ context = get_context()
51
+ context._queue_resource_list_changed() # type: ignore[private-use]
52
+ except RuntimeError:
53
+ pass # No context available
54
+
55
+ def disable(self) -> None:
56
+ super().disable()
57
+ try:
58
+ context = get_context()
59
+ context._queue_resource_list_changed() # type: ignore[private-use]
60
+ except RuntimeError:
61
+ pass # No context available
62
+
47
63
  @staticmethod
48
64
  def from_function(
49
65
  fn: Callable[[], Any],
@@ -422,12 +422,12 @@ class ResourceManager:
422
422
 
423
423
  # raise ResourceErrors as-is
424
424
  except ResourceError as e:
425
- logger.exception(f"Error reading resource {uri_str!r}: {e}")
425
+ logger.exception(f"Error reading resource {uri_str!r}")
426
426
  raise e
427
427
 
428
428
  # Handle other exceptions
429
429
  except Exception as e:
430
- logger.exception(f"Error reading resource {uri_str!r}: {e}")
430
+ logger.exception(f"Error reading resource {uri_str!r}")
431
431
  if self.mask_error_details:
432
432
  # Mask internal details
433
433
  raise ResourceError(f"Error reading resource {uri_str!r}") from e
@@ -445,12 +445,12 @@ class ResourceManager:
445
445
  return await resource.read()
446
446
  except ResourceError as e:
447
447
  logger.exception(
448
- f"Error reading resource from template {uri_str!r}: {e}"
448
+ f"Error reading resource from template {uri_str!r}"
449
449
  )
450
450
  raise e
451
451
  except Exception as e:
452
452
  logger.exception(
453
- f"Error reading resource from template {uri_str!r}: {e}"
453
+ f"Error reading resource from template {uri_str!r}"
454
454
  )
455
455
  if self.mask_error_details:
456
456
  raise ResourceError(
@@ -15,7 +15,7 @@ from pydantic import (
15
15
  validate_call,
16
16
  )
17
17
 
18
- from fastmcp.resources.types import Resource
18
+ from fastmcp.resources.resource import Resource
19
19
  from fastmcp.server.dependencies import get_context
20
20
  from fastmcp.utilities.components import FastMCPComponent
21
21
  from fastmcp.utilities.json_schema import compress_schema
@@ -65,6 +65,22 @@ class ResourceTemplate(FastMCPComponent):
65
65
  def __repr__(self) -> str:
66
66
  return f"{self.__class__.__name__}(uri_template={self.uri_template!r}, name={self.name!r}, description={self.description!r}, tags={self.tags})"
67
67
 
68
+ def enable(self) -> None:
69
+ super().enable()
70
+ try:
71
+ context = get_context()
72
+ context._queue_resource_list_changed() # type: ignore[private-use]
73
+ except RuntimeError:
74
+ pass # No context available
75
+
76
+ def disable(self) -> None:
77
+ super().disable()
78
+ try:
79
+ context = get_context()
80
+ context._queue_resource_list_changed() # type: ignore[private-use]
81
+ except RuntimeError:
82
+ pass # No context available
83
+
68
84
  @staticmethod
69
85
  def from_function(
70
86
  fn: Callable[..., Any],
@@ -24,6 +24,7 @@ from fastmcp.server.auth.auth import (
24
24
  OAuthProvider,
25
25
  RevocationOptions,
26
26
  )
27
+ from fastmcp.utilities.logging import get_logger
27
28
 
28
29
 
29
30
  class JWKData(TypedDict, total=False):
@@ -199,6 +200,7 @@ class BearerAuthProvider(OAuthProvider):
199
200
  self.public_key = public_key
200
201
  self.jwks_uri = jwks_uri
201
202
  self.jwt = JsonWebToken(["RS256"])
203
+ self.logger = get_logger(__name__)
202
204
 
203
205
  # Simple JWKS cache
204
206
  self._jwks_cache: dict[str, str] = {}
@@ -265,6 +267,9 @@ class BearerAuthProvider(OAuthProvider):
265
267
  # Select the appropriate key
266
268
  if kid:
267
269
  if kid not in self._jwks_cache:
270
+ self.logger.debug(
271
+ "JWKS key lookup failed: key ID '%s' not found", kid
272
+ )
268
273
  raise ValueError(f"Key ID '{kid}' not found in JWKS")
269
274
  return self._jwks_cache[kid]
270
275
  else:
@@ -279,6 +284,7 @@ class BearerAuthProvider(OAuthProvider):
279
284
  raise ValueError("No keys found in JWKS")
280
285
 
281
286
  except Exception as e:
287
+ self.logger.debug("JWKS fetch failed: %s", str(e))
282
288
  raise ValueError(f"Failed to fetch JWKS: {e}")
283
289
 
284
290
  async def load_access_token(self, token: str) -> AccessToken | None:
@@ -298,15 +304,27 @@ class BearerAuthProvider(OAuthProvider):
298
304
  # Decode and verify the JWT token
299
305
  claims = self.jwt.decode(token, verification_key)
300
306
 
307
+ # Extract client ID early for logging
308
+ client_id = claims.get("client_id") or claims.get("sub") or "unknown"
309
+
301
310
  # Validate expiration
302
311
  exp = claims.get("exp")
303
312
  if exp and exp < time.time():
313
+ self.logger.debug(
314
+ "Token validation failed: expired token for client %s", client_id
315
+ )
316
+ self.logger.info("Bearer token rejected for client %s", client_id)
304
317
  return None
305
318
 
306
319
  # Validate issuer - note we use issuer instead of issuer_url here because
307
320
  # issuer is optional, allowing users to make this check optional
308
321
  if self.issuer:
309
322
  if claims.get("iss") != self.issuer:
323
+ self.logger.debug(
324
+ "Token validation failed: issuer mismatch for client %s",
325
+ client_id,
326
+ )
327
+ self.logger.info("Bearer token rejected for client %s", client_id)
310
328
  return None
311
329
 
312
330
  # Validate audience if configured
@@ -314,26 +332,33 @@ class BearerAuthProvider(OAuthProvider):
314
332
  aud = claims.get("aud")
315
333
 
316
334
  # Handle different combinations of audience types
335
+ audience_valid = False
317
336
  if isinstance(self.audience, list):
318
337
  # self.audience is a list - check if any expected audience is present
319
338
  if isinstance(aud, list):
320
339
  # Both are lists - check for intersection
321
- if not any(expected in aud for expected in self.audience):
322
- return None
340
+ audience_valid = any(
341
+ expected in aud for expected in self.audience
342
+ )
323
343
  else:
324
344
  # aud is a string - check if it's in our expected list
325
- if aud not in self.audience:
326
- return None
345
+ audience_valid = aud in self.audience
327
346
  else:
328
347
  # self.audience is a string - use original logic
329
348
  if isinstance(aud, list):
330
- if self.audience not in aud:
331
- return None
332
- elif aud != self.audience:
333
- return None
349
+ audience_valid = self.audience in aud
350
+ else:
351
+ audience_valid = aud == self.audience
334
352
 
335
- # Extract claims - prefer client_id over sub for OAuth application identification
336
- client_id = claims.get("client_id") or claims.get("sub") or "unknown"
353
+ if not audience_valid:
354
+ self.logger.debug(
355
+ "Token validation failed: audience mismatch for client %s",
356
+ client_id,
357
+ )
358
+ self.logger.info("Bearer token rejected for client %s", client_id)
359
+ return None
360
+
361
+ # Extract scopes
337
362
  scopes = self._extract_scopes(claims)
338
363
 
339
364
  return AccessToken(
@@ -344,8 +369,10 @@ class BearerAuthProvider(OAuthProvider):
344
369
  )
345
370
 
346
371
  except JoseError:
372
+ self.logger.debug("Token validation failed: JWT signature/format invalid")
347
373
  return None
348
- except Exception:
374
+ except Exception as e:
375
+ self.logger.debug("Token validation failed: %s", str(e))
349
376
  return None
350
377
 
351
378
  def _extract_scopes(self, claims: dict[str, Any]) -> list[str]:
fastmcp/server/context.py CHANGED
@@ -1,12 +1,13 @@
1
1
  from __future__ import annotations as _annotations
2
2
 
3
+ import asyncio
3
4
  import warnings
4
5
  from collections.abc import Generator
5
6
  from contextlib import contextmanager
6
7
  from contextvars import ContextVar, Token
7
8
  from dataclasses import dataclass
8
9
 
9
- from mcp import LoggingLevel
10
+ from mcp import LoggingLevel, ServerSession
10
11
  from mcp.server.lowlevel.helper_types import ReadResourceContents
11
12
  from mcp.server.lowlevel.server import request_ctx
12
13
  from mcp.shared.context import RequestContext
@@ -30,6 +31,7 @@ from fastmcp.utilities.types import MCPContent
30
31
  logger = get_logger(__name__)
31
32
 
32
33
  _current_context: ContextVar[Context | None] = ContextVar("context", default=None)
34
+ _flush_lock = asyncio.Lock()
33
35
 
34
36
 
35
37
  @contextmanager
@@ -80,16 +82,20 @@ class Context:
80
82
  def __init__(self, fastmcp: FastMCP):
81
83
  self.fastmcp = fastmcp
82
84
  self._tokens: list[Token] = []
85
+ self._notification_queue: set[str] = set() # Dedupe notifications
83
86
 
84
- def __enter__(self) -> Context:
87
+ async def __aenter__(self) -> Context:
85
88
  """Enter the context manager and set this context as the current context."""
86
89
  # Always set this context and save the token
87
90
  token = _current_context.set(self)
88
91
  self._tokens.append(token)
89
92
  return self
90
93
 
91
- def __exit__(self, exc_type, exc_val, exc_tb) -> None:
94
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
92
95
  """Exit the context manager and reset the most recent token."""
96
+ # Flush any remaining notifications before exiting
97
+ await self._flush_notifications()
98
+
93
99
  if self._tokens:
94
100
  token = self._tokens.pop()
95
101
  _current_context.reset(token)
@@ -124,7 +130,7 @@ class Context:
124
130
  if progress_token is None:
125
131
  return
126
132
 
127
- await self.request_context.session.send_progress_notification(
133
+ await self.session.send_progress_notification(
128
134
  progress_token=progress_token,
129
135
  progress=progress,
130
136
  total=total,
@@ -160,7 +166,7 @@ class Context:
160
166
  """
161
167
  if level is None:
162
168
  level = "info"
163
- await self.request_context.session.send_log_message(
169
+ await self.session.send_log_message(
164
170
  level=level, data=message, logger=logger_name
165
171
  )
166
172
 
@@ -210,7 +216,7 @@ class Context:
210
216
  return None
211
217
 
212
218
  @property
213
- def session(self):
219
+ def session(self) -> ServerSession:
214
220
  """Access to the underlying session for advanced usage."""
215
221
  return self.request_context.session
216
222
 
@@ -233,9 +239,21 @@ class Context:
233
239
 
234
240
  async def list_roots(self) -> list[Root]:
235
241
  """List the roots available to the server, as indicated by the client."""
236
- result = await self.request_context.session.list_roots()
242
+ result = await self.session.list_roots()
237
243
  return result.roots
238
244
 
245
+ async def send_tool_list_changed(self) -> None:
246
+ """Send a tool list changed notification to the client."""
247
+ await self.session.send_tool_list_changed()
248
+
249
+ async def send_resource_list_changed(self) -> None:
250
+ """Send a resource list changed notification to the client."""
251
+ await self.session.send_resource_list_changed()
252
+
253
+ async def send_prompt_list_changed(self) -> None:
254
+ """Send a prompt list changed notification to the client."""
255
+ await self.session.send_prompt_list_changed()
256
+
239
257
  async def sample(
240
258
  self,
241
259
  messages: str | list[str | SamplingMessage],
@@ -269,7 +287,7 @@ class Context:
269
287
  for m in messages
270
288
  ]
271
289
 
272
- result: CreateMessageResult = await self.request_context.session.create_message(
290
+ result: CreateMessageResult = await self.session.create_message(
273
291
  messages=sampling_messages,
274
292
  system_prompt=system_prompt,
275
293
  temperature=temperature,
@@ -294,6 +312,52 @@ class Context:
294
312
 
295
313
  return fastmcp.server.dependencies.get_http_request()
296
314
 
315
+ def _queue_tool_list_changed(self) -> None:
316
+ """Queue a tool list changed notification."""
317
+ self._notification_queue.add("notifications/tools/list_changed")
318
+ self._try_flush_notifications()
319
+
320
+ def _queue_resource_list_changed(self) -> None:
321
+ """Queue a resource list changed notification."""
322
+ self._notification_queue.add("notifications/resources/list_changed")
323
+ self._try_flush_notifications()
324
+
325
+ def _queue_prompt_list_changed(self) -> None:
326
+ """Queue a prompt list changed notification."""
327
+ self._notification_queue.add("notifications/prompts/list_changed")
328
+ self._try_flush_notifications()
329
+
330
+ def _try_flush_notifications(self) -> None:
331
+ """Synchronous method that attempts to flush notifications if we're in an async context."""
332
+ try:
333
+ # Check if we're in an async context
334
+ loop = asyncio.get_running_loop()
335
+ if loop and not loop.is_running():
336
+ return
337
+ # Schedule flush as a task (fire-and-forget)
338
+ asyncio.create_task(self._flush_notifications())
339
+ except RuntimeError:
340
+ # No event loop - will flush later
341
+ pass
342
+
343
+ async def _flush_notifications(self) -> None:
344
+ """Send all queued notifications."""
345
+ async with _flush_lock:
346
+ if not self._notification_queue:
347
+ return
348
+
349
+ try:
350
+ if "notifications/tools/list_changed" in self._notification_queue:
351
+ await self.session.send_tool_list_changed()
352
+ if "notifications/resources/list_changed" in self._notification_queue:
353
+ await self.session.send_resource_list_changed()
354
+ if "notifications/prompts/list_changed" in self._notification_queue:
355
+ await self.session.send_prompt_list_changed()
356
+ self._notification_queue.clear()
357
+ except Exception:
358
+ # Don't let notification failures break the request
359
+ pass
360
+
297
361
  def _parse_model_preferences(
298
362
  self, model_preferences: ModelPreferences | str | list[str] | None
299
363
  ) -> ModelPreferences | None:
@@ -0,0 +1,35 @@
1
+ from typing import Any
2
+
3
+ from mcp.server.lowlevel.server import (
4
+ LifespanResultT,
5
+ NotificationOptions,
6
+ RequestT,
7
+ Server,
8
+ )
9
+ from mcp.server.models import InitializationOptions
10
+
11
+
12
+ class LowLevelServer(Server[LifespanResultT, RequestT]):
13
+ def __init__(self, *args, **kwargs):
14
+ super().__init__(*args, **kwargs)
15
+ # FastMCP servers support notifications for all components
16
+ self.notification_options = NotificationOptions(
17
+ prompts_changed=True,
18
+ resources_changed=True,
19
+ tools_changed=True,
20
+ )
21
+
22
+ def create_initialization_options(
23
+ self,
24
+ notification_options: NotificationOptions | None = None,
25
+ experimental_capabilities: dict[str, dict[str, Any]] | None = None,
26
+ **kwargs: Any,
27
+ ) -> InitializationOptions:
28
+ # ensure we use the FastMCP notification options
29
+ if notification_options is None:
30
+ notification_options = self.notification_options
31
+ return super().create_initialization_options(
32
+ notification_options=notification_options,
33
+ experimental_capabilities=experimental_capabilities,
34
+ **kwargs,
35
+ )