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.
- fastmcp/cli/cli.py +3 -4
- fastmcp/cli/install/cursor.py +12 -6
- fastmcp/client/auth/oauth.py +11 -6
- fastmcp/client/client.py +86 -20
- fastmcp/client/transports.py +4 -4
- fastmcp/experimental/utilities/openapi/director.py +13 -14
- fastmcp/experimental/utilities/openapi/parser.py +18 -15
- fastmcp/mcp_config.py +1 -1
- fastmcp/resources/resource_manager.py +3 -3
- fastmcp/server/auth/__init__.py +4 -0
- fastmcp/server/auth/auth.py +28 -9
- fastmcp/server/auth/handlers/authorize.py +7 -5
- fastmcp/server/auth/oauth_proxy.py +170 -30
- fastmcp/server/auth/oidc_proxy.py +28 -9
- fastmcp/server/auth/providers/azure.py +26 -5
- fastmcp/server/auth/providers/debug.py +114 -0
- fastmcp/server/auth/providers/descope.py +1 -1
- fastmcp/server/auth/providers/in_memory.py +25 -1
- fastmcp/server/auth/providers/jwt.py +38 -26
- fastmcp/server/auth/providers/oci.py +233 -0
- fastmcp/server/auth/providers/supabase.py +21 -5
- fastmcp/server/auth/providers/workos.py +1 -1
- fastmcp/server/context.py +50 -8
- fastmcp/server/dependencies.py +8 -2
- fastmcp/server/middleware/caching.py +9 -2
- fastmcp/server/middleware/logging.py +2 -2
- fastmcp/server/middleware/middleware.py +2 -2
- fastmcp/server/proxy.py +1 -1
- fastmcp/server/server.py +11 -5
- fastmcp/tools/tool.py +33 -8
- fastmcp/utilities/components.py +2 -2
- fastmcp/utilities/json_schema.py +4 -4
- fastmcp/utilities/logging.py +13 -9
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +1 -1
- fastmcp/utilities/openapi.py +2 -2
- fastmcp/utilities/types.py +28 -15
- fastmcp/utilities/ui.py +1 -1
- {fastmcp-2.13.0.1.dist-info → fastmcp-2.13.1.dist-info}/METADATA +14 -11
- {fastmcp-2.13.0.1.dist-info → fastmcp-2.13.1.dist-info}/RECORD +42 -40
- {fastmcp-2.13.0.1.dist-info → fastmcp-2.13.1.dist-info}/WHEEL +0 -0
- {fastmcp-2.13.0.1.dist-info → fastmcp-2.13.1.dist-info}/entry_points.txt +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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)
|
fastmcp/cli/install/cursor.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
fastmcp/client/auth/oauth.py
CHANGED
|
@@ -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
|
|
196
|
-
+ "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
361
|
-
self.
|
|
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
|
fastmcp/client/transports.py
CHANGED
|
@@ -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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
66
|
+
json_body = body
|
|
68
67
|
else:
|
|
69
|
-
|
|
68
|
+
content = body
|
|
70
69
|
|
|
71
70
|
# Step 5: Create httpx.Request
|
|
72
71
|
return httpx.Request(
|
|
73
|
-
method=
|
|
74
|
-
url=
|
|
75
|
-
params=
|
|
76
|
-
headers=
|
|
77
|
-
json=
|
|
78
|
-
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
|
-
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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()
|
fastmcp/server/auth/__init__.py
CHANGED
|
@@ -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",
|
fastmcp/server/auth/auth.py
CHANGED
|
@@ -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.
|
|
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)
|