fastmcp 2.8.1__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.
Files changed (43) hide show
  1. fastmcp/cli/cli.py +99 -1
  2. fastmcp/cli/run.py +1 -3
  3. fastmcp/client/auth/oauth.py +1 -2
  4. fastmcp/client/client.py +23 -7
  5. fastmcp/client/logging.py +1 -2
  6. fastmcp/client/messages.py +126 -0
  7. fastmcp/client/transports.py +17 -2
  8. fastmcp/contrib/mcp_mixin/README.md +79 -2
  9. fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -0
  10. fastmcp/prompts/prompt.py +109 -13
  11. fastmcp/prompts/prompt_manager.py +119 -43
  12. fastmcp/resources/resource.py +27 -1
  13. fastmcp/resources/resource_manager.py +249 -76
  14. fastmcp/resources/template.py +44 -2
  15. fastmcp/server/auth/providers/bearer.py +62 -13
  16. fastmcp/server/context.py +113 -10
  17. fastmcp/server/http.py +8 -0
  18. fastmcp/server/low_level.py +35 -0
  19. fastmcp/server/middleware/__init__.py +6 -0
  20. fastmcp/server/middleware/error_handling.py +206 -0
  21. fastmcp/server/middleware/logging.py +165 -0
  22. fastmcp/server/middleware/middleware.py +236 -0
  23. fastmcp/server/middleware/rate_limiting.py +231 -0
  24. fastmcp/server/middleware/timing.py +156 -0
  25. fastmcp/server/proxy.py +250 -140
  26. fastmcp/server/server.py +446 -280
  27. fastmcp/settings.py +2 -2
  28. fastmcp/tools/tool.py +22 -2
  29. fastmcp/tools/tool_manager.py +114 -45
  30. fastmcp/tools/tool_transform.py +42 -16
  31. fastmcp/utilities/components.py +22 -2
  32. fastmcp/utilities/inspect.py +326 -0
  33. fastmcp/utilities/json_schema.py +67 -23
  34. fastmcp/utilities/mcp_config.py +13 -7
  35. fastmcp/utilities/openapi.py +75 -5
  36. fastmcp/utilities/tests.py +1 -1
  37. fastmcp/utilities/types.py +90 -1
  38. {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/METADATA +2 -2
  39. fastmcp-2.9.1.dist-info/RECORD +78 -0
  40. fastmcp-2.8.1.dist-info/RECORD +0 -69
  41. {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/WHEEL +0 -0
  42. {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/entry_points.txt +0 -0
  43. {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,9 +1,11 @@
1
1
  """Resource manager functionality."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  import inspect
4
6
  import warnings
5
7
  from collections.abc import Callable
6
- from typing import Any
8
+ from typing import TYPE_CHECKING, Any
7
9
 
8
10
  from pydantic import AnyUrl
9
11
 
@@ -17,6 +19,9 @@ from fastmcp.resources.template import (
17
19
  from fastmcp.settings import DuplicateBehavior
18
20
  from fastmcp.utilities.logging import get_logger
19
21
 
22
+ if TYPE_CHECKING:
23
+ from fastmcp.server.server import MountedServer
24
+
20
25
  logger = get_logger(__name__)
21
26
 
22
27
 
@@ -38,6 +43,7 @@ class ResourceManager:
38
43
  """
39
44
  self._resources: dict[str, Resource] = {}
40
45
  self._templates: dict[str, ResourceTemplate] = {}
46
+ self._mounted_servers: list[MountedServer] = []
41
47
  self.mask_error_details = mask_error_details or settings.mask_error_details
42
48
 
43
49
  # Default to "warn" if None is provided
@@ -51,6 +57,128 @@ class ResourceManager:
51
57
  )
52
58
  self.duplicate_behavior = duplicate_behavior
53
59
 
60
+ def mount(self, server: MountedServer) -> None:
61
+ """Adds a mounted server as a source for resources and templates."""
62
+ self._mounted_servers.append(server)
63
+
64
+ async def get_resources(self) -> dict[str, Resource]:
65
+ """Get all registered resources, keyed by URI."""
66
+ return await self._load_resources(via_server=False)
67
+
68
+ async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
69
+ """Get all registered templates, keyed by URI template."""
70
+ return await self._load_resource_templates(via_server=False)
71
+
72
+ async def _load_resources(self, *, via_server: bool = False) -> dict[str, Resource]:
73
+ """
74
+ The single, consolidated recursive method for fetching resources. The 'via_server'
75
+ parameter determines the communication path.
76
+
77
+ - via_server=False: Manager-to-manager path for complete, unfiltered inventory
78
+ - via_server=True: Server-to-server path for filtered MCP requests
79
+ """
80
+ all_resources: dict[str, Resource] = {}
81
+
82
+ for mounted in self._mounted_servers:
83
+ try:
84
+ if via_server:
85
+ # Use the server-to-server filtered path
86
+ child_resources_list = await mounted.server._list_resources()
87
+ child_resources = {
88
+ resource.key: resource for resource in child_resources_list
89
+ }
90
+ else:
91
+ # Use the manager-to-manager unfiltered path
92
+ child_resources = (
93
+ await mounted.server._resource_manager.get_resources()
94
+ )
95
+
96
+ # Apply prefix if needed
97
+ if mounted.prefix:
98
+ from fastmcp.server.server import add_resource_prefix
99
+
100
+ for uri, resource in child_resources.items():
101
+ prefixed_uri = add_resource_prefix(
102
+ uri, mounted.prefix, mounted.resource_prefix_format
103
+ )
104
+ # Create a copy of the resource with the prefixed key
105
+ prefixed_resource = resource.with_key(prefixed_uri)
106
+ all_resources[prefixed_uri] = prefixed_resource
107
+ else:
108
+ all_resources.update(child_resources)
109
+ except Exception as e:
110
+ # Skip failed mounts silently, matches existing behavior
111
+ logger.warning(
112
+ f"Failed to get resources from mounted server '{mounted.prefix}': {e}"
113
+ )
114
+ continue
115
+
116
+ # Finally, add local resources, which always take precedence
117
+ all_resources.update(self._resources)
118
+ return all_resources
119
+
120
+ async def _load_resource_templates(
121
+ self, *, via_server: bool = False
122
+ ) -> dict[str, ResourceTemplate]:
123
+ """
124
+ The single, consolidated recursive method for fetching templates. The 'via_server'
125
+ parameter determines the communication path.
126
+
127
+ - via_server=False: Manager-to-manager path for complete, unfiltered inventory
128
+ - via_server=True: Server-to-server path for filtered MCP requests
129
+ """
130
+ all_templates: dict[str, ResourceTemplate] = {}
131
+
132
+ for mounted in self._mounted_servers:
133
+ try:
134
+ if via_server:
135
+ # Use the server-to-server filtered path
136
+ child_templates = await mounted.server._list_resource_templates()
137
+ else:
138
+ # Use the manager-to-manager unfiltered path
139
+ child_templates = (
140
+ await mounted.server._resource_manager.list_resource_templates()
141
+ )
142
+ child_dict = {template.key: template for template in child_templates}
143
+
144
+ # Apply prefix if needed
145
+ if mounted.prefix:
146
+ from fastmcp.server.server import add_resource_prefix
147
+
148
+ for uri_template, template in child_dict.items():
149
+ prefixed_uri_template = add_resource_prefix(
150
+ uri_template, mounted.prefix, mounted.resource_prefix_format
151
+ )
152
+ # Create a copy of the template with the prefixed key
153
+ prefixed_template = template.with_key(prefixed_uri_template)
154
+ all_templates[prefixed_uri_template] = prefixed_template
155
+ else:
156
+ all_templates.update(child_dict)
157
+ except Exception as e:
158
+ # Skip failed mounts silently, matches existing behavior
159
+ logger.warning(
160
+ f"Failed to get templates from mounted server '{mounted.prefix}': {e}"
161
+ )
162
+ continue
163
+
164
+ # Finally, add local templates, which always take precedence
165
+ all_templates.update(self._templates)
166
+ return all_templates
167
+
168
+ async def list_resources(self) -> list[Resource]:
169
+ """
170
+ Lists all resources, applying protocol filtering.
171
+ """
172
+ resources_dict = await self._load_resources(via_server=True)
173
+ return list(resources_dict.values())
174
+
175
+ async def list_resource_templates(self) -> list[ResourceTemplate]:
176
+ """
177
+ Lists all templates, applying protocol filtering.
178
+ """
179
+ templates_dict = await self._load_resource_templates(via_server=True)
180
+ return list(templates_dict.values())
181
+
54
182
  def add_resource_or_template_from_fn(
55
183
  self,
56
184
  fn: Callable[..., Any],
@@ -139,35 +267,26 @@ class ResourceManager:
139
267
  )
140
268
  return self.add_resource(resource)
141
269
 
142
- def add_resource(self, resource: Resource, key: str | None = None) -> Resource:
270
+ def add_resource(self, resource: Resource) -> Resource:
143
271
  """Add a resource to the manager.
144
272
 
145
273
  Args:
146
- resource: A Resource instance to add
147
- key: Optional URI to use as the storage key (if different from resource.uri)
274
+ resource: A Resource instance to add. The resource's .key attribute
275
+ will be used as the storage key. To overwrite it, call
276
+ Resource.with_key() before calling this method.
148
277
  """
149
- storage_key = key or str(resource.uri)
150
- logger.debug(
151
- "Adding resource",
152
- extra={
153
- "uri": resource.uri,
154
- "storage_key": storage_key,
155
- "type": type(resource).__name__,
156
- "resource_name": resource.name,
157
- },
158
- )
159
- existing = self._resources.get(storage_key)
278
+ existing = self._resources.get(resource.key)
160
279
  if existing:
161
280
  if self.duplicate_behavior == "warn":
162
- logger.warning(f"Resource already exists: {storage_key}")
163
- self._resources[storage_key] = resource
281
+ logger.warning(f"Resource already exists: {resource.key}")
282
+ self._resources[resource.key] = resource
164
283
  elif self.duplicate_behavior == "replace":
165
- self._resources[storage_key] = resource
284
+ self._resources[resource.key] = resource
166
285
  elif self.duplicate_behavior == "error":
167
- raise ValueError(f"Resource already exists: {storage_key}")
286
+ raise ValueError(f"Resource already exists: {resource.key}")
168
287
  elif self.duplicate_behavior == "ignore":
169
288
  return existing
170
- self._resources[storage_key] = resource
289
+ self._resources[resource.key] = resource
171
290
  return resource
172
291
 
173
292
  def add_template_from_fn(
@@ -197,52 +316,47 @@ class ResourceManager:
197
316
  )
198
317
  return self.add_template(template)
199
318
 
200
- def add_template(
201
- self, template: ResourceTemplate, key: str | None = None
202
- ) -> ResourceTemplate:
319
+ def add_template(self, template: ResourceTemplate) -> ResourceTemplate:
203
320
  """Add a template to the manager.
204
321
 
205
322
  Args:
206
- template: A ResourceTemplate instance to add
207
- key: Optional URI template to use as the storage key (if different from template.uri_template)
323
+ template: A ResourceTemplate instance to add. The template's .key attribute
324
+ will be used as the storage key. To overwrite it, call
325
+ ResourceTemplate.with_key() before calling this method.
208
326
 
209
327
  Returns:
210
328
  The added template. If a template with the same URI already exists,
211
329
  returns the existing template.
212
330
  """
213
- uri_template_str = str(template.uri_template)
214
- storage_key = key or uri_template_str
215
- logger.debug(
216
- "Adding template",
217
- extra={
218
- "uri_template": uri_template_str,
219
- "storage_key": storage_key,
220
- "type": type(template).__name__,
221
- "template_name": template.name,
222
- },
223
- )
224
- existing = self._templates.get(storage_key)
331
+ existing = self._templates.get(template.key)
225
332
  if existing:
226
333
  if self.duplicate_behavior == "warn":
227
- logger.warning(f"Template already exists: {storage_key}")
228
- self._templates[storage_key] = template
334
+ logger.warning(f"Template already exists: {template.key}")
335
+ self._templates[template.key] = template
229
336
  elif self.duplicate_behavior == "replace":
230
- self._templates[storage_key] = template
337
+ self._templates[template.key] = template
231
338
  elif self.duplicate_behavior == "error":
232
- raise ValueError(f"Template already exists: {storage_key}")
339
+ raise ValueError(f"Template already exists: {template.key}")
233
340
  elif self.duplicate_behavior == "ignore":
234
341
  return existing
235
- self._templates[storage_key] = template
342
+ self._templates[template.key] = template
236
343
  return template
237
344
 
238
- def has_resource(self, uri: AnyUrl | str) -> bool:
345
+ async def has_resource(self, uri: AnyUrl | str) -> bool:
239
346
  """Check if a resource exists."""
240
347
  uri_str = str(uri)
241
- if uri_str in self._resources:
348
+
349
+ # First check concrete resources (local and mounted)
350
+ resources = await self.get_resources()
351
+ if uri_str in resources:
242
352
  return True
243
- for template_key in self._templates.keys():
353
+
354
+ # Then check templates (local and mounted) only if not found in concrete resources
355
+ templates = await self.get_resource_templates()
356
+ for template_key in templates.keys():
244
357
  if match_uri_template(uri_str, template_key):
245
358
  return True
359
+
246
360
  return False
247
361
 
248
362
  async def get_resource(self, uri: AnyUrl | str) -> Resource:
@@ -257,12 +371,14 @@ class ResourceManager:
257
371
  uri_str = str(uri)
258
372
  logger.debug("Getting resource", extra={"uri": uri_str})
259
373
 
260
- # First check concrete resources
261
- if resource := self._resources.get(uri_str):
374
+ # First check concrete resources (local and mounted)
375
+ resources = await self.get_resources()
376
+ if resource := resources.get(uri_str):
262
377
  return resource
263
378
 
264
- # Then check templates - use the utility function to match against storage keys
265
- for storage_key, template in self._templates.items():
379
+ # Then check templates (local and mounted) - use the utility function to match against storage keys
380
+ templates = await self.get_resource_templates()
381
+ for storage_key, template in templates.items():
266
382
  # Try to match against the storage key (which might be a custom key)
267
383
  if params := match_uri_template(uri_str, storage_key):
268
384
  try:
@@ -289,31 +405,88 @@ class ResourceManager:
289
405
  raise NotFoundError(f"Unknown resource: {uri_str}")
290
406
 
291
407
  async def read_resource(self, uri: AnyUrl | str) -> str | bytes:
292
- """Read a resource contents."""
293
- resource = await self.get_resource(uri)
294
-
295
- try:
296
- return await resource.read()
297
-
298
- # raise ResourceErrors as-is
299
- except ResourceError as e:
300
- logger.error(f"Error reading resource {uri!r}: {e}")
301
- raise e
302
-
303
- # Handle other exceptions
304
- except Exception as e:
305
- logger.error(f"Error reading resource {uri!r}: {e}")
306
- if self.mask_error_details:
307
- # Mask internal details
308
- raise ResourceError(f"Error reading resource {uri!r}") from e
309
- else:
310
- # Include original error details
311
- raise ResourceError(f"Error reading resource {uri!r}: {e}") from e
312
-
313
- def get_resources(self) -> dict[str, Resource]:
314
- """Get all registered resources, keyed by URI."""
315
- return self._resources
408
+ """
409
+ Internal API for servers: Finds and reads a resource, respecting the
410
+ filtered protocol path.
411
+ """
412
+ uri_str = str(uri)
316
413
 
317
- def get_templates(self) -> dict[str, ResourceTemplate]:
318
- """Get all registered templates, keyed by URI template."""
319
- return self._templates
414
+ # 1. Check local resources first. The server will have already applied its filter.
415
+ if uri_str in self._resources:
416
+ resource = await self.get_resource(uri_str)
417
+ if not resource:
418
+ raise NotFoundError(f"Resource {uri_str!r} not found")
419
+
420
+ try:
421
+ return await resource.read()
422
+
423
+ # raise ResourceErrors as-is
424
+ except ResourceError as e:
425
+ logger.exception(f"Error reading resource {uri_str!r}")
426
+ raise e
427
+
428
+ # Handle other exceptions
429
+ except Exception as e:
430
+ logger.exception(f"Error reading resource {uri_str!r}")
431
+ if self.mask_error_details:
432
+ # Mask internal details
433
+ raise ResourceError(f"Error reading resource {uri_str!r}") from e
434
+ else:
435
+ # Include original error details
436
+ raise ResourceError(
437
+ f"Error reading resource {uri_str!r}: {e}"
438
+ ) from e
439
+
440
+ # 1b. Check local templates if not found in concrete resources
441
+ for key, template in self._templates.items():
442
+ if params := match_uri_template(uri_str, key):
443
+ try:
444
+ resource = await template.create_resource(uri_str, params=params)
445
+ return await resource.read()
446
+ except ResourceError as e:
447
+ logger.exception(
448
+ f"Error reading resource from template {uri_str!r}"
449
+ )
450
+ raise e
451
+ except Exception as e:
452
+ logger.exception(
453
+ f"Error reading resource from template {uri_str!r}"
454
+ )
455
+ if self.mask_error_details:
456
+ raise ResourceError(
457
+ f"Error reading resource from template {uri_str!r}"
458
+ ) from e
459
+ else:
460
+ raise ResourceError(
461
+ f"Error reading resource from template {uri_str!r}: {e}"
462
+ ) from e
463
+
464
+ # 2. Check mounted servers using the filtered protocol path.
465
+ from fastmcp.server.server import has_resource_prefix, remove_resource_prefix
466
+
467
+ for mounted in reversed(self._mounted_servers):
468
+ key = uri_str
469
+ try:
470
+ if mounted.prefix:
471
+ if has_resource_prefix(
472
+ key,
473
+ mounted.prefix,
474
+ mounted.resource_prefix_format,
475
+ ):
476
+ key = remove_resource_prefix(
477
+ key,
478
+ mounted.prefix,
479
+ mounted.resource_prefix_format,
480
+ )
481
+ else:
482
+ continue
483
+
484
+ try:
485
+ result = await mounted.server._read_resource(key)
486
+ return result[0].content
487
+ except NotFoundError:
488
+ continue
489
+ except NotFoundError:
490
+ continue
491
+
492
+ raise NotFoundError(f"Resource {uri_str!r} not found.")
@@ -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
@@ -62,6 +62,25 @@ class ResourceTemplate(FastMCPComponent):
62
62
  description="JSON schema for function parameters"
63
63
  )
64
64
 
65
+ def __repr__(self) -> str:
66
+ return f"{self.__class__.__name__}(uri_template={self.uri_template!r}, name={self.name!r}, description={self.description!r}, tags={self.tags})"
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
+
65
84
  @staticmethod
66
85
  def from_function(
67
86
  fn: Callable[..., Any],
@@ -128,6 +147,29 @@ class ResourceTemplate(FastMCPComponent):
128
147
  }
129
148
  return MCPResourceTemplate(**kwargs | overrides)
130
149
 
150
+ @classmethod
151
+ def from_mcp_template(cls, mcp_template: MCPResourceTemplate) -> ResourceTemplate:
152
+ """Creates a FastMCP ResourceTemplate from a raw MCP ResourceTemplate object."""
153
+ # Note: This creates a simple ResourceTemplate instance. For function-based templates,
154
+ # the original function is lost, which is expected for remote templates.
155
+ return cls(
156
+ uri_template=mcp_template.uriTemplate,
157
+ name=mcp_template.name,
158
+ description=mcp_template.description,
159
+ mime_type=mcp_template.mimeType or "text/plain",
160
+ parameters={}, # Remote templates don't have local parameters
161
+ )
162
+
163
+ @property
164
+ def key(self) -> str:
165
+ """
166
+ The key of the component. This is used for internal bookkeeping
167
+ and may reflect e.g. prefixes or other identifiers. You should not depend on
168
+ keys having a certain value, as the same tool loaded from different
169
+ hierarchies of servers may have different keys.
170
+ """
171
+ return self._key or self.uri_template
172
+
131
173
 
132
174
  class FunctionResourceTemplate(ResourceTemplate):
133
175
  """A template for dynamically creating resources."""
@@ -214,7 +256,7 @@ class FunctionResourceTemplate(ResourceTemplate):
214
256
  f"URI parameters {uri_params} must be a subset of the function arguments: {func_params}"
215
257
  )
216
258
 
217
- description = description or fn.__doc__
259
+ description = description or inspect.getdoc(fn)
218
260
 
219
261
  # if the fn is a callable class, we need to get the __call__ method from here out
220
262
  if not inspect.isroutine(fn):
@@ -17,13 +17,14 @@ from mcp.shared.auth import (
17
17
  OAuthClientInformationFull,
18
18
  OAuthToken,
19
19
  )
20
- from pydantic import SecretStr
20
+ from pydantic import AnyHttpUrl, SecretStr, ValidationError
21
21
 
22
22
  from fastmcp.server.auth.auth import (
23
23
  ClientRegistrationOptions,
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):
@@ -89,7 +90,7 @@ class RSAKeyPair:
89
90
  self,
90
91
  subject: str = "fastmcp-user",
91
92
  issuer: str = "https://fastmcp.example.com",
92
- audience: str | None = None,
93
+ audience: str | list[str] | None = None,
93
94
  scopes: list[str] | None = None,
94
95
  expires_in_seconds: int = 3600,
95
96
  additional_claims: dict[str, Any] | None = None,
@@ -102,7 +103,7 @@ class RSAKeyPair:
102
103
  private_key_pem: RSA private key in PEM format
103
104
  subject: Subject claim (usually user ID)
104
105
  issuer: Issuer claim
105
- audience: Audience claim (optional)
106
+ audience: Audience claim - can be a string or list of strings (optional)
106
107
  scopes: List of scopes to include
107
108
  expires_in_seconds: Token expiration time in seconds
108
109
  additional_claims: Any additional claims to include
@@ -161,7 +162,7 @@ class BearerAuthProvider(OAuthProvider):
161
162
  public_key: str | None = None,
162
163
  jwks_uri: str | None = None,
163
164
  issuer: str | None = None,
164
- audience: str | None = None,
165
+ audience: str | list[str] | None = None,
165
166
  required_scopes: list[str] | None = None,
166
167
  ):
167
168
  """
@@ -171,7 +172,7 @@ class BearerAuthProvider(OAuthProvider):
171
172
  public_key: RSA public key in PEM format (for static key)
172
173
  jwks_uri: URI to fetch keys from (for key rotation)
173
174
  issuer: Expected issuer claim (optional)
174
- audience: Expected audience claim (optional)
175
+ audience: Expected audience claim - can be a string or list of strings (optional)
175
176
  required_scopes: List of required scopes for access (optional)
176
177
  """
177
178
  if not (public_key or jwks_uri):
@@ -179,8 +180,16 @@ class BearerAuthProvider(OAuthProvider):
179
180
  if public_key and jwks_uri:
180
181
  raise ValueError("Provide either public_key or jwks_uri, not both")
181
182
 
183
+ # Only pass issuer to parent if it's a valid URL, otherwise use default
184
+ # This allows the issuer claim validation to work with string issuers per RFC 7519
185
+ try:
186
+ issuer_url = AnyHttpUrl(issuer) if issuer else "https://fastmcp.example.com"
187
+ except ValidationError:
188
+ # Issuer is not a valid URL, use default for parent class
189
+ issuer_url = "https://fastmcp.example.com"
190
+
182
191
  super().__init__(
183
- issuer_url=issuer or "https://fastmcp.example.com",
192
+ issuer_url=issuer_url,
184
193
  client_registration_options=ClientRegistrationOptions(enabled=False),
185
194
  revocation_options=RevocationOptions(enabled=False),
186
195
  required_scopes=required_scopes,
@@ -191,6 +200,7 @@ class BearerAuthProvider(OAuthProvider):
191
200
  self.public_key = public_key
192
201
  self.jwks_uri = jwks_uri
193
202
  self.jwt = JsonWebToken(["RS256"])
203
+ self.logger = get_logger(__name__)
194
204
 
195
205
  # Simple JWKS cache
196
206
  self._jwks_cache: dict[str, str] = {}
@@ -257,6 +267,9 @@ class BearerAuthProvider(OAuthProvider):
257
267
  # Select the appropriate key
258
268
  if kid:
259
269
  if kid not in self._jwks_cache:
270
+ self.logger.debug(
271
+ "JWKS key lookup failed: key ID '%s' not found", kid
272
+ )
260
273
  raise ValueError(f"Key ID '{kid}' not found in JWKS")
261
274
  return self._jwks_cache[kid]
262
275
  else:
@@ -271,6 +284,7 @@ class BearerAuthProvider(OAuthProvider):
271
284
  raise ValueError("No keys found in JWKS")
272
285
 
273
286
  except Exception as e:
287
+ self.logger.debug("JWKS fetch failed: %s", str(e))
274
288
  raise ValueError(f"Failed to fetch JWKS: {e}")
275
289
 
276
290
  async def load_access_token(self, token: str) -> AccessToken | None:
@@ -290,28 +304,61 @@ class BearerAuthProvider(OAuthProvider):
290
304
  # Decode and verify the JWT token
291
305
  claims = self.jwt.decode(token, verification_key)
292
306
 
307
+ # Extract client ID early for logging
308
+ client_id = claims.get("client_id") or claims.get("sub") or "unknown"
309
+
293
310
  # Validate expiration
294
311
  exp = claims.get("exp")
295
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)
296
317
  return None
297
318
 
298
319
  # Validate issuer - note we use issuer instead of issuer_url here because
299
320
  # issuer is optional, allowing users to make this check optional
300
321
  if self.issuer:
301
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)
302
328
  return None
303
329
 
304
330
  # Validate audience if configured
305
331
  if self.audience:
306
332
  aud = claims.get("aud")
307
- if isinstance(aud, list):
308
- if self.audience not in aud:
309
- return None
310
- elif aud != self.audience:
333
+
334
+ # Handle different combinations of audience types
335
+ audience_valid = False
336
+ if isinstance(self.audience, list):
337
+ # self.audience is a list - check if any expected audience is present
338
+ if isinstance(aud, list):
339
+ # Both are lists - check for intersection
340
+ audience_valid = any(
341
+ expected in aud for expected in self.audience
342
+ )
343
+ else:
344
+ # aud is a string - check if it's in our expected list
345
+ audience_valid = aud in self.audience
346
+ else:
347
+ # self.audience is a string - use original logic
348
+ if isinstance(aud, list):
349
+ audience_valid = self.audience in aud
350
+ else:
351
+ audience_valid = aud == self.audience
352
+
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)
311
359
  return None
312
360
 
313
- # Extract claims - prefer client_id over sub for OAuth application identification
314
- client_id = claims.get("client_id") or claims.get("sub") or "unknown"
361
+ # Extract scopes
315
362
  scopes = self._extract_scopes(claims)
316
363
 
317
364
  return AccessToken(
@@ -322,8 +369,10 @@ class BearerAuthProvider(OAuthProvider):
322
369
  )
323
370
 
324
371
  except JoseError:
372
+ self.logger.debug("Token validation failed: JWT signature/format invalid")
325
373
  return None
326
- except Exception:
374
+ except Exception as e:
375
+ self.logger.debug("Token validation failed: %s", str(e))
327
376
  return None
328
377
 
329
378
  def _extract_scopes(self, claims: dict[str, Any]) -> list[str]: