fastmcp 2.13.3__py3-none-any.whl → 2.14.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. fastmcp/__init__.py +0 -21
  2. fastmcp/cli/__init__.py +0 -3
  3. fastmcp/cli/__main__.py +5 -0
  4. fastmcp/cli/cli.py +8 -22
  5. fastmcp/cli/install/shared.py +0 -15
  6. fastmcp/cli/tasks.py +110 -0
  7. fastmcp/client/auth/oauth.py +9 -9
  8. fastmcp/client/client.py +739 -136
  9. fastmcp/client/elicitation.py +11 -5
  10. fastmcp/client/messages.py +7 -5
  11. fastmcp/client/roots.py +2 -1
  12. fastmcp/client/sampling/__init__.py +69 -0
  13. fastmcp/client/sampling/handlers/__init__.py +0 -0
  14. fastmcp/client/sampling/handlers/anthropic.py +387 -0
  15. fastmcp/client/sampling/handlers/openai.py +399 -0
  16. fastmcp/client/tasks.py +551 -0
  17. fastmcp/client/transports.py +72 -21
  18. fastmcp/contrib/component_manager/component_service.py +4 -20
  19. fastmcp/dependencies.py +25 -0
  20. fastmcp/experimental/sampling/handlers/__init__.py +5 -0
  21. fastmcp/experimental/sampling/handlers/openai.py +4 -169
  22. fastmcp/experimental/server/openapi/__init__.py +15 -13
  23. fastmcp/experimental/utilities/openapi/__init__.py +12 -38
  24. fastmcp/prompts/prompt.py +38 -38
  25. fastmcp/resources/resource.py +33 -16
  26. fastmcp/resources/template.py +69 -59
  27. fastmcp/server/auth/__init__.py +0 -9
  28. fastmcp/server/auth/auth.py +127 -3
  29. fastmcp/server/auth/oauth_proxy.py +47 -97
  30. fastmcp/server/auth/oidc_proxy.py +7 -0
  31. fastmcp/server/auth/providers/in_memory.py +2 -2
  32. fastmcp/server/auth/providers/oci.py +2 -2
  33. fastmcp/server/context.py +509 -180
  34. fastmcp/server/dependencies.py +464 -6
  35. fastmcp/server/elicitation.py +285 -47
  36. fastmcp/server/event_store.py +177 -0
  37. fastmcp/server/http.py +15 -3
  38. fastmcp/server/low_level.py +56 -12
  39. fastmcp/server/middleware/middleware.py +2 -2
  40. fastmcp/server/openapi/__init__.py +35 -0
  41. fastmcp/{experimental/server → server}/openapi/components.py +4 -3
  42. fastmcp/{experimental/server → server}/openapi/routing.py +1 -1
  43. fastmcp/{experimental/server → server}/openapi/server.py +6 -5
  44. fastmcp/server/proxy.py +53 -40
  45. fastmcp/server/sampling/__init__.py +10 -0
  46. fastmcp/server/sampling/run.py +301 -0
  47. fastmcp/server/sampling/sampling_tool.py +108 -0
  48. fastmcp/server/server.py +793 -552
  49. fastmcp/server/tasks/__init__.py +21 -0
  50. fastmcp/server/tasks/capabilities.py +22 -0
  51. fastmcp/server/tasks/config.py +89 -0
  52. fastmcp/server/tasks/converters.py +206 -0
  53. fastmcp/server/tasks/handlers.py +356 -0
  54. fastmcp/server/tasks/keys.py +93 -0
  55. fastmcp/server/tasks/protocol.py +355 -0
  56. fastmcp/server/tasks/subscriptions.py +205 -0
  57. fastmcp/settings.py +101 -103
  58. fastmcp/tools/tool.py +83 -49
  59. fastmcp/tools/tool_transform.py +1 -12
  60. fastmcp/utilities/components.py +3 -3
  61. fastmcp/utilities/json_schema_type.py +4 -4
  62. fastmcp/utilities/mcp_config.py +1 -2
  63. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +1 -1
  64. fastmcp/{experimental/utilities → utilities}/openapi/README.md +7 -35
  65. fastmcp/utilities/openapi/__init__.py +63 -0
  66. fastmcp/{experimental/utilities → utilities}/openapi/formatters.py +5 -5
  67. fastmcp/{experimental/utilities → utilities}/openapi/json_schema_converter.py +1 -1
  68. fastmcp/utilities/tests.py +11 -5
  69. fastmcp/utilities/types.py +8 -0
  70. {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/METADATA +7 -4
  71. {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/RECORD +79 -63
  72. fastmcp/client/sampling.py +0 -56
  73. fastmcp/experimental/sampling/handlers/base.py +0 -21
  74. fastmcp/server/auth/providers/bearer.py +0 -25
  75. fastmcp/server/openapi.py +0 -1087
  76. fastmcp/server/sampling/handler.py +0 -19
  77. fastmcp/utilities/openapi.py +0 -1568
  78. /fastmcp/{experimental/server → server}/openapi/README.md +0 -0
  79. /fastmcp/{experimental/utilities → utilities}/openapi/director.py +0 -0
  80. /fastmcp/{experimental/utilities → utilities}/openapi/models.py +0 -0
  81. /fastmcp/{experimental/utilities → utilities}/openapi/parser.py +0 -0
  82. /fastmcp/{experimental/utilities → utilities}/openapi/schemas.py +0 -0
  83. {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/WHEEL +0 -0
  84. {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/entry_points.txt +0 -0
  85. {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/licenses/LICENSE +0 -0
fastmcp/server/context.py CHANGED
@@ -1,17 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import copy
4
- import inspect
4
+ import json
5
5
  import logging
6
- import warnings
7
6
  import weakref
8
- from collections.abc import Generator, Mapping, Sequence
7
+ from collections.abc import Callable, Generator, Mapping, Sequence
9
8
  from contextlib import contextmanager
10
9
  from contextvars import ContextVar, Token
11
10
  from dataclasses import dataclass
12
- from enum import Enum
13
11
  from logging import Logger
14
- from typing import Any, Literal, cast, get_origin, overload
12
+ from typing import Any, Literal, cast, overload
15
13
 
16
14
  import anyio
17
15
  from mcp import LoggingLevel, ServerSession
@@ -19,36 +17,45 @@ from mcp.server.lowlevel.helper_types import ReadResourceContents
19
17
  from mcp.server.lowlevel.server import request_ctx
20
18
  from mcp.shared.context import RequestContext
21
19
  from mcp.types import (
22
- AudioContent,
23
- ClientCapabilities,
24
20
  CreateMessageResult,
21
+ CreateMessageResultWithTools,
25
22
  GetPromptResult,
26
- ImageContent,
27
- IncludeContext,
28
- ModelHint,
29
23
  ModelPreferences,
30
24
  Root,
31
- SamplingCapability,
32
25
  SamplingMessage,
26
+ SamplingMessageContentBlock,
33
27
  TextContent,
28
+ ToolChoice,
29
+ ToolResultContent,
30
+ ToolUseContent,
34
31
  )
35
- from mcp.types import CreateMessageRequestParams as SamplingParams
36
- from mcp.types import Prompt as MCPPrompt
37
- from mcp.types import Resource as MCPResource
32
+ from mcp.types import Prompt as SDKPrompt
33
+ from mcp.types import Resource as SDKResource
34
+ from mcp.types import Tool as SDKTool
35
+ from pydantic import ValidationError
38
36
  from pydantic.networks import AnyUrl
39
37
  from starlette.requests import Request
40
38
  from typing_extensions import TypeVar
41
39
 
42
- import fastmcp.server.dependencies
43
40
  from fastmcp import settings
44
41
  from fastmcp.server.elicitation import (
45
42
  AcceptedElicitation,
46
43
  CancelledElicitation,
47
44
  DeclinedElicitation,
48
- ScalarElicitationType,
49
- get_elicitation_schema,
45
+ handle_elicit_accept,
46
+ parse_elicit_response_type,
47
+ )
48
+ from fastmcp.server.sampling import SampleStep, SamplingResult, SamplingTool
49
+ from fastmcp.server.sampling.run import (
50
+ _parse_model_preferences,
51
+ call_sampling_handler,
52
+ determine_handler_mode,
53
+ )
54
+ from fastmcp.server.sampling.run import (
55
+ execute_tools as run_sampling_tools,
50
56
  )
51
57
  from fastmcp.server.server import FastMCP
58
+ from fastmcp.utilities.json_schema import compress_schema
52
59
  from fastmcp.utilities.logging import _clamp_logger, get_logger
53
60
  from fastmcp.utilities.types import get_cached_typeadapter
54
61
 
@@ -62,7 +69,14 @@ _clamp_logger(logger=to_client_logger, max_level="DEBUG")
62
69
 
63
70
 
64
71
  T = TypeVar("T", default=Any)
72
+ ResultT = TypeVar("ResultT", default=str)
73
+
74
+ # Simplified tool choice type - just the mode string instead of the full MCP object
75
+ ToolChoiceOption = Literal["auto", "required", "none"]
76
+
65
77
  _current_context: ContextVar[Context | None] = ContextVar("context", default=None) # type: ignore[assignment]
78
+
79
+
66
80
  _flush_lock = anyio.Lock()
67
81
 
68
82
 
@@ -169,6 +183,12 @@ class Context:
169
183
  # Always set this context and save the token
170
184
  token = _current_context.set(self)
171
185
  self._tokens.append(token)
186
+
187
+ # Set current server for dependency injection (use weakref to avoid reference cycles)
188
+ from fastmcp.server.dependencies import _current_server
189
+
190
+ self._server_token = _current_server.set(weakref.ref(self.fastmcp))
191
+
172
192
  return self
173
193
 
174
194
  async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
@@ -176,6 +196,14 @@ class Context:
176
196
  # Flush any remaining notifications before exiting
177
197
  await self._flush_notifications()
178
198
 
199
+ # Reset server token
200
+ if hasattr(self, "_server_token"):
201
+ from fastmcp.server.dependencies import _current_server
202
+
203
+ _current_server.reset(self._server_token)
204
+ delattr(self, "_server_token")
205
+
206
+ # Reset context token
179
207
  if self._tokens:
180
208
  token = self._tokens.pop()
181
209
  _current_context.reset(token)
@@ -236,7 +264,7 @@ class Context:
236
264
  related_request_id=self.request_id,
237
265
  )
238
266
 
239
- async def list_resources(self) -> list[MCPResource]:
267
+ async def list_resources(self) -> list[SDKResource]:
240
268
  """List all available resources from the server.
241
269
 
242
270
  Returns:
@@ -244,7 +272,7 @@ class Context:
244
272
  """
245
273
  return await self.fastmcp._list_resources_mcp()
246
274
 
247
- async def list_prompts(self) -> list[MCPPrompt]:
275
+ async def list_prompts(self) -> list[SDKPrompt]:
248
276
  """List all available prompts from the server.
249
277
 
250
278
  Returns:
@@ -275,7 +303,8 @@ class Context:
275
303
  Returns:
276
304
  The resource content as either text or bytes
277
305
  """
278
- return await self.fastmcp._read_resource_mcp(uri)
306
+ # Context calls don't have task metadata, so always returns list
307
+ return await self.fastmcp._read_resource_mcp(uri) # type: ignore[return-value]
279
308
 
280
309
  async def log(
281
310
  self,
@@ -376,7 +405,7 @@ class Context:
376
405
  session_id = str(uuid4())
377
406
 
378
407
  # Save the session id to the session attributes
379
- session._fastmcp_id = session_id
408
+ session._fastmcp_id = session_id # type: ignore[attr-defined]
380
409
  return session_id
381
410
 
382
411
  @property
@@ -474,88 +503,382 @@ class Context:
474
503
  """Send a prompt list changed notification to the client."""
475
504
  await self.session.send_prompt_list_changed()
476
505
 
477
- async def sample(
506
+ async def close_sse_stream(self) -> None:
507
+ """Close the current response stream to trigger client reconnection.
508
+
509
+ When using StreamableHTTP transport with an EventStore configured, this
510
+ method gracefully closes the HTTP connection for the current request.
511
+ The client will automatically reconnect (after `retry_interval` milliseconds)
512
+ and resume receiving events from where it left off via the EventStore.
513
+
514
+ This is useful for long-running operations to avoid load balancer timeouts.
515
+ Instead of holding a connection open for minutes, you can periodically close
516
+ and let the client reconnect.
517
+
518
+ Example:
519
+ ```python
520
+ @mcp.tool
521
+ async def long_running_task(ctx: Context) -> str:
522
+ for i in range(100):
523
+ await ctx.report_progress(i, 100)
524
+
525
+ # Close connection every 30 iterations to avoid LB timeouts
526
+ if i % 30 == 0 and i > 0:
527
+ await ctx.close_sse_stream()
528
+
529
+ await do_work()
530
+ return "Done"
531
+ ```
532
+
533
+ Note:
534
+ This is a no-op (with a debug log) if not using StreamableHTTP
535
+ transport with an EventStore configured.
536
+ """
537
+ if not self.request_context or not self.request_context.close_sse_stream:
538
+ logger.debug(
539
+ "close_sse_stream() called but not applicable "
540
+ "(requires StreamableHTTP transport with event_store)"
541
+ )
542
+ return
543
+ await self.request_context.close_sse_stream()
544
+
545
+ async def sample_step(
478
546
  self,
479
547
  messages: str | Sequence[str | SamplingMessage],
548
+ *,
480
549
  system_prompt: str | None = None,
481
- include_context: IncludeContext | None = None,
482
550
  temperature: float | None = None,
483
551
  max_tokens: int | None = None,
484
552
  model_preferences: ModelPreferences | str | list[str] | None = None,
485
- ) -> TextContent | ImageContent | AudioContent:
553
+ tools: Sequence[SamplingTool | Callable[..., Any]] | None = None,
554
+ tool_choice: ToolChoiceOption | str | None = None,
555
+ execute_tools: bool = True,
556
+ mask_error_details: bool | None = None,
557
+ ) -> SampleStep:
486
558
  """
487
- Send a sampling request to the client and await the response.
559
+ Make a single LLM sampling call.
488
560
 
489
- Call this method at any time to have the server request an LLM
490
- completion from the client. The client must be appropriately configured,
491
- or the request will error.
561
+ This is a stateless function that makes exactly one LLM call and optionally
562
+ executes any requested tools. Use this for fine-grained control over the
563
+ sampling loop.
564
+
565
+ Args:
566
+ messages: The message(s) to send. Can be a string, list of strings,
567
+ or list of SamplingMessage objects.
568
+ system_prompt: Optional system prompt for the LLM.
569
+ temperature: Optional sampling temperature.
570
+ max_tokens: Maximum tokens to generate. Defaults to 512.
571
+ model_preferences: Optional model preferences.
572
+ tools: Optional list of tools the LLM can use.
573
+ tool_choice: Tool choice mode ("auto", "required", or "none").
574
+ execute_tools: If True (default), execute tool calls and append results
575
+ to history. If False, return immediately with tool_calls available
576
+ in the step for manual execution.
577
+ mask_error_details: If True, mask detailed error messages from tool
578
+ execution. When None (default), uses the global settings value.
579
+ Tools can raise ToolError to bypass masking.
580
+
581
+ Returns:
582
+ SampleStep containing:
583
+ - .response: The raw LLM response
584
+ - .history: Messages including input, assistant response, and tool results
585
+ - .is_tool_use: True if the LLM requested tool execution
586
+ - .tool_calls: List of tool calls (if any)
587
+ - .text: The text content (if any)
588
+
589
+ Example:
590
+ messages = "Research X"
591
+
592
+ while True:
593
+ step = await ctx.sample_step(messages, tools=[search])
594
+
595
+ if not step.is_tool_use:
596
+ print(step.text)
597
+ break
598
+
599
+ # Continue with tool results
600
+ messages = step.history
492
601
  """
602
+ # Convert messages to SamplingMessage objects
603
+ current_messages = _prepare_messages(messages)
493
604
 
494
- if max_tokens is None:
495
- max_tokens = 512
605
+ # Convert tools to SamplingTools
606
+ sampling_tools = _prepare_tools(tools)
607
+ sdk_tools: list[SDKTool] | None = (
608
+ [t._to_sdk_tool() for t in sampling_tools] if sampling_tools else None
609
+ )
610
+ tool_map: dict[str, SamplingTool] = (
611
+ {t.name: t for t in sampling_tools} if sampling_tools else {}
612
+ )
496
613
 
497
- if isinstance(messages, str):
498
- sampling_messages = [
499
- SamplingMessage(
500
- content=TextContent(text=messages, type="text"), role="user"
614
+ # Determine whether to use fallback handler or client
615
+ use_fallback = determine_handler_mode(self, bool(sampling_tools))
616
+
617
+ # Build tool choice
618
+ effective_tool_choice: ToolChoice | None = None
619
+ if tool_choice is not None:
620
+ if tool_choice not in ("auto", "required", "none"):
621
+ raise ValueError(
622
+ f"Invalid tool_choice: {tool_choice!r}. "
623
+ "Must be 'auto', 'required', or 'none'."
501
624
  )
502
- ]
503
- elif isinstance(messages, Sequence):
504
- sampling_messages = [
505
- SamplingMessage(content=TextContent(text=m, type="text"), role="user")
506
- if isinstance(m, str)
507
- else m
508
- for m in messages
509
- ]
510
-
511
- should_fallback = (
512
- self.fastmcp.sampling_handler_behavior == "fallback"
513
- and not self.session.check_client_capability(
514
- capability=ClientCapabilities(sampling=SamplingCapability())
625
+ effective_tool_choice = ToolChoice(
626
+ mode=cast(Literal["auto", "required", "none"], tool_choice)
627
+ )
628
+
629
+ # Effective max_tokens
630
+ effective_max_tokens = max_tokens if max_tokens is not None else 512
631
+
632
+ # Make the LLM call
633
+ if use_fallback:
634
+ response = await call_sampling_handler(
635
+ self,
636
+ current_messages,
637
+ system_prompt=system_prompt,
638
+ temperature=temperature,
639
+ max_tokens=effective_max_tokens,
640
+ model_preferences=model_preferences,
641
+ sdk_tools=sdk_tools,
642
+ tool_choice=effective_tool_choice,
515
643
  )
644
+ else:
645
+ response = await self.session.create_message(
646
+ messages=current_messages,
647
+ system_prompt=system_prompt,
648
+ temperature=temperature,
649
+ max_tokens=effective_max_tokens,
650
+ model_preferences=_parse_model_preferences(model_preferences),
651
+ tools=sdk_tools,
652
+ tool_choice=effective_tool_choice,
653
+ related_request_id=self.request_id,
654
+ )
655
+
656
+ # Check if this is a tool use response
657
+ is_tool_use_response = (
658
+ isinstance(response, CreateMessageResultWithTools)
659
+ and response.stopReason == "toolUse"
660
+ )
661
+
662
+ # Always include the assistant response in history
663
+ current_messages.append(
664
+ SamplingMessage(role="assistant", content=response.content)
516
665
  )
517
666
 
518
- if self.fastmcp.sampling_handler_behavior == "always" or should_fallback:
519
- if self.fastmcp.sampling_handler is None:
520
- raise ValueError("Client does not support sampling")
521
-
522
- create_message_result = self.fastmcp.sampling_handler(
523
- sampling_messages,
524
- SamplingParams(
525
- systemPrompt=system_prompt,
526
- messages=sampling_messages,
527
- temperature=temperature,
528
- maxTokens=max_tokens,
529
- modelPreferences=_parse_model_preferences(model_preferences),
530
- ),
531
- self.request_context,
667
+ # If not a tool use, return immediately
668
+ if not is_tool_use_response:
669
+ return SampleStep(response=response, history=current_messages)
670
+
671
+ # If not executing tools, return with assistant message but no tool results
672
+ if not execute_tools:
673
+ return SampleStep(response=response, history=current_messages)
674
+
675
+ # Execute tools and add results to history
676
+ step_tool_calls = _extract_tool_calls(response)
677
+ if step_tool_calls:
678
+ effective_mask = (
679
+ mask_error_details
680
+ if mask_error_details is not None
681
+ else settings.mask_error_details
682
+ )
683
+ tool_results = await run_sampling_tools(
684
+ step_tool_calls, tool_map, mask_error_details=effective_mask
532
685
  )
533
686
 
534
- if inspect.isawaitable(create_message_result):
535
- create_message_result = await create_message_result
687
+ if tool_results:
688
+ current_messages.append(
689
+ SamplingMessage(
690
+ role="user",
691
+ content=tool_results, # type: ignore[arg-type]
692
+ )
693
+ )
536
694
 
537
- if isinstance(create_message_result, str):
538
- return TextContent(text=create_message_result, type="text")
695
+ return SampleStep(response=response, history=current_messages)
539
696
 
540
- if isinstance(create_message_result, CreateMessageResult):
541
- return create_message_result.content
697
+ @overload
698
+ async def sample(
699
+ self,
700
+ messages: str | Sequence[str | SamplingMessage],
701
+ *,
702
+ system_prompt: str | None = None,
703
+ temperature: float | None = None,
704
+ max_tokens: int | None = None,
705
+ model_preferences: ModelPreferences | str | list[str] | None = None,
706
+ tools: Sequence[SamplingTool | Callable[..., Any]] | None = None,
707
+ result_type: type[ResultT],
708
+ mask_error_details: bool | None = None,
709
+ ) -> SamplingResult[ResultT]:
710
+ """Overload: With result_type, returns SamplingResult[ResultT]."""
542
711
 
543
- else:
544
- raise ValueError(
545
- f"Unexpected sampling handler result: {create_message_result}"
712
+ @overload
713
+ async def sample(
714
+ self,
715
+ messages: str | Sequence[str | SamplingMessage],
716
+ *,
717
+ system_prompt: str | None = None,
718
+ temperature: float | None = None,
719
+ max_tokens: int | None = None,
720
+ model_preferences: ModelPreferences | str | list[str] | None = None,
721
+ tools: Sequence[SamplingTool | Callable[..., Any]] | None = None,
722
+ result_type: None = None,
723
+ mask_error_details: bool | None = None,
724
+ ) -> SamplingResult[str]:
725
+ """Overload: Without result_type, returns SamplingResult[str]."""
726
+
727
+ async def sample(
728
+ self,
729
+ messages: str | Sequence[str | SamplingMessage],
730
+ *,
731
+ system_prompt: str | None = None,
732
+ temperature: float | None = None,
733
+ max_tokens: int | None = None,
734
+ model_preferences: ModelPreferences | str | list[str] | None = None,
735
+ tools: Sequence[SamplingTool | Callable[..., Any]] | None = None,
736
+ result_type: type[ResultT] | None = None,
737
+ mask_error_details: bool | None = None,
738
+ ) -> SamplingResult[ResultT] | SamplingResult[str]:
739
+ """
740
+ Send a sampling request to the client and await the response.
741
+
742
+ This method runs to completion automatically. When tools are provided,
743
+ it executes a tool loop: if the LLM returns a tool use request, the tools
744
+ are executed and the results are sent back to the LLM. This continues
745
+ until the LLM provides a final text response.
746
+
747
+ When result_type is specified, a synthetic `final_response` tool is
748
+ created. The LLM calls this tool to provide the structured response,
749
+ which is validated against the result_type and returned as `.result`.
750
+
751
+ For fine-grained control over the sampling loop, use sample_step() instead.
752
+
753
+ Args:
754
+ messages: The message(s) to send. Can be a string, list of strings,
755
+ or list of SamplingMessage objects.
756
+ system_prompt: Optional system prompt for the LLM.
757
+ temperature: Optional sampling temperature.
758
+ max_tokens: Maximum tokens to generate. Defaults to 512.
759
+ model_preferences: Optional model preferences.
760
+ tools: Optional list of tools the LLM can use. Accepts plain
761
+ functions or SamplingTools.
762
+ result_type: Optional type for structured output. When specified,
763
+ a synthetic `final_response` tool is created and the LLM's
764
+ response is validated against this type.
765
+ mask_error_details: If True, mask detailed error messages from tool
766
+ execution. When None (default), uses the global settings value.
767
+ Tools can raise ToolError to bypass masking.
768
+
769
+ Returns:
770
+ SamplingResult[T] containing:
771
+ - .text: The text representation (raw text or JSON for structured)
772
+ - .result: The typed result (str for text, parsed object for structured)
773
+ - .history: All messages exchanged during sampling
774
+ """
775
+ # Safety limit to prevent infinite loops
776
+ max_iterations = 100
777
+
778
+ # Convert tools to SamplingTools
779
+ sampling_tools = _prepare_tools(tools)
780
+
781
+ # Handle structured output with result_type
782
+ tool_choice: str | None = None
783
+ if result_type is not None and result_type is not str:
784
+ final_response_tool = _create_final_response_tool(result_type)
785
+ sampling_tools = list(sampling_tools) if sampling_tools else []
786
+ sampling_tools.append(final_response_tool)
787
+
788
+ # Always require tool calls when result_type is set - the LLM must
789
+ # eventually call final_response (text responses are not accepted)
790
+ tool_choice = "required"
791
+
792
+ # Convert messages for the loop
793
+ current_messages: str | Sequence[str | SamplingMessage] = messages
794
+
795
+ for _iteration in range(max_iterations):
796
+ step = await self.sample_step(
797
+ messages=current_messages,
798
+ system_prompt=system_prompt,
799
+ temperature=temperature,
800
+ max_tokens=max_tokens,
801
+ model_preferences=model_preferences,
802
+ tools=sampling_tools,
803
+ tool_choice=tool_choice,
804
+ mask_error_details=mask_error_details,
805
+ )
806
+
807
+ # Check for final_response tool call for structured output
808
+ if result_type is not None and result_type is not str and step.is_tool_use:
809
+ for tool_call in step.tool_calls:
810
+ if tool_call.name == "final_response":
811
+ # Validate and return the structured result
812
+ type_adapter = get_cached_typeadapter(result_type)
813
+
814
+ # Unwrap if we wrapped primitives (non-object schemas)
815
+ input_data = tool_call.input
816
+ original_schema = compress_schema(
817
+ type_adapter.json_schema(), prune_titles=True
818
+ )
819
+ if (
820
+ original_schema.get("type") != "object"
821
+ and isinstance(input_data, dict)
822
+ and "value" in input_data
823
+ ):
824
+ input_data = input_data["value"]
825
+
826
+ try:
827
+ validated_result = type_adapter.validate_python(input_data)
828
+ text = json.dumps(
829
+ type_adapter.dump_python(validated_result, mode="json")
830
+ )
831
+ return SamplingResult(
832
+ text=text,
833
+ result=validated_result,
834
+ history=step.history,
835
+ )
836
+ except ValidationError as e:
837
+ # Validation failed - add error as tool result
838
+ step.history.append(
839
+ SamplingMessage(
840
+ role="user",
841
+ content=[
842
+ ToolResultContent(
843
+ type="tool_result",
844
+ toolUseId=tool_call.id,
845
+ content=[
846
+ TextContent(
847
+ type="text",
848
+ text=(
849
+ f"Validation error: {e}. "
850
+ "Please try again with valid data."
851
+ ),
852
+ )
853
+ ],
854
+ isError=True,
855
+ )
856
+ ], # type: ignore[arg-type]
857
+ )
858
+ )
859
+
860
+ # If not a tool use response, we're done
861
+ if not step.is_tool_use:
862
+ # For structured output, the LLM must use the final_response tool
863
+ if result_type is not None and result_type is not str:
864
+ raise RuntimeError(
865
+ f"Expected structured output of type {result_type.__name__}, "
866
+ "but the LLM returned a text response instead of calling "
867
+ "the final_response tool."
868
+ )
869
+ return SamplingResult(
870
+ text=step.text,
871
+ result=cast(ResultT, step.text if step.text else ""),
872
+ history=step.history,
546
873
  )
547
874
 
548
- result: CreateMessageResult = await self.session.create_message(
549
- messages=sampling_messages,
550
- system_prompt=system_prompt,
551
- include_context=include_context,
552
- temperature=temperature,
553
- max_tokens=max_tokens,
554
- model_preferences=_parse_model_preferences(model_preferences),
555
- related_request_id=self.request_id,
556
- )
875
+ # Continue with the updated history
876
+ current_messages = step.history
557
877
 
558
- return result.content
878
+ # After first iteration, reset tool_choice to auto
879
+ tool_choice = None
880
+
881
+ raise RuntimeError(f"Sampling exceeded maximum iterations ({max_iterations})")
559
882
 
560
883
  @overload
561
884
  async def elicit(
@@ -592,11 +915,12 @@ class Context:
592
915
  async def elicit(
593
916
  self,
594
917
  message: str,
595
- response_type: type[T] | list[str] | None = None,
918
+ response_type: type[T] | list[str] | dict[str, dict[str, str]] | None = None,
596
919
  ) -> (
597
920
  AcceptedElicitation[T]
598
921
  | AcceptedElicitation[dict[str, Any]]
599
922
  | AcceptedElicitation[str]
923
+ | AcceptedElicitation[list[str]]
600
924
  | DeclinedElicitation
601
925
  | CancelledElicitation
602
926
  ):
@@ -623,78 +947,23 @@ class Context:
623
947
  type or dataclass or BaseModel. If it is a primitive type, an
624
948
  object schema with a single "value" field will be generated.
625
949
  """
626
- if response_type is None:
627
- schema = {"type": "object", "properties": {}}
628
- else:
629
- # if the user provided a list of strings, treat it as a Literal
630
- if isinstance(response_type, list):
631
- if not all(isinstance(item, str) for item in response_type):
632
- raise ValueError(
633
- "List of options must be a list of strings. Received: "
634
- f"{response_type}"
635
- )
636
- # Convert list of options to Literal type and wrap
637
- choice_literal = Literal[tuple(response_type)] # type: ignore
638
- response_type = ScalarElicitationType[choice_literal] # type: ignore
639
- # if the user provided a primitive scalar, wrap it in an object schema
640
- elif (
641
- response_type in {bool, int, float, str}
642
- or get_origin(response_type) is Literal
643
- or (isinstance(response_type, type) and issubclass(response_type, Enum))
644
- ):
645
- response_type = ScalarElicitationType[response_type] # type: ignore
646
-
647
- response_type = cast(type[T], response_type)
648
-
649
- schema = get_elicitation_schema(response_type)
950
+ config = parse_elicit_response_type(response_type)
650
951
 
651
952
  result = await self.session.elicit(
652
953
  message=message,
653
- requestedSchema=schema,
954
+ requestedSchema=config.schema,
654
955
  related_request_id=self.request_id,
655
956
  )
656
957
 
657
958
  if result.action == "accept":
658
- if response_type is not None:
659
- type_adapter = get_cached_typeadapter(response_type)
660
- validated_data = cast(
661
- T | ScalarElicitationType[T],
662
- type_adapter.validate_python(result.content),
663
- )
664
- if isinstance(validated_data, ScalarElicitationType):
665
- return AcceptedElicitation[T](data=validated_data.value)
666
- else:
667
- return AcceptedElicitation[T](data=cast(T, validated_data))
668
- elif result.content:
669
- raise ValueError(
670
- "Elicitation expected an empty response, but received: "
671
- f"{result.content}"
672
- )
673
- else:
674
- return AcceptedElicitation[dict[str, Any]](data={})
959
+ return handle_elicit_accept(config, result.content)
675
960
  elif result.action == "decline":
676
961
  return DeclinedElicitation()
677
962
  elif result.action == "cancel":
678
963
  return CancelledElicitation()
679
964
  else:
680
- # This should never happen, but handle it just in case
681
965
  raise ValueError(f"Unexpected elicitation action: {result.action}")
682
966
 
683
- def get_http_request(self) -> Request:
684
- """Get the active starlette request."""
685
-
686
- # Deprecated in 2.2.11
687
- if settings.deprecation_warnings:
688
- warnings.warn(
689
- "Context.get_http_request() is deprecated and will be removed in a future version. "
690
- "Use get_http_request() from fastmcp.server.dependencies instead. "
691
- "See https://gofastmcp.com/servers/context#http-requests for more details.",
692
- DeprecationWarning,
693
- stacklevel=2,
694
- )
695
-
696
- return fastmcp.server.dependencies.get_http_request()
697
-
698
967
  def set_state(self, key: str, value: Any) -> None:
699
968
  """Set a value in the context state."""
700
969
  self._state[key] = value
@@ -734,47 +1003,6 @@ class Context:
734
1003
  pass
735
1004
 
736
1005
 
737
- def _parse_model_preferences(
738
- model_preferences: ModelPreferences | str | list[str] | None,
739
- ) -> ModelPreferences | None:
740
- """
741
- Validates and converts user input for model_preferences into a ModelPreferences object.
742
-
743
- Args:
744
- model_preferences (ModelPreferences | str | list[str] | None):
745
- The model preferences to use. Accepts:
746
- - ModelPreferences (returns as-is)
747
- - str (single model hint)
748
- - list[str] (multiple model hints)
749
- - None (no preferences)
750
-
751
- Returns:
752
- ModelPreferences | None: The parsed ModelPreferences object, or None if not provided.
753
-
754
- Raises:
755
- ValueError: If the input is not a supported type or contains invalid values.
756
- """
757
- if model_preferences is None:
758
- return None
759
- elif isinstance(model_preferences, ModelPreferences):
760
- return model_preferences
761
- elif isinstance(model_preferences, str):
762
- # Single model hint
763
- return ModelPreferences(hints=[ModelHint(name=model_preferences)])
764
- elif isinstance(model_preferences, list):
765
- # List of model hints (strings)
766
- if not all(isinstance(h, str) for h in model_preferences):
767
- raise ValueError(
768
- "All elements of model_preferences list must be"
769
- " strings (model name hints)."
770
- )
771
- return ModelPreferences(hints=[ModelHint(name=h) for h in model_preferences])
772
- else:
773
- raise ValueError(
774
- "model_preferences must be one of: ModelPreferences, str, list[str], or None."
775
- )
776
-
777
-
778
1006
  async def _log_to_server_and_client(
779
1007
  data: LogData,
780
1008
  session: ServerSession,
@@ -801,3 +1029,104 @@ async def _log_to_server_and_client(
801
1029
  logger=logger_name,
802
1030
  related_request_id=related_request_id,
803
1031
  )
1032
+
1033
+
1034
+ def _create_final_response_tool(result_type: type) -> SamplingTool:
1035
+ """Create a synthetic 'final_response' tool for structured output.
1036
+
1037
+ This tool is used to capture structured responses from the LLM.
1038
+ The tool's schema is derived from the result_type.
1039
+ """
1040
+ type_adapter = get_cached_typeadapter(result_type)
1041
+ schema = type_adapter.json_schema()
1042
+ schema = compress_schema(schema, prune_titles=True)
1043
+
1044
+ # Tool parameters must be object-shaped. Wrap primitives in {"value": <schema>}
1045
+ if schema.get("type") != "object":
1046
+ schema = {
1047
+ "type": "object",
1048
+ "properties": {"value": schema},
1049
+ "required": ["value"],
1050
+ }
1051
+
1052
+ # The fn just returns the input as-is (validation happens in the loop)
1053
+ def final_response(**kwargs: Any) -> dict[str, Any]:
1054
+ return kwargs
1055
+
1056
+ return SamplingTool(
1057
+ name="final_response",
1058
+ description=(
1059
+ "Call this tool to provide your final response. "
1060
+ "Use this when you have completed the task and are ready to return the result."
1061
+ ),
1062
+ parameters=schema,
1063
+ fn=final_response,
1064
+ )
1065
+
1066
+
1067
+ def _extract_text_from_content(
1068
+ content: SamplingMessageContentBlock | list[SamplingMessageContentBlock],
1069
+ ) -> str | None:
1070
+ """Extract text from content block(s).
1071
+
1072
+ Returns the text if content is a TextContent or list containing TextContent,
1073
+ otherwise returns None.
1074
+ """
1075
+ if isinstance(content, list):
1076
+ for block in content:
1077
+ if isinstance(block, TextContent):
1078
+ return block.text
1079
+ return None
1080
+ elif isinstance(content, TextContent):
1081
+ return content.text
1082
+ return None
1083
+
1084
+
1085
+ def _prepare_messages(
1086
+ messages: str | Sequence[str | SamplingMessage],
1087
+ ) -> list[SamplingMessage]:
1088
+ """Convert various message formats to a list of SamplingMessage objects."""
1089
+ if isinstance(messages, str):
1090
+ return [
1091
+ SamplingMessage(
1092
+ content=TextContent(text=messages, type="text"), role="user"
1093
+ )
1094
+ ]
1095
+ else:
1096
+ return [
1097
+ SamplingMessage(content=TextContent(text=m, type="text"), role="user")
1098
+ if isinstance(m, str)
1099
+ else m
1100
+ for m in messages
1101
+ ]
1102
+
1103
+
1104
+ def _prepare_tools(
1105
+ tools: Sequence[SamplingTool | Callable[..., Any]] | None,
1106
+ ) -> list[SamplingTool] | None:
1107
+ """Convert tools to SamplingTool objects."""
1108
+ if tools is None:
1109
+ return None
1110
+
1111
+ sampling_tools: list[SamplingTool] = []
1112
+ for t in tools:
1113
+ if isinstance(t, SamplingTool):
1114
+ sampling_tools.append(t)
1115
+ elif callable(t):
1116
+ sampling_tools.append(SamplingTool.from_function(t))
1117
+ else:
1118
+ raise TypeError(f"Expected SamplingTool or callable, got {type(t)}")
1119
+
1120
+ return sampling_tools if sampling_tools else None
1121
+
1122
+
1123
+ def _extract_tool_calls(
1124
+ response: CreateMessageResult | CreateMessageResultWithTools,
1125
+ ) -> list[ToolUseContent]:
1126
+ """Extract tool calls from a response."""
1127
+ content = response.content
1128
+ if isinstance(content, list):
1129
+ return [c for c in content if isinstance(c, ToolUseContent)]
1130
+ elif isinstance(content, ToolUseContent):
1131
+ return [content]
1132
+ return []