Jeremiah Lowin commited on
Commit
7248aad
·
unverified ·
2 Parent(s): 85655660875611

Merge pull request #316 from jlowin/context-kwarg

Browse files
src/fastmcp/prompts/prompt.py CHANGED
@@ -12,9 +12,11 @@ from mcp.types import Prompt as MCPPrompt
12
  from mcp.types import PromptArgument as MCPPromptArgument
13
  from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
14
 
 
15
  from fastmcp.utilities.types import (
16
  _convert_set_defaults,
17
- is_class_member_of_type,
 
18
  )
19
 
20
  if TYPE_CHECKING:
@@ -110,34 +112,24 @@ class Prompt(BaseModel):
110
  if func_name == "<lambda>":
111
  raise ValueError("You must provide a name for lambda functions")
112
 
 
 
 
113
  # Auto-detect context parameter if not provided
114
  if context_kwarg is None:
115
- if inspect.ismethod(fn) and hasattr(fn, "__func__"):
116
- sig = inspect.signature(fn.__func__)
117
- else:
118
- sig = inspect.signature(fn)
119
- for param_name, param in sig.parameters.items():
120
- if is_class_member_of_type(param.annotation, Context):
121
- context_kwarg = param_name
122
- break
123
-
124
- # Get schema from TypeAdapter - will fail if function isn't properly typed
125
- parameters = TypeAdapter(fn).json_schema()
126
 
127
  # Convert parameters to PromptArguments
128
  arguments: list[PromptArgument] = []
129
  if "properties" in parameters:
130
  for param_name, param in parameters["properties"].items():
131
- # Skip context parameter
132
- if param_name == context_kwarg:
133
- continue
134
-
135
- required = param_name in parameters.get("required", [])
136
  arguments.append(
137
  PromptArgument(
138
  name=param_name,
139
  description=param.get("description"),
140
- required=required,
141
  )
142
  )
143
 
 
12
  from mcp.types import PromptArgument as MCPPromptArgument
13
  from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
14
 
15
+ from fastmcp.utilities.json_schema import prune_params
16
  from fastmcp.utilities.types import (
17
  _convert_set_defaults,
18
+ find_kwarg_by_type,
19
+ get_cached_typeadapter,
20
  )
21
 
22
  if TYPE_CHECKING:
 
112
  if func_name == "<lambda>":
113
  raise ValueError("You must provide a name for lambda functions")
114
 
115
+ type_adapter = get_cached_typeadapter(fn)
116
+ parameters = type_adapter.json_schema()
117
+
118
  # Auto-detect context parameter if not provided
119
  if context_kwarg is None:
120
+ context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
121
+ if context_kwarg:
122
+ parameters = prune_params(parameters, params=[context_kwarg])
 
 
 
 
 
 
 
 
123
 
124
  # Convert parameters to PromptArguments
125
  arguments: list[PromptArgument] = []
126
  if "properties" in parameters:
127
  for param_name, param in parameters["properties"].items():
 
 
 
 
 
128
  arguments.append(
129
  PromptArgument(
130
  name=param_name,
131
  description=param.get("description"),
132
+ required=param_name in parameters.get("required", []),
133
  )
134
  )
135
 
src/fastmcp/resources/template.py CHANGED
@@ -22,7 +22,7 @@ from pydantic import (
22
  from fastmcp.resources.types import FunctionResource, Resource
23
  from fastmcp.utilities.types import (
24
  _convert_set_defaults,
25
- is_class_member_of_type,
26
  )
27
 
28
  if TYPE_CHECKING:
@@ -111,14 +111,7 @@ class ResourceTemplate(BaseModel):
111
 
112
  # Auto-detect context parameter if not provided
113
  if context_kwarg is None:
114
- if inspect.ismethod(fn) and hasattr(fn, "__func__"):
115
- sig = inspect.signature(fn.__func__)
116
- else:
117
- sig = inspect.signature(fn)
118
- for param_name, param in sig.parameters.items():
119
- if is_class_member_of_type(param.annotation, Context):
120
- context_kwarg = param_name
121
- break
122
 
123
  # Validate that URI params match function params
124
  uri_params = set(re.findall(r"{(\w+)(?:\*)?}", uri_template))
 
22
  from fastmcp.resources.types import FunctionResource, Resource
23
  from fastmcp.utilities.types import (
24
  _convert_set_defaults,
25
+ find_kwarg_by_type,
26
  )
27
 
28
  if TYPE_CHECKING:
 
111
 
112
  # Auto-detect context parameter if not provided
113
  if context_kwarg is None:
114
+ context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
 
 
 
 
 
 
 
115
 
116
  # Validate that URI params match function params
117
  uri_params = set(re.findall(r"{(\w+)(?:\*)?}", uri_template))
src/fastmcp/tools/tool.py CHANGED
@@ -16,8 +16,8 @@ from fastmcp.utilities.logging import get_logger
16
  from fastmcp.utilities.types import (
17
  Image,
18
  _convert_set_defaults,
 
19
  get_cached_typeadapter,
20
- is_class_member_of_type,
21
  )
22
 
23
  if TYPE_CHECKING:
@@ -74,18 +74,11 @@ class Tool(BaseModel):
74
 
75
  func_doc = description or fn.__doc__ or ""
76
 
77
- if inspect.ismethod(fn) and hasattr(fn, "__func__"):
78
- sig = inspect.signature(fn.__func__)
79
- else:
80
- sig = inspect.signature(fn)
81
- if context_kwarg is None:
82
- for param_name, param in sig.parameters.items():
83
- if is_class_member_of_type(param.annotation, Context):
84
- context_kwarg = param_name
85
- break
86
-
87
  type_adapter = get_cached_typeadapter(fn)
88
  schema = type_adapter.json_schema()
 
 
 
89
  if context_kwarg:
90
  schema = prune_params(schema, params=[context_kwarg])
91
 
 
16
  from fastmcp.utilities.types import (
17
  Image,
18
  _convert_set_defaults,
19
+ find_kwarg_by_type,
20
  get_cached_typeadapter,
 
21
  )
22
 
23
  if TYPE_CHECKING:
 
74
 
75
  func_doc = description or fn.__doc__ or ""
76
 
 
 
 
 
 
 
 
 
 
 
77
  type_adapter = get_cached_typeadapter(fn)
78
  schema = type_adapter.json_schema()
79
+
80
+ if context_kwarg is None:
81
+ context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
82
  if context_kwarg:
83
  schema = prune_params(schema, params=[context_kwarg])
84
 
src/fastmcp/utilities/types.py CHANGED
@@ -1,6 +1,8 @@
1
  """Common types used across FastMCP."""
2
 
3
  import base64
 
 
4
  from functools import lru_cache
5
  from pathlib import Path
6
  from types import UnionType
@@ -34,7 +36,12 @@ def issubclass_safe(cls: type, base: type) -> bool:
34
 
35
 
36
  def is_class_member_of_type(cls: type, base: type) -> bool:
37
- """Check if cls is a member of base, even if cls is a type variable."""
 
 
 
 
 
38
  origin = get_origin(cls)
39
  # Handle both types of unions: UnionType (from types module, used with | syntax)
40
  # and typing.Union (used with Union[] syntax)
@@ -50,6 +57,23 @@ def is_class_member_of_type(cls: type, base: type) -> bool:
50
  return issubclass_safe(cls, base)
51
 
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  def _convert_set_defaults(maybe_set: set[T] | list[T] | None) -> set[T]:
54
  """Convert a set or list to a set, defaulting to an empty set if None."""
55
  if maybe_set is None:
 
1
  """Common types used across FastMCP."""
2
 
3
  import base64
4
+ import inspect
5
+ from collections.abc import Callable
6
  from functools import lru_cache
7
  from pathlib import Path
8
  from types import UnionType
 
36
 
37
 
38
  def is_class_member_of_type(cls: type, base: type) -> bool:
39
+ """
40
+ Check if cls is a member of base, even if cls is a type variable.
41
+
42
+ Base can be a type, a UnionType, or an Annotated type. Generic types are not
43
+ considered members (e.g. T is not a member of list[T]).
44
+ """
45
  origin = get_origin(cls)
46
  # Handle both types of unions: UnionType (from types module, used with | syntax)
47
  # and typing.Union (used with Union[] syntax)
 
57
  return issubclass_safe(cls, base)
58
 
59
 
60
+ def find_kwarg_by_type(fn: Callable, kwarg_type: type) -> str | None:
61
+ """
62
+ Find the name of the kwarg that is of type kwarg_type.
63
+
64
+ Includes union types that contain the kwarg_type, as well as Annotated types.
65
+ """
66
+ if inspect.ismethod(fn) and hasattr(fn, "__func__"):
67
+ sig = inspect.signature(fn.__func__)
68
+ else:
69
+ sig = inspect.signature(fn)
70
+
71
+ for name, param in sig.parameters.items():
72
+ if is_class_member_of_type(param.annotation, kwarg_type):
73
+ return name
74
+ return None
75
+
76
+
77
  def _convert_set_defaults(maybe_set: set[T] | list[T] | None) -> set[T]:
78
  """Convert a set or list to a set, defaulting to an empty set if None."""
79
  if maybe_set is None:
tests/utilities/test_types.py CHANGED
@@ -2,7 +2,12 @@ from typing import Annotated, Any
2
 
3
  import pytest
4
 
5
- from fastmcp.utilities.types import Image, is_class_member_of_type, issubclass_safe
 
 
 
 
 
6
 
7
 
8
  class BaseClass:
@@ -143,3 +148,119 @@ class TestImage:
143
  ValueError, match="Only one of path or data can be provided"
144
  ):
145
  Image(path="test.png", data=b"test")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  import pytest
4
 
5
+ from fastmcp.utilities.types import (
6
+ Image,
7
+ find_kwarg_by_type,
8
+ is_class_member_of_type,
9
+ issubclass_safe,
10
+ )
11
 
12
 
13
  class BaseClass:
 
148
  ValueError, match="Only one of path or data can be provided"
149
  ):
150
  Image(path="test.png", data=b"test")
151
+
152
+
153
+ class TestFindKwargByType:
154
+ def test_exact_type_match(self):
155
+ """Test finding parameter with exact type match."""
156
+
157
+ def func(a: int, b: str, c: BaseClass):
158
+ pass
159
+
160
+ assert find_kwarg_by_type(func, BaseClass) == "c"
161
+
162
+ def test_no_matching_parameter(self):
163
+ """Test finding parameter when no match exists."""
164
+
165
+ def func(a: int, b: str, c: OtherClass):
166
+ pass
167
+
168
+ assert find_kwarg_by_type(func, BaseClass) is None
169
+
170
+ def test_parameter_with_no_annotation(self):
171
+ """Test with a parameter that has no type annotation."""
172
+
173
+ def func(a: int, b, c: BaseClass):
174
+ pass
175
+
176
+ assert find_kwarg_by_type(func, BaseClass) == "c"
177
+
178
+ def test_union_type_match_pipe_syntax(self):
179
+ """Test finding parameter with union type using pipe syntax."""
180
+
181
+ def func(a: int, b: str | BaseClass, c: str):
182
+ pass
183
+
184
+ assert find_kwarg_by_type(func, BaseClass) == "b"
185
+
186
+ def test_union_type_match_typing_union(self):
187
+ """Test finding parameter with union type using Union."""
188
+
189
+ def func(a: int, b: str | BaseClass, c: str):
190
+ pass
191
+
192
+ assert find_kwarg_by_type(func, BaseClass) == "b"
193
+
194
+ def test_annotated_type_match(self):
195
+ """Test finding parameter with Annotated type."""
196
+
197
+ def func(a: int, b: Annotated[BaseClass, "metadata"], c: str):
198
+ pass
199
+
200
+ assert find_kwarg_by_type(func, BaseClass) == "b"
201
+
202
+ def test_method_parameter(self):
203
+ """Test finding parameter in a class method."""
204
+
205
+ class TestClass:
206
+ def method(self, a: int, b: BaseClass):
207
+ pass
208
+
209
+ instance = TestClass()
210
+ assert find_kwarg_by_type(instance.method, BaseClass) == "b"
211
+
212
+ def test_static_method_parameter(self):
213
+ """Test finding parameter in a static method."""
214
+
215
+ class TestClass:
216
+ @staticmethod
217
+ def static_method(a: int, b: BaseClass, c: str):
218
+ pass
219
+
220
+ assert find_kwarg_by_type(TestClass.static_method, BaseClass) == "b"
221
+
222
+ def test_class_method_parameter(self):
223
+ """Test finding parameter in a class method."""
224
+
225
+ class TestClass:
226
+ @classmethod
227
+ def class_method(cls, a: int, b: BaseClass, c: str):
228
+ pass
229
+
230
+ assert find_kwarg_by_type(TestClass.class_method, BaseClass) == "b"
231
+
232
+ def test_multiple_matching_parameters(self):
233
+ """Test finding first parameter when multiple matches exist."""
234
+
235
+ def func(a: BaseClass, b: str, c: BaseClass):
236
+ pass
237
+
238
+ # Should return the first match
239
+ assert find_kwarg_by_type(func, BaseClass) == "a"
240
+
241
+ def test_subclass_match(self):
242
+ """Test finding parameter with a subclass of the target type."""
243
+
244
+ def func(a: int, b: ChildClass, c: str):
245
+ pass
246
+
247
+ assert find_kwarg_by_type(func, BaseClass) == "b"
248
+
249
+ def test_nonstandard_annotation(self):
250
+ """Test finding parameter with a nonstandard annotation like an
251
+ instance. This is irregular."""
252
+
253
+ SENTINEL = object()
254
+
255
+ def func(a: int, b: SENTINEL, c: str): # type: ignore
256
+ pass
257
+
258
+ assert find_kwarg_by_type(func, SENTINEL) is None # type: ignore
259
+
260
+ def test_missing_type_annotation(self):
261
+ """Test finding parameter with a missing type annotation."""
262
+
263
+ def func(a: int, b, c: str):
264
+ pass
265
+
266
+ assert find_kwarg_by_type(func, str) == "c"