fastmcp 2.8.1__py3-none-any.whl → 2.9.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. fastmcp/cli/cli.py +99 -1
  2. fastmcp/cli/run.py +1 -3
  3. fastmcp/client/auth/oauth.py +1 -2
  4. fastmcp/client/client.py +23 -7
  5. fastmcp/client/logging.py +1 -2
  6. fastmcp/client/messages.py +126 -0
  7. fastmcp/client/transports.py +17 -2
  8. fastmcp/contrib/mcp_mixin/README.md +79 -2
  9. fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -0
  10. fastmcp/prompts/prompt.py +109 -13
  11. fastmcp/prompts/prompt_manager.py +119 -43
  12. fastmcp/resources/resource.py +27 -1
  13. fastmcp/resources/resource_manager.py +249 -76
  14. fastmcp/resources/template.py +44 -2
  15. fastmcp/server/auth/providers/bearer.py +62 -13
  16. fastmcp/server/context.py +113 -10
  17. fastmcp/server/http.py +8 -0
  18. fastmcp/server/low_level.py +35 -0
  19. fastmcp/server/middleware/__init__.py +6 -0
  20. fastmcp/server/middleware/error_handling.py +206 -0
  21. fastmcp/server/middleware/logging.py +165 -0
  22. fastmcp/server/middleware/middleware.py +236 -0
  23. fastmcp/server/middleware/rate_limiting.py +231 -0
  24. fastmcp/server/middleware/timing.py +156 -0
  25. fastmcp/server/proxy.py +250 -140
  26. fastmcp/server/server.py +446 -280
  27. fastmcp/settings.py +2 -2
  28. fastmcp/tools/tool.py +22 -2
  29. fastmcp/tools/tool_manager.py +114 -45
  30. fastmcp/tools/tool_transform.py +42 -16
  31. fastmcp/utilities/components.py +22 -2
  32. fastmcp/utilities/inspect.py +326 -0
  33. fastmcp/utilities/json_schema.py +67 -23
  34. fastmcp/utilities/mcp_config.py +13 -7
  35. fastmcp/utilities/openapi.py +75 -5
  36. fastmcp/utilities/tests.py +1 -1
  37. fastmcp/utilities/types.py +90 -1
  38. {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/METADATA +2 -2
  39. fastmcp-2.9.1.dist-info/RECORD +78 -0
  40. fastmcp-2.8.1.dist-info/RECORD +0 -69
  41. {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/WHEEL +0 -0
  42. {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/entry_points.txt +0 -0
  43. {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/licenses/LICENSE +0 -0
fastmcp/prompts/prompt.py CHANGED
@@ -3,15 +3,16 @@
3
3
  from __future__ import annotations as _annotations
4
4
 
5
5
  import inspect
6
+ import json
6
7
  from abc import ABC, abstractmethod
7
8
  from collections.abc import Awaitable, Callable, Sequence
8
- from typing import TYPE_CHECKING, Any
9
+ from typing import Any
9
10
 
10
11
  import pydantic_core
11
12
  from mcp.types import Prompt as MCPPrompt
12
13
  from mcp.types import PromptArgument as MCPPromptArgument
13
14
  from mcp.types import PromptMessage, Role, TextContent
14
- from pydantic import Field, TypeAdapter, validate_call
15
+ from pydantic import Field, TypeAdapter
15
16
 
16
17
  from fastmcp.exceptions import PromptError
17
18
  from fastmcp.server.dependencies import get_context
@@ -25,10 +26,6 @@ from fastmcp.utilities.types import (
25
26
  get_cached_typeadapter,
26
27
  )
27
28
 
28
- if TYPE_CHECKING:
29
- pass
30
-
31
-
32
29
  logger = get_logger(__name__)
33
30
 
34
31
 
@@ -73,6 +70,22 @@ class Prompt(FastMCPComponent, ABC):
73
70
  default=None, description="Arguments that can be passed to the prompt"
74
71
  )
75
72
 
73
+ def enable(self) -> None:
74
+ super().enable()
75
+ try:
76
+ context = get_context()
77
+ context._queue_prompt_list_changed() # type: ignore[private-use]
78
+ except RuntimeError:
79
+ pass # No context available
80
+
81
+ def disable(self) -> None:
82
+ super().disable()
83
+ try:
84
+ context = get_context()
85
+ context._queue_prompt_list_changed() # type: ignore[private-use]
86
+ except RuntimeError:
87
+ pass # No context available
88
+
76
89
  def to_mcp_prompt(self, **overrides: Any) -> MCPPrompt:
77
90
  """Convert the prompt to an MCP prompt."""
78
91
  arguments = [
@@ -155,7 +168,7 @@ class FunctionPrompt(Prompt):
155
168
  if param.kind == inspect.Parameter.VAR_KEYWORD:
156
169
  raise ValueError("Functions with **kwargs are not supported as prompts")
157
170
 
158
- description = description or fn.__doc__
171
+ description = description or inspect.getdoc(fn)
159
172
 
160
173
  # if the fn is a callable class, we need to get the __call__ method from here out
161
174
  if not inspect.isroutine(fn):
@@ -181,17 +194,43 @@ class FunctionPrompt(Prompt):
181
194
  arguments: list[PromptArgument] = []
182
195
  if "properties" in parameters:
183
196
  for param_name, param in parameters["properties"].items():
197
+ arg_description = param.get("description")
198
+
199
+ # For non-string parameters, append JSON schema info to help users
200
+ # understand the expected format when passing as strings (MCP requirement)
201
+ if param_name in sig.parameters:
202
+ sig_param = sig.parameters[param_name]
203
+ if (
204
+ sig_param.annotation != inspect.Parameter.empty
205
+ and sig_param.annotation is not str
206
+ and param_name != context_kwarg
207
+ ):
208
+ # Get the JSON schema for this specific parameter type
209
+ try:
210
+ param_adapter = get_cached_typeadapter(sig_param.annotation)
211
+ param_schema = param_adapter.json_schema()
212
+
213
+ # Create compact schema representation
214
+ schema_str = json.dumps(param_schema, separators=(",", ":"))
215
+
216
+ # Append schema info to description
217
+ schema_note = f"Provide as a JSON string matching the following schema: {schema_str}"
218
+ if arg_description:
219
+ arg_description = f"{arg_description}\n\n{schema_note}"
220
+ else:
221
+ arg_description = schema_note
222
+ except Exception:
223
+ # If schema generation fails, skip enhancement
224
+ pass
225
+
184
226
  arguments.append(
185
227
  PromptArgument(
186
228
  name=param_name,
187
- description=param.get("description"),
229
+ description=arg_description,
188
230
  required=param_name in parameters.get("required", []),
189
231
  )
190
232
  )
191
233
 
192
- # ensure the arguments are properly cast
193
- fn = validate_call(fn)
194
-
195
234
  return cls(
196
235
  name=func_name,
197
236
  description=description,
@@ -201,6 +240,60 @@ class FunctionPrompt(Prompt):
201
240
  fn=fn,
202
241
  )
203
242
 
243
+ def _convert_string_arguments(self, kwargs: dict[str, Any]) -> dict[str, Any]:
244
+ """Convert string arguments to expected types based on function signature."""
245
+ from fastmcp.server.context import Context
246
+
247
+ sig = inspect.signature(self.fn)
248
+ converted_kwargs = {}
249
+
250
+ # Find context parameter name if any
251
+ context_param_name = find_kwarg_by_type(self.fn, kwarg_type=Context)
252
+
253
+ for param_name, param_value in kwargs.items():
254
+ if param_name in sig.parameters:
255
+ param = sig.parameters[param_name]
256
+
257
+ # Skip Context parameters - they're handled separately
258
+ if param_name == context_param_name:
259
+ converted_kwargs[param_name] = param_value
260
+ continue
261
+
262
+ # If parameter has no annotation or annotation is str, pass as-is
263
+ if (
264
+ param.annotation == inspect.Parameter.empty
265
+ or param.annotation is str
266
+ ):
267
+ converted_kwargs[param_name] = param_value
268
+ # If argument is not a string, pass as-is (already properly typed)
269
+ elif not isinstance(param_value, str):
270
+ converted_kwargs[param_name] = param_value
271
+ else:
272
+ # Try to convert string argument using type adapter
273
+ try:
274
+ adapter = get_cached_typeadapter(param.annotation)
275
+ # Try JSON parsing first for complex types
276
+ try:
277
+ converted_kwargs[param_name] = adapter.validate_json(
278
+ param_value
279
+ )
280
+ except (ValueError, TypeError, pydantic_core.ValidationError):
281
+ # Fallback to direct validation
282
+ converted_kwargs[param_name] = adapter.validate_python(
283
+ param_value
284
+ )
285
+ except (ValueError, TypeError, pydantic_core.ValidationError) as e:
286
+ # If conversion fails, provide informative error
287
+ raise PromptError(
288
+ f"Could not convert argument '{param_name}' with value '{param_value}' "
289
+ f"to expected type {param.annotation}. Error: {e}"
290
+ )
291
+ else:
292
+ # Parameter not in function signature, pass as-is
293
+ converted_kwargs[param_name] = param_value
294
+
295
+ return converted_kwargs
296
+
204
297
  async def render(
205
298
  self,
206
299
  arguments: dict[str, Any] | None = None,
@@ -223,6 +316,9 @@ class FunctionPrompt(Prompt):
223
316
  if context_kwarg and context_kwarg not in kwargs:
224
317
  kwargs[context_kwarg] = get_context()
225
318
 
319
+ # Convert string arguments to expected types when needed
320
+ kwargs = self._convert_string_arguments(kwargs)
321
+
226
322
  # Call function and check if result is a coroutine
227
323
  result = self.fn(**kwargs)
228
324
  if inspect.iscoroutine(result):
@@ -259,6 +355,6 @@ class FunctionPrompt(Prompt):
259
355
  raise PromptError("Could not convert prompt result to message.")
260
356
 
261
357
  return messages
262
- except Exception as e:
263
- logger.exception(f"Error rendering prompt {self.name}: {e}")
358
+ except Exception:
359
+ logger.exception(f"Error rendering prompt {self.name}")
264
360
  raise PromptError(f"Error rendering prompt {self.name}.")
@@ -13,7 +13,7 @@ from fastmcp.settings import DuplicateBehavior
13
13
  from fastmcp.utilities.logging import get_logger
14
14
 
15
15
  if TYPE_CHECKING:
16
- pass
16
+ from fastmcp.server.server import MountedServer
17
17
 
18
18
  logger = get_logger(__name__)
19
19
 
@@ -27,6 +27,7 @@ class PromptManager:
27
27
  mask_error_details: bool | None = None,
28
28
  ):
29
29
  self._prompts: dict[str, Prompt] = {}
30
+ self._mounted_servers: list[MountedServer] = []
30
31
  self.mask_error_details = mask_error_details or settings.mask_error_details
31
32
 
32
33
  # Default to "warn" if None is provided
@@ -41,15 +42,74 @@ class PromptManager:
41
42
 
42
43
  self.duplicate_behavior = duplicate_behavior
43
44
 
44
- def get_prompt(self, key: str) -> Prompt:
45
+ def mount(self, server: MountedServer) -> None:
46
+ """Adds a mounted server as a source for prompts."""
47
+ self._mounted_servers.append(server)
48
+
49
+ async def _load_prompts(self, *, via_server: bool = False) -> dict[str, Prompt]:
50
+ """
51
+ The single, consolidated recursive method for fetching prompts. The 'via_server'
52
+ parameter determines the communication path.
53
+
54
+ - via_server=False: Manager-to-manager path for complete, unfiltered inventory
55
+ - via_server=True: Server-to-server path for filtered MCP requests
56
+ """
57
+ all_prompts: dict[str, Prompt] = {}
58
+
59
+ for mounted in self._mounted_servers:
60
+ try:
61
+ if via_server:
62
+ # Use the server-to-server filtered path
63
+ child_results = await mounted.server._list_prompts()
64
+ else:
65
+ # Use the manager-to-manager unfiltered path
66
+ child_results = await mounted.server._prompt_manager.list_prompts()
67
+
68
+ # The combination logic is the same for both paths
69
+ child_dict = {p.key: p for p in child_results}
70
+ if mounted.prefix:
71
+ for prompt in child_dict.values():
72
+ prefixed_prompt = prompt.with_key(
73
+ f"{mounted.prefix}_{prompt.key}"
74
+ )
75
+ all_prompts[prefixed_prompt.key] = prefixed_prompt
76
+ else:
77
+ all_prompts.update(child_dict)
78
+ except Exception as e:
79
+ # Skip failed mounts silently, matches existing behavior
80
+ logger.warning(
81
+ f"Failed to get prompts from mounted server '{mounted.prefix}': {e}"
82
+ )
83
+ continue
84
+
85
+ # Finally, add local prompts, which always take precedence
86
+ all_prompts.update(self._prompts)
87
+ return all_prompts
88
+
89
+ async def has_prompt(self, key: str) -> bool:
90
+ """Check if a prompt exists."""
91
+ prompts = await self.get_prompts()
92
+ return key in prompts
93
+
94
+ async def get_prompt(self, key: str) -> Prompt:
45
95
  """Get prompt by key."""
46
- if key in self._prompts:
47
- return self._prompts[key]
96
+ prompts = await self.get_prompts()
97
+ if key in prompts:
98
+ return prompts[key]
48
99
  raise NotFoundError(f"Unknown prompt: {key}")
49
100
 
50
- def get_prompts(self) -> dict[str, Prompt]:
51
- """Get all registered prompts, indexed by registered key."""
52
- return self._prompts
101
+ async def get_prompts(self) -> dict[str, Prompt]:
102
+ """
103
+ Gets the complete, unfiltered inventory of all prompts.
104
+ """
105
+ return await self._load_prompts(via_server=False)
106
+
107
+ async def list_prompts(self) -> list[Prompt]:
108
+ """
109
+ Lists all prompts, applying protocol filtering.
110
+ """
111
+ prompts_dict = await self._load_prompts(via_server=True)
112
+ return list(prompts_dict.values())
53
113
 
54
114
  def add_prompt_from_fn(
55
115
  self,
@@ -71,24 +131,22 @@ class PromptManager:
71
131
  )
72
132
  return self.add_prompt(prompt) # type: ignore
73
133
 
74
- def add_prompt(self, prompt: Prompt, key: str | None = None) -> Prompt:
134
+ def add_prompt(self, prompt: Prompt) -> Prompt:
75
135
  """Add a prompt to the manager."""
76
- key = key or prompt.name
77
-
78
136
  # Check for duplicates
79
- existing = self._prompts.get(key)
137
+ existing = self._prompts.get(prompt.key)
80
138
  if existing:
81
139
  if self.duplicate_behavior == "warn":
82
- logger.warning(f"Prompt already exists: {key}")
83
- self._prompts[key] = prompt
140
+ logger.warning(f"Prompt already exists: {prompt.key}")
141
+ self._prompts[prompt.key] = prompt
84
142
  elif self.duplicate_behavior == "replace":
85
- self._prompts[key] = prompt
143
+ self._prompts[prompt.key] = prompt
86
144
  elif self.duplicate_behavior == "error":
87
- raise ValueError(f"Prompt already exists: {key}")
145
+ raise ValueError(f"Prompt already exists: {prompt.key}")
88
146
  elif self.duplicate_behavior == "ignore":
89
147
  return existing
90
148
  else:
91
- self._prompts[key] = prompt
149
+ self._prompts[prompt.key] = prompt
92
150
  return prompt
93
151
 
94
152
  async def render_prompt(
@@ -96,30 +154,48 @@ class PromptManager:
96
154
  name: str,
97
155
  arguments: dict[str, Any] | None = None,
98
156
  ) -> GetPromptResult:
99
- """Render a prompt by name with arguments."""
100
- prompt = self.get_prompt(name)
101
- if not prompt:
102
- raise NotFoundError(f"Unknown prompt: {name}")
103
-
104
- try:
105
- messages = await prompt.render(arguments)
106
- return GetPromptResult(description=prompt.description, messages=messages)
107
-
108
- # Pass through PromptErrors as-is
109
- except PromptError as e:
110
- logger.exception(f"Error rendering prompt {name!r}: {e}")
111
- raise e
112
-
113
- # Handle other exceptions
114
- except Exception as e:
115
- logger.exception(f"Error rendering prompt {name!r}: {e}")
116
- if self.mask_error_details:
117
- # Mask internal details
118
- raise PromptError(f"Error rendering prompt {name!r}")
119
- else:
120
- # Include original error details
121
- raise PromptError(f"Error rendering prompt {name!r}: {e}")
122
-
123
- def has_prompt(self, key: str) -> bool:
124
- """Check if a prompt exists."""
125
- return key in self._prompts
157
+ """
158
+ Internal API for servers: Finds and renders a prompt, respecting the
159
+ filtered protocol path.
160
+ """
161
+ # 1. Check local prompts first. The server will have already applied its filter.
162
+ if name in self._prompts:
163
+ prompt = await self.get_prompt(name)
164
+ if not prompt:
165
+ raise NotFoundError(f"Unknown prompt: {name}")
166
+
167
+ try:
168
+ messages = await prompt.render(arguments)
169
+ return GetPromptResult(
170
+ description=prompt.description, messages=messages
171
+ )
172
+
173
+ # Pass through PromptErrors as-is
174
+ except PromptError as e:
175
+ logger.exception(f"Error rendering prompt {name!r}")
176
+ raise e
177
+
178
+ # Handle other exceptions
179
+ except Exception as e:
180
+ logger.exception(f"Error rendering prompt {name!r}")
181
+ if self.mask_error_details:
182
+ # Mask internal details
183
+ raise PromptError(f"Error rendering prompt {name!r}") from e
184
+ else:
185
+ # Include original error details
186
+ raise PromptError(f"Error rendering prompt {name!r}: {e}") from e
187
+
188
+ # 2. Check mounted servers using the filtered protocol path.
189
+ for mounted in reversed(self._mounted_servers):
190
+ prompt_key = name
191
+ if mounted.prefix:
192
+ if name.startswith(f"{mounted.prefix}_"):
193
+ prompt_key = name.removeprefix(f"{mounted.prefix}_")
194
+ else:
195
+ continue
196
+ try:
197
+ return await mounted.server._get_prompt(prompt_key, arguments)
198
+ except NotFoundError:
199
+ continue
200
+
201
+ raise NotFoundError(f"Unknown prompt: {name}")
@@ -44,6 +44,22 @@ class Resource(FastMCPComponent, abc.ABC):
44
44
  pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$",
45
45
  )
46
46
 
47
+ def enable(self) -> None:
48
+ super().enable()
49
+ try:
50
+ context = get_context()
51
+ context._queue_resource_list_changed() # type: ignore[private-use]
52
+ except RuntimeError:
53
+ pass # No context available
54
+
55
+ def disable(self) -> None:
56
+ super().disable()
57
+ try:
58
+ context = get_context()
59
+ context._queue_resource_list_changed() # type: ignore[private-use]
60
+ except RuntimeError:
61
+ pass # No context available
62
+
47
63
  @staticmethod
48
64
  def from_function(
49
65
  fn: Callable[[], Any],
@@ -101,6 +117,16 @@ class Resource(FastMCPComponent, abc.ABC):
101
117
  def __repr__(self) -> str:
102
118
  return f"{self.__class__.__name__}(uri={self.uri!r}, name={self.name!r}, description={self.description!r}, tags={self.tags})"
103
119
 
120
+ @property
121
+ def key(self) -> str:
122
+ """
123
+ The key of the component. This is used for internal bookkeeping
124
+ and may reflect e.g. prefixes or other identifiers. You should not depend on
125
+ keys having a certain value, as the same tool loaded from different
126
+ hierarchies of servers may have different keys.
127
+ """
128
+ return self._key or str(self.uri)
129
+
104
130
 
105
131
  class FunctionResource(Resource):
106
132
  """A resource that defers data loading by wrapping a function.
@@ -135,7 +161,7 @@ class FunctionResource(Resource):
135
161
  fn=fn,
136
162
  uri=uri,
137
163
  name=name or fn.__name__,
138
- description=description or fn.__doc__,
164
+ description=description or inspect.getdoc(fn),
139
165
  mime_type=mime_type or "text/plain",
140
166
  tags=tags or set(),
141
167
  enabled=enabled if enabled is not None else True,