fastmcp 2.9.0__py3-none-any.whl → 2.9.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastmcp/client/client.py +2 -2
- fastmcp/client/logging.py +1 -2
- fastmcp/client/messages.py +126 -0
- fastmcp/prompts/prompt.py +18 -2
- fastmcp/prompts/prompt_manager.py +2 -2
- fastmcp/resources/resource.py +16 -0
- fastmcp/resources/resource_manager.py +4 -4
- fastmcp/resources/template.py +17 -1
- fastmcp/server/auth/providers/bearer.py +38 -11
- fastmcp/server/context.py +72 -8
- fastmcp/server/low_level.py +35 -0
- fastmcp/server/server.py +134 -46
- fastmcp/tools/tool.py +16 -0
- fastmcp/tools/tool_manager.py +2 -2
- fastmcp/tools/tool_transform.py +42 -16
- fastmcp/utilities/openapi.py +70 -2
- {fastmcp-2.9.0.dist-info → fastmcp-2.9.1.dist-info}/METADATA +1 -1
- {fastmcp-2.9.0.dist-info → fastmcp-2.9.1.dist-info}/RECORD +21 -19
- {fastmcp-2.9.0.dist-info → fastmcp-2.9.1.dist-info}/WHEEL +0 -0
- {fastmcp-2.9.0.dist-info → fastmcp-2.9.1.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.9.0.dist-info → fastmcp-2.9.1.dist-info}/licenses/LICENSE +0 -0
fastmcp/client/client.py
CHANGED
|
@@ -15,10 +15,10 @@ from pydantic import AnyUrl
|
|
|
15
15
|
import fastmcp
|
|
16
16
|
from fastmcp.client.logging import (
|
|
17
17
|
LogHandler,
|
|
18
|
-
MessageHandler,
|
|
19
18
|
create_log_callback,
|
|
20
19
|
default_log_handler,
|
|
21
20
|
)
|
|
21
|
+
from fastmcp.client.messages import MessageHandler, MessageHandlerT
|
|
22
22
|
from fastmcp.client.progress import ProgressHandler, default_progress_handler
|
|
23
23
|
from fastmcp.client.roots import (
|
|
24
24
|
RootsHandler,
|
|
@@ -143,7 +143,7 @@ class Client(Generic[ClientTransportT]):
|
|
|
143
143
|
roots: RootsList | RootsHandler | None = None,
|
|
144
144
|
sampling_handler: SamplingHandler | None = None,
|
|
145
145
|
log_handler: LogHandler | None = None,
|
|
146
|
-
message_handler: MessageHandler | None = None,
|
|
146
|
+
message_handler: MessageHandlerT | MessageHandler | None = None,
|
|
147
147
|
progress_handler: ProgressHandler | None = None,
|
|
148
148
|
timeout: datetime.timedelta | float | int | None = None,
|
|
149
149
|
init_timeout: datetime.timedelta | float | int | None = None,
|
fastmcp/client/logging.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from collections.abc import Awaitable, Callable
|
|
2
2
|
from typing import TypeAlias
|
|
3
3
|
|
|
4
|
-
from mcp.client.session import LoggingFnT
|
|
4
|
+
from mcp.client.session import LoggingFnT
|
|
5
5
|
from mcp.types import LoggingMessageNotificationParams
|
|
6
6
|
|
|
7
7
|
from fastmcp.utilities.logging import get_logger
|
|
@@ -10,7 +10,6 @@ logger = get_logger(__name__)
|
|
|
10
10
|
|
|
11
11
|
LogMessage: TypeAlias = LoggingMessageNotificationParams
|
|
12
12
|
LogHandler: TypeAlias = Callable[[LogMessage], Awaitable[None]]
|
|
13
|
-
MessageHandler: TypeAlias = MessageHandlerFnT
|
|
14
13
|
|
|
15
14
|
|
|
16
15
|
async def default_log_handler(message: LogMessage) -> None:
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from typing import TypeAlias
|
|
2
|
+
|
|
3
|
+
import mcp.types
|
|
4
|
+
from mcp.client.session import MessageHandlerFnT
|
|
5
|
+
from mcp.shared.session import RequestResponder
|
|
6
|
+
|
|
7
|
+
Message: TypeAlias = (
|
|
8
|
+
RequestResponder[mcp.types.ServerRequest, mcp.types.ClientResult]
|
|
9
|
+
| mcp.types.ServerNotification
|
|
10
|
+
| Exception
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
MessageHandlerT: TypeAlias = MessageHandlerFnT
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MessageHandler:
|
|
17
|
+
"""
|
|
18
|
+
This class is used to handle MCP messages sent to the client. It is used to handle all messages,
|
|
19
|
+
requests, notifications, and exceptions. Users can override any of the hooks
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
async def __call__(
|
|
23
|
+
self,
|
|
24
|
+
message: RequestResponder[mcp.types.ServerRequest, mcp.types.ClientResult]
|
|
25
|
+
| mcp.types.ServerNotification
|
|
26
|
+
| Exception,
|
|
27
|
+
) -> None:
|
|
28
|
+
return await self.dispatch(message)
|
|
29
|
+
|
|
30
|
+
async def dispatch(self, message: Message) -> None:
|
|
31
|
+
# handle all messages
|
|
32
|
+
await self.on_message(message)
|
|
33
|
+
|
|
34
|
+
match message:
|
|
35
|
+
# requests
|
|
36
|
+
case RequestResponder():
|
|
37
|
+
# handle all requests
|
|
38
|
+
await self.on_request(message)
|
|
39
|
+
|
|
40
|
+
# handle specific requests
|
|
41
|
+
match message.request.root:
|
|
42
|
+
case mcp.types.PingRequest():
|
|
43
|
+
await self.on_ping(message.request.root)
|
|
44
|
+
case mcp.types.ListRootsRequest():
|
|
45
|
+
await self.on_list_roots(message.request.root)
|
|
46
|
+
case mcp.types.CreateMessageRequest():
|
|
47
|
+
await self.on_create_message(message.request.root)
|
|
48
|
+
|
|
49
|
+
# notifications
|
|
50
|
+
case mcp.types.ServerNotification():
|
|
51
|
+
# handle all notifications
|
|
52
|
+
await self.on_notification(message)
|
|
53
|
+
|
|
54
|
+
# handle specific notifications
|
|
55
|
+
match message.root:
|
|
56
|
+
case mcp.types.CancelledNotification():
|
|
57
|
+
await self.on_cancelled(message.root)
|
|
58
|
+
case mcp.types.ProgressNotification():
|
|
59
|
+
await self.on_progress(message.root)
|
|
60
|
+
case mcp.types.LoggingMessageNotification():
|
|
61
|
+
await self.on_logging_message(message.root)
|
|
62
|
+
case mcp.types.ToolListChangedNotification():
|
|
63
|
+
await self.on_tool_list_changed(message.root)
|
|
64
|
+
case mcp.types.ResourceListChangedNotification():
|
|
65
|
+
await self.on_resource_list_changed(message.root)
|
|
66
|
+
case mcp.types.PromptListChangedNotification():
|
|
67
|
+
await self.on_prompt_list_changed(message.root)
|
|
68
|
+
case mcp.types.ResourceUpdatedNotification():
|
|
69
|
+
await self.on_resource_updated(message.root)
|
|
70
|
+
|
|
71
|
+
case Exception():
|
|
72
|
+
await self.on_exception(message)
|
|
73
|
+
|
|
74
|
+
async def on_message(self, message: Message) -> None:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
async def on_request(
|
|
78
|
+
self, message: RequestResponder[mcp.types.ServerRequest, mcp.types.ClientResult]
|
|
79
|
+
) -> None:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
async def on_ping(self, message: mcp.types.PingRequest) -> None:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
async def on_list_roots(self, message: mcp.types.ListRootsRequest) -> None:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
async def on_create_message(self, message: mcp.types.CreateMessageRequest) -> None:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
async def on_notification(self, message: mcp.types.ServerNotification) -> None:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
async def on_exception(self, message: Exception) -> None:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
async def on_progress(self, message: mcp.types.ProgressNotification) -> None:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
async def on_logging_message(
|
|
101
|
+
self, message: mcp.types.LoggingMessageNotification
|
|
102
|
+
) -> None:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
async def on_tool_list_changed(
|
|
106
|
+
self, message: mcp.types.ToolListChangedNotification
|
|
107
|
+
) -> None:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
async def on_resource_list_changed(
|
|
111
|
+
self, message: mcp.types.ResourceListChangedNotification
|
|
112
|
+
) -> None:
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
async def on_prompt_list_changed(
|
|
116
|
+
self, message: mcp.types.PromptListChangedNotification
|
|
117
|
+
) -> None:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
async def on_resource_updated(
|
|
121
|
+
self, message: mcp.types.ResourceUpdatedNotification
|
|
122
|
+
) -> None:
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
async def on_cancelled(self, message: mcp.types.CancelledNotification) -> None:
|
|
126
|
+
pass
|
fastmcp/prompts/prompt.py
CHANGED
|
@@ -70,6 +70,22 @@ class Prompt(FastMCPComponent, ABC):
|
|
|
70
70
|
default=None, description="Arguments that can be passed to the prompt"
|
|
71
71
|
)
|
|
72
72
|
|
|
73
|
+
def enable(self) -> None:
|
|
74
|
+
super().enable()
|
|
75
|
+
try:
|
|
76
|
+
context = get_context()
|
|
77
|
+
context._queue_prompt_list_changed() # type: ignore[private-use]
|
|
78
|
+
except RuntimeError:
|
|
79
|
+
pass # No context available
|
|
80
|
+
|
|
81
|
+
def disable(self) -> None:
|
|
82
|
+
super().disable()
|
|
83
|
+
try:
|
|
84
|
+
context = get_context()
|
|
85
|
+
context._queue_prompt_list_changed() # type: ignore[private-use]
|
|
86
|
+
except RuntimeError:
|
|
87
|
+
pass # No context available
|
|
88
|
+
|
|
73
89
|
def to_mcp_prompt(self, **overrides: Any) -> MCPPrompt:
|
|
74
90
|
"""Convert the prompt to an MCP prompt."""
|
|
75
91
|
arguments = [
|
|
@@ -339,6 +355,6 @@ class FunctionPrompt(Prompt):
|
|
|
339
355
|
raise PromptError("Could not convert prompt result to message.")
|
|
340
356
|
|
|
341
357
|
return messages
|
|
342
|
-
except Exception
|
|
343
|
-
logger.exception(f"Error rendering prompt {self.name}
|
|
358
|
+
except Exception:
|
|
359
|
+
logger.exception(f"Error rendering prompt {self.name}")
|
|
344
360
|
raise PromptError(f"Error rendering prompt {self.name}.")
|
|
@@ -172,12 +172,12 @@ class PromptManager:
|
|
|
172
172
|
|
|
173
173
|
# Pass through PromptErrors as-is
|
|
174
174
|
except PromptError as e:
|
|
175
|
-
logger.exception(f"Error rendering prompt {name!r}
|
|
175
|
+
logger.exception(f"Error rendering prompt {name!r}")
|
|
176
176
|
raise e
|
|
177
177
|
|
|
178
178
|
# Handle other exceptions
|
|
179
179
|
except Exception as e:
|
|
180
|
-
logger.exception(f"Error rendering prompt {name!r}
|
|
180
|
+
logger.exception(f"Error rendering prompt {name!r}")
|
|
181
181
|
if self.mask_error_details:
|
|
182
182
|
# Mask internal details
|
|
183
183
|
raise PromptError(f"Error rendering prompt {name!r}") from e
|
fastmcp/resources/resource.py
CHANGED
|
@@ -44,6 +44,22 @@ class Resource(FastMCPComponent, abc.ABC):
|
|
|
44
44
|
pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$",
|
|
45
45
|
)
|
|
46
46
|
|
|
47
|
+
def enable(self) -> None:
|
|
48
|
+
super().enable()
|
|
49
|
+
try:
|
|
50
|
+
context = get_context()
|
|
51
|
+
context._queue_resource_list_changed() # type: ignore[private-use]
|
|
52
|
+
except RuntimeError:
|
|
53
|
+
pass # No context available
|
|
54
|
+
|
|
55
|
+
def disable(self) -> None:
|
|
56
|
+
super().disable()
|
|
57
|
+
try:
|
|
58
|
+
context = get_context()
|
|
59
|
+
context._queue_resource_list_changed() # type: ignore[private-use]
|
|
60
|
+
except RuntimeError:
|
|
61
|
+
pass # No context available
|
|
62
|
+
|
|
47
63
|
@staticmethod
|
|
48
64
|
def from_function(
|
|
49
65
|
fn: Callable[[], Any],
|
|
@@ -422,12 +422,12 @@ class ResourceManager:
|
|
|
422
422
|
|
|
423
423
|
# raise ResourceErrors as-is
|
|
424
424
|
except ResourceError as e:
|
|
425
|
-
logger.exception(f"Error reading resource {uri_str!r}
|
|
425
|
+
logger.exception(f"Error reading resource {uri_str!r}")
|
|
426
426
|
raise e
|
|
427
427
|
|
|
428
428
|
# Handle other exceptions
|
|
429
429
|
except Exception as e:
|
|
430
|
-
logger.exception(f"Error reading resource {uri_str!r}
|
|
430
|
+
logger.exception(f"Error reading resource {uri_str!r}")
|
|
431
431
|
if self.mask_error_details:
|
|
432
432
|
# Mask internal details
|
|
433
433
|
raise ResourceError(f"Error reading resource {uri_str!r}") from e
|
|
@@ -445,12 +445,12 @@ class ResourceManager:
|
|
|
445
445
|
return await resource.read()
|
|
446
446
|
except ResourceError as e:
|
|
447
447
|
logger.exception(
|
|
448
|
-
f"Error reading resource from template {uri_str!r}
|
|
448
|
+
f"Error reading resource from template {uri_str!r}"
|
|
449
449
|
)
|
|
450
450
|
raise e
|
|
451
451
|
except Exception as e:
|
|
452
452
|
logger.exception(
|
|
453
|
-
f"Error reading resource from template {uri_str!r}
|
|
453
|
+
f"Error reading resource from template {uri_str!r}"
|
|
454
454
|
)
|
|
455
455
|
if self.mask_error_details:
|
|
456
456
|
raise ResourceError(
|
fastmcp/resources/template.py
CHANGED
|
@@ -15,7 +15,7 @@ from pydantic import (
|
|
|
15
15
|
validate_call,
|
|
16
16
|
)
|
|
17
17
|
|
|
18
|
-
from fastmcp.resources.
|
|
18
|
+
from fastmcp.resources.resource import Resource
|
|
19
19
|
from fastmcp.server.dependencies import get_context
|
|
20
20
|
from fastmcp.utilities.components import FastMCPComponent
|
|
21
21
|
from fastmcp.utilities.json_schema import compress_schema
|
|
@@ -65,6 +65,22 @@ class ResourceTemplate(FastMCPComponent):
|
|
|
65
65
|
def __repr__(self) -> str:
|
|
66
66
|
return f"{self.__class__.__name__}(uri_template={self.uri_template!r}, name={self.name!r}, description={self.description!r}, tags={self.tags})"
|
|
67
67
|
|
|
68
|
+
def enable(self) -> None:
|
|
69
|
+
super().enable()
|
|
70
|
+
try:
|
|
71
|
+
context = get_context()
|
|
72
|
+
context._queue_resource_list_changed() # type: ignore[private-use]
|
|
73
|
+
except RuntimeError:
|
|
74
|
+
pass # No context available
|
|
75
|
+
|
|
76
|
+
def disable(self) -> None:
|
|
77
|
+
super().disable()
|
|
78
|
+
try:
|
|
79
|
+
context = get_context()
|
|
80
|
+
context._queue_resource_list_changed() # type: ignore[private-use]
|
|
81
|
+
except RuntimeError:
|
|
82
|
+
pass # No context available
|
|
83
|
+
|
|
68
84
|
@staticmethod
|
|
69
85
|
def from_function(
|
|
70
86
|
fn: Callable[..., Any],
|
|
@@ -24,6 +24,7 @@ from fastmcp.server.auth.auth import (
|
|
|
24
24
|
OAuthProvider,
|
|
25
25
|
RevocationOptions,
|
|
26
26
|
)
|
|
27
|
+
from fastmcp.utilities.logging import get_logger
|
|
27
28
|
|
|
28
29
|
|
|
29
30
|
class JWKData(TypedDict, total=False):
|
|
@@ -199,6 +200,7 @@ class BearerAuthProvider(OAuthProvider):
|
|
|
199
200
|
self.public_key = public_key
|
|
200
201
|
self.jwks_uri = jwks_uri
|
|
201
202
|
self.jwt = JsonWebToken(["RS256"])
|
|
203
|
+
self.logger = get_logger(__name__)
|
|
202
204
|
|
|
203
205
|
# Simple JWKS cache
|
|
204
206
|
self._jwks_cache: dict[str, str] = {}
|
|
@@ -265,6 +267,9 @@ class BearerAuthProvider(OAuthProvider):
|
|
|
265
267
|
# Select the appropriate key
|
|
266
268
|
if kid:
|
|
267
269
|
if kid not in self._jwks_cache:
|
|
270
|
+
self.logger.debug(
|
|
271
|
+
"JWKS key lookup failed: key ID '%s' not found", kid
|
|
272
|
+
)
|
|
268
273
|
raise ValueError(f"Key ID '{kid}' not found in JWKS")
|
|
269
274
|
return self._jwks_cache[kid]
|
|
270
275
|
else:
|
|
@@ -279,6 +284,7 @@ class BearerAuthProvider(OAuthProvider):
|
|
|
279
284
|
raise ValueError("No keys found in JWKS")
|
|
280
285
|
|
|
281
286
|
except Exception as e:
|
|
287
|
+
self.logger.debug("JWKS fetch failed: %s", str(e))
|
|
282
288
|
raise ValueError(f"Failed to fetch JWKS: {e}")
|
|
283
289
|
|
|
284
290
|
async def load_access_token(self, token: str) -> AccessToken | None:
|
|
@@ -298,15 +304,27 @@ class BearerAuthProvider(OAuthProvider):
|
|
|
298
304
|
# Decode and verify the JWT token
|
|
299
305
|
claims = self.jwt.decode(token, verification_key)
|
|
300
306
|
|
|
307
|
+
# Extract client ID early for logging
|
|
308
|
+
client_id = claims.get("client_id") or claims.get("sub") or "unknown"
|
|
309
|
+
|
|
301
310
|
# Validate expiration
|
|
302
311
|
exp = claims.get("exp")
|
|
303
312
|
if exp and exp < time.time():
|
|
313
|
+
self.logger.debug(
|
|
314
|
+
"Token validation failed: expired token for client %s", client_id
|
|
315
|
+
)
|
|
316
|
+
self.logger.info("Bearer token rejected for client %s", client_id)
|
|
304
317
|
return None
|
|
305
318
|
|
|
306
319
|
# Validate issuer - note we use issuer instead of issuer_url here because
|
|
307
320
|
# issuer is optional, allowing users to make this check optional
|
|
308
321
|
if self.issuer:
|
|
309
322
|
if claims.get("iss") != self.issuer:
|
|
323
|
+
self.logger.debug(
|
|
324
|
+
"Token validation failed: issuer mismatch for client %s",
|
|
325
|
+
client_id,
|
|
326
|
+
)
|
|
327
|
+
self.logger.info("Bearer token rejected for client %s", client_id)
|
|
310
328
|
return None
|
|
311
329
|
|
|
312
330
|
# Validate audience if configured
|
|
@@ -314,26 +332,33 @@ class BearerAuthProvider(OAuthProvider):
|
|
|
314
332
|
aud = claims.get("aud")
|
|
315
333
|
|
|
316
334
|
# Handle different combinations of audience types
|
|
335
|
+
audience_valid = False
|
|
317
336
|
if isinstance(self.audience, list):
|
|
318
337
|
# self.audience is a list - check if any expected audience is present
|
|
319
338
|
if isinstance(aud, list):
|
|
320
339
|
# Both are lists - check for intersection
|
|
321
|
-
|
|
322
|
-
|
|
340
|
+
audience_valid = any(
|
|
341
|
+
expected in aud for expected in self.audience
|
|
342
|
+
)
|
|
323
343
|
else:
|
|
324
344
|
# aud is a string - check if it's in our expected list
|
|
325
|
-
|
|
326
|
-
return None
|
|
345
|
+
audience_valid = aud in self.audience
|
|
327
346
|
else:
|
|
328
347
|
# self.audience is a string - use original logic
|
|
329
348
|
if isinstance(aud, list):
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
return None
|
|
349
|
+
audience_valid = self.audience in aud
|
|
350
|
+
else:
|
|
351
|
+
audience_valid = aud == self.audience
|
|
334
352
|
|
|
335
|
-
|
|
336
|
-
|
|
353
|
+
if not audience_valid:
|
|
354
|
+
self.logger.debug(
|
|
355
|
+
"Token validation failed: audience mismatch for client %s",
|
|
356
|
+
client_id,
|
|
357
|
+
)
|
|
358
|
+
self.logger.info("Bearer token rejected for client %s", client_id)
|
|
359
|
+
return None
|
|
360
|
+
|
|
361
|
+
# Extract scopes
|
|
337
362
|
scopes = self._extract_scopes(claims)
|
|
338
363
|
|
|
339
364
|
return AccessToken(
|
|
@@ -344,8 +369,10 @@ class BearerAuthProvider(OAuthProvider):
|
|
|
344
369
|
)
|
|
345
370
|
|
|
346
371
|
except JoseError:
|
|
372
|
+
self.logger.debug("Token validation failed: JWT signature/format invalid")
|
|
347
373
|
return None
|
|
348
|
-
except Exception:
|
|
374
|
+
except Exception as e:
|
|
375
|
+
self.logger.debug("Token validation failed: %s", str(e))
|
|
349
376
|
return None
|
|
350
377
|
|
|
351
378
|
def _extract_scopes(self, claims: dict[str, Any]) -> list[str]:
|
fastmcp/server/context.py
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations as _annotations
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import warnings
|
|
4
5
|
from collections.abc import Generator
|
|
5
6
|
from contextlib import contextmanager
|
|
6
7
|
from contextvars import ContextVar, Token
|
|
7
8
|
from dataclasses import dataclass
|
|
8
9
|
|
|
9
|
-
from mcp import LoggingLevel
|
|
10
|
+
from mcp import LoggingLevel, ServerSession
|
|
10
11
|
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
|
11
12
|
from mcp.server.lowlevel.server import request_ctx
|
|
12
13
|
from mcp.shared.context import RequestContext
|
|
@@ -30,6 +31,7 @@ from fastmcp.utilities.types import MCPContent
|
|
|
30
31
|
logger = get_logger(__name__)
|
|
31
32
|
|
|
32
33
|
_current_context: ContextVar[Context | None] = ContextVar("context", default=None)
|
|
34
|
+
_flush_lock = asyncio.Lock()
|
|
33
35
|
|
|
34
36
|
|
|
35
37
|
@contextmanager
|
|
@@ -80,16 +82,20 @@ class Context:
|
|
|
80
82
|
def __init__(self, fastmcp: FastMCP):
|
|
81
83
|
self.fastmcp = fastmcp
|
|
82
84
|
self._tokens: list[Token] = []
|
|
85
|
+
self._notification_queue: set[str] = set() # Dedupe notifications
|
|
83
86
|
|
|
84
|
-
def
|
|
87
|
+
async def __aenter__(self) -> Context:
|
|
85
88
|
"""Enter the context manager and set this context as the current context."""
|
|
86
89
|
# Always set this context and save the token
|
|
87
90
|
token = _current_context.set(self)
|
|
88
91
|
self._tokens.append(token)
|
|
89
92
|
return self
|
|
90
93
|
|
|
91
|
-
def
|
|
94
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
92
95
|
"""Exit the context manager and reset the most recent token."""
|
|
96
|
+
# Flush any remaining notifications before exiting
|
|
97
|
+
await self._flush_notifications()
|
|
98
|
+
|
|
93
99
|
if self._tokens:
|
|
94
100
|
token = self._tokens.pop()
|
|
95
101
|
_current_context.reset(token)
|
|
@@ -124,7 +130,7 @@ class Context:
|
|
|
124
130
|
if progress_token is None:
|
|
125
131
|
return
|
|
126
132
|
|
|
127
|
-
await self.
|
|
133
|
+
await self.session.send_progress_notification(
|
|
128
134
|
progress_token=progress_token,
|
|
129
135
|
progress=progress,
|
|
130
136
|
total=total,
|
|
@@ -160,7 +166,7 @@ class Context:
|
|
|
160
166
|
"""
|
|
161
167
|
if level is None:
|
|
162
168
|
level = "info"
|
|
163
|
-
await self.
|
|
169
|
+
await self.session.send_log_message(
|
|
164
170
|
level=level, data=message, logger=logger_name
|
|
165
171
|
)
|
|
166
172
|
|
|
@@ -210,7 +216,7 @@ class Context:
|
|
|
210
216
|
return None
|
|
211
217
|
|
|
212
218
|
@property
|
|
213
|
-
def session(self):
|
|
219
|
+
def session(self) -> ServerSession:
|
|
214
220
|
"""Access to the underlying session for advanced usage."""
|
|
215
221
|
return self.request_context.session
|
|
216
222
|
|
|
@@ -233,9 +239,21 @@ class Context:
|
|
|
233
239
|
|
|
234
240
|
async def list_roots(self) -> list[Root]:
|
|
235
241
|
"""List the roots available to the server, as indicated by the client."""
|
|
236
|
-
result = await self.
|
|
242
|
+
result = await self.session.list_roots()
|
|
237
243
|
return result.roots
|
|
238
244
|
|
|
245
|
+
async def send_tool_list_changed(self) -> None:
|
|
246
|
+
"""Send a tool list changed notification to the client."""
|
|
247
|
+
await self.session.send_tool_list_changed()
|
|
248
|
+
|
|
249
|
+
async def send_resource_list_changed(self) -> None:
|
|
250
|
+
"""Send a resource list changed notification to the client."""
|
|
251
|
+
await self.session.send_resource_list_changed()
|
|
252
|
+
|
|
253
|
+
async def send_prompt_list_changed(self) -> None:
|
|
254
|
+
"""Send a prompt list changed notification to the client."""
|
|
255
|
+
await self.session.send_prompt_list_changed()
|
|
256
|
+
|
|
239
257
|
async def sample(
|
|
240
258
|
self,
|
|
241
259
|
messages: str | list[str | SamplingMessage],
|
|
@@ -269,7 +287,7 @@ class Context:
|
|
|
269
287
|
for m in messages
|
|
270
288
|
]
|
|
271
289
|
|
|
272
|
-
result: CreateMessageResult = await self.
|
|
290
|
+
result: CreateMessageResult = await self.session.create_message(
|
|
273
291
|
messages=sampling_messages,
|
|
274
292
|
system_prompt=system_prompt,
|
|
275
293
|
temperature=temperature,
|
|
@@ -294,6 +312,52 @@ class Context:
|
|
|
294
312
|
|
|
295
313
|
return fastmcp.server.dependencies.get_http_request()
|
|
296
314
|
|
|
315
|
+
def _queue_tool_list_changed(self) -> None:
|
|
316
|
+
"""Queue a tool list changed notification."""
|
|
317
|
+
self._notification_queue.add("notifications/tools/list_changed")
|
|
318
|
+
self._try_flush_notifications()
|
|
319
|
+
|
|
320
|
+
def _queue_resource_list_changed(self) -> None:
|
|
321
|
+
"""Queue a resource list changed notification."""
|
|
322
|
+
self._notification_queue.add("notifications/resources/list_changed")
|
|
323
|
+
self._try_flush_notifications()
|
|
324
|
+
|
|
325
|
+
def _queue_prompt_list_changed(self) -> None:
|
|
326
|
+
"""Queue a prompt list changed notification."""
|
|
327
|
+
self._notification_queue.add("notifications/prompts/list_changed")
|
|
328
|
+
self._try_flush_notifications()
|
|
329
|
+
|
|
330
|
+
def _try_flush_notifications(self) -> None:
|
|
331
|
+
"""Synchronous method that attempts to flush notifications if we're in an async context."""
|
|
332
|
+
try:
|
|
333
|
+
# Check if we're in an async context
|
|
334
|
+
loop = asyncio.get_running_loop()
|
|
335
|
+
if loop and not loop.is_running():
|
|
336
|
+
return
|
|
337
|
+
# Schedule flush as a task (fire-and-forget)
|
|
338
|
+
asyncio.create_task(self._flush_notifications())
|
|
339
|
+
except RuntimeError:
|
|
340
|
+
# No event loop - will flush later
|
|
341
|
+
pass
|
|
342
|
+
|
|
343
|
+
async def _flush_notifications(self) -> None:
|
|
344
|
+
"""Send all queued notifications."""
|
|
345
|
+
async with _flush_lock:
|
|
346
|
+
if not self._notification_queue:
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
try:
|
|
350
|
+
if "notifications/tools/list_changed" in self._notification_queue:
|
|
351
|
+
await self.session.send_tool_list_changed()
|
|
352
|
+
if "notifications/resources/list_changed" in self._notification_queue:
|
|
353
|
+
await self.session.send_resource_list_changed()
|
|
354
|
+
if "notifications/prompts/list_changed" in self._notification_queue:
|
|
355
|
+
await self.session.send_prompt_list_changed()
|
|
356
|
+
self._notification_queue.clear()
|
|
357
|
+
except Exception:
|
|
358
|
+
# Don't let notification failures break the request
|
|
359
|
+
pass
|
|
360
|
+
|
|
297
361
|
def _parse_model_preferences(
|
|
298
362
|
self, model_preferences: ModelPreferences | str | list[str] | None
|
|
299
363
|
) -> ModelPreferences | None:
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from mcp.server.lowlevel.server import (
|
|
4
|
+
LifespanResultT,
|
|
5
|
+
NotificationOptions,
|
|
6
|
+
RequestT,
|
|
7
|
+
Server,
|
|
8
|
+
)
|
|
9
|
+
from mcp.server.models import InitializationOptions
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LowLevelServer(Server[LifespanResultT, RequestT]):
|
|
13
|
+
def __init__(self, *args, **kwargs):
|
|
14
|
+
super().__init__(*args, **kwargs)
|
|
15
|
+
# FastMCP servers support notifications for all components
|
|
16
|
+
self.notification_options = NotificationOptions(
|
|
17
|
+
prompts_changed=True,
|
|
18
|
+
resources_changed=True,
|
|
19
|
+
tools_changed=True,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def create_initialization_options(
|
|
23
|
+
self,
|
|
24
|
+
notification_options: NotificationOptions | None = None,
|
|
25
|
+
experimental_capabilities: dict[str, dict[str, Any]] | None = None,
|
|
26
|
+
**kwargs: Any,
|
|
27
|
+
) -> InitializationOptions:
|
|
28
|
+
# ensure we use the FastMCP notification options
|
|
29
|
+
if notification_options is None:
|
|
30
|
+
notification_options = self.notification_options
|
|
31
|
+
return super().create_initialization_options(
|
|
32
|
+
notification_options=notification_options,
|
|
33
|
+
experimental_capabilities=experimental_capabilities,
|
|
34
|
+
**kwargs,
|
|
35
|
+
)
|