fastmcp 2.14.4__py3-none-any.whl → 3.0.0b1__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 (175) hide show
  1. fastmcp/_vendor/__init__.py +1 -0
  2. fastmcp/_vendor/docket_di/README.md +7 -0
  3. fastmcp/_vendor/docket_di/__init__.py +163 -0
  4. fastmcp/cli/cli.py +112 -28
  5. fastmcp/cli/install/claude_code.py +1 -5
  6. fastmcp/cli/install/claude_desktop.py +1 -5
  7. fastmcp/cli/install/cursor.py +1 -5
  8. fastmcp/cli/install/gemini_cli.py +1 -5
  9. fastmcp/cli/install/mcp_json.py +1 -6
  10. fastmcp/cli/run.py +146 -5
  11. fastmcp/client/__init__.py +7 -9
  12. fastmcp/client/auth/oauth.py +18 -17
  13. fastmcp/client/client.py +100 -870
  14. fastmcp/client/elicitation.py +1 -1
  15. fastmcp/client/mixins/__init__.py +13 -0
  16. fastmcp/client/mixins/prompts.py +295 -0
  17. fastmcp/client/mixins/resources.py +325 -0
  18. fastmcp/client/mixins/task_management.py +157 -0
  19. fastmcp/client/mixins/tools.py +397 -0
  20. fastmcp/client/sampling/handlers/anthropic.py +2 -2
  21. fastmcp/client/sampling/handlers/openai.py +1 -1
  22. fastmcp/client/tasks.py +3 -3
  23. fastmcp/client/telemetry.py +47 -0
  24. fastmcp/client/transports/__init__.py +38 -0
  25. fastmcp/client/transports/base.py +82 -0
  26. fastmcp/client/transports/config.py +170 -0
  27. fastmcp/client/transports/http.py +145 -0
  28. fastmcp/client/transports/inference.py +154 -0
  29. fastmcp/client/transports/memory.py +90 -0
  30. fastmcp/client/transports/sse.py +89 -0
  31. fastmcp/client/transports/stdio.py +543 -0
  32. fastmcp/contrib/component_manager/README.md +4 -10
  33. fastmcp/contrib/component_manager/__init__.py +1 -2
  34. fastmcp/contrib/component_manager/component_manager.py +95 -160
  35. fastmcp/contrib/component_manager/example.py +1 -1
  36. fastmcp/contrib/mcp_mixin/example.py +4 -4
  37. fastmcp/contrib/mcp_mixin/mcp_mixin.py +11 -4
  38. fastmcp/decorators.py +41 -0
  39. fastmcp/dependencies.py +12 -1
  40. fastmcp/exceptions.py +4 -0
  41. fastmcp/experimental/server/openapi/__init__.py +18 -15
  42. fastmcp/mcp_config.py +13 -4
  43. fastmcp/prompts/__init__.py +6 -3
  44. fastmcp/prompts/function_prompt.py +465 -0
  45. fastmcp/prompts/prompt.py +321 -271
  46. fastmcp/resources/__init__.py +5 -3
  47. fastmcp/resources/function_resource.py +335 -0
  48. fastmcp/resources/resource.py +325 -115
  49. fastmcp/resources/template.py +215 -43
  50. fastmcp/resources/types.py +27 -12
  51. fastmcp/server/__init__.py +2 -2
  52. fastmcp/server/auth/__init__.py +14 -0
  53. fastmcp/server/auth/auth.py +30 -10
  54. fastmcp/server/auth/authorization.py +190 -0
  55. fastmcp/server/auth/oauth_proxy/__init__.py +14 -0
  56. fastmcp/server/auth/oauth_proxy/consent.py +361 -0
  57. fastmcp/server/auth/oauth_proxy/models.py +178 -0
  58. fastmcp/server/auth/{oauth_proxy.py → oauth_proxy/proxy.py} +24 -778
  59. fastmcp/server/auth/oauth_proxy/ui.py +277 -0
  60. fastmcp/server/auth/oidc_proxy.py +2 -2
  61. fastmcp/server/auth/providers/auth0.py +24 -94
  62. fastmcp/server/auth/providers/aws.py +26 -95
  63. fastmcp/server/auth/providers/azure.py +41 -129
  64. fastmcp/server/auth/providers/descope.py +18 -49
  65. fastmcp/server/auth/providers/discord.py +25 -86
  66. fastmcp/server/auth/providers/github.py +23 -87
  67. fastmcp/server/auth/providers/google.py +24 -87
  68. fastmcp/server/auth/providers/introspection.py +60 -79
  69. fastmcp/server/auth/providers/jwt.py +30 -67
  70. fastmcp/server/auth/providers/oci.py +47 -110
  71. fastmcp/server/auth/providers/scalekit.py +23 -61
  72. fastmcp/server/auth/providers/supabase.py +18 -47
  73. fastmcp/server/auth/providers/workos.py +34 -127
  74. fastmcp/server/context.py +372 -419
  75. fastmcp/server/dependencies.py +541 -251
  76. fastmcp/server/elicitation.py +20 -18
  77. fastmcp/server/event_store.py +3 -3
  78. fastmcp/server/http.py +16 -6
  79. fastmcp/server/lifespan.py +198 -0
  80. fastmcp/server/low_level.py +92 -2
  81. fastmcp/server/middleware/__init__.py +5 -1
  82. fastmcp/server/middleware/authorization.py +312 -0
  83. fastmcp/server/middleware/caching.py +101 -54
  84. fastmcp/server/middleware/middleware.py +6 -9
  85. fastmcp/server/middleware/ping.py +70 -0
  86. fastmcp/server/middleware/tool_injection.py +2 -2
  87. fastmcp/server/mixins/__init__.py +7 -0
  88. fastmcp/server/mixins/lifespan.py +217 -0
  89. fastmcp/server/mixins/mcp_operations.py +392 -0
  90. fastmcp/server/mixins/transport.py +342 -0
  91. fastmcp/server/openapi/__init__.py +41 -21
  92. fastmcp/server/openapi/components.py +16 -339
  93. fastmcp/server/openapi/routing.py +34 -118
  94. fastmcp/server/openapi/server.py +67 -392
  95. fastmcp/server/providers/__init__.py +71 -0
  96. fastmcp/server/providers/aggregate.py +261 -0
  97. fastmcp/server/providers/base.py +578 -0
  98. fastmcp/server/providers/fastmcp_provider.py +674 -0
  99. fastmcp/server/providers/filesystem.py +226 -0
  100. fastmcp/server/providers/filesystem_discovery.py +327 -0
  101. fastmcp/server/providers/local_provider/__init__.py +11 -0
  102. fastmcp/server/providers/local_provider/decorators/__init__.py +15 -0
  103. fastmcp/server/providers/local_provider/decorators/prompts.py +256 -0
  104. fastmcp/server/providers/local_provider/decorators/resources.py +240 -0
  105. fastmcp/server/providers/local_provider/decorators/tools.py +315 -0
  106. fastmcp/server/providers/local_provider/local_provider.py +465 -0
  107. fastmcp/server/providers/openapi/__init__.py +39 -0
  108. fastmcp/server/providers/openapi/components.py +332 -0
  109. fastmcp/server/providers/openapi/provider.py +405 -0
  110. fastmcp/server/providers/openapi/routing.py +109 -0
  111. fastmcp/server/providers/proxy.py +867 -0
  112. fastmcp/server/providers/skills/__init__.py +59 -0
  113. fastmcp/server/providers/skills/_common.py +101 -0
  114. fastmcp/server/providers/skills/claude_provider.py +44 -0
  115. fastmcp/server/providers/skills/directory_provider.py +153 -0
  116. fastmcp/server/providers/skills/skill_provider.py +432 -0
  117. fastmcp/server/providers/skills/vendor_providers.py +142 -0
  118. fastmcp/server/providers/wrapped_provider.py +140 -0
  119. fastmcp/server/proxy.py +34 -700
  120. fastmcp/server/sampling/run.py +341 -2
  121. fastmcp/server/sampling/sampling_tool.py +4 -3
  122. fastmcp/server/server.py +1214 -2171
  123. fastmcp/server/tasks/__init__.py +2 -1
  124. fastmcp/server/tasks/capabilities.py +13 -1
  125. fastmcp/server/tasks/config.py +66 -3
  126. fastmcp/server/tasks/handlers.py +65 -273
  127. fastmcp/server/tasks/keys.py +4 -6
  128. fastmcp/server/tasks/requests.py +474 -0
  129. fastmcp/server/tasks/routing.py +76 -0
  130. fastmcp/server/tasks/subscriptions.py +20 -11
  131. fastmcp/server/telemetry.py +131 -0
  132. fastmcp/server/transforms/__init__.py +244 -0
  133. fastmcp/server/transforms/namespace.py +193 -0
  134. fastmcp/server/transforms/prompts_as_tools.py +175 -0
  135. fastmcp/server/transforms/resources_as_tools.py +190 -0
  136. fastmcp/server/transforms/tool_transform.py +96 -0
  137. fastmcp/server/transforms/version_filter.py +124 -0
  138. fastmcp/server/transforms/visibility.py +526 -0
  139. fastmcp/settings.py +34 -96
  140. fastmcp/telemetry.py +122 -0
  141. fastmcp/tools/__init__.py +10 -3
  142. fastmcp/tools/function_parsing.py +201 -0
  143. fastmcp/tools/function_tool.py +467 -0
  144. fastmcp/tools/tool.py +215 -362
  145. fastmcp/tools/tool_transform.py +38 -21
  146. fastmcp/utilities/async_utils.py +69 -0
  147. fastmcp/utilities/components.py +152 -91
  148. fastmcp/utilities/inspect.py +8 -20
  149. fastmcp/utilities/json_schema.py +12 -5
  150. fastmcp/utilities/json_schema_type.py +17 -15
  151. fastmcp/utilities/lifespan.py +56 -0
  152. fastmcp/utilities/logging.py +12 -4
  153. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +3 -3
  154. fastmcp/utilities/openapi/parser.py +3 -3
  155. fastmcp/utilities/pagination.py +80 -0
  156. fastmcp/utilities/skills.py +253 -0
  157. fastmcp/utilities/tests.py +0 -16
  158. fastmcp/utilities/timeout.py +47 -0
  159. fastmcp/utilities/types.py +1 -1
  160. fastmcp/utilities/versions.py +285 -0
  161. {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/METADATA +8 -5
  162. fastmcp-3.0.0b1.dist-info/RECORD +228 -0
  163. fastmcp/client/transports.py +0 -1170
  164. fastmcp/contrib/component_manager/component_service.py +0 -209
  165. fastmcp/prompts/prompt_manager.py +0 -117
  166. fastmcp/resources/resource_manager.py +0 -338
  167. fastmcp/server/tasks/converters.py +0 -206
  168. fastmcp/server/tasks/protocol.py +0 -359
  169. fastmcp/tools/tool_manager.py +0 -170
  170. fastmcp/utilities/mcp_config.py +0 -56
  171. fastmcp-2.14.4.dist-info/RECORD +0 -161
  172. /fastmcp/server/{openapi → providers/openapi}/README.md +0 -0
  173. {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/WHEEL +0 -0
  174. {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/entry_points.txt +0 -0
  175. {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/licenses/LICENSE +0 -0
fastmcp/server/context.py CHANGED
@@ -1,7 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import copy
4
- import json
5
3
  import logging
6
4
  import weakref
7
5
  from collections.abc import Callable, Generator, Mapping, Sequence
@@ -9,35 +7,25 @@ from contextlib import contextmanager
9
7
  from contextvars import ContextVar, Token
10
8
  from dataclasses import dataclass
11
9
  from logging import Logger
12
- from typing import Any, Literal, cast, overload
10
+ from typing import Any, Literal, overload
13
11
 
14
- import anyio
12
+ import mcp.types
15
13
  from mcp import LoggingLevel, ServerSession
16
- from mcp.server.lowlevel.helper_types import ReadResourceContents
17
14
  from mcp.server.lowlevel.server import request_ctx
18
15
  from mcp.shared.context import RequestContext
19
16
  from mcp.types import (
20
- CreateMessageResult,
21
- CreateMessageResultWithTools,
22
17
  GetPromptResult,
23
18
  ModelPreferences,
24
19
  Root,
25
20
  SamplingMessage,
26
- SamplingMessageContentBlock,
27
- TextContent,
28
- ToolChoice,
29
- ToolResultContent,
30
- ToolUseContent,
31
21
  )
32
22
  from mcp.types import Prompt as SDKPrompt
33
23
  from mcp.types import Resource as SDKResource
34
- from mcp.types import Tool as SDKTool
35
- from pydantic import ValidationError
36
24
  from pydantic.networks import AnyUrl
37
25
  from starlette.requests import Request
38
26
  from typing_extensions import TypeVar
39
27
 
40
- from fastmcp import settings
28
+ from fastmcp.resources.resource import ResourceResult
41
29
  from fastmcp.server.elicitation import (
42
30
  AcceptedElicitation,
43
31
  CancelledElicitation,
@@ -47,17 +35,30 @@ from fastmcp.server.elicitation import (
47
35
  )
48
36
  from fastmcp.server.sampling import SampleStep, SamplingResult, SamplingTool
49
37
  from fastmcp.server.sampling.run import (
50
- _parse_model_preferences,
51
- call_sampling_handler,
52
- determine_handler_mode,
38
+ sample_impl,
39
+ sample_step_impl,
53
40
  )
54
- from fastmcp.server.sampling.run import (
55
- execute_tools as run_sampling_tools,
41
+ from fastmcp.server.server import FastMCP, StateValue
42
+ from fastmcp.server.transforms.visibility import (
43
+ Visibility,
44
+ )
45
+ from fastmcp.server.transforms.visibility import (
46
+ disable_components as _disable_components,
47
+ )
48
+ from fastmcp.server.transforms.visibility import (
49
+ enable_components as _enable_components,
50
+ )
51
+ from fastmcp.server.transforms.visibility import (
52
+ get_session_transforms as _get_session_transforms,
53
+ )
54
+ from fastmcp.server.transforms.visibility import (
55
+ get_visibility_rules as _get_visibility_rules,
56
+ )
57
+ from fastmcp.server.transforms.visibility import (
58
+ reset_visibility as _reset_visibility,
56
59
  )
57
- from fastmcp.server.server import FastMCP
58
- from fastmcp.utilities.json_schema import compress_schema
59
60
  from fastmcp.utilities.logging import _clamp_logger, get_logger
60
- from fastmcp.utilities.types import get_cached_typeadapter
61
+ from fastmcp.utilities.versions import VersionSpec
61
62
 
62
63
  logger: Logger = get_logger(name=__name__)
63
64
  to_client_logger: Logger = logger.getChild(suffix="to_client")
@@ -71,13 +72,27 @@ _clamp_logger(logger=to_client_logger, max_level="DEBUG")
71
72
  T = TypeVar("T", default=Any)
72
73
  ResultT = TypeVar("ResultT", default=str)
73
74
 
74
- # Simplified tool choice type - just the mode string instead of the full MCP object
75
- ToolChoiceOption = Literal["auto", "required", "none"]
75
+ # Import ToolChoiceOption from sampling module (after other imports)
76
+ from fastmcp.server.sampling.run import ToolChoiceOption # noqa: E402
77
+
78
+ _current_context: ContextVar[Context | None] = ContextVar("context", default=None)
79
+
80
+ TransportType = Literal["stdio", "sse", "streamable-http"]
81
+ _current_transport: ContextVar[TransportType | None] = ContextVar(
82
+ "transport", default=None
83
+ )
84
+
76
85
 
77
- _current_context: ContextVar[Context | None] = ContextVar("context", default=None) # type: ignore[assignment]
86
+ def set_transport(
87
+ transport: TransportType,
88
+ ) -> Token[TransportType | None]:
89
+ """Set the current transport type. Returns token for reset."""
90
+ return _current_transport.set(transport)
78
91
 
79
92
 
80
- _flush_lock = anyio.Lock()
93
+ def reset_transport(token: Token[TransportType | None]) -> None:
94
+ """Reset transport to previous value."""
95
+ _current_transport.reset(token)
81
96
 
82
97
 
83
98
  @dataclass
@@ -141,29 +156,35 @@ class Context:
141
156
  request_id = ctx.request_id
142
157
  client_id = ctx.client_id
143
158
 
144
- # Manage state across the request
145
- ctx.set_state("key", "value")
146
- value = ctx.get_state("key")
159
+ # Manage state across the session (persists across requests)
160
+ await ctx.set_state("key", "value")
161
+ value = await ctx.get_state("key")
147
162
 
148
163
  return str(x)
149
164
  ```
150
165
 
151
166
  State Management:
152
- Context objects maintain a state dictionary that can be used to store and share
153
- data across middleware and tool calls within a request. When a new context
154
- is created (nested contexts), it inherits a copy of its parent's state, ensuring
155
- that modifications in child contexts don't affect parent contexts.
167
+ Context provides session-scoped state that persists across requests within
168
+ the same MCP session. State is automatically keyed by session, ensuring
169
+ isolation between different clients.
170
+
171
+ State set during `on_initialize` middleware will persist to subsequent tool
172
+ calls when using the same session object (STDIO, SSE, single-server HTTP).
173
+ For distributed/serverless HTTP deployments where different machines handle
174
+ the init and tool calls, state is isolated by the mcp-session-id header.
156
175
 
157
176
  The context parameter name can be anything as long as it's annotated with Context.
158
177
  The context is optional - tools that don't need it can omit the parameter.
159
178
 
160
179
  """
161
180
 
162
- def __init__(self, fastmcp: FastMCP):
181
+ # Default TTL for session state: 1 day in seconds
182
+ _STATE_TTL_SECONDS: int = 86400
183
+
184
+ def __init__(self, fastmcp: FastMCP, session: ServerSession | None = None):
163
185
  self._fastmcp: weakref.ref[FastMCP] = weakref.ref(fastmcp)
186
+ self._session: ServerSession | None = session # For state ops during init
164
187
  self._tokens: list[Token] = []
165
- self._notification_queue: set[str] = set() # Dedupe notifications
166
- self._state: dict[str, Any] = {}
167
188
 
168
189
  @property
169
190
  def fastmcp(self) -> FastMCP:
@@ -175,11 +196,6 @@ class Context:
175
196
 
176
197
  async def __aenter__(self) -> Context:
177
198
  """Enter the context manager and set this context as the current context."""
178
- parent_context = _current_context.get(None)
179
- if parent_context is not None:
180
- # Inherit state from parent context
181
- self._state = copy.deepcopy(parent_context._state)
182
-
183
199
  # Always set this context and save the token
184
200
  token = _current_context.set(self)
185
201
  self._tokens.append(token)
@@ -194,8 +210,8 @@ class Context:
194
210
  self._server_token = _current_server.set(weakref.ref(self.fastmcp))
195
211
 
196
212
  # Set docket/worker from server instance for this request's context.
197
- # This ensures ContextVars work even in environments (like Lambda) where
198
- # lifespan ContextVars don't propagate to request handlers.
213
+ # This ensures ContextVars work even in ASGI environments (Lambda, FastAPI mount)
214
+ # where lifespan ContextVars don't propagate to request handlers.
199
215
  server = self.fastmcp
200
216
  if server._docket is not None:
201
217
  self._docket_token = _current_docket.set(server._docket)
@@ -207,9 +223,6 @@ class Context:
207
223
 
208
224
  async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
209
225
  """Exit the context manager and reset the most recent token."""
210
- # Flush any remaining notifications before exiting
211
- await self._flush_notifications()
212
-
213
226
  # Reset server/docket/worker tokens
214
227
  from fastmcp.server.dependencies import (
215
228
  _current_docket,
@@ -261,6 +274,29 @@ class Context:
261
274
  except LookupError:
262
275
  return None
263
276
 
277
+ @property
278
+ def lifespan_context(self) -> dict[str, Any]:
279
+ """Access the server's lifespan context.
280
+
281
+ Returns the context dict yielded by the server's lifespan function.
282
+ Returns an empty dict if no lifespan was configured or if the MCP
283
+ session is not yet established.
284
+
285
+ Example:
286
+ ```python
287
+ @server.tool
288
+ def my_tool(ctx: Context) -> str:
289
+ db = ctx.lifespan_context.get("db")
290
+ if db:
291
+ return db.query("SELECT 1")
292
+ return "No database connection"
293
+ ```
294
+ """
295
+ rc = self.request_context
296
+ if rc is None:
297
+ return {}
298
+ return rc.lifespan_context
299
+
264
300
  async def report_progress(
265
301
  self, progress: float, total: float | None = None, message: str | None = None
266
302
  ) -> None:
@@ -288,13 +324,48 @@ class Context:
288
324
  related_request_id=self.request_id,
289
325
  )
290
326
 
327
+ async def _paginate_list(
328
+ self,
329
+ request_factory: Callable[[str | None], Any],
330
+ call_method: Callable[[Any], Any],
331
+ extract_items: Callable[[Any], list[Any]],
332
+ ) -> list[Any]:
333
+ """Generic pagination helper for list operations.
334
+
335
+ Args:
336
+ request_factory: Function that creates a request from a cursor
337
+ call_method: Async method to call with the request
338
+ extract_items: Function to extract items from the result
339
+
340
+ Returns:
341
+ List of all items across all pages
342
+ """
343
+ all_items: list[Any] = []
344
+ cursor: str | None = None
345
+ while True:
346
+ request = request_factory(cursor)
347
+ result = await call_method(request)
348
+ all_items.extend(extract_items(result))
349
+ if result.nextCursor is None:
350
+ break
351
+ cursor = result.nextCursor
352
+ return all_items
353
+
291
354
  async def list_resources(self) -> list[SDKResource]:
292
355
  """List all available resources from the server.
293
356
 
294
357
  Returns:
295
358
  List of Resource objects available on the server
296
359
  """
297
- return await self.fastmcp._list_resources_mcp()
360
+ return await self._paginate_list(
361
+ request_factory=lambda cursor: mcp.types.ListResourcesRequest(
362
+ params=mcp.types.PaginatedRequestParams(cursor=cursor)
363
+ if cursor
364
+ else None
365
+ ),
366
+ call_method=self.fastmcp._list_resources_mcp,
367
+ extract_items=lambda result: result.resources,
368
+ )
298
369
 
299
370
  async def list_prompts(self) -> list[SDKPrompt]:
300
371
  """List all available prompts from the server.
@@ -302,7 +373,15 @@ class Context:
302
373
  Returns:
303
374
  List of Prompt objects available on the server
304
375
  """
305
- return await self.fastmcp._list_prompts_mcp()
376
+ return await self._paginate_list(
377
+ request_factory=lambda cursor: mcp.types.ListPromptsRequest(
378
+ params=mcp.types.PaginatedRequestParams(cursor=cursor)
379
+ if cursor
380
+ else None
381
+ ),
382
+ call_method=self.fastmcp._list_prompts_mcp,
383
+ extract_items=lambda result: result.prompts,
384
+ )
306
385
 
307
386
  async def get_prompt(
308
387
  self, name: str, arguments: dict[str, Any] | None = None
@@ -316,19 +395,28 @@ class Context:
316
395
  Returns:
317
396
  The prompt result
318
397
  """
319
- return await self.fastmcp._get_prompt_mcp(name, arguments)
398
+ result = await self.fastmcp.render_prompt(name, arguments)
399
+ if isinstance(result, mcp.types.CreateTaskResult):
400
+ raise RuntimeError(
401
+ "Unexpected CreateTaskResult: Context calls should not have task metadata"
402
+ )
403
+ return result.to_mcp_prompt_result()
320
404
 
321
- async def read_resource(self, uri: str | AnyUrl) -> list[ReadResourceContents]:
405
+ async def read_resource(self, uri: str | AnyUrl) -> ResourceResult:
322
406
  """Read a resource by URI.
323
407
 
324
408
  Args:
325
409
  uri: Resource URI to read
326
410
 
327
411
  Returns:
328
- The resource content as either text or bytes
412
+ ResourceResult with contents
329
413
  """
330
- # Context calls don't have task metadata, so always returns list
331
- return await self.fastmcp._read_resource_mcp(uri) # type: ignore[return-value]
414
+ result = await self.fastmcp.read_resource(str(uri))
415
+ if isinstance(result, mcp.types.CreateTaskResult):
416
+ raise RuntimeError(
417
+ "Unexpected CreateTaskResult: Context calls should not have task metadata"
418
+ )
419
+ return result
332
420
 
333
421
  async def log(
334
422
  self,
@@ -358,6 +446,15 @@ class Context:
358
446
  related_request_id=self.request_id,
359
447
  )
360
448
 
449
+ @property
450
+ def transport(self) -> TransportType | None:
451
+ """Get the current transport type.
452
+
453
+ Returns the transport type used to run this server: "stdio", "sse",
454
+ or "streamable-http". Returns None if called outside of a server context.
455
+ """
456
+ return _current_transport.get()
457
+
361
458
  @property
362
459
  def client_id(self) -> str | None:
363
460
  """Get the client ID if available."""
@@ -393,7 +490,7 @@ class Context:
393
490
  for other transports.
394
491
 
395
492
  Raises:
396
- RuntimeError if MCP request context is not available.
493
+ RuntimeError if no session is available.
397
494
 
398
495
  Example:
399
496
  ```python
@@ -404,32 +501,37 @@ class Context:
404
501
  return f"Data stored for session {session_id}"
405
502
  ```
406
503
  """
504
+ from uuid import uuid4
505
+
506
+ # Get session from request context or _session (for on_initialize)
407
507
  request_ctx = self.request_context
408
- if request_ctx is None:
508
+ if request_ctx is not None:
509
+ session = request_ctx.session
510
+ elif self._session is not None:
511
+ session = self._session
512
+ else:
409
513
  raise RuntimeError(
410
- "session_id is not available because the MCP session has not been established yet. "
411
- "Check `context.request_context` for None before accessing this attribute."
514
+ "session_id is not available because no session exists. "
515
+ "This typically means you're outside a request context."
412
516
  )
413
- session = request_ctx.session
414
517
 
415
- # Try to get the session ID from the session attributes
416
- session_id = getattr(session, "_fastmcp_id", None)
518
+ # Check for cached session ID
519
+ session_id = getattr(session, "_fastmcp_state_prefix", None)
417
520
  if session_id is not None:
418
521
  return session_id
419
522
 
420
- # Try to get the session ID from the http request headers
421
- request = request_ctx.request
422
- if request:
423
- session_id = request.headers.get("mcp-session-id")
523
+ # For HTTP, try to get from header
524
+ if request_ctx is not None:
525
+ request = request_ctx.request
526
+ if request:
527
+ session_id = request.headers.get("mcp-session-id")
424
528
 
425
- # Generate a session ID if it doesn't exist.
529
+ # For STDIO/SSE/in-memory, generate a UUID
426
530
  if session_id is None:
427
- from uuid import uuid4
428
-
429
531
  session_id = str(uuid4())
430
532
 
431
- # Save the session id to the session attributes
432
- session._fastmcp_id = session_id # type: ignore[attr-defined]
533
+ # Cache on session for consistency
534
+ session._fastmcp_state_prefix = session_id # type: ignore[attr-defined]
433
535
  return session_id
434
536
 
435
537
  @property
@@ -515,17 +617,15 @@ class Context:
515
617
  result = await self.session.list_roots()
516
618
  return result.roots
517
619
 
518
- async def send_tool_list_changed(self) -> None:
519
- """Send a tool list changed notification to the client."""
520
- await self.session.send_tool_list_changed()
521
-
522
- async def send_resource_list_changed(self) -> None:
523
- """Send a resource list changed notification to the client."""
524
- await self.session.send_resource_list_changed()
620
+ async def send_notification(
621
+ self, notification: mcp.types.ServerNotificationType
622
+ ) -> None:
623
+ """Send a notification to the client immediately.
525
624
 
526
- async def send_prompt_list_changed(self) -> None:
527
- """Send a prompt list changed notification to the client."""
528
- await self.session.send_prompt_list_changed()
625
+ Args:
626
+ notification: An MCP notification instance (e.g., ToolListChangedNotification())
627
+ """
628
+ await self.session.send_notification(mcp.types.ServerNotification(notification))
529
629
 
530
630
  async def close_sse_stream(self) -> None:
531
631
  """Close the current response stream to trigger client reconnection.
@@ -623,101 +723,19 @@ class Context:
623
723
  # Continue with tool results
624
724
  messages = step.history
625
725
  """
626
- # Convert messages to SamplingMessage objects
627
- current_messages = _prepare_messages(messages)
628
-
629
- # Convert tools to SamplingTools
630
- sampling_tools = _prepare_tools(tools)
631
- sdk_tools: list[SDKTool] | None = (
632
- [t._to_sdk_tool() for t in sampling_tools] if sampling_tools else None
633
- )
634
- tool_map: dict[str, SamplingTool] = (
635
- {t.name: t for t in sampling_tools} if sampling_tools else {}
726
+ return await sample_step_impl(
727
+ self,
728
+ messages=messages,
729
+ system_prompt=system_prompt,
730
+ temperature=temperature,
731
+ max_tokens=max_tokens,
732
+ model_preferences=model_preferences,
733
+ tools=tools,
734
+ tool_choice=tool_choice,
735
+ auto_execute_tools=execute_tools,
736
+ mask_error_details=mask_error_details,
636
737
  )
637
738
 
638
- # Determine whether to use fallback handler or client
639
- use_fallback = determine_handler_mode(self, bool(sampling_tools))
640
-
641
- # Build tool choice
642
- effective_tool_choice: ToolChoice | None = None
643
- if tool_choice is not None:
644
- if tool_choice not in ("auto", "required", "none"):
645
- raise ValueError(
646
- f"Invalid tool_choice: {tool_choice!r}. "
647
- "Must be 'auto', 'required', or 'none'."
648
- )
649
- effective_tool_choice = ToolChoice(
650
- mode=cast(Literal["auto", "required", "none"], tool_choice)
651
- )
652
-
653
- # Effective max_tokens
654
- effective_max_tokens = max_tokens if max_tokens is not None else 512
655
-
656
- # Make the LLM call
657
- if use_fallback:
658
- response = await call_sampling_handler(
659
- self,
660
- current_messages,
661
- system_prompt=system_prompt,
662
- temperature=temperature,
663
- max_tokens=effective_max_tokens,
664
- model_preferences=model_preferences,
665
- sdk_tools=sdk_tools,
666
- tool_choice=effective_tool_choice,
667
- )
668
- else:
669
- response = await self.session.create_message(
670
- messages=current_messages,
671
- system_prompt=system_prompt,
672
- temperature=temperature,
673
- max_tokens=effective_max_tokens,
674
- model_preferences=_parse_model_preferences(model_preferences),
675
- tools=sdk_tools,
676
- tool_choice=effective_tool_choice,
677
- related_request_id=self.request_id,
678
- )
679
-
680
- # Check if this is a tool use response
681
- is_tool_use_response = (
682
- isinstance(response, CreateMessageResultWithTools)
683
- and response.stopReason == "toolUse"
684
- )
685
-
686
- # Always include the assistant response in history
687
- current_messages.append(
688
- SamplingMessage(role="assistant", content=response.content)
689
- )
690
-
691
- # If not a tool use, return immediately
692
- if not is_tool_use_response:
693
- return SampleStep(response=response, history=current_messages)
694
-
695
- # If not executing tools, return with assistant message but no tool results
696
- if not execute_tools:
697
- return SampleStep(response=response, history=current_messages)
698
-
699
- # Execute tools and add results to history
700
- step_tool_calls = _extract_tool_calls(response)
701
- if step_tool_calls:
702
- effective_mask = (
703
- mask_error_details
704
- if mask_error_details is not None
705
- else settings.mask_error_details
706
- )
707
- tool_results = await run_sampling_tools(
708
- step_tool_calls, tool_map, mask_error_details=effective_mask
709
- )
710
-
711
- if tool_results:
712
- current_messages.append(
713
- SamplingMessage(
714
- role="user",
715
- content=tool_results, # type: ignore[arg-type]
716
- )
717
- )
718
-
719
- return SampleStep(response=response, history=current_messages)
720
-
721
739
  @overload
722
740
  async def sample(
723
741
  self,
@@ -796,113 +814,17 @@ class Context:
796
814
  - .result: The typed result (str for text, parsed object for structured)
797
815
  - .history: All messages exchanged during sampling
798
816
  """
799
- # Safety limit to prevent infinite loops
800
- max_iterations = 100
801
-
802
- # Convert tools to SamplingTools
803
- sampling_tools = _prepare_tools(tools)
804
-
805
- # Handle structured output with result_type
806
- tool_choice: str | None = None
807
- if result_type is not None and result_type is not str:
808
- final_response_tool = _create_final_response_tool(result_type)
809
- sampling_tools = list(sampling_tools) if sampling_tools else []
810
- sampling_tools.append(final_response_tool)
811
-
812
- # Always require tool calls when result_type is set - the LLM must
813
- # eventually call final_response (text responses are not accepted)
814
- tool_choice = "required"
815
-
816
- # Convert messages for the loop
817
- current_messages: str | Sequence[str | SamplingMessage] = messages
818
-
819
- for _iteration in range(max_iterations):
820
- step = await self.sample_step(
821
- messages=current_messages,
822
- system_prompt=system_prompt,
823
- temperature=temperature,
824
- max_tokens=max_tokens,
825
- model_preferences=model_preferences,
826
- tools=sampling_tools,
827
- tool_choice=tool_choice,
828
- mask_error_details=mask_error_details,
829
- )
830
-
831
- # Check for final_response tool call for structured output
832
- if result_type is not None and result_type is not str and step.is_tool_use:
833
- for tool_call in step.tool_calls:
834
- if tool_call.name == "final_response":
835
- # Validate and return the structured result
836
- type_adapter = get_cached_typeadapter(result_type)
837
-
838
- # Unwrap if we wrapped primitives (non-object schemas)
839
- input_data = tool_call.input
840
- original_schema = compress_schema(
841
- type_adapter.json_schema(), prune_titles=True
842
- )
843
- if (
844
- original_schema.get("type") != "object"
845
- and isinstance(input_data, dict)
846
- and "value" in input_data
847
- ):
848
- input_data = input_data["value"]
849
-
850
- try:
851
- validated_result = type_adapter.validate_python(input_data)
852
- text = json.dumps(
853
- type_adapter.dump_python(validated_result, mode="json")
854
- )
855
- return SamplingResult(
856
- text=text,
857
- result=validated_result,
858
- history=step.history,
859
- )
860
- except ValidationError as e:
861
- # Validation failed - add error as tool result
862
- step.history.append(
863
- SamplingMessage(
864
- role="user",
865
- content=[
866
- ToolResultContent(
867
- type="tool_result",
868
- toolUseId=tool_call.id,
869
- content=[
870
- TextContent(
871
- type="text",
872
- text=(
873
- f"Validation error: {e}. "
874
- "Please try again with valid data."
875
- ),
876
- )
877
- ],
878
- isError=True,
879
- )
880
- ], # type: ignore[arg-type]
881
- )
882
- )
883
-
884
- # If not a tool use response, we're done
885
- if not step.is_tool_use:
886
- # For structured output, the LLM must use the final_response tool
887
- if result_type is not None and result_type is not str:
888
- raise RuntimeError(
889
- f"Expected structured output of type {result_type.__name__}, "
890
- "but the LLM returned a text response instead of calling "
891
- "the final_response tool."
892
- )
893
- return SamplingResult(
894
- text=step.text,
895
- result=cast(ResultT, step.text if step.text else ""),
896
- history=step.history,
897
- )
898
-
899
- # Continue with the updated history
900
- current_messages = step.history
901
-
902
- # After first iteration, reset tool_choice to auto
903
- tool_choice = None
904
-
905
- raise RuntimeError(f"Sampling exceeded maximum iterations ({max_iterations})")
817
+ return await sample_impl(
818
+ self,
819
+ messages=messages,
820
+ system_prompt=system_prompt,
821
+ temperature=temperature,
822
+ max_tokens=max_tokens,
823
+ model_preferences=model_preferences,
824
+ tools=tools,
825
+ result_type=result_type,
826
+ mask_error_details=mask_error_details,
827
+ )
906
828
 
907
829
  @overload
908
830
  async def elicit(
@@ -936,10 +858,50 @@ class Context:
936
858
  """When response_type is a list of strings, the accepted elicitation will
937
859
  contain the selected string response"""
938
860
 
861
+ @overload
939
862
  async def elicit(
940
863
  self,
941
864
  message: str,
942
- response_type: type[T] | list[str] | dict[str, dict[str, str]] | None = None,
865
+ response_type: dict[str, dict[str, str]],
866
+ ) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation: ...
867
+
868
+ """When response_type is a dict mapping keys to title dicts, the accepted
869
+ elicitation will contain the selected key"""
870
+
871
+ @overload
872
+ async def elicit(
873
+ self,
874
+ message: str,
875
+ response_type: list[list[str]],
876
+ ) -> (
877
+ AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation
878
+ ): ...
879
+
880
+ """When response_type is a list containing a list of strings (multi-select),
881
+ the accepted elicitation will contain a list of selected strings"""
882
+
883
+ @overload
884
+ async def elicit(
885
+ self,
886
+ message: str,
887
+ response_type: list[dict[str, dict[str, str]]],
888
+ ) -> (
889
+ AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation
890
+ ): ...
891
+
892
+ """When response_type is a list containing a dict mapping keys to title dicts
893
+ (multi-select with titles), the accepted elicitation will contain a list of
894
+ selected keys"""
895
+
896
+ async def elicit(
897
+ self,
898
+ message: str,
899
+ response_type: type[T]
900
+ | list[str]
901
+ | dict[str, dict[str, str]]
902
+ | list[list[str]]
903
+ | list[dict[str, dict[str, str]]]
904
+ | None = None,
943
905
  ) -> (
944
906
  AcceptedElicitation[T]
945
907
  | AcceptedElicitation[dict[str, Any]]
@@ -988,43 +950,135 @@ class Context:
988
950
  else:
989
951
  raise ValueError(f"Unexpected elicitation action: {result.action}")
990
952
 
991
- def set_state(self, key: str, value: Any) -> None:
992
- """Set a value in the context state."""
993
- self._state[key] = value
994
-
995
- def get_state(self, key: str) -> Any:
996
- """Get a value from the context state. Returns None if the key is not found."""
997
- return self._state.get(key)
998
-
999
- def _queue_tool_list_changed(self) -> None:
1000
- """Queue a tool list changed notification."""
1001
- self._notification_queue.add("notifications/tools/list_changed")
1002
-
1003
- def _queue_resource_list_changed(self) -> None:
1004
- """Queue a resource list changed notification."""
1005
- self._notification_queue.add("notifications/resources/list_changed")
1006
-
1007
- def _queue_prompt_list_changed(self) -> None:
1008
- """Queue a prompt list changed notification."""
1009
- self._notification_queue.add("notifications/prompts/list_changed")
1010
-
1011
- async def _flush_notifications(self) -> None:
1012
- """Send all queued notifications."""
1013
- async with _flush_lock:
1014
- if not self._notification_queue:
1015
- return
1016
-
1017
- try:
1018
- if "notifications/tools/list_changed" in self._notification_queue:
1019
- await self.session.send_tool_list_changed()
1020
- if "notifications/resources/list_changed" in self._notification_queue:
1021
- await self.session.send_resource_list_changed()
1022
- if "notifications/prompts/list_changed" in self._notification_queue:
1023
- await self.session.send_prompt_list_changed()
1024
- self._notification_queue.clear()
1025
- except Exception:
1026
- # Don't let notification failures break the request
1027
- pass
953
+ def _make_state_key(self, key: str) -> str:
954
+ """Create session-prefixed key for state storage."""
955
+ return f"{self.session_id}:{key}"
956
+
957
+ async def set_state(self, key: str, value: Any) -> None:
958
+ """Set a value in the session-scoped state store.
959
+
960
+ Values persist across requests within the same MCP session.
961
+ The key is automatically prefixed with the session identifier.
962
+ State expires after 1 day to prevent unbounded memory growth.
963
+ """
964
+ prefixed_key = self._make_state_key(key)
965
+ await self.fastmcp._state_store.put(
966
+ key=prefixed_key,
967
+ value=StateValue(value=value),
968
+ ttl=self._STATE_TTL_SECONDS,
969
+ )
970
+
971
+ async def get_state(self, key: str) -> Any:
972
+ """Get a value from the session-scoped state store.
973
+
974
+ Returns None if the key is not found.
975
+ """
976
+ prefixed_key = self._make_state_key(key)
977
+ result = await self.fastmcp._state_store.get(key=prefixed_key)
978
+ return result.value if result is not None else None
979
+
980
+ async def delete_state(self, key: str) -> None:
981
+ """Delete a value from the session-scoped state store."""
982
+ prefixed_key = self._make_state_key(key)
983
+ await self.fastmcp._state_store.delete(key=prefixed_key)
984
+
985
+ # -------------------------------------------------------------------------
986
+ # Session visibility control
987
+ # -------------------------------------------------------------------------
988
+
989
+ async def _get_visibility_rules(self) -> list[dict[str, Any]]:
990
+ """Load visibility rule dicts from session state."""
991
+ return await _get_visibility_rules(self)
992
+
993
+ async def _get_session_transforms(self) -> list[Visibility]:
994
+ """Get session-specific Visibility transforms from state store."""
995
+ return await _get_session_transforms(self)
996
+
997
+ async def enable_components(
998
+ self,
999
+ *,
1000
+ names: set[str] | None = None,
1001
+ keys: set[str] | None = None,
1002
+ version: VersionSpec | None = None,
1003
+ tags: set[str] | None = None,
1004
+ components: set[Literal["tool", "resource", "template", "prompt"]]
1005
+ | None = None,
1006
+ match_all: bool = False,
1007
+ ) -> None:
1008
+ """Enable components matching criteria for this session only.
1009
+
1010
+ Session rules override global transforms. Rules accumulate - each call
1011
+ adds a new rule to the session. Later marks override earlier ones
1012
+ (Visibility transform semantics).
1013
+
1014
+ Sends notifications to this session only: ToolListChangedNotification,
1015
+ ResourceListChangedNotification, and PromptListChangedNotification.
1016
+
1017
+ Args:
1018
+ names: Component names or URIs to match.
1019
+ keys: Component keys to match (e.g., {"tool:my_tool@v1"}).
1020
+ version: Component version spec to match.
1021
+ tags: Tags to match (component must have at least one).
1022
+ components: Component types to match (e.g., {"tool", "prompt"}).
1023
+ match_all: If True, matches all components regardless of other criteria.
1024
+ """
1025
+ await _enable_components(
1026
+ self,
1027
+ names=names,
1028
+ keys=keys,
1029
+ version=version,
1030
+ tags=tags,
1031
+ components=components,
1032
+ match_all=match_all,
1033
+ )
1034
+
1035
+ async def disable_components(
1036
+ self,
1037
+ *,
1038
+ names: set[str] | None = None,
1039
+ keys: set[str] | None = None,
1040
+ version: VersionSpec | None = None,
1041
+ tags: set[str] | None = None,
1042
+ components: set[Literal["tool", "resource", "template", "prompt"]]
1043
+ | None = None,
1044
+ match_all: bool = False,
1045
+ ) -> None:
1046
+ """Disable components matching criteria for this session only.
1047
+
1048
+ Session rules override global transforms. Rules accumulate - each call
1049
+ adds a new rule to the session. Later marks override earlier ones
1050
+ (Visibility transform semantics).
1051
+
1052
+ Sends notifications to this session only: ToolListChangedNotification,
1053
+ ResourceListChangedNotification, and PromptListChangedNotification.
1054
+
1055
+ Args:
1056
+ names: Component names or URIs to match.
1057
+ keys: Component keys to match (e.g., {"tool:my_tool@v1"}).
1058
+ version: Component version spec to match.
1059
+ tags: Tags to match (component must have at least one).
1060
+ components: Component types to match (e.g., {"tool", "prompt"}).
1061
+ match_all: If True, matches all components regardless of other criteria.
1062
+ """
1063
+ await _disable_components(
1064
+ self,
1065
+ names=names,
1066
+ keys=keys,
1067
+ version=version,
1068
+ tags=tags,
1069
+ components=components,
1070
+ match_all=match_all,
1071
+ )
1072
+
1073
+ async def reset_visibility(self) -> None:
1074
+ """Clear all session visibility rules.
1075
+
1076
+ Use this to reset session visibility back to global defaults.
1077
+
1078
+ Sends notifications to this session only: ToolListChangedNotification,
1079
+ ResourceListChangedNotification, and PromptListChangedNotification.
1080
+ """
1081
+ await _reset_visibility(self)
1028
1082
 
1029
1083
 
1030
1084
  async def _log_to_server_and_client(
@@ -1053,104 +1107,3 @@ async def _log_to_server_and_client(
1053
1107
  logger=logger_name,
1054
1108
  related_request_id=related_request_id,
1055
1109
  )
1056
-
1057
-
1058
- def _create_final_response_tool(result_type: type) -> SamplingTool:
1059
- """Create a synthetic 'final_response' tool for structured output.
1060
-
1061
- This tool is used to capture structured responses from the LLM.
1062
- The tool's schema is derived from the result_type.
1063
- """
1064
- type_adapter = get_cached_typeadapter(result_type)
1065
- schema = type_adapter.json_schema()
1066
- schema = compress_schema(schema, prune_titles=True)
1067
-
1068
- # Tool parameters must be object-shaped. Wrap primitives in {"value": <schema>}
1069
- if schema.get("type") != "object":
1070
- schema = {
1071
- "type": "object",
1072
- "properties": {"value": schema},
1073
- "required": ["value"],
1074
- }
1075
-
1076
- # The fn just returns the input as-is (validation happens in the loop)
1077
- def final_response(**kwargs: Any) -> dict[str, Any]:
1078
- return kwargs
1079
-
1080
- return SamplingTool(
1081
- name="final_response",
1082
- description=(
1083
- "Call this tool to provide your final response. "
1084
- "Use this when you have completed the task and are ready to return the result."
1085
- ),
1086
- parameters=schema,
1087
- fn=final_response,
1088
- )
1089
-
1090
-
1091
- def _extract_text_from_content(
1092
- content: SamplingMessageContentBlock | list[SamplingMessageContentBlock],
1093
- ) -> str | None:
1094
- """Extract text from content block(s).
1095
-
1096
- Returns the text if content is a TextContent or list containing TextContent,
1097
- otherwise returns None.
1098
- """
1099
- if isinstance(content, list):
1100
- for block in content:
1101
- if isinstance(block, TextContent):
1102
- return block.text
1103
- return None
1104
- elif isinstance(content, TextContent):
1105
- return content.text
1106
- return None
1107
-
1108
-
1109
- def _prepare_messages(
1110
- messages: str | Sequence[str | SamplingMessage],
1111
- ) -> list[SamplingMessage]:
1112
- """Convert various message formats to a list of SamplingMessage objects."""
1113
- if isinstance(messages, str):
1114
- return [
1115
- SamplingMessage(
1116
- content=TextContent(text=messages, type="text"), role="user"
1117
- )
1118
- ]
1119
- else:
1120
- return [
1121
- SamplingMessage(content=TextContent(text=m, type="text"), role="user")
1122
- if isinstance(m, str)
1123
- else m
1124
- for m in messages
1125
- ]
1126
-
1127
-
1128
- def _prepare_tools(
1129
- tools: Sequence[SamplingTool | Callable[..., Any]] | None,
1130
- ) -> list[SamplingTool] | None:
1131
- """Convert tools to SamplingTool objects."""
1132
- if tools is None:
1133
- return None
1134
-
1135
- sampling_tools: list[SamplingTool] = []
1136
- for t in tools:
1137
- if isinstance(t, SamplingTool):
1138
- sampling_tools.append(t)
1139
- elif callable(t):
1140
- sampling_tools.append(SamplingTool.from_function(t))
1141
- else:
1142
- raise TypeError(f"Expected SamplingTool or callable, got {type(t)}")
1143
-
1144
- return sampling_tools if sampling_tools else None
1145
-
1146
-
1147
- def _extract_tool_calls(
1148
- response: CreateMessageResult | CreateMessageResultWithTools,
1149
- ) -> list[ToolUseContent]:
1150
- """Extract tool calls from a response."""
1151
- content = response.content
1152
- if isinstance(content, list):
1153
- return [c for c in content if isinstance(c, ToolUseContent)]
1154
- elif isinstance(content, ToolUseContent):
1155
- return [content]
1156
- return []