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
@@ -6,9 +6,9 @@ import inspect
6
6
  import re
7
7
  from collections.abc import Callable
8
8
  from typing import Any
9
- from urllib.parse import unquote
9
+ from urllib.parse import parse_qs, unquote
10
10
 
11
- from mcp.types import Annotations
11
+ from mcp.types import Annotations, Icon
12
12
  from mcp.types import ResourceTemplate as MCPResourceTemplate
13
13
  from pydantic import (
14
14
  Field,
@@ -26,8 +26,26 @@ from fastmcp.utilities.types import (
26
26
  )
27
27
 
28
28
 
29
+ def extract_query_params(uri_template: str) -> set[str]:
30
+ """Extract query parameter names from RFC 6570 `{?param1,param2}` syntax."""
31
+ match = re.search(r"\{\?([^}]+)\}", uri_template)
32
+ if match:
33
+ return {p.strip() for p in match.group(1).split(",")}
34
+ return set()
35
+
36
+
29
37
  def build_regex(template: str) -> re.Pattern:
30
- parts = re.split(r"(\{[^}]+\})", template)
38
+ """Build regex pattern for URI template, handling RFC 6570 syntax.
39
+
40
+ Supports:
41
+ - `{var}` - simple path parameter
42
+ - `{var*}` - wildcard path parameter (captures multiple segments)
43
+ - `{?var1,var2}` - query parameters (ignored in path matching)
44
+ """
45
+ # Remove query parameter syntax for path matching
46
+ template_without_query = re.sub(r"\{\?[^}]+\}", "", template)
47
+
48
+ parts = re.split(r"(\{[^}]+\})", template_without_query)
31
49
  pattern = ""
32
50
  for part in parts:
33
51
  if part.startswith("{") and part.endswith("}"):
@@ -43,11 +61,34 @@ def build_regex(template: str) -> re.Pattern:
43
61
 
44
62
 
45
63
  def match_uri_template(uri: str, uri_template: str) -> dict[str, str] | None:
64
+ """Match URI against template and extract both path and query parameters.
65
+
66
+ Supports RFC 6570 URI templates:
67
+ - Path params: `{var}`, `{var*}`
68
+ - Query params: `{?var1,var2}`
69
+ """
70
+ # Split URI into path and query parts
71
+ uri_path, _, query_string = uri.partition("?")
72
+
73
+ # Match path parameters
46
74
  regex = build_regex(uri_template)
47
- match = regex.match(uri)
48
- if match:
49
- return {k: unquote(v) for k, v in match.groupdict().items()}
50
- return None
75
+ match = regex.match(uri_path)
76
+ if not match:
77
+ return None
78
+
79
+ params = {k: unquote(v) for k, v in match.groupdict().items()}
80
+
81
+ # Extract query parameters if present in URI and template
82
+ if query_string:
83
+ query_param_names = extract_query_params(uri_template)
84
+ parsed_query = parse_qs(query_string)
85
+
86
+ for name in query_param_names:
87
+ if name in parsed_query:
88
+ # Take first value if multiple provided
89
+ params[name] = parsed_query[name][0] # type: ignore[index]
90
+
91
+ return params
51
92
 
52
93
 
53
94
  class ResourceTemplate(FastMCPComponent):
@@ -92,6 +133,7 @@ class ResourceTemplate(FastMCPComponent):
92
133
  name: str | None = None,
93
134
  title: str | None = None,
94
135
  description: str | None = None,
136
+ icons: list[Icon] | None = None,
95
137
  mime_type: str | None = None,
96
138
  tags: set[str] | None = None,
97
139
  enabled: bool | None = None,
@@ -104,6 +146,7 @@ class ResourceTemplate(FastMCPComponent):
104
146
  name=name,
105
147
  title=title,
106
148
  description=description,
149
+ icons=icons,
107
150
  mime_type=mime_type,
108
151
  tags=tags,
109
152
  enabled=enabled,
@@ -161,6 +204,7 @@ class ResourceTemplate(FastMCPComponent):
161
204
  description=overrides.get("description", self.description),
162
205
  mimeType=overrides.get("mimeType", self.mime_type),
163
206
  title=overrides.get("title", self.title),
207
+ icons=overrides.get("icons", self.icons),
164
208
  annotations=overrides.get("annotations", self.annotations),
165
209
  _meta=overrides.get(
166
210
  "_meta", self.get_meta(include_fastmcp_meta=include_fastmcp_meta)
@@ -206,6 +250,31 @@ class FunctionResourceTemplate(ResourceTemplate):
206
250
  if context_kwarg and context_kwarg not in kwargs:
207
251
  kwargs[context_kwarg] = get_context()
208
252
 
253
+ # Type coercion for query parameters (which arrive as strings)
254
+ # Get function signature for type hints
255
+ sig = inspect.signature(self.fn)
256
+ for param_name, param_value in list(kwargs.items()):
257
+ if param_name in sig.parameters and isinstance(param_value, str):
258
+ param = sig.parameters[param_name]
259
+ annotation = param.annotation
260
+
261
+ # Skip if no annotation or annotation is str
262
+ if annotation is inspect.Parameter.empty or annotation is str:
263
+ continue
264
+
265
+ # Handle common type coercions
266
+ try:
267
+ if annotation is int:
268
+ kwargs[param_name] = int(param_value)
269
+ elif annotation is float:
270
+ kwargs[param_name] = float(param_value)
271
+ elif annotation is bool:
272
+ # Handle boolean strings
273
+ kwargs[param_name] = param_value.lower() in ("true", "1", "yes")
274
+ except (ValueError, AttributeError):
275
+ # Let validate_call handle the error
276
+ pass
277
+
209
278
  result = self.fn(**kwargs)
210
279
  if inspect.isawaitable(result):
211
280
  result = await result
@@ -219,6 +288,7 @@ class FunctionResourceTemplate(ResourceTemplate):
219
288
  name: str | None = None,
220
289
  title: str | None = None,
221
290
  description: str | None = None,
291
+ icons: list[Icon] | None = None,
222
292
  mime_type: str | None = None,
223
293
  tags: set[str] | None = None,
224
294
  enabled: bool | None = None,
@@ -245,16 +315,19 @@ class FunctionResourceTemplate(ResourceTemplate):
245
315
 
246
316
  context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
247
317
 
248
- # Validate that URI params match function params
249
- uri_params = set(re.findall(r"{(\w+)(?:\*)?}", uri_template))
250
- if not uri_params:
318
+ # Extract path and query parameters from URI template
319
+ path_params = set(re.findall(r"{(\w+)(?:\*)?}", uri_template))
320
+ query_params = extract_query_params(uri_template)
321
+ all_uri_params = path_params | query_params
322
+
323
+ if not all_uri_params:
251
324
  raise ValueError("URI template must contain at least one parameter")
252
325
 
253
326
  func_params = set(sig.parameters.keys())
254
327
  if context_kwarg:
255
328
  func_params.discard(context_kwarg)
256
329
 
257
- # get the parameters that are required
330
+ # Get required and optional function parameters
258
331
  required_params = {
259
332
  p
260
333
  for p in func_params
@@ -262,21 +335,37 @@ class FunctionResourceTemplate(ResourceTemplate):
262
335
  and sig.parameters[p].kind != inspect.Parameter.VAR_KEYWORD
263
336
  and p != context_kwarg
264
337
  }
338
+ optional_params = {
339
+ p
340
+ for p in func_params
341
+ if sig.parameters[p].default is not inspect.Parameter.empty
342
+ and sig.parameters[p].kind != inspect.Parameter.VAR_KEYWORD
343
+ and p != context_kwarg
344
+ }
345
+
346
+ # Validate RFC 6570 query parameters
347
+ # Query params must be optional (have defaults)
348
+ if query_params:
349
+ invalid_query_params = query_params - optional_params
350
+ if invalid_query_params:
351
+ raise ValueError(
352
+ f"Query parameters {invalid_query_params} must be optional function parameters with default values"
353
+ )
265
354
 
266
- # Check if required parameters are a subset of the URI parameters
267
- if not required_params.issubset(uri_params):
355
+ # Check if required parameters are a subset of the path parameters
356
+ if not required_params.issubset(path_params):
268
357
  raise ValueError(
269
- f"Required function arguments {required_params} must be a subset of the URI parameters {uri_params}"
358
+ f"Required function arguments {required_params} must be a subset of the URI path parameters {path_params}"
270
359
  )
271
360
 
272
- # Check if the URI parameters are a subset of the function parameters (skip if **kwargs present)
361
+ # Check if all URI parameters are valid function parameters (skip if **kwargs present)
273
362
  if not any(
274
363
  param.kind == inspect.Parameter.VAR_KEYWORD
275
364
  for param in sig.parameters.values()
276
365
  ):
277
- if not uri_params.issubset(func_params):
366
+ if not all_uri_params.issubset(func_params):
278
367
  raise ValueError(
279
- f"URI parameters {uri_params} must be a subset of the function arguments: {func_params}"
368
+ f"URI parameters {all_uri_params} must be a subset of the function arguments: {func_params}"
280
369
  )
281
370
 
282
371
  description = description or inspect.getdoc(fn)
@@ -303,6 +392,7 @@ class FunctionResourceTemplate(ResourceTemplate):
303
392
  name=func_name,
304
393
  title=title,
305
394
  description=description,
395
+ icons=icons,
306
396
  mime_type=mime_type or "text/plain",
307
397
  fn=fn,
308
398
  parameters=parameters,
@@ -5,11 +5,11 @@ from __future__ import annotations
5
5
  import json
6
6
  from pathlib import Path
7
7
 
8
- import anyio
9
- import anyio.to_thread
10
8
  import httpx
11
9
  import pydantic.json
10
+ from anyio import Path as AsyncPath
12
11
  from pydantic import Field, ValidationInfo
12
+ from typing_extensions import override
13
13
 
14
14
  from fastmcp.exceptions import ResourceError
15
15
  from fastmcp.resources.resource import Resource
@@ -54,6 +54,10 @@ class FileResource(Resource):
54
54
  description="MIME type of the resource content",
55
55
  )
56
56
 
57
+ @property
58
+ def _async_path(self) -> AsyncPath:
59
+ return AsyncPath(self.path)
60
+
57
61
  @pydantic.field_validator("path")
58
62
  @classmethod
59
63
  def validate_absolute_path(cls, path: Path) -> Path:
@@ -71,12 +75,13 @@ class FileResource(Resource):
71
75
  mime_type = info.data.get("mime_type", "text/plain")
72
76
  return not mime_type.startswith("text/")
73
77
 
78
+ @override
74
79
  async def read(self) -> str | bytes:
75
80
  """Read the file content."""
76
81
  try:
77
82
  if self.is_binary:
78
- return await anyio.to_thread.run_sync(self.path.read_bytes)
79
- return await anyio.to_thread.run_sync(self.path.read_text)
83
+ return await self._async_path.read_bytes()
84
+ return await self._async_path.read_text()
80
85
  except Exception as e:
81
86
  raise ResourceError(f"Error reading file {self.path}") from e
82
87
 
@@ -89,11 +94,12 @@ class HttpResource(Resource):
89
94
  default="application/json", description="MIME type of the resource content"
90
95
  )
91
96
 
97
+ @override
92
98
  async def read(self) -> str | bytes:
93
99
  """Read the HTTP content."""
94
100
  async with httpx.AsyncClient() as client:
95
101
  response = await client.get(self.url)
96
- response.raise_for_status()
102
+ _ = response.raise_for_status()
97
103
  return response.text
98
104
 
99
105
 
@@ -111,6 +117,10 @@ class DirectoryResource(Resource):
111
117
  default="application/json", description="MIME type of the resource content"
112
118
  )
113
119
 
120
+ @property
121
+ def _async_path(self) -> AsyncPath:
122
+ return AsyncPath(self.path)
123
+
114
124
  @pydantic.field_validator("path")
115
125
  @classmethod
116
126
  def validate_absolute_path(cls, path: Path) -> Path:
@@ -119,33 +129,29 @@ class DirectoryResource(Resource):
119
129
  raise ValueError("Path must be absolute")
120
130
  return path
121
131
 
122
- def list_files(self) -> list[Path]:
132
+ async def list_files(self) -> list[Path]:
123
133
  """List files in the directory."""
124
- if not self.path.exists():
134
+ if not await self._async_path.exists():
125
135
  raise FileNotFoundError(f"Directory not found: {self.path}")
126
- if not self.path.is_dir():
136
+ if not await self._async_path.is_dir():
127
137
  raise NotADirectoryError(f"Not a directory: {self.path}")
128
138
 
139
+ pattern = self.pattern or "*"
140
+
141
+ glob_fn = self._async_path.rglob if self.recursive else self._async_path.glob
129
142
  try:
130
- if self.pattern:
131
- return (
132
- list(self.path.glob(self.pattern))
133
- if not self.recursive
134
- else list(self.path.rglob(self.pattern))
135
- )
136
- return (
137
- list(self.path.glob("*"))
138
- if not self.recursive
139
- else list(self.path.rglob("*"))
140
- )
143
+ return [Path(p) async for p in glob_fn(pattern) if await p.is_file()]
141
144
  except Exception as e:
142
- raise ResourceError(f"Error listing directory {self.path}: {e}")
145
+ raise ResourceError(f"Error listing directory {self.path}") from e
143
146
 
147
+ @override
144
148
  async def read(self) -> str: # Always returns JSON string
145
149
  """Read the directory listing."""
146
150
  try:
147
- files = await anyio.to_thread.run_sync(self.list_files)
148
- file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()]
151
+ files: list[Path] = await self.list_files()
152
+
153
+ file_list = [str(f.relative_to(self.path)) for f in files]
154
+
149
155
  return json.dumps({"files": file_list}, indent=2)
150
- except Exception:
151
- raise ResourceError(f"Error reading directory {self.path}")
156
+ except Exception as e:
157
+ raise ResourceError(f"Error reading directory {self.path}") from e
@@ -3,4 +3,4 @@ from .context import Context
3
3
  from . import dependencies
4
4
 
5
5
 
6
- __all__ = ["FastMCP", "Context"]
6
+ __all__ = ["Context", "FastMCP"]
@@ -5,19 +5,23 @@ from .auth import (
5
5
  AccessToken,
6
6
  AuthProvider,
7
7
  )
8
+ from .providers.debug import DebugTokenVerifier
8
9
  from .providers.jwt import JWTVerifier, StaticTokenVerifier
9
10
  from .oauth_proxy import OAuthProxy
11
+ from .oidc_proxy import OIDCProxy
10
12
 
11
13
 
12
14
  __all__ = [
15
+ "AccessToken",
13
16
  "AuthProvider",
14
- "OAuthProvider",
15
- "TokenVerifier",
17
+ "DebugTokenVerifier",
16
18
  "JWTVerifier",
17
- "StaticTokenVerifier",
18
- "RemoteAuthProvider",
19
- "AccessToken",
19
+ "OAuthProvider",
20
20
  "OAuthProxy",
21
+ "OIDCProxy",
22
+ "RemoteAuthProvider",
23
+ "StaticTokenVerifier",
24
+ "TokenVerifier",
21
25
  ]
22
26
 
23
27
 
@@ -1,12 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any
3
+ from typing import Any, cast
4
4
 
5
5
  from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
6
- from mcp.server.auth.middleware.bearer_auth import (
7
- BearerAuthBackend,
8
- RequireAuthMiddleware,
9
- )
6
+ from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend
10
7
  from mcp.server.auth.provider import (
11
8
  AccessToken as _SDKAccessToken,
12
9
  )
@@ -26,16 +23,20 @@ from mcp.server.auth.settings import (
26
23
  ClientRegistrationOptions,
27
24
  RevocationOptions,
28
25
  )
29
- from pydantic import AnyHttpUrl
26
+ from pydantic import AnyHttpUrl, Field
30
27
  from starlette.middleware import Middleware
31
28
  from starlette.middleware.authentication import AuthenticationMiddleware
32
29
  from starlette.routing import Route
33
30
 
31
+ from fastmcp.utilities.logging import get_logger
32
+
33
+ logger = get_logger(__name__)
34
+
34
35
 
35
36
  class AccessToken(_SDKAccessToken):
36
37
  """AccessToken that includes all JWT claims."""
37
38
 
38
- claims: dict[str, Any] = {}
39
+ claims: dict[str, Any] = Field(default_factory=dict)
39
40
 
40
41
 
41
42
  class AuthProvider(TokenVerifierProtocol):
@@ -81,10 +82,10 @@ class AuthProvider(TokenVerifierProtocol):
81
82
  def get_routes(
82
83
  self,
83
84
  mcp_path: str | None = None,
84
- mcp_endpoint: Any | None = None,
85
85
  ) -> list[Route]:
86
- """Get the routes for this authentication provider.
86
+ """Get all routes for this authentication provider.
87
87
 
88
+ This includes both well-known discovery routes and operational routes.
88
89
  Each provider is responsible for creating whatever routes it needs:
89
90
  - TokenVerifier: typically no routes (default implementation)
90
91
  - RemoteAuthProvider: protected resource metadata routes
@@ -93,30 +94,45 @@ class AuthProvider(TokenVerifierProtocol):
93
94
 
94
95
  Args:
95
96
  mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
96
- mcp_endpoint: The MCP endpoint handler to protect with auth
97
+ This is used to advertise the resource URL in metadata, but the
98
+ provider does not create the actual MCP endpoint route.
97
99
 
98
100
  Returns:
99
- List of routes for this provider, including protected MCP endpoints if provided
101
+ List of all routes for this provider (excluding the MCP endpoint itself)
100
102
  """
103
+ return []
101
104
 
102
- routes = []
105
+ def get_well_known_routes(
106
+ self,
107
+ mcp_path: str | None = None,
108
+ ) -> list[Route]:
109
+ """Get well-known discovery routes for this authentication provider.
103
110
 
104
- # Add protected MCP endpoint if provided
105
- if mcp_path and mcp_endpoint:
106
- resource_metadata_url = self._get_resource_url(
107
- "/.well-known/oauth-protected-resource"
108
- )
111
+ This is a utility method that filters get_routes() to return only
112
+ well-known discovery routes (those starting with /.well-known/).
109
113
 
110
- routes.append(
111
- Route(
112
- mcp_path,
113
- endpoint=RequireAuthMiddleware(
114
- mcp_endpoint, self.required_scopes, resource_metadata_url
115
- ),
116
- )
117
- )
114
+ Well-known routes provide OAuth metadata and discovery endpoints that
115
+ clients use to discover authentication capabilities. These routes should
116
+ be mounted at the root level of the application to comply with RFC 8414
117
+ and RFC 9728.
118
118
 
119
- return routes
119
+ Common well-known routes:
120
+ - /.well-known/oauth-authorization-server (authorization server metadata)
121
+ - /.well-known/oauth-protected-resource/* (protected resource metadata)
122
+
123
+ Args:
124
+ mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
125
+ This is used to construct path-scoped well-known URLs.
126
+
127
+ Returns:
128
+ List of well-known discovery routes (typically mounted at root level)
129
+ """
130
+ all_routes = self.get_routes(mcp_path)
131
+ return [
132
+ route
133
+ for route in all_routes
134
+ if isinstance(route, Route) and route.path.startswith("/.well-known/")
135
+ ]
120
136
 
121
137
  def get_middleware(self) -> list:
122
138
  """Get HTTP application-level middleware for this auth provider.
@@ -225,14 +241,12 @@ class RemoteAuthProvider(AuthProvider):
225
241
  def get_routes(
226
242
  self,
227
243
  mcp_path: str | None = None,
228
- mcp_endpoint: Any | None = None,
229
244
  ) -> list[Route]:
230
- """Get OAuth routes for this provider.
245
+ """Get routes for this provider.
231
246
 
232
- Creates protected resource metadata routes and optionally wraps MCP endpoints with auth.
247
+ Creates protected resource metadata routes (RFC 9728).
233
248
  """
234
- # Start with base routes (protected MCP endpoint)
235
- routes = super().get_routes(mcp_path, mcp_endpoint)
249
+ routes = []
236
250
 
237
251
  # Get the resource URL based on the MCP path
238
252
  resource_url = self._get_resource_url(mcp_path)
@@ -284,20 +298,27 @@ class OAuthProvider(
284
298
  required_scopes: Scopes that are required for all requests.
285
299
  """
286
300
 
287
- # Convert URLs to proper types
288
- if isinstance(base_url, str):
289
- base_url = AnyHttpUrl(base_url)
290
-
291
301
  super().__init__(base_url=base_url, required_scopes=required_scopes)
292
- self.base_url = base_url
293
302
 
294
303
  if issuer_url is None:
295
- self.issuer_url = base_url
304
+ self.issuer_url = self.base_url
296
305
  elif isinstance(issuer_url, str):
297
306
  self.issuer_url = AnyHttpUrl(issuer_url)
298
307
  else:
299
308
  self.issuer_url = issuer_url
300
309
 
310
+ # Log if issuer_url and base_url differ (requires additional setup)
311
+ if (
312
+ self.base_url is not None
313
+ and self.issuer_url is not None
314
+ and str(self.base_url) != str(self.issuer_url)
315
+ ):
316
+ logger.info(
317
+ f"OAuth endpoints at {self.base_url}, issuer at {self.issuer_url}. "
318
+ f"Ensure well-known routes are accessible at root ({self.issuer_url}/.well-known/). "
319
+ f"See: https://gofastmcp.com/deployment/http#mounting-authenticated-servers"
320
+ )
321
+
301
322
  # Initialize OAuth Authorization Server Provider
302
323
  OAuthAuthorizationServerProvider.__init__(self)
303
324
 
@@ -326,23 +347,29 @@ class OAuthProvider(
326
347
  def get_routes(
327
348
  self,
328
349
  mcp_path: str | None = None,
329
- mcp_endpoint: Any | None = None,
330
350
  ) -> list[Route]:
331
351
  """Get OAuth authorization server routes and optional protected resource routes.
332
352
 
333
353
  This method creates the full set of OAuth routes including:
334
354
  - Standard OAuth authorization server routes (/.well-known/oauth-authorization-server, /authorize, /token, etc.)
335
355
  - Optional protected resource routes
336
- - Protected MCP endpoints if provided
337
356
 
338
357
  Returns:
339
358
  List of OAuth routes
340
359
  """
341
360
 
342
361
  # Create standard OAuth authorization server routes
362
+ # Pass base_url as issuer_url to ensure metadata declares endpoints where
363
+ # they're actually accessible (operational routes are mounted at
364
+ # base_url)
365
+ assert self.base_url is not None # typing check
366
+ assert (
367
+ self.issuer_url is not None
368
+ ) # typing check (issuer_url defaults to base_url)
369
+
343
370
  oauth_routes = create_auth_routes(
344
371
  provider=self,
345
- issuer_url=self.issuer_url,
372
+ issuer_url=self.base_url,
346
373
  service_documentation_url=self.service_documentation_url,
347
374
  client_registration_options=self.client_registration_options,
348
375
  revocation_options=self.revocation_options,
@@ -361,12 +388,12 @@ class OAuthProvider(
361
388
  )
362
389
  protected_routes = create_protected_resource_routes(
363
390
  resource_url=resource_url,
364
- authorization_servers=[self.issuer_url],
391
+ authorization_servers=[cast(AnyHttpUrl, self.issuer_url)],
365
392
  scopes_supported=supported_scopes,
366
393
  )
367
394
  oauth_routes.extend(protected_routes)
368
395
 
369
- # Add protected MCP endpoint from base class
370
- oauth_routes.extend(super().get_routes(mcp_path, mcp_endpoint))
396
+ # Add base routes
397
+ oauth_routes.extend(super().get_routes(mcp_path))
371
398
 
372
399
  return oauth_routes