fastmcp 2.13.0.1__py3-none-any.whl → 2.13.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 (42) hide show
  1. fastmcp/cli/cli.py +3 -4
  2. fastmcp/cli/install/cursor.py +12 -6
  3. fastmcp/client/auth/oauth.py +11 -6
  4. fastmcp/client/client.py +86 -20
  5. fastmcp/client/transports.py +4 -4
  6. fastmcp/experimental/utilities/openapi/director.py +13 -14
  7. fastmcp/experimental/utilities/openapi/parser.py +18 -15
  8. fastmcp/mcp_config.py +1 -1
  9. fastmcp/resources/resource_manager.py +3 -3
  10. fastmcp/server/auth/__init__.py +4 -0
  11. fastmcp/server/auth/auth.py +28 -9
  12. fastmcp/server/auth/handlers/authorize.py +7 -5
  13. fastmcp/server/auth/oauth_proxy.py +170 -30
  14. fastmcp/server/auth/oidc_proxy.py +28 -9
  15. fastmcp/server/auth/providers/azure.py +26 -5
  16. fastmcp/server/auth/providers/debug.py +114 -0
  17. fastmcp/server/auth/providers/descope.py +1 -1
  18. fastmcp/server/auth/providers/in_memory.py +25 -1
  19. fastmcp/server/auth/providers/jwt.py +38 -26
  20. fastmcp/server/auth/providers/oci.py +233 -0
  21. fastmcp/server/auth/providers/supabase.py +21 -5
  22. fastmcp/server/auth/providers/workos.py +1 -1
  23. fastmcp/server/context.py +50 -8
  24. fastmcp/server/dependencies.py +8 -2
  25. fastmcp/server/middleware/caching.py +9 -2
  26. fastmcp/server/middleware/logging.py +2 -2
  27. fastmcp/server/middleware/middleware.py +2 -2
  28. fastmcp/server/proxy.py +1 -1
  29. fastmcp/server/server.py +11 -5
  30. fastmcp/tools/tool.py +33 -8
  31. fastmcp/utilities/components.py +2 -2
  32. fastmcp/utilities/json_schema.py +4 -4
  33. fastmcp/utilities/logging.py +13 -9
  34. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +1 -1
  35. fastmcp/utilities/openapi.py +2 -2
  36. fastmcp/utilities/types.py +28 -15
  37. fastmcp/utilities/ui.py +1 -1
  38. {fastmcp-2.13.0.1.dist-info → fastmcp-2.13.1.dist-info}/METADATA +14 -11
  39. {fastmcp-2.13.0.1.dist-info → fastmcp-2.13.1.dist-info}/RECORD +42 -40
  40. {fastmcp-2.13.0.1.dist-info → fastmcp-2.13.1.dist-info}/WHEEL +0 -0
  41. {fastmcp-2.13.0.1.dist-info → fastmcp-2.13.1.dist-info}/entry_points.txt +0 -0
  42. {fastmcp-2.13.0.1.dist-info → fastmcp-2.13.1.dist-info}/licenses/LICENSE +0 -0
fastmcp/cli/cli.py CHANGED
@@ -502,7 +502,7 @@ async def run(
502
502
  process = subprocess.run(cmd, check=True, env=env)
503
503
  sys.exit(process.returncode)
504
504
  except subprocess.CalledProcessError as e:
505
- logger.error(
505
+ logger.exception(
506
506
  f"Failed to run: {e}",
507
507
  extra={
508
508
  "server_spec": server_spec,
@@ -526,7 +526,7 @@ async def run(
526
526
  skip_source=skip_source,
527
527
  )
528
528
  except Exception as e:
529
- logger.error(
529
+ logger.exception(
530
530
  f"Failed to run: {e}",
531
531
  extra={
532
532
  "server_spec": server_spec,
@@ -766,13 +766,12 @@ async def inspect(
766
766
  console.print(formatted_json.decode("utf-8"))
767
767
 
768
768
  except Exception as e:
769
- logger.error(
769
+ logger.exception(
770
770
  f"Failed to inspect server: {e}",
771
771
  extra={
772
772
  "server_spec": server_spec,
773
773
  "error": str(e),
774
774
  },
775
- exc_info=True,
776
775
  )
777
776
  console.print(f"[bold red]✗[/bold red] Failed to inspect server: {e}")
778
777
  sys.exit(1)
@@ -1,10 +1,12 @@
1
1
  """Cursor integration for FastMCP install using Cyclopts."""
2
2
 
3
3
  import base64
4
+ import os
4
5
  import subprocess
5
6
  import sys
6
7
  from pathlib import Path
7
8
  from typing import Annotated
9
+ from urllib.parse import quote, urlparse
8
10
 
9
11
  import cyclopts
10
12
  from rich import print
@@ -36,8 +38,9 @@ def generate_cursor_deeplink(
36
38
  config_json = server_config.model_dump_json(exclude_none=True)
37
39
  config_b64 = base64.urlsafe_b64encode(config_json.encode()).decode()
38
40
 
39
- # Generate the deeplink URL
40
- deeplink = f"cursor://anysphere.cursor-deeplink/mcp/install?name={server_name}&config={config_b64}"
41
+ # Generate the deeplink URL with properly encoded server name
42
+ encoded_name = quote(server_name, safe="")
43
+ deeplink = f"cursor://anysphere.cursor-deeplink/mcp/install?name={encoded_name}&config={config_b64}"
41
44
 
42
45
  return deeplink
43
46
 
@@ -51,17 +54,20 @@ def open_deeplink(deeplink: str) -> bool:
51
54
  Returns:
52
55
  True if the command succeeded, False otherwise
53
56
  """
57
+ parsed = urlparse(deeplink)
58
+ if parsed.scheme != "cursor":
59
+ logger.warning(f"Invalid deeplink scheme: {parsed.scheme}")
60
+ return False
61
+
54
62
  try:
55
63
  if sys.platform == "darwin": # macOS
56
64
  subprocess.run(["open", deeplink], check=True, capture_output=True)
57
65
  elif sys.platform == "win32": # Windows
58
- subprocess.run(
59
- ["cmd", "/c", "start", deeplink], check=True, capture_output=True
60
- )
66
+ os.startfile(deeplink)
61
67
  else: # Linux and others
62
68
  subprocess.run(["xdg-open", deeplink], check=True, capture_output=True)
63
69
  return True
64
- except (subprocess.CalledProcessError, FileNotFoundError):
70
+ except (subprocess.CalledProcessError, FileNotFoundError, OSError):
65
71
  return False
66
72
 
67
73
 
@@ -12,6 +12,7 @@ from key_value.aio.adapters.pydantic import PydanticAdapter
12
12
  from key_value.aio.protocols import AsyncKeyValue
13
13
  from key_value.aio.stores.memory import MemoryStore
14
14
  from mcp.client.auth import OAuthClientProvider, TokenStorage
15
+ from mcp.shared._httpx_utils import McpHttpClientFactory
15
16
  from mcp.shared.auth import (
16
17
  OAuthClientInformationFull,
17
18
  OAuthClientMetadata,
@@ -147,6 +148,7 @@ class OAuth(OAuthClientProvider):
147
148
  token_storage: AsyncKeyValue | None = None,
148
149
  additional_client_metadata: dict[str, Any] | None = None,
149
150
  callback_port: int | None = None,
151
+ httpx_client_factory: McpHttpClientFactory | None = None,
150
152
  ):
151
153
  """
152
154
  Initialize OAuth client provider for an MCP server.
@@ -164,6 +166,7 @@ class OAuth(OAuthClientProvider):
164
166
  server_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
165
167
 
166
168
  # Setup OAuth client
169
+ self.httpx_client_factory = httpx_client_factory or httpx.AsyncClient
167
170
  self.redirect_port = callback_port or find_available_port()
168
171
  redirect_uri = f"http://localhost:{self.redirect_port}/callback"
169
172
 
@@ -192,8 +195,9 @@ class OAuth(OAuthClientProvider):
192
195
  from warnings import warn
193
196
 
194
197
  warn(
195
- message="Using in-memory token storage is not recommended for production use -- "
196
- + "tokens will be lost on server restart.",
198
+ message="Using in-memory token storage -- tokens will be lost when the client restarts. "
199
+ + "For persistent storage across multiple MCP servers, provide an encrypted AsyncKeyValue backend. "
200
+ + "See https://gofastmcp.com/clients/auth/oauth#token-storage for details.",
197
201
  stacklevel=2,
198
202
  )
199
203
 
@@ -225,7 +229,7 @@ class OAuth(OAuthClientProvider):
225
229
  async def redirect_handler(self, authorization_url: str) -> None:
226
230
  """Open browser for authorization, with pre-flight check for invalid client."""
227
231
  # Pre-flight check to detect invalid client_id before opening browser
228
- async with httpx.AsyncClient() as client:
232
+ async with self.httpx_client_factory() as client:
229
233
  response = await client.get(authorization_url, follow_redirects=False)
230
234
 
231
235
  # Check for client not found error (400 typically means bad client_id)
@@ -296,7 +300,8 @@ class OAuth(OAuthClientProvider):
296
300
  response = None
297
301
  while True:
298
302
  try:
299
- yielded_request = await gen.asend(response)
303
+ # First iteration sends None, subsequent iterations send response
304
+ yielded_request = await gen.asend(response) # ty: ignore[invalid-argument-type]
300
305
  response = yield yielded_request
301
306
  except StopAsyncIteration:
302
307
  break
@@ -305,16 +310,16 @@ class OAuth(OAuthClientProvider):
305
310
  logger.debug(
306
311
  "OAuth client not found on server, clearing cache and retrying..."
307
312
  )
308
-
309
313
  # Clear cached state and retry once
310
314
  self._initialized = False
311
315
  await self.token_storage_adapter.clear()
312
316
 
317
+ # Retry with fresh registration
313
318
  gen = super().async_auth_flow(request)
314
319
  response = None
315
320
  while True:
316
321
  try:
317
- yielded_request = await gen.asend(response)
322
+ yielded_request = await gen.asend(response) # ty: ignore[invalid-argument-type]
318
323
  response = yield yielded_request
319
324
  except StopAsyncIteration:
320
325
  break
fastmcp/client/client.py CHANGED
@@ -77,6 +77,16 @@ logger = get_logger(__name__)
77
77
  T = TypeVar("T", bound="ClientTransport")
78
78
 
79
79
 
80
+ def _timeout_to_seconds(
81
+ timeout: datetime.timedelta | float | int | None,
82
+ ) -> float | None:
83
+ if timeout is None:
84
+ return None
85
+ if isinstance(timeout, datetime.timedelta):
86
+ return timeout.total_seconds()
87
+ return float(timeout)
88
+
89
+
80
90
  @dataclass
81
91
  class ClientSessionState:
82
92
  """Holds all session-related state for a Client instance.
@@ -222,6 +232,7 @@ class Client(Generic[ClientTransportT]):
222
232
  message_handler: MessageHandlerT | MessageHandler | None = None,
223
233
  progress_handler: ProgressHandler | None = None,
224
234
  timeout: datetime.timedelta | float | int | None = None,
235
+ auto_initialize: bool = True,
225
236
  init_timeout: datetime.timedelta | float | int | None = None,
226
237
  client_info: mcp.types.Implementation | None = None,
227
238
  auth: httpx.Auth | Literal["oauth"] | str | None = None,
@@ -240,26 +251,23 @@ class Client(Generic[ClientTransportT]):
240
251
 
241
252
  self._progress_handler = progress_handler
242
253
 
254
+ # Convert timeout to timedelta if needed
243
255
  if isinstance(timeout, int | float):
244
256
  timeout = datetime.timedelta(seconds=float(timeout))
245
257
 
246
258
  # handle init handshake timeout
247
259
  if init_timeout is None:
248
260
  init_timeout = fastmcp.settings.client_init_timeout
249
- if isinstance(init_timeout, datetime.timedelta):
250
- init_timeout = init_timeout.total_seconds()
251
- elif not init_timeout:
252
- init_timeout = None
253
- else:
254
- init_timeout = float(init_timeout)
255
- self._init_timeout = init_timeout
261
+ self._init_timeout = _timeout_to_seconds(init_timeout)
262
+
263
+ self.auto_initialize = auto_initialize
256
264
 
257
265
  self._session_kwargs: SessionKwargs = {
258
266
  "sampling_callback": None,
259
267
  "list_roots_callback": None,
260
268
  "logging_callback": create_log_callback(log_handler),
261
269
  "message_handler": message_handler,
262
- "read_timeout_seconds": timeout,
270
+ "read_timeout_seconds": timeout, # ty: ignore[invalid-argument-type]
263
271
  "client_info": client_info,
264
272
  }
265
273
 
@@ -290,12 +298,8 @@ class Client(Generic[ClientTransportT]):
290
298
  return self._session_state.session
291
299
 
292
300
  @property
293
- def initialize_result(self) -> mcp.types.InitializeResult:
301
+ def initialize_result(self) -> mcp.types.InitializeResult | None:
294
302
  """Get the result of the initialization request."""
295
- if self._session_state.initialize_result is None:
296
- raise RuntimeError(
297
- "Client is not connected. Use the 'async with client:' context manager first."
298
- )
299
303
  return self._session_state.initialize_result
300
304
 
301
305
  def set_roots(self, roots: RootsList | RootsHandler) -> None:
@@ -357,15 +361,11 @@ class Client(Generic[ClientTransportT]):
357
361
  self._session_state.session = session
358
362
  # Initialize the session
359
363
  try:
360
- with anyio.fail_after(self._init_timeout):
361
- self._session_state.initialize_result = (
362
- await self._session_state.session.initialize()
363
- )
364
+ if self.auto_initialize:
365
+ await self.initialize()
364
366
  yield
365
367
  except anyio.ClosedResourceError as e:
366
368
  raise RuntimeError("Server session was closed unexpectedly") from e
367
- except TimeoutError as e:
368
- raise RuntimeError("Failed to initialize server session") from e
369
369
  finally:
370
370
  self._session_state.session = None
371
371
  self._session_state.initialize_result = None
@@ -493,6 +493,55 @@ class Client(Generic[ClientTransportT]):
493
493
 
494
494
  # --- MCP Client Methods ---
495
495
 
496
+ async def initialize(
497
+ self,
498
+ timeout: datetime.timedelta | float | int | None = None,
499
+ ) -> mcp.types.InitializeResult:
500
+ """Send an initialize request to the server.
501
+
502
+ This method performs the MCP initialization handshake with the server,
503
+ exchanging capabilities and server information. It is idempotent - calling
504
+ it multiple times returns the cached result from the first call.
505
+
506
+ The initialization happens automatically when entering the client context
507
+ manager unless `auto_initialize=False` was set during client construction.
508
+ Manual calls to this method are only needed when auto-initialization is disabled.
509
+
510
+ Args:
511
+ timeout: Optional timeout for the initialization request (seconds or timedelta).
512
+ If None, uses the client's init_timeout setting.
513
+
514
+ Returns:
515
+ InitializeResult: The server's initialization response containing server info,
516
+ capabilities, protocol version, and optional instructions.
517
+
518
+ Raises:
519
+ RuntimeError: If the client is not connected or initialization times out.
520
+
521
+ Example:
522
+ ```python
523
+ # With auto-initialization disabled
524
+ client = Client(server, auto_initialize=False)
525
+ async with client:
526
+ result = await client.initialize()
527
+ print(f"Server: {result.serverInfo.name}")
528
+ print(f"Instructions: {result.instructions}")
529
+ ```
530
+ """
531
+
532
+ if self.initialize_result is not None:
533
+ return self.initialize_result
534
+
535
+ if timeout is None:
536
+ timeout = self._init_timeout
537
+ try:
538
+ with anyio.fail_after(_timeout_to_seconds(timeout)):
539
+ initialize_result = await self.session.initialize()
540
+ self._session_state.initialize_result = initialize_result
541
+ return initialize_result
542
+ except TimeoutError as e:
543
+ raise RuntimeError("Failed to initialize server session") from e
544
+
496
545
  async def ping(self) -> bool:
497
546
  """Send a ping request."""
498
547
  result = await self.session.send_ping()
@@ -831,6 +880,7 @@ class Client(Generic[ClientTransportT]):
831
880
  arguments: dict[str, Any],
832
881
  progress_handler: ProgressHandler | None = None,
833
882
  timeout: datetime.timedelta | float | int | None = None,
883
+ meta: dict[str, Any] | None = None,
834
884
  ) -> mcp.types.CallToolResult:
835
885
  """Send a tools/call request and return the complete MCP protocol result.
836
886
 
@@ -842,6 +892,10 @@ class Client(Generic[ClientTransportT]):
842
892
  arguments (dict[str, Any]): Arguments to pass to the tool.
843
893
  timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None.
844
894
  progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None.
895
+ meta (dict[str, Any] | None, optional): Additional metadata to include with the request.
896
+ This is useful for passing contextual information (like user IDs, trace IDs, or preferences)
897
+ that shouldn't be tool arguments but may influence server-side processing. The server
898
+ can access this via `context.request_context.meta`. Defaults to None.
845
899
 
846
900
  Returns:
847
901
  mcp.types.CallToolResult: The complete response object from the protocol,
@@ -852,13 +906,16 @@ class Client(Generic[ClientTransportT]):
852
906
  """
853
907
  logger.debug(f"[{self.name}] called call_tool: {name}")
854
908
 
909
+ # Convert timeout to timedelta if needed
855
910
  if isinstance(timeout, int | float):
856
911
  timeout = datetime.timedelta(seconds=float(timeout))
912
+
857
913
  result = await self.session.call_tool(
858
914
  name=name,
859
915
  arguments=arguments,
860
- read_timeout_seconds=timeout,
916
+ read_timeout_seconds=timeout, # ty: ignore[invalid-argument-type]
861
917
  progress_callback=progress_handler or self._progress_handler,
918
+ meta=meta,
862
919
  )
863
920
  return result
864
921
 
@@ -869,6 +926,7 @@ class Client(Generic[ClientTransportT]):
869
926
  timeout: datetime.timedelta | float | int | None = None,
870
927
  progress_handler: ProgressHandler | None = None,
871
928
  raise_on_error: bool = True,
929
+ meta: dict[str, Any] | None = None,
872
930
  ) -> CallToolResult:
873
931
  """Call a tool on the server.
874
932
 
@@ -879,6 +937,11 @@ class Client(Generic[ClientTransportT]):
879
937
  arguments (dict[str, Any] | None, optional): Arguments to pass to the tool. Defaults to None.
880
938
  timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None.
881
939
  progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None.
940
+ raise_on_error (bool, optional): Whether to raise a ToolError if the tool call results in an error. Defaults to True.
941
+ meta (dict[str, Any] | None, optional): Additional metadata to include with the request.
942
+ This is useful for passing contextual information (like user IDs, trace IDs, or preferences)
943
+ that shouldn't be tool arguments but may influence server-side processing. The server
944
+ can access this via `context.request_context.meta`. Defaults to None.
882
945
 
883
946
  Returns:
884
947
  CallToolResult:
@@ -898,6 +961,7 @@ class Client(Generic[ClientTransportT]):
898
961
  arguments=arguments or {},
899
962
  timeout=timeout,
900
963
  progress_handler=progress_handler,
964
+ meta=meta,
901
965
  )
902
966
  data = None
903
967
  if result.isError and raise_on_error:
@@ -928,6 +992,7 @@ class Client(Generic[ClientTransportT]):
928
992
  return CallToolResult(
929
993
  content=result.content,
930
994
  structured_content=result.structuredContent,
995
+ meta=result.meta,
931
996
  data=data,
932
997
  is_error=result.isError,
933
998
  )
@@ -945,5 +1010,6 @@ class Client(Generic[ClientTransportT]):
945
1010
  class CallToolResult:
946
1011
  content: list[mcp.types.ContentBlock]
947
1012
  structured_content: dict[str, Any] | None
1013
+ meta: dict[str, Any] | None
948
1014
  data: Any = None
949
1015
  is_error: bool = False
@@ -177,8 +177,8 @@ class SSETransport(ClientTransport):
177
177
 
178
178
  self.url = url
179
179
  self.headers = headers or {}
180
- self._set_auth(auth)
181
180
  self.httpx_client_factory = httpx_client_factory
181
+ self._set_auth(auth)
182
182
 
183
183
  if isinstance(sse_read_timeout, int | float):
184
184
  sse_read_timeout = datetime.timedelta(seconds=float(sse_read_timeout))
@@ -186,7 +186,7 @@ class SSETransport(ClientTransport):
186
186
 
187
187
  def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
188
188
  if auth == "oauth":
189
- auth = OAuth(self.url)
189
+ auth = OAuth(self.url, httpx_client_factory=self.httpx_client_factory)
190
190
  elif isinstance(auth, str):
191
191
  auth = BearerAuth(auth)
192
192
  self.auth = auth
@@ -247,8 +247,8 @@ class StreamableHttpTransport(ClientTransport):
247
247
 
248
248
  self.url = url
249
249
  self.headers = headers or {}
250
- self._set_auth(auth)
251
250
  self.httpx_client_factory = httpx_client_factory
251
+ self._set_auth(auth)
252
252
 
253
253
  if isinstance(sse_read_timeout, int | float):
254
254
  sse_read_timeout = datetime.timedelta(seconds=float(sse_read_timeout))
@@ -256,7 +256,7 @@ class StreamableHttpTransport(ClientTransport):
256
256
 
257
257
  def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
258
258
  if auth == "oauth":
259
- auth = OAuth(self.url)
259
+ auth = OAuth(self.url, httpx_client_factory=self.httpx_client_factory)
260
260
  elif isinstance(auth, str):
261
261
  auth = BearerAuth(auth)
262
262
  self.auth = auth
@@ -54,28 +54,27 @@ class RequestDirector:
54
54
  url = self._build_url(route.path, path_params, base_url)
55
55
 
56
56
  # Step 3: Prepare request data
57
- request_data = {
58
- "method": route.method.upper(),
59
- "url": url,
60
- "params": query_params if query_params else None,
61
- "headers": header_params if header_params else None,
62
- }
57
+ method: str = route.method.upper()
58
+ params = query_params if query_params else None
59
+ headers = header_params if header_params else None
60
+ json_body: dict[str, Any] | list[Any] | None = None
61
+ content: str | bytes | None = None
63
62
 
64
63
  # Step 4: Handle request body
65
64
  if body is not None:
66
65
  if isinstance(body, dict | list):
67
- request_data["json"] = body
66
+ json_body = body
68
67
  else:
69
- request_data["content"] = body
68
+ content = body
70
69
 
71
70
  # Step 5: Create httpx.Request
72
71
  return httpx.Request(
73
- method=request_data["method"],
74
- url=request_data["url"],
75
- params=request_data.get("params"),
76
- headers=request_data.get("headers"),
77
- json=request_data.get("json"),
78
- content=request_data.get("content"),
72
+ method=method,
73
+ url=url,
74
+ params=params,
75
+ headers=headers,
76
+ json=json_body,
77
+ content=content,
79
78
  )
80
79
 
81
80
  def _unflatten_arguments(
@@ -630,25 +630,28 @@ class OpenAPIParser(
630
630
  Returns:
631
631
  Dictionary containing only the schemas needed for outputs
632
632
  """
633
- needed_schemas = set()
633
+ if not responses or not all_schemas:
634
+ return {}
635
+
636
+ needed_schemas: set[str] = set()
634
637
 
635
- # Check responses for schema references
636
638
  for response in responses.values():
637
- if response.content_schema:
638
- for content_schema in response.content_schema.values():
639
- # Check if this schema was originally a top-level $ref
640
- if "x-fastmcp-top-level-schema" in content_schema:
641
- schema_name = content_schema["x-fastmcp-top-level-schema"]
642
- if schema_name in all_schemas:
643
- needed_schemas.add(schema_name)
644
-
645
- # Extract all dependencies (transitive refs within the schema)
646
- deps = self._extract_schema_dependencies(
647
- content_schema, all_schemas
639
+ if not response.content_schema:
640
+ continue
641
+
642
+ for content_schema in response.content_schema.values():
643
+ deps = self._extract_schema_dependencies(content_schema, all_schemas)
644
+ needed_schemas.update(deps)
645
+
646
+ schema_name = content_schema.get("x-fastmcp-top-level-schema")
647
+ if isinstance(schema_name, str) and schema_name in all_schemas:
648
+ needed_schemas.add(schema_name)
649
+ self._extract_schema_dependencies(
650
+ all_schemas[schema_name],
651
+ all_schemas,
652
+ collected=needed_schemas,
648
653
  )
649
- needed_schemas.update(deps)
650
654
 
651
- # Return only the needed output schemas
652
655
  return {
653
656
  name: all_schemas[name] for name in needed_schemas if name in all_schemas
654
657
  }
fastmcp/mcp_config.py CHANGED
@@ -101,7 +101,7 @@ class _TransformingMCPServerMixin(FastMCPBaseModel):
101
101
  ClientTransport, # pyright: ignore[reportUnusedImport]
102
102
  )
103
103
 
104
- transport: ClientTransport = super().to_transport() # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue, reportUnknownVariableType]
104
+ transport: ClientTransport = super().to_transport() # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue, reportUnknownVariableType] # ty: ignore[unresolved-attribute]
105
105
  transport = cast(ClientTransport, transport)
106
106
 
107
107
  client: Client[ClientTransport] = Client(transport=transport, name=client_name)
@@ -236,7 +236,7 @@ class ResourceManager:
236
236
  # Then check templates (local and mounted) only if not found in concrete resources
237
237
  templates = await self.get_resource_templates()
238
238
  for template_key in templates:
239
- if match_uri_template(uri_str, template_key):
239
+ if match_uri_template(uri_str, template_key) is not None:
240
240
  return True
241
241
 
242
242
  return False
@@ -262,7 +262,7 @@ class ResourceManager:
262
262
  templates = await self.get_resource_templates()
263
263
  for storage_key, template in templates.items():
264
264
  # Try to match against the storage key (which might be a custom key)
265
- if params := match_uri_template(uri_str, storage_key):
265
+ if (params := match_uri_template(uri_str, storage_key)) is not None:
266
266
  try:
267
267
  return await template.create_resource(
268
268
  uri_str,
@@ -318,7 +318,7 @@ class ResourceManager:
318
318
 
319
319
  # 1b. Check local templates if not found in concrete resources
320
320
  for key, template in self._templates.items():
321
- if params := match_uri_template(uri_str, key):
321
+ if (params := match_uri_template(uri_str, key)) is not None:
322
322
  try:
323
323
  resource = await template.create_resource(uri_str, params=params)
324
324
  return await resource.read()
@@ -5,16 +5,20 @@ 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__ = [
13
15
  "AccessToken",
14
16
  "AuthProvider",
17
+ "DebugTokenVerifier",
15
18
  "JWTVerifier",
16
19
  "OAuthProvider",
17
20
  "OAuthProxy",
21
+ "OIDCProxy",
18
22
  "RemoteAuthProvider",
19
23
  "StaticTokenVerifier",
20
24
  "TokenVerifier",
@@ -1,6 +1,6 @@
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
6
  from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend
@@ -28,6 +28,10 @@ from starlette.middleware import Middleware
28
28
  from starlette.middleware.authentication import AuthenticationMiddleware
29
29
  from starlette.routing import Route
30
30
 
31
+ from fastmcp.utilities.logging import get_logger
32
+
33
+ logger = get_logger(__name__)
34
+
31
35
 
32
36
  class AccessToken(_SDKAccessToken):
33
37
  """AccessToken that includes all JWT claims."""
@@ -294,20 +298,27 @@ class OAuthProvider(
294
298
  required_scopes: Scopes that are required for all requests.
295
299
  """
296
300
 
297
- # Convert URLs to proper types
298
- if isinstance(base_url, str):
299
- base_url = AnyHttpUrl(base_url)
300
-
301
301
  super().__init__(base_url=base_url, required_scopes=required_scopes)
302
- self.base_url = base_url
303
302
 
304
303
  if issuer_url is None:
305
- self.issuer_url = base_url
304
+ self.issuer_url = self.base_url
306
305
  elif isinstance(issuer_url, str):
307
306
  self.issuer_url = AnyHttpUrl(issuer_url)
308
307
  else:
309
308
  self.issuer_url = issuer_url
310
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
+
311
322
  # Initialize OAuth Authorization Server Provider
312
323
  OAuthAuthorizationServerProvider.__init__(self)
313
324
 
@@ -348,9 +359,17 @@ class OAuthProvider(
348
359
  """
349
360
 
350
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
+
351
370
  oauth_routes = create_auth_routes(
352
371
  provider=self,
353
- issuer_url=self.issuer_url,
372
+ issuer_url=self.base_url,
354
373
  service_documentation_url=self.service_documentation_url,
355
374
  client_registration_options=self.client_registration_options,
356
375
  revocation_options=self.revocation_options,
@@ -369,7 +388,7 @@ class OAuthProvider(
369
388
  )
370
389
  protected_routes = create_protected_resource_routes(
371
390
  resource_url=resource_url,
372
- authorization_servers=[self.issuer_url],
391
+ authorization_servers=[cast(AnyHttpUrl, self.issuer_url)],
373
392
  scopes_supported=supported_scopes,
374
393
  )
375
394
  oauth_routes.extend(protected_routes)