fastmcp 2.14.0__py3-none-any.whl → 2.14.2__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 (41) hide show
  1. fastmcp/client/client.py +79 -12
  2. fastmcp/client/sampling/__init__.py +69 -0
  3. fastmcp/client/sampling/handlers/__init__.py +0 -0
  4. fastmcp/client/sampling/handlers/anthropic.py +387 -0
  5. fastmcp/client/sampling/handlers/openai.py +399 -0
  6. fastmcp/client/tasks.py +0 -63
  7. fastmcp/client/transports.py +35 -16
  8. fastmcp/experimental/sampling/handlers/__init__.py +5 -0
  9. fastmcp/experimental/sampling/handlers/openai.py +4 -169
  10. fastmcp/prompts/prompt.py +5 -5
  11. fastmcp/prompts/prompt_manager.py +3 -4
  12. fastmcp/resources/resource.py +4 -4
  13. fastmcp/resources/resource_manager.py +9 -14
  14. fastmcp/resources/template.py +5 -5
  15. fastmcp/server/auth/auth.py +20 -5
  16. fastmcp/server/auth/oauth_proxy.py +73 -15
  17. fastmcp/server/auth/providers/supabase.py +11 -6
  18. fastmcp/server/context.py +448 -113
  19. fastmcp/server/dependencies.py +5 -0
  20. fastmcp/server/elicitation.py +7 -3
  21. fastmcp/server/middleware/error_handling.py +1 -1
  22. fastmcp/server/openapi/components.py +2 -4
  23. fastmcp/server/proxy.py +3 -3
  24. fastmcp/server/sampling/__init__.py +10 -0
  25. fastmcp/server/sampling/run.py +301 -0
  26. fastmcp/server/sampling/sampling_tool.py +108 -0
  27. fastmcp/server/server.py +84 -78
  28. fastmcp/server/tasks/converters.py +2 -1
  29. fastmcp/tools/tool.py +8 -6
  30. fastmcp/tools/tool_manager.py +5 -7
  31. fastmcp/utilities/cli.py +23 -43
  32. fastmcp/utilities/json_schema.py +40 -0
  33. fastmcp/utilities/openapi/schemas.py +4 -4
  34. {fastmcp-2.14.0.dist-info → fastmcp-2.14.2.dist-info}/METADATA +8 -3
  35. {fastmcp-2.14.0.dist-info → fastmcp-2.14.2.dist-info}/RECORD +38 -34
  36. fastmcp/client/sampling.py +0 -56
  37. fastmcp/experimental/sampling/handlers/base.py +0 -21
  38. fastmcp/server/sampling/handler.py +0 -19
  39. {fastmcp-2.14.0.dist-info → fastmcp-2.14.2.dist-info}/WHEEL +0 -0
  40. {fastmcp-2.14.0.dist-info → fastmcp-2.14.2.dist-info}/entry_points.txt +0 -0
  41. {fastmcp-2.14.0.dist-info → fastmcp-2.14.2.dist-info}/licenses/LICENSE +0 -0
@@ -21,6 +21,7 @@ from mcp.server.auth.provider import (
21
21
  from mcp.server.lowlevel.server import request_ctx
22
22
  from starlette.requests import Request
23
23
 
24
+ from fastmcp.exceptions import FastMCPError
24
25
  from fastmcp.server.auth import AccessToken
25
26
  from fastmcp.server.http import _current_http_request
26
27
  from fastmcp.utilities.types import is_class_member_of_type
@@ -188,6 +189,10 @@ async def _resolve_fastmcp_dependencies(
188
189
  resolved[parameter] = await stack.enter_async_context(
189
190
  dependency
190
191
  )
192
+ except FastMCPError:
193
+ # Let FastMCPError subclasses (ToolError, ResourceError, etc.)
194
+ # propagate unchanged so they can be handled appropriately
195
+ raise
191
196
  except Exception as error:
192
197
  fn_name = getattr(fn, "__name__", repr(fn))
193
198
  raise RuntimeError(
@@ -304,15 +304,19 @@ def _dict_to_enum_schema(
304
304
  multi_select: If True, use anyOf pattern; if False, use oneOf pattern
305
305
 
306
306
  Returns:
307
- {"oneOf": [{"const": "low", "title": "Low Priority"}, ...]} for single-select
308
- {"anyOf": [{"const": "low", "title": "Low Priority"}, ...]} for multi-select
307
+ {"type": "string", "oneOf": [...]} for single-select
308
+ {"anyOf": [...]} for multi-select (used as array items)
309
309
  """
310
310
  pattern_key = "anyOf" if multi_select else "oneOf"
311
311
  pattern = []
312
312
  for value, metadata in enum_dict.items():
313
313
  title = metadata.get("title", value)
314
314
  pattern.append({"const": value, "title": title})
315
- return {pattern_key: pattern}
315
+
316
+ result: dict[str, Any] = {pattern_key: pattern}
317
+ if not multi_select:
318
+ result["type"] = "string"
319
+ return result
316
320
 
317
321
 
318
322
  def get_elicitation_schema(response_type: type[T]) -> dict[str, Any]:
@@ -87,7 +87,7 @@ class ErrorHandlingMiddleware(Middleware):
87
87
  return error
88
88
 
89
89
  # Map common exceptions to appropriate MCP error codes
90
- error_type = type(error)
90
+ error_type = type(error.__cause__) if error.__cause__ else type(error)
91
91
 
92
92
  if error_type in (ValueError, TypeError):
93
93
  return McpError(
@@ -64,10 +64,8 @@ class OpenAPITool(Tool):
64
64
  try:
65
65
  # Get base URL from client
66
66
  base_url = (
67
- str(self._client.base_url)
68
- if hasattr(self._client, "base_url") and self._client.base_url
69
- else "http://localhost"
70
- )
67
+ str(self._client.base_url) if hasattr(self._client, "base_url") else ""
68
+ ) or "http://localhost"
71
69
 
72
70
  # Get Headers from client
73
71
  cli_headers = (
fastmcp/server/proxy.py CHANGED
@@ -589,15 +589,15 @@ class ProxyClient(Client[ClientTransportT]):
589
589
  A handler that forwards the sampling request from the remote server to the proxy's connected clients and relays the response back to the remote server.
590
590
  """
591
591
  ctx = get_context()
592
- content = await ctx.sample(
592
+ result = await ctx.sample(
593
593
  list(messages),
594
594
  system_prompt=params.systemPrompt,
595
595
  temperature=params.temperature,
596
596
  max_tokens=params.maxTokens,
597
597
  model_preferences=params.modelPreferences,
598
598
  )
599
- if isinstance(content, mcp.types.ResourceLink | mcp.types.EmbeddedResource):
600
- raise RuntimeError("Content is not supported")
599
+ # Create TextContent from the result text
600
+ content = mcp.types.TextContent(type="text", text=result.text or "")
601
601
  return mcp.types.CreateMessageResult(
602
602
  role="assistant",
603
603
  model="fastmcp-client",
@@ -0,0 +1,10 @@
1
+ """Sampling module for FastMCP servers."""
2
+
3
+ from fastmcp.server.sampling.run import SampleStep, SamplingResult
4
+ from fastmcp.server.sampling.sampling_tool import SamplingTool
5
+
6
+ __all__ = [
7
+ "SampleStep",
8
+ "SamplingResult",
9
+ "SamplingTool",
10
+ ]
@@ -0,0 +1,301 @@
1
+ """Sampling types and helper functions for FastMCP servers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING, Generic
8
+
9
+ from mcp.types import (
10
+ ClientCapabilities,
11
+ CreateMessageResult,
12
+ CreateMessageResultWithTools,
13
+ ModelHint,
14
+ ModelPreferences,
15
+ SamplingCapability,
16
+ SamplingMessage,
17
+ SamplingToolsCapability,
18
+ TextContent,
19
+ ToolChoice,
20
+ ToolResultContent,
21
+ ToolUseContent,
22
+ )
23
+ from mcp.types import CreateMessageRequestParams as SamplingParams
24
+ from mcp.types import Tool as SDKTool
25
+ from typing_extensions import TypeVar
26
+
27
+ from fastmcp.exceptions import ToolError
28
+ from fastmcp.server.sampling.sampling_tool import SamplingTool
29
+ from fastmcp.utilities.logging import get_logger
30
+
31
+ logger = get_logger(__name__)
32
+
33
+ if TYPE_CHECKING:
34
+ from fastmcp.server.context import Context
35
+
36
+ ResultT = TypeVar("ResultT", default=str)
37
+
38
+
39
+ @dataclass
40
+ class SamplingResult(Generic[ResultT]):
41
+ """Result of a sampling operation.
42
+
43
+ Attributes:
44
+ text: The text representation of the result (raw text or JSON for structured).
45
+ result: The typed result (str for text, parsed object for structured output).
46
+ history: All messages exchanged during sampling.
47
+ """
48
+
49
+ text: str | None
50
+ result: ResultT
51
+ history: list[SamplingMessage]
52
+
53
+
54
+ @dataclass
55
+ class SampleStep:
56
+ """Result of a single sampling call.
57
+
58
+ Represents what the LLM returned in this step plus the message history.
59
+ """
60
+
61
+ response: CreateMessageResult | CreateMessageResultWithTools
62
+ history: list[SamplingMessage]
63
+
64
+ @property
65
+ def is_tool_use(self) -> bool:
66
+ """True if the LLM is requesting tool execution."""
67
+ if isinstance(self.response, CreateMessageResultWithTools):
68
+ return self.response.stopReason == "toolUse"
69
+ return False
70
+
71
+ @property
72
+ def text(self) -> str | None:
73
+ """Extract text from the response, if available."""
74
+ content = self.response.content
75
+ if isinstance(content, list):
76
+ for block in content:
77
+ if isinstance(block, TextContent):
78
+ return block.text
79
+ return None
80
+ elif isinstance(content, TextContent):
81
+ return content.text
82
+ return None
83
+
84
+ @property
85
+ def tool_calls(self) -> list[ToolUseContent]:
86
+ """Get the list of tool calls from the response."""
87
+ content = self.response.content
88
+ if isinstance(content, list):
89
+ return [c for c in content if isinstance(c, ToolUseContent)]
90
+ elif isinstance(content, ToolUseContent):
91
+ return [content]
92
+ return []
93
+
94
+
95
+ def _parse_model_preferences(
96
+ model_preferences: ModelPreferences | str | list[str] | None,
97
+ ) -> ModelPreferences | None:
98
+ """Convert model preferences to ModelPreferences object."""
99
+ if model_preferences is None:
100
+ return None
101
+ elif isinstance(model_preferences, ModelPreferences):
102
+ return model_preferences
103
+ elif isinstance(model_preferences, str):
104
+ return ModelPreferences(hints=[ModelHint(name=model_preferences)])
105
+ elif isinstance(model_preferences, list):
106
+ if not all(isinstance(h, str) for h in model_preferences):
107
+ raise ValueError("All elements of model_preferences list must be strings.")
108
+ return ModelPreferences(hints=[ModelHint(name=h) for h in model_preferences])
109
+ else:
110
+ raise ValueError(
111
+ "model_preferences must be one of: ModelPreferences, str, list[str], or None."
112
+ )
113
+
114
+
115
+ # --- Standalone functions for sample_step() ---
116
+
117
+
118
+ def determine_handler_mode(context: Context, needs_tools: bool) -> bool:
119
+ """Determine whether to use fallback handler or client for sampling.
120
+
121
+ Args:
122
+ context: The MCP context.
123
+ needs_tools: Whether the sampling request requires tool support.
124
+
125
+ Returns:
126
+ True if fallback handler should be used, False to use client.
127
+
128
+ Raises:
129
+ ValueError: If client lacks required capability and no fallback configured.
130
+ """
131
+ fastmcp = context.fastmcp
132
+ session = context.session
133
+
134
+ # Check what capabilities the client has
135
+ has_sampling = session.check_client_capability(
136
+ capability=ClientCapabilities(sampling=SamplingCapability())
137
+ )
138
+ has_tools_capability = session.check_client_capability(
139
+ capability=ClientCapabilities(
140
+ sampling=SamplingCapability(tools=SamplingToolsCapability())
141
+ )
142
+ )
143
+
144
+ if fastmcp.sampling_handler_behavior == "always":
145
+ if fastmcp.sampling_handler is None:
146
+ raise ValueError(
147
+ "sampling_handler_behavior is 'always' but no handler configured"
148
+ )
149
+ return True
150
+ elif fastmcp.sampling_handler_behavior == "fallback":
151
+ client_sufficient = has_sampling and (not needs_tools or has_tools_capability)
152
+ if not client_sufficient:
153
+ if fastmcp.sampling_handler is None:
154
+ if needs_tools and has_sampling and not has_tools_capability:
155
+ raise ValueError(
156
+ "Client does not support sampling with tools. "
157
+ "The client must advertise the sampling.tools capability."
158
+ )
159
+ raise ValueError("Client does not support sampling")
160
+ return True
161
+ elif fastmcp.sampling_handler_behavior is not None:
162
+ raise ValueError(
163
+ f"Invalid sampling_handler_behavior: {fastmcp.sampling_handler_behavior!r}. "
164
+ "Must be 'always', 'fallback', or None."
165
+ )
166
+ elif not has_sampling:
167
+ raise ValueError("Client does not support sampling")
168
+ elif needs_tools and not has_tools_capability:
169
+ raise ValueError(
170
+ "Client does not support sampling with tools. "
171
+ "The client must advertise the sampling.tools capability."
172
+ )
173
+
174
+ return False
175
+
176
+
177
+ async def call_sampling_handler(
178
+ context: Context,
179
+ messages: list[SamplingMessage],
180
+ *,
181
+ system_prompt: str | None,
182
+ temperature: float | None,
183
+ max_tokens: int,
184
+ model_preferences: ModelPreferences | str | list[str] | None,
185
+ sdk_tools: list[SDKTool] | None,
186
+ tool_choice: ToolChoice | None,
187
+ ) -> CreateMessageResult | CreateMessageResultWithTools:
188
+ """Make LLM call using the fallback handler.
189
+
190
+ Note: This function expects the caller (sample_step) to have validated that
191
+ sampling_handler is set via determine_handler_mode(). The checks below are
192
+ safeguards against internal misuse.
193
+ """
194
+ if context.fastmcp.sampling_handler is None:
195
+ raise RuntimeError("sampling_handler is None")
196
+ if context.request_context is None:
197
+ raise RuntimeError("request_context is None")
198
+
199
+ result = context.fastmcp.sampling_handler(
200
+ messages,
201
+ SamplingParams(
202
+ systemPrompt=system_prompt,
203
+ messages=messages,
204
+ temperature=temperature,
205
+ maxTokens=max_tokens,
206
+ modelPreferences=_parse_model_preferences(model_preferences),
207
+ tools=sdk_tools,
208
+ toolChoice=tool_choice,
209
+ ),
210
+ context.request_context,
211
+ )
212
+
213
+ if inspect.isawaitable(result):
214
+ result = await result
215
+
216
+ # Convert string to CreateMessageResult
217
+ if isinstance(result, str):
218
+ return CreateMessageResult(
219
+ role="assistant",
220
+ content=TextContent(type="text", text=result),
221
+ model="unknown",
222
+ stopReason="endTurn",
223
+ )
224
+
225
+ return result
226
+
227
+
228
+ async def execute_tools(
229
+ tool_calls: list[ToolUseContent],
230
+ tool_map: dict[str, SamplingTool],
231
+ mask_error_details: bool = False,
232
+ ) -> list[ToolResultContent]:
233
+ """Execute tool calls and return results.
234
+
235
+ Args:
236
+ tool_calls: List of tool use requests from the LLM.
237
+ tool_map: Mapping from tool name to SamplingTool.
238
+ mask_error_details: If True, mask detailed error messages from tool execution.
239
+ When masked, only generic error messages are returned to the LLM.
240
+ Tools can explicitly raise ToolError to bypass masking when they want
241
+ to provide specific error messages to the LLM.
242
+
243
+ Returns:
244
+ List of tool result content blocks.
245
+ """
246
+ tool_results: list[ToolResultContent] = []
247
+
248
+ for tool_use in tool_calls:
249
+ tool = tool_map.get(tool_use.name)
250
+ if tool is None:
251
+ tool_results.append(
252
+ ToolResultContent(
253
+ type="tool_result",
254
+ toolUseId=tool_use.id,
255
+ content=[
256
+ TextContent(
257
+ type="text",
258
+ text=f"Error: Unknown tool '{tool_use.name}'",
259
+ )
260
+ ],
261
+ isError=True,
262
+ )
263
+ )
264
+ else:
265
+ try:
266
+ result_value = await tool.run(tool_use.input)
267
+ tool_results.append(
268
+ ToolResultContent(
269
+ type="tool_result",
270
+ toolUseId=tool_use.id,
271
+ content=[TextContent(type="text", text=str(result_value))],
272
+ )
273
+ )
274
+ except ToolError as e:
275
+ # ToolError is the escape hatch - always pass message through
276
+ logger.exception(f"Error calling sampling tool '{tool_use.name}'")
277
+ tool_results.append(
278
+ ToolResultContent(
279
+ type="tool_result",
280
+ toolUseId=tool_use.id,
281
+ content=[TextContent(type="text", text=str(e))],
282
+ isError=True,
283
+ )
284
+ )
285
+ except Exception as e:
286
+ # Generic exceptions - mask based on setting
287
+ logger.exception(f"Error calling sampling tool '{tool_use.name}'")
288
+ if mask_error_details:
289
+ error_text = f"Error executing tool '{tool_use.name}'"
290
+ else:
291
+ error_text = f"Error executing tool '{tool_use.name}': {e}"
292
+ tool_results.append(
293
+ ToolResultContent(
294
+ type="tool_result",
295
+ toolUseId=tool_use.id,
296
+ content=[TextContent(type="text", text=error_text)],
297
+ isError=True,
298
+ )
299
+ )
300
+
301
+ return tool_results
@@ -0,0 +1,108 @@
1
+ """SamplingTool for use during LLM sampling requests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ from mcp.types import Tool as SDKTool
10
+ from pydantic import BaseModel, ConfigDict
11
+
12
+ from fastmcp.tools.tool import ParsedFunction
13
+
14
+
15
+ class SamplingTool(BaseModel):
16
+ """A tool that can be used during LLM sampling.
17
+
18
+ SamplingTools bundle a tool's schema (name, description, parameters) with
19
+ an executor function, enabling servers to execute agentic workflows where
20
+ the LLM can request tool calls during sampling.
21
+
22
+ In most cases, pass functions directly to ctx.sample():
23
+
24
+ def search(query: str) -> str:
25
+ '''Search the web.'''
26
+ return web_search(query)
27
+
28
+ result = await context.sample(
29
+ messages="Find info about Python",
30
+ tools=[search], # Plain functions work directly
31
+ )
32
+
33
+ Create a SamplingTool explicitly when you need custom name/description:
34
+
35
+ tool = SamplingTool.from_function(search, name="web_search")
36
+ """
37
+
38
+ name: str
39
+ description: str | None = None
40
+ parameters: dict[str, Any]
41
+ fn: Callable[..., Any]
42
+
43
+ model_config = ConfigDict(arbitrary_types_allowed=True)
44
+
45
+ async def run(self, arguments: dict[str, Any] | None = None) -> Any:
46
+ """Execute the tool with the given arguments.
47
+
48
+ Args:
49
+ arguments: Dictionary of arguments to pass to the tool function.
50
+
51
+ Returns:
52
+ The result of executing the tool function.
53
+ """
54
+ if arguments is None:
55
+ arguments = {}
56
+
57
+ result = self.fn(**arguments)
58
+ if inspect.isawaitable(result):
59
+ result = await result
60
+ return result
61
+
62
+ def _to_sdk_tool(self) -> SDKTool:
63
+ """Convert to an mcp.types.Tool for SDK compatibility.
64
+
65
+ This is used internally when passing tools to the MCP SDK's
66
+ create_message() method.
67
+ """
68
+ return SDKTool(
69
+ name=self.name,
70
+ description=self.description,
71
+ inputSchema=self.parameters,
72
+ )
73
+
74
+ @classmethod
75
+ def from_function(
76
+ cls,
77
+ fn: Callable[..., Any],
78
+ *,
79
+ name: str | None = None,
80
+ description: str | None = None,
81
+ ) -> SamplingTool:
82
+ """Create a SamplingTool from a function.
83
+
84
+ The function's signature is analyzed to generate a JSON schema for
85
+ the tool's parameters. Type hints are used to determine parameter types.
86
+
87
+ Args:
88
+ fn: The function to create a tool from.
89
+ name: Optional name override. Defaults to the function's name.
90
+ description: Optional description override. Defaults to the function's docstring.
91
+
92
+ Returns:
93
+ A SamplingTool wrapping the function.
94
+
95
+ Raises:
96
+ ValueError: If the function is a lambda without a name override.
97
+ """
98
+ parsed = ParsedFunction.from_function(fn, validate=True)
99
+
100
+ if name is None and parsed.name == "<lambda>":
101
+ raise ValueError("You must provide a name for lambda functions")
102
+
103
+ return cls(
104
+ name=name or parsed.name,
105
+ description=description or parsed.description,
106
+ parameters=parsed.input_schema,
107
+ fn=parsed.fn,
108
+ )