fastmcp 2.11.2__py3-none-any.whl → 2.12.0rc1__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/__init__.py +5 -4
- fastmcp/cli/claude.py +22 -18
- fastmcp/cli/cli.py +472 -136
- fastmcp/cli/install/claude_code.py +37 -40
- fastmcp/cli/install/claude_desktop.py +37 -42
- fastmcp/cli/install/cursor.py +148 -38
- fastmcp/cli/install/mcp_json.py +38 -43
- fastmcp/cli/install/shared.py +64 -7
- fastmcp/cli/run.py +122 -215
- fastmcp/client/auth/oauth.py +69 -13
- fastmcp/client/client.py +46 -9
- fastmcp/client/logging.py +25 -1
- fastmcp/client/oauth_callback.py +91 -91
- fastmcp/client/sampling.py +12 -4
- fastmcp/client/transports.py +143 -67
- fastmcp/experimental/sampling/__init__.py +0 -0
- fastmcp/experimental/sampling/handlers/__init__.py +3 -0
- fastmcp/experimental/sampling/handlers/base.py +21 -0
- fastmcp/experimental/sampling/handlers/openai.py +163 -0
- fastmcp/experimental/server/openapi/routing.py +1 -3
- fastmcp/experimental/server/openapi/server.py +10 -25
- fastmcp/experimental/utilities/openapi/__init__.py +2 -2
- fastmcp/experimental/utilities/openapi/formatters.py +34 -0
- fastmcp/experimental/utilities/openapi/models.py +5 -2
- fastmcp/experimental/utilities/openapi/parser.py +252 -70
- fastmcp/experimental/utilities/openapi/schemas.py +135 -106
- fastmcp/mcp_config.py +40 -20
- fastmcp/prompts/prompt_manager.py +4 -2
- fastmcp/resources/resource_manager.py +16 -6
- fastmcp/server/auth/__init__.py +11 -1
- fastmcp/server/auth/auth.py +19 -2
- fastmcp/server/auth/oauth_proxy.py +1047 -0
- fastmcp/server/auth/providers/azure.py +270 -0
- fastmcp/server/auth/providers/github.py +287 -0
- fastmcp/server/auth/providers/google.py +305 -0
- fastmcp/server/auth/providers/jwt.py +27 -16
- fastmcp/server/auth/providers/workos.py +256 -2
- fastmcp/server/auth/redirect_validation.py +65 -0
- fastmcp/server/auth/registry.py +1 -1
- fastmcp/server/context.py +91 -41
- fastmcp/server/dependencies.py +32 -2
- fastmcp/server/elicitation.py +60 -1
- fastmcp/server/http.py +44 -37
- fastmcp/server/middleware/logging.py +66 -28
- fastmcp/server/proxy.py +2 -0
- fastmcp/server/sampling/handler.py +19 -0
- fastmcp/server/server.py +85 -20
- fastmcp/settings.py +18 -3
- fastmcp/tools/tool.py +23 -10
- fastmcp/tools/tool_manager.py +5 -1
- fastmcp/tools/tool_transform.py +75 -32
- fastmcp/utilities/auth.py +34 -0
- fastmcp/utilities/cli.py +148 -15
- fastmcp/utilities/components.py +21 -5
- fastmcp/utilities/inspect.py +166 -37
- fastmcp/utilities/json_schema_type.py +4 -2
- fastmcp/utilities/logging.py +4 -1
- fastmcp/utilities/mcp_config.py +47 -18
- fastmcp/utilities/mcp_server_config/__init__.py +25 -0
- fastmcp/utilities/mcp_server_config/v1/__init__.py +0 -0
- fastmcp/utilities/mcp_server_config/v1/environments/__init__.py +6 -0
- fastmcp/utilities/mcp_server_config/v1/environments/base.py +30 -0
- fastmcp/utilities/mcp_server_config/v1/environments/uv.py +306 -0
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +446 -0
- fastmcp/utilities/mcp_server_config/v1/schema.json +361 -0
- fastmcp/utilities/mcp_server_config/v1/sources/__init__.py +0 -0
- fastmcp/utilities/mcp_server_config/v1/sources/base.py +30 -0
- fastmcp/utilities/mcp_server_config/v1/sources/filesystem.py +216 -0
- fastmcp/utilities/openapi.py +4 -4
- fastmcp/utilities/tests.py +7 -2
- fastmcp/utilities/types.py +15 -2
- {fastmcp-2.11.2.dist-info → fastmcp-2.12.0rc1.dist-info}/METADATA +3 -2
- fastmcp-2.12.0rc1.dist-info/RECORD +129 -0
- fastmcp-2.11.2.dist-info/RECORD +0 -108
- {fastmcp-2.11.2.dist-info → fastmcp-2.12.0rc1.dist-info}/WHEEL +0 -0
- {fastmcp-2.11.2.dist-info → fastmcp-2.12.0rc1.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.11.2.dist-info → fastmcp-2.12.0rc1.dist-info}/licenses/LICENSE +0 -0
fastmcp/client/auth/oauth.py
CHANGED
|
@@ -3,6 +3,8 @@ from __future__ import annotations
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import json
|
|
5
5
|
import webbrowser
|
|
6
|
+
from asyncio import Future
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
6
8
|
from pathlib import Path
|
|
7
9
|
from typing import Any, Literal
|
|
8
10
|
from urllib.parse import urlparse
|
|
@@ -17,7 +19,8 @@ from mcp.shared.auth import (
|
|
|
17
19
|
from mcp.shared.auth import (
|
|
18
20
|
OAuthToken as OAuthToken,
|
|
19
21
|
)
|
|
20
|
-
from pydantic import AnyHttpUrl, ValidationError
|
|
22
|
+
from pydantic import AnyHttpUrl, BaseModel, TypeAdapter, ValidationError
|
|
23
|
+
from uvicorn.server import Server
|
|
21
24
|
|
|
22
25
|
from fastmcp import settings as fastmcp_global_settings
|
|
23
26
|
from fastmcp.client.oauth_callback import (
|
|
@@ -31,6 +34,17 @@ __all__ = ["OAuth"]
|
|
|
31
34
|
logger = get_logger(__name__)
|
|
32
35
|
|
|
33
36
|
|
|
37
|
+
class StoredToken(BaseModel):
|
|
38
|
+
"""Token storage format with absolute expiry time."""
|
|
39
|
+
|
|
40
|
+
token_payload: OAuthToken
|
|
41
|
+
expires_at: datetime | None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Create TypeAdapter at module level for efficient parsing
|
|
45
|
+
stored_token_adapter = TypeAdapter(StoredToken)
|
|
46
|
+
|
|
47
|
+
|
|
34
48
|
def default_cache_dir() -> Path:
|
|
35
49
|
return fastmcp_global_settings.home / "oauth-mcp-client-cache"
|
|
36
50
|
|
|
@@ -75,13 +89,28 @@ class FileTokenStorage(TokenStorage):
|
|
|
75
89
|
path = self._get_file_path("tokens")
|
|
76
90
|
|
|
77
91
|
try:
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
#
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
92
|
+
# Parse JSON and validate as StoredToken
|
|
93
|
+
stored = stored_token_adapter.validate_json(path.read_text())
|
|
94
|
+
|
|
95
|
+
# Check if token is expired
|
|
96
|
+
if stored.expires_at is not None:
|
|
97
|
+
now = datetime.now(timezone.utc)
|
|
98
|
+
if now >= stored.expires_at:
|
|
99
|
+
logger.debug(
|
|
100
|
+
f"Token expired for {self.get_base_url(self.server_url)}"
|
|
101
|
+
)
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
# Recalculate expires_in to be correct relative to now
|
|
105
|
+
if stored.token_payload.expires_in is not None:
|
|
106
|
+
remaining = stored.expires_at - now
|
|
107
|
+
stored.token_payload.expires_in = max(
|
|
108
|
+
0, int(remaining.total_seconds())
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
return stored.token_payload
|
|
112
|
+
|
|
113
|
+
except (FileNotFoundError, ValidationError) as e:
|
|
85
114
|
logger.debug(
|
|
86
115
|
f"Could not load tokens for {self.get_base_url(self.server_url)}: {e}"
|
|
87
116
|
)
|
|
@@ -90,7 +119,18 @@ class FileTokenStorage(TokenStorage):
|
|
|
90
119
|
async def set_tokens(self, tokens: OAuthToken) -> None:
|
|
91
120
|
"""Save tokens to file storage."""
|
|
92
121
|
path = self._get_file_path("tokens")
|
|
93
|
-
|
|
122
|
+
|
|
123
|
+
# Calculate absolute expiry time if expires_in is present
|
|
124
|
+
expires_at = None
|
|
125
|
+
if tokens.expires_in is not None:
|
|
126
|
+
expires_at = datetime.now(timezone.utc) + timedelta(
|
|
127
|
+
seconds=tokens.expires_in
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Create StoredToken and save using Pydantic serialization
|
|
131
|
+
stored = StoredToken(token_payload=tokens, expires_at=expires_at)
|
|
132
|
+
|
|
133
|
+
path.write_text(stored.model_dump_json(indent=2))
|
|
94
134
|
logger.debug(f"Saved tokens for {self.get_base_url(self.server_url)}")
|
|
95
135
|
|
|
96
136
|
async def get_client_info(self) -> OAuthClientInformationFull | None:
|
|
@@ -215,8 +255,13 @@ class OAuth(OAuthClientProvider):
|
|
|
215
255
|
self.redirect_port = callback_port or find_available_port()
|
|
216
256
|
redirect_uri = f"http://localhost:{self.redirect_port}/callback"
|
|
217
257
|
|
|
258
|
+
scopes_str: str
|
|
218
259
|
if isinstance(scopes, list):
|
|
219
|
-
|
|
260
|
+
scopes_str = " ".join(scopes)
|
|
261
|
+
elif scopes is not None:
|
|
262
|
+
scopes_str = str(scopes)
|
|
263
|
+
else:
|
|
264
|
+
scopes_str = ""
|
|
220
265
|
|
|
221
266
|
client_metadata = OAuthClientMetadata(
|
|
222
267
|
client_name=client_name,
|
|
@@ -224,7 +269,7 @@ class OAuth(OAuthClientProvider):
|
|
|
224
269
|
grant_types=["authorization_code", "refresh_token"],
|
|
225
270
|
response_types=["code"],
|
|
226
271
|
# token_endpoint_auth_method="client_secret_post",
|
|
227
|
-
scope=
|
|
272
|
+
scope=scopes_str,
|
|
228
273
|
**(additional_client_metadata or {}),
|
|
229
274
|
)
|
|
230
275
|
|
|
@@ -245,6 +290,15 @@ class OAuth(OAuthClientProvider):
|
|
|
245
290
|
callback_handler=self.callback_handler,
|
|
246
291
|
)
|
|
247
292
|
|
|
293
|
+
async def _initialize(self) -> None:
|
|
294
|
+
"""Load stored tokens and client info, properly setting token expiry."""
|
|
295
|
+
# Call parent's _initialize to load tokens and client info
|
|
296
|
+
await super()._initialize()
|
|
297
|
+
|
|
298
|
+
# If tokens were loaded and have expires_in, update the context's token_expiry_time
|
|
299
|
+
if self.context.current_tokens and self.context.current_tokens.expires_in:
|
|
300
|
+
self.context.update_token_expiry(self.context.current_tokens)
|
|
301
|
+
|
|
248
302
|
async def redirect_handler(self, authorization_url: str) -> None:
|
|
249
303
|
"""Open browser for authorization."""
|
|
250
304
|
logger.info(f"OAuth authorization URL: {authorization_url}")
|
|
@@ -253,10 +307,10 @@ class OAuth(OAuthClientProvider):
|
|
|
253
307
|
async def callback_handler(self) -> tuple[str, str | None]:
|
|
254
308
|
"""Handle OAuth callback and return (auth_code, state)."""
|
|
255
309
|
# Create a future to capture the OAuth response
|
|
256
|
-
response_future = asyncio.get_running_loop().create_future()
|
|
310
|
+
response_future: Future[Any] = asyncio.get_running_loop().create_future()
|
|
257
311
|
|
|
258
312
|
# Create server with the future
|
|
259
|
-
server = create_oauth_callback_server(
|
|
313
|
+
server: Server = create_oauth_callback_server(
|
|
260
314
|
port=self.redirect_port,
|
|
261
315
|
server_url=self.server_base_url,
|
|
262
316
|
response_future=response_future,
|
|
@@ -280,3 +334,5 @@ class OAuth(OAuthClientProvider):
|
|
|
280
334
|
server.should_exit = True
|
|
281
335
|
await asyncio.sleep(0.1) # Allow server to shutdown gracefully
|
|
282
336
|
tg.cancel_scope.cancel()
|
|
337
|
+
|
|
338
|
+
raise RuntimeError("OAuth callback handler could not be started")
|
fastmcp/client/client.py
CHANGED
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import copy
|
|
5
5
|
import datetime
|
|
6
|
+
import secrets
|
|
6
7
|
from contextlib import AsyncExitStack, asynccontextmanager
|
|
7
8
|
from dataclasses import dataclass, field
|
|
8
9
|
from pathlib import Path
|
|
@@ -30,7 +31,11 @@ from fastmcp.client.roots import (
|
|
|
30
31
|
RootsList,
|
|
31
32
|
create_roots_callback,
|
|
32
33
|
)
|
|
33
|
-
from fastmcp.client.sampling import
|
|
34
|
+
from fastmcp.client.sampling import (
|
|
35
|
+
ClientSamplingHandler,
|
|
36
|
+
SamplingHandler,
|
|
37
|
+
create_sampling_callback,
|
|
38
|
+
)
|
|
34
39
|
from fastmcp.exceptions import ToolError
|
|
35
40
|
from fastmcp.mcp_config import MCPConfig
|
|
36
41
|
from fastmcp.server import FastMCP
|
|
@@ -49,6 +54,7 @@ from .transports import (
|
|
|
49
54
|
PythonStdioTransport,
|
|
50
55
|
SessionKwargs,
|
|
51
56
|
SSETransport,
|
|
57
|
+
StdioTransport,
|
|
52
58
|
StreamableHttpTransport,
|
|
53
59
|
infer_transport,
|
|
54
60
|
)
|
|
@@ -60,6 +66,7 @@ __all__ = [
|
|
|
60
66
|
"RootsList",
|
|
61
67
|
"LogHandler",
|
|
62
68
|
"MessageHandler",
|
|
69
|
+
"ClientSamplingHandler",
|
|
63
70
|
"SamplingHandler",
|
|
64
71
|
"ElicitationHandler",
|
|
65
72
|
"ProgressHandler",
|
|
@@ -207,8 +214,9 @@ class Client(Generic[ClientTransportT]):
|
|
|
207
214
|
| dict[str, Any]
|
|
208
215
|
| str
|
|
209
216
|
),
|
|
217
|
+
name: str | None = None,
|
|
210
218
|
roots: RootsList | RootsHandler | None = None,
|
|
211
|
-
sampling_handler:
|
|
219
|
+
sampling_handler: ClientSamplingHandler | None = None,
|
|
212
220
|
elicitation_handler: ElicitationHandler | None = None,
|
|
213
221
|
log_handler: LogHandler | None = None,
|
|
214
222
|
message_handler: MessageHandlerT | MessageHandler | None = None,
|
|
@@ -218,6 +226,8 @@ class Client(Generic[ClientTransportT]):
|
|
|
218
226
|
client_info: mcp.types.Implementation | None = None,
|
|
219
227
|
auth: httpx.Auth | Literal["oauth"] | str | None = None,
|
|
220
228
|
) -> None:
|
|
229
|
+
self.name = name or self.generate_name()
|
|
230
|
+
|
|
221
231
|
self.transport = cast(ClientTransportT, infer_transport(transport))
|
|
222
232
|
if auth is not None:
|
|
223
233
|
self.transport._set_auth(auth)
|
|
@@ -231,7 +241,7 @@ class Client(Generic[ClientTransportT]):
|
|
|
231
241
|
self._progress_handler = progress_handler
|
|
232
242
|
|
|
233
243
|
if isinstance(timeout, int | float):
|
|
234
|
-
timeout = datetime.timedelta(seconds=timeout)
|
|
244
|
+
timeout = datetime.timedelta(seconds=float(timeout))
|
|
235
245
|
|
|
236
246
|
# handle init handshake timeout
|
|
237
247
|
if init_timeout is None:
|
|
@@ -292,7 +302,7 @@ class Client(Generic[ClientTransportT]):
|
|
|
292
302
|
"""Set the roots for the client. This does not automatically call `send_roots_list_changed`."""
|
|
293
303
|
self._session_kwargs["list_roots_callback"] = create_roots_callback(roots)
|
|
294
304
|
|
|
295
|
-
def set_sampling_callback(self, sampling_callback:
|
|
305
|
+
def set_sampling_callback(self, sampling_callback: ClientSamplingHandler) -> None:
|
|
296
306
|
"""Set the sampling callback for the client."""
|
|
297
307
|
self._session_kwargs["sampling_callback"] = create_sampling_callback(
|
|
298
308
|
sampling_callback
|
|
@@ -328,11 +338,13 @@ class Client(Generic[ClientTransportT]):
|
|
|
328
338
|
await fresh_client.call_tool("some_tool", {})
|
|
329
339
|
```
|
|
330
340
|
"""
|
|
331
|
-
|
|
332
341
|
new_client = copy.copy(self)
|
|
333
342
|
|
|
334
|
-
|
|
335
|
-
|
|
343
|
+
if not isinstance(self.transport, StdioTransport):
|
|
344
|
+
# Reset session state to fresh state
|
|
345
|
+
new_client._session_state = ClientSessionState()
|
|
346
|
+
|
|
347
|
+
new_client.name += f":{secrets.token_hex(2)}"
|
|
336
348
|
|
|
337
349
|
return new_client
|
|
338
350
|
|
|
@@ -383,6 +395,7 @@ class Client(Generic[ClientTransportT]):
|
|
|
383
395
|
self._session_state.session_task is None
|
|
384
396
|
or self._session_state.session_task.done()
|
|
385
397
|
)
|
|
398
|
+
|
|
386
399
|
if need_to_start:
|
|
387
400
|
if self._session_state.nesting_counter != 0:
|
|
388
401
|
raise RuntimeError(
|
|
@@ -408,6 +421,7 @@ class Client(Generic[ClientTransportT]):
|
|
|
408
421
|
) from exception
|
|
409
422
|
|
|
410
423
|
self._session_state.nesting_counter += 1
|
|
424
|
+
|
|
411
425
|
return self
|
|
412
426
|
|
|
413
427
|
async def _disconnect(self, force: bool = False):
|
|
@@ -533,6 +547,8 @@ class Client(Generic[ClientTransportT]):
|
|
|
533
547
|
Raises:
|
|
534
548
|
RuntimeError: If called while the client is not connected.
|
|
535
549
|
"""
|
|
550
|
+
logger.debug(f"[{self.name}] called list_resources")
|
|
551
|
+
|
|
536
552
|
result = await self.session.list_resources()
|
|
537
553
|
return result
|
|
538
554
|
|
|
@@ -560,6 +576,8 @@ class Client(Generic[ClientTransportT]):
|
|
|
560
576
|
Raises:
|
|
561
577
|
RuntimeError: If called while the client is not connected.
|
|
562
578
|
"""
|
|
579
|
+
logger.debug(f"[{self.name}] called list_resource_templates")
|
|
580
|
+
|
|
563
581
|
result = await self.session.list_resource_templates()
|
|
564
582
|
return result
|
|
565
583
|
|
|
@@ -592,6 +610,8 @@ class Client(Generic[ClientTransportT]):
|
|
|
592
610
|
Raises:
|
|
593
611
|
RuntimeError: If called while the client is not connected.
|
|
594
612
|
"""
|
|
613
|
+
logger.debug(f"[{self.name}] called read_resource: {uri}")
|
|
614
|
+
|
|
595
615
|
if isinstance(uri, str):
|
|
596
616
|
uri = AnyUrl(uri) # Ensure AnyUrl
|
|
597
617
|
result = await self.session.read_resource(uri)
|
|
@@ -646,6 +666,8 @@ class Client(Generic[ClientTransportT]):
|
|
|
646
666
|
Raises:
|
|
647
667
|
RuntimeError: If called while the client is not connected.
|
|
648
668
|
"""
|
|
669
|
+
logger.debug(f"[{self.name}] called list_prompts")
|
|
670
|
+
|
|
649
671
|
result = await self.session.list_prompts()
|
|
650
672
|
return result
|
|
651
673
|
|
|
@@ -678,6 +700,8 @@ class Client(Generic[ClientTransportT]):
|
|
|
678
700
|
Raises:
|
|
679
701
|
RuntimeError: If called while the client is not connected.
|
|
680
702
|
"""
|
|
703
|
+
logger.debug(f"[{self.name}] called get_prompt: {name}")
|
|
704
|
+
|
|
681
705
|
# Serialize arguments for MCP protocol - convert non-string values to JSON
|
|
682
706
|
serialized_arguments: dict[str, str] | None = None
|
|
683
707
|
if arguments:
|
|
@@ -735,6 +759,8 @@ class Client(Generic[ClientTransportT]):
|
|
|
735
759
|
Raises:
|
|
736
760
|
RuntimeError: If called while the client is not connected.
|
|
737
761
|
"""
|
|
762
|
+
logger.debug(f"[{self.name}] called complete: {ref}")
|
|
763
|
+
|
|
738
764
|
result = await self.session.complete(ref=ref, argument=argument)
|
|
739
765
|
return result
|
|
740
766
|
|
|
@@ -770,6 +796,8 @@ class Client(Generic[ClientTransportT]):
|
|
|
770
796
|
Raises:
|
|
771
797
|
RuntimeError: If called while the client is not connected.
|
|
772
798
|
"""
|
|
799
|
+
logger.debug(f"[{self.name}] called list_tools")
|
|
800
|
+
|
|
773
801
|
result = await self.session.list_tools()
|
|
774
802
|
return result
|
|
775
803
|
|
|
@@ -812,9 +840,10 @@ class Client(Generic[ClientTransportT]):
|
|
|
812
840
|
Raises:
|
|
813
841
|
RuntimeError: If called while the client is not connected.
|
|
814
842
|
"""
|
|
843
|
+
logger.debug(f"[{self.name}] called call_tool: {name}")
|
|
815
844
|
|
|
816
845
|
if isinstance(timeout, int | float):
|
|
817
|
-
timeout = datetime.timedelta(seconds=timeout)
|
|
846
|
+
timeout = datetime.timedelta(seconds=float(timeout))
|
|
818
847
|
result = await self.session.call_tool(
|
|
819
848
|
name=name,
|
|
820
849
|
arguments=arguments,
|
|
@@ -884,7 +913,7 @@ class Client(Generic[ClientTransportT]):
|
|
|
884
913
|
else:
|
|
885
914
|
data = result.structuredContent
|
|
886
915
|
except Exception as e:
|
|
887
|
-
logger.error(f"Error parsing structured content: {e}")
|
|
916
|
+
logger.error(f"[{self.name}] Error parsing structured content: {e}")
|
|
888
917
|
|
|
889
918
|
return CallToolResult(
|
|
890
919
|
content=result.content,
|
|
@@ -893,6 +922,14 @@ class Client(Generic[ClientTransportT]):
|
|
|
893
922
|
is_error=result.isError,
|
|
894
923
|
)
|
|
895
924
|
|
|
925
|
+
@classmethod
|
|
926
|
+
def generate_name(cls, name: str | None = None) -> str:
|
|
927
|
+
class_name = cls.__name__
|
|
928
|
+
if name is None:
|
|
929
|
+
return f"{class_name}-{secrets.token_hex(2)}"
|
|
930
|
+
else:
|
|
931
|
+
return f"{class_name}-{name}-{secrets.token_hex(2)}"
|
|
932
|
+
|
|
896
933
|
|
|
897
934
|
@dataclass
|
|
898
935
|
class CallToolResult:
|
fastmcp/client/logging.py
CHANGED
|
@@ -13,7 +13,31 @@ LogHandler: TypeAlias = Callable[[LogMessage], Awaitable[None]]
|
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
async def default_log_handler(message: LogMessage) -> None:
|
|
16
|
-
|
|
16
|
+
"""Default handler that properly routes server log messages to appropriate log levels."""
|
|
17
|
+
msg = message.data.get("msg", str(message))
|
|
18
|
+
extra = message.data.get("extra", {})
|
|
19
|
+
|
|
20
|
+
# Map MCP log levels to Python logging levels
|
|
21
|
+
level_map = {
|
|
22
|
+
"debug": logger.debug,
|
|
23
|
+
"info": logger.info,
|
|
24
|
+
"notice": logger.info, # Python doesn't have 'notice', map to info
|
|
25
|
+
"warning": logger.warning,
|
|
26
|
+
"error": logger.error,
|
|
27
|
+
"critical": logger.critical,
|
|
28
|
+
"alert": logger.critical, # Map alert to critical
|
|
29
|
+
"emergency": logger.critical, # Map emergency to critical
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# Get the appropriate logging function based on the message level
|
|
33
|
+
log_fn = level_map.get(message.level.lower(), logger.info)
|
|
34
|
+
|
|
35
|
+
# Include logger name if available
|
|
36
|
+
if message.logger:
|
|
37
|
+
msg = f"[{message.logger}] {msg}"
|
|
38
|
+
|
|
39
|
+
# Log with appropriate level and extra data
|
|
40
|
+
log_fn(f"Server log: {msg}", extra=extra)
|
|
17
41
|
|
|
18
42
|
|
|
19
43
|
def create_log_callback(handler: LogHandler | None = None) -> LoggingFnT:
|
fastmcp/client/oauth_callback.py
CHANGED
|
@@ -29,17 +29,32 @@ def create_callback_html(
|
|
|
29
29
|
server_url: str | None = None,
|
|
30
30
|
) -> str:
|
|
31
31
|
"""Create a styled HTML response for OAuth callbacks."""
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
logo_url = "https://gofastmcp.com/assets/brand/blue-logo.png"
|
|
33
|
+
|
|
34
|
+
# Build the main status message
|
|
35
|
+
if is_success:
|
|
36
|
+
status_title = "Authentication successful"
|
|
37
|
+
status_icon = "✓"
|
|
38
|
+
icon_bg = "#10b98120"
|
|
39
|
+
else:
|
|
40
|
+
status_title = "Authentication failed"
|
|
41
|
+
status_icon = "✕"
|
|
42
|
+
icon_bg = "#ef444420"
|
|
43
|
+
|
|
44
|
+
# Add detail info box for both success and error cases
|
|
45
|
+
detail_info = ""
|
|
37
46
|
if is_success and server_url:
|
|
38
|
-
|
|
39
|
-
<div class="
|
|
47
|
+
detail_info = f"""
|
|
48
|
+
<div class="info-box">
|
|
40
49
|
Connected to: <strong>{server_url}</strong>
|
|
41
50
|
</div>
|
|
42
51
|
"""
|
|
52
|
+
elif not is_success:
|
|
53
|
+
detail_info = f"""
|
|
54
|
+
<div class="info-box error">
|
|
55
|
+
{message}
|
|
56
|
+
</div>
|
|
57
|
+
"""
|
|
43
58
|
|
|
44
59
|
return f"""
|
|
45
60
|
<!DOCTYPE html>
|
|
@@ -49,127 +64,112 @@ def create_callback_html(
|
|
|
49
64
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
50
65
|
<title>{title}</title>
|
|
51
66
|
<style>
|
|
67
|
+
* {{
|
|
68
|
+
margin: 0;
|
|
69
|
+
padding: 0;
|
|
70
|
+
box-sizing: border-box;
|
|
71
|
+
}}
|
|
72
|
+
|
|
52
73
|
body {{
|
|
53
|
-
font-family:
|
|
74
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
54
75
|
margin: 0;
|
|
55
76
|
padding: 0;
|
|
56
77
|
min-height: 100vh;
|
|
57
78
|
display: flex;
|
|
58
79
|
align-items: center;
|
|
59
80
|
justify-content: center;
|
|
60
|
-
background:
|
|
61
|
-
color: #
|
|
62
|
-
overflow: hidden;
|
|
63
|
-
}}
|
|
64
|
-
|
|
65
|
-
body::before {{
|
|
66
|
-
content: '';
|
|
67
|
-
position: fixed;
|
|
68
|
-
top: 0;
|
|
69
|
-
left: 0;
|
|
70
|
-
width: 100%;
|
|
71
|
-
height: 100%;
|
|
72
|
-
background:
|
|
73
|
-
radial-gradient(circle at 20% 80%, rgba(120, 119, 198, 0.1) 0%, transparent 50%),
|
|
74
|
-
radial-gradient(circle at 80% 20%, rgba(16, 185, 129, 0.1) 0%, transparent 50%),
|
|
75
|
-
radial-gradient(circle at 40% 40%, rgba(14, 165, 233, 0.1) 0%, transparent 50%);
|
|
76
|
-
pointer-events: none;
|
|
77
|
-
z-index: -1;
|
|
81
|
+
background: #ffffff;
|
|
82
|
+
color: #0a0a0a;
|
|
78
83
|
}}
|
|
79
84
|
|
|
80
85
|
.container {{
|
|
81
|
-
background:
|
|
82
|
-
|
|
83
|
-
border: 1px solid rgba(71, 85, 105, 0.3);
|
|
86
|
+
background: #ffffff;
|
|
87
|
+
border: 1px solid #e5e5e5;
|
|
84
88
|
padding: 3rem 2rem;
|
|
85
|
-
border-radius:
|
|
86
|
-
box-shadow:
|
|
87
|
-
0 25px 50px -12px rgba(0, 0, 0, 0.7),
|
|
88
|
-
0 0 0 1px rgba(255, 255, 255, 0.05),
|
|
89
|
-
inset 0 1px 0 0 rgba(255, 255, 255, 0.1);
|
|
89
|
+
border-radius: 0.75rem;
|
|
90
|
+
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
|
|
90
91
|
text-align: center;
|
|
91
|
-
max-width:
|
|
92
|
+
max-width: 28rem;
|
|
92
93
|
margin: 1rem;
|
|
93
94
|
position: relative;
|
|
94
95
|
}}
|
|
95
96
|
|
|
96
|
-
.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
97
|
+
.logo {{
|
|
98
|
+
width: 60px;
|
|
99
|
+
height: auto;
|
|
100
|
+
margin-bottom: 2rem;
|
|
101
|
+
display: block;
|
|
102
|
+
margin-left: auto;
|
|
103
|
+
margin-right: auto;
|
|
104
|
+
}}
|
|
105
|
+
|
|
106
|
+
.status-message {{
|
|
107
|
+
display: flex;
|
|
108
|
+
align-items: center;
|
|
109
|
+
justify-content: center;
|
|
110
|
+
gap: 0.75rem;
|
|
111
|
+
margin-bottom: 1.5rem;
|
|
104
112
|
}}
|
|
105
113
|
|
|
106
114
|
.status-icon {{
|
|
107
|
-
font-size:
|
|
108
|
-
|
|
109
|
-
display:
|
|
110
|
-
|
|
115
|
+
font-size: 1.5rem;
|
|
116
|
+
line-height: 1;
|
|
117
|
+
display: inline-flex;
|
|
118
|
+
align-items: center;
|
|
119
|
+
justify-content: center;
|
|
120
|
+
width: 2rem;
|
|
121
|
+
height: 2rem;
|
|
122
|
+
background: {icon_bg};
|
|
123
|
+
border-radius: 0.5rem;
|
|
124
|
+
flex-shrink: 0;
|
|
111
125
|
}}
|
|
112
126
|
|
|
113
127
|
.message {{
|
|
114
|
-
font-size: 1.
|
|
115
|
-
line-height: 1.
|
|
116
|
-
color:
|
|
117
|
-
margin-bottom: 1.5rem;
|
|
128
|
+
font-size: 1.125rem;
|
|
129
|
+
line-height: 1.75;
|
|
130
|
+
color: #0a0a0a;
|
|
118
131
|
font-weight: 600;
|
|
119
|
-
text-
|
|
120
|
-
"16, 185, 129" if is_success else "239, 68, 68"
|
|
121
|
-
}, 0.3);
|
|
132
|
+
text-align: left;
|
|
122
133
|
}}
|
|
123
134
|
|
|
124
|
-
.
|
|
125
|
-
background:
|
|
126
|
-
border: 1px solid
|
|
127
|
-
border-radius: 0.
|
|
128
|
-
padding:
|
|
129
|
-
margin:
|
|
130
|
-
font-size: 0.
|
|
131
|
-
color: #
|
|
132
|
-
font-family: 'SF Mono', 'Monaco', 'Consolas', '
|
|
133
|
-
text-
|
|
135
|
+
.info-box {{
|
|
136
|
+
background: #f5f5f5;
|
|
137
|
+
border: 1px solid #e5e5e5;
|
|
138
|
+
border-radius: 0.5rem;
|
|
139
|
+
padding: 0.875rem;
|
|
140
|
+
margin: 1.25rem 0;
|
|
141
|
+
font-size: 0.875rem;
|
|
142
|
+
color: #525252;
|
|
143
|
+
font-family: 'SF Mono', 'Monaco', 'Consolas', 'Courier New', monospace;
|
|
144
|
+
text-align: left;
|
|
134
145
|
}}
|
|
135
146
|
|
|
136
|
-
.
|
|
137
|
-
|
|
138
|
-
|
|
147
|
+
.info-box.error {{
|
|
148
|
+
background: #fef2f2;
|
|
149
|
+
border-color: #fecaca;
|
|
150
|
+
color: #991b1b;
|
|
139
151
|
}}
|
|
140
152
|
|
|
141
|
-
.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
margin-top: 1rem;
|
|
153
|
+
.info-box strong {{
|
|
154
|
+
color: #0a0a0a;
|
|
155
|
+
font-weight: 600;
|
|
145
156
|
}}
|
|
146
157
|
|
|
147
158
|
.close-instruction {{
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
border-radius: 0.75rem;
|
|
151
|
-
padding: 1rem;
|
|
159
|
+
font-size: 0.875rem;
|
|
160
|
+
color: #737373;
|
|
152
161
|
margin-top: 1.5rem;
|
|
153
|
-
font-size: 0.9rem;
|
|
154
|
-
color: #cbd5e1;
|
|
155
|
-
font-family: 'SF Mono', 'Monaco', 'Consolas', 'Roboto Mono', monospace;
|
|
156
|
-
}}
|
|
157
|
-
|
|
158
|
-
@keyframes glow {{
|
|
159
|
-
0%, 100% {{ opacity: 1; }}
|
|
160
|
-
50% {{ opacity: 0.7; }}
|
|
161
|
-
}}
|
|
162
|
-
|
|
163
|
-
.status-icon {{
|
|
164
|
-
animation: glow 2s ease-in-out infinite;
|
|
165
162
|
}}
|
|
166
163
|
</style>
|
|
167
164
|
</head>
|
|
168
165
|
<body>
|
|
169
166
|
<div class="container">
|
|
170
|
-
<
|
|
171
|
-
<div class="message">
|
|
172
|
-
|
|
167
|
+
<img src="{logo_url}" alt="FastMCP" class="logo" />
|
|
168
|
+
<div class="status-message">
|
|
169
|
+
<span class="status-icon">{status_icon}</span>
|
|
170
|
+
<div class="message">{status_title}</div>
|
|
171
|
+
</div>
|
|
172
|
+
{detail_info}
|
|
173
173
|
<div class="close-instruction">
|
|
174
174
|
You can safely close this tab now.
|
|
175
175
|
</div>
|
|
@@ -277,7 +277,7 @@ def create_oauth_callback_server(
|
|
|
277
277
|
)
|
|
278
278
|
|
|
279
279
|
return HTMLResponse(
|
|
280
|
-
create_callback_html("
|
|
280
|
+
create_callback_html("", is_success=True, server_url=server_url)
|
|
281
281
|
)
|
|
282
282
|
|
|
283
283
|
app = Starlette(routes=[Route(callback_path, callback_handler)])
|
fastmcp/client/sampling.py
CHANGED
|
@@ -3,16 +3,18 @@ from collections.abc import Awaitable, Callable
|
|
|
3
3
|
from typing import TypeAlias
|
|
4
4
|
|
|
5
5
|
import mcp.types
|
|
6
|
-
from mcp import
|
|
7
|
-
from mcp.client.session import SamplingFnT
|
|
6
|
+
from mcp import CreateMessageResult
|
|
7
|
+
from mcp.client.session import ClientSession, SamplingFnT
|
|
8
8
|
from mcp.shared.context import LifespanContextT, RequestContext
|
|
9
9
|
from mcp.types import CreateMessageRequestParams as SamplingParams
|
|
10
10
|
from mcp.types import SamplingMessage
|
|
11
11
|
|
|
12
|
+
from fastmcp.server.sampling.handler import ServerSamplingHandler
|
|
13
|
+
|
|
12
14
|
__all__ = ["SamplingMessage", "SamplingParams", "SamplingHandler"]
|
|
13
15
|
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
ClientSamplingHandler: TypeAlias = Callable[
|
|
16
18
|
[
|
|
17
19
|
list[SamplingMessage],
|
|
18
20
|
SamplingParams,
|
|
@@ -21,8 +23,14 @@ SamplingHandler: TypeAlias = Callable[
|
|
|
21
23
|
str | CreateMessageResult | Awaitable[str | CreateMessageResult],
|
|
22
24
|
]
|
|
23
25
|
|
|
26
|
+
SamplingHandler: TypeAlias = (
|
|
27
|
+
ClientSamplingHandler[LifespanContextT] | ServerSamplingHandler[LifespanContextT]
|
|
28
|
+
)
|
|
29
|
+
|
|
24
30
|
|
|
25
|
-
def create_sampling_callback(
|
|
31
|
+
def create_sampling_callback(
|
|
32
|
+
sampling_handler: ClientSamplingHandler[LifespanContextT],
|
|
33
|
+
) -> SamplingFnT:
|
|
26
34
|
async def _sampling_handler(
|
|
27
35
|
context: RequestContext[ClientSession, LifespanContextT],
|
|
28
36
|
params: SamplingParams,
|