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.
- fastmcp/__init__.py +0 -21
- fastmcp/cli/__init__.py +0 -3
- fastmcp/cli/__main__.py +5 -0
- fastmcp/cli/cli.py +8 -22
- fastmcp/cli/install/shared.py +0 -15
- fastmcp/cli/tasks.py +110 -0
- fastmcp/client/auth/oauth.py +9 -9
- fastmcp/client/client.py +739 -136
- fastmcp/client/elicitation.py +11 -5
- fastmcp/client/messages.py +7 -5
- fastmcp/client/roots.py +2 -1
- fastmcp/client/sampling/__init__.py +69 -0
- fastmcp/client/sampling/handlers/__init__.py +0 -0
- fastmcp/client/sampling/handlers/anthropic.py +387 -0
- fastmcp/client/sampling/handlers/openai.py +399 -0
- fastmcp/client/tasks.py +551 -0
- fastmcp/client/transports.py +72 -21
- fastmcp/contrib/component_manager/component_service.py +4 -20
- fastmcp/dependencies.py +25 -0
- fastmcp/experimental/sampling/handlers/__init__.py +5 -0
- fastmcp/experimental/sampling/handlers/openai.py +4 -169
- fastmcp/experimental/server/openapi/__init__.py +15 -13
- fastmcp/experimental/utilities/openapi/__init__.py +12 -38
- fastmcp/prompts/prompt.py +38 -38
- fastmcp/resources/resource.py +33 -16
- fastmcp/resources/template.py +69 -59
- fastmcp/server/auth/__init__.py +0 -9
- fastmcp/server/auth/auth.py +127 -3
- fastmcp/server/auth/oauth_proxy.py +47 -97
- fastmcp/server/auth/oidc_proxy.py +7 -0
- fastmcp/server/auth/providers/in_memory.py +2 -2
- fastmcp/server/auth/providers/oci.py +2 -2
- fastmcp/server/context.py +509 -180
- fastmcp/server/dependencies.py +464 -6
- fastmcp/server/elicitation.py +285 -47
- fastmcp/server/event_store.py +177 -0
- fastmcp/server/http.py +15 -3
- fastmcp/server/low_level.py +56 -12
- fastmcp/server/middleware/middleware.py +2 -2
- fastmcp/server/openapi/__init__.py +35 -0
- fastmcp/{experimental/server → server}/openapi/components.py +4 -3
- fastmcp/{experimental/server → server}/openapi/routing.py +1 -1
- fastmcp/{experimental/server → server}/openapi/server.py +6 -5
- fastmcp/server/proxy.py +53 -40
- fastmcp/server/sampling/__init__.py +10 -0
- fastmcp/server/sampling/run.py +301 -0
- fastmcp/server/sampling/sampling_tool.py +108 -0
- fastmcp/server/server.py +793 -552
- fastmcp/server/tasks/__init__.py +21 -0
- fastmcp/server/tasks/capabilities.py +22 -0
- fastmcp/server/tasks/config.py +89 -0
- fastmcp/server/tasks/converters.py +206 -0
- fastmcp/server/tasks/handlers.py +356 -0
- fastmcp/server/tasks/keys.py +93 -0
- fastmcp/server/tasks/protocol.py +355 -0
- fastmcp/server/tasks/subscriptions.py +205 -0
- fastmcp/settings.py +101 -103
- fastmcp/tools/tool.py +83 -49
- fastmcp/tools/tool_transform.py +1 -12
- fastmcp/utilities/components.py +3 -3
- fastmcp/utilities/json_schema_type.py +4 -4
- fastmcp/utilities/mcp_config.py +1 -2
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +1 -1
- fastmcp/{experimental/utilities → utilities}/openapi/README.md +7 -35
- fastmcp/utilities/openapi/__init__.py +63 -0
- fastmcp/{experimental/utilities → utilities}/openapi/formatters.py +5 -5
- fastmcp/{experimental/utilities → utilities}/openapi/json_schema_converter.py +1 -1
- fastmcp/utilities/tests.py +11 -5
- fastmcp/utilities/types.py +8 -0
- {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/METADATA +7 -4
- {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/RECORD +79 -63
- fastmcp/client/sampling.py +0 -56
- fastmcp/experimental/sampling/handlers/base.py +0 -21
- fastmcp/server/auth/providers/bearer.py +0 -25
- fastmcp/server/openapi.py +0 -1087
- fastmcp/server/sampling/handler.py +0 -19
- fastmcp/utilities/openapi.py +0 -1568
- /fastmcp/{experimental/server → server}/openapi/README.md +0 -0
- /fastmcp/{experimental/utilities → utilities}/openapi/director.py +0 -0
- /fastmcp/{experimental/utilities → utilities}/openapi/models.py +0 -0
- /fastmcp/{experimental/utilities → utilities}/openapi/parser.py +0 -0
- /fastmcp/{experimental/utilities → utilities}/openapi/schemas.py +0 -0
- {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/WHEEL +0 -0
- {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/entry_points.txt +0 -0
- {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
|
|
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,
|
|
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
|
|
36
|
-
from mcp.types import
|
|
37
|
-
from mcp.types import
|
|
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
|
-
|
|
49
|
-
|
|
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[
|
|
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[
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
559
|
+
Make a single LLM sampling call.
|
|
488
560
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
495
|
-
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
|
535
|
-
|
|
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
|
-
|
|
538
|
-
return TextContent(text=create_message_result, type="text")
|
|
695
|
+
return SampleStep(response=response, history=current_messages)
|
|
539
696
|
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
549
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 []
|