Jeremiah Lowin commited on
Commit
1868fa8
·
unverified ·
2 Parent(s): 7248aadcd8bc6a

Merge pull request #317 from jlowin/prevent-args-kwargs

Browse files

Handle *args/**kwargs appropriately for various components

docs/servers/prompts.mdx CHANGED
@@ -53,6 +53,9 @@ def generate_code_request(language: str, task_description: str) -> UserMessage:
53
  * **Inferred Metadata:** By default:
54
  * Prompt Name: Taken from the function name (`ask_about_topic`).
55
  * Prompt Description: Taken from the function's docstring.
 
 
 
56
 
57
  ### Return Values
58
 
@@ -105,6 +108,7 @@ def generate_content_request(
105
  return prompt
106
  ```
107
 
 
108
  ### Required vs. Optional Parameters
109
 
110
  Parameters in your function signature are considered **required** unless they have a default value.
 
53
  * **Inferred Metadata:** By default:
54
  * Prompt Name: Taken from the function name (`ask_about_topic`).
55
  * Prompt Description: Taken from the function's docstring.
56
+ <Tip>
57
+ Functions with `*args` or `**kwargs` are not supported as prompts. This restriction exists because FastMCP needs to generate a complete parameter schema for the MCP protocol, which isn't possible with variable argument lists.
58
+ </Tip>
59
 
60
  ### Return Values
61
 
 
108
  return prompt
109
  ```
110
 
111
+
112
  ### Required vs. Optional Parameters
113
 
114
  Parameters in your function signature are considered **required** unless they have a default value.
docs/servers/resources.mdx CHANGED
@@ -239,6 +239,10 @@ Resource templates share most configuration options with regular resources (name
239
 
240
  Resource templates generate a new resource for each unique set of parameters, which means that resources can be dynamically created on-demand. For example, if the resource template `"user://profile/{name}"` is registered, MCP clients could request `"user://profile/ford"` or `"user://profile/marvin"` to retrieve either of those two user profiles as resources, without having to register each resource individually.
241
 
 
 
 
 
242
  Here is a complete example that shows how to define two resource templates:
243
 
244
  ```python
 
239
 
240
  Resource templates generate a new resource for each unique set of parameters, which means that resources can be dynamically created on-demand. For example, if the resource template `"user://profile/{name}"` is registered, MCP clients could request `"user://profile/ford"` or `"user://profile/marvin"` to retrieve either of those two user profiles as resources, without having to register each resource individually.
241
 
242
+ <Tip>
243
+ Functions with `*args` are not supported as resource templates. However, unlike tools and prompts, resource templates do support `**kwargs` because the URI template defines specific parameter names that will be collected and passed as keyword arguments.
244
+ </Tip>
245
+
246
  Here is a complete example that shows how to define two resource templates:
247
 
248
  ```python
docs/servers/tools.mdx CHANGED
@@ -43,9 +43,12 @@ When this tool is registered, FastMCP automatically:
43
  - Generates an input schema based on the function's parameters and type annotations.
44
  - Handles parameter validation and error reporting.
45
 
46
-
47
  The way you define your Python function dictates how the tool appears and behaves for the LLM client.
48
 
 
 
 
 
49
  ### Parameters
50
 
51
  #### Annotations
@@ -90,6 +93,7 @@ def process_image(
90
  # Implementation...
91
  ```
92
 
 
93
  You can also use the Field as a default value, though the Annotated approach is preferred:
94
 
95
  ```python
 
43
  - Generates an input schema based on the function's parameters and type annotations.
44
  - Handles parameter validation and error reporting.
45
 
 
46
  The way you define your Python function dictates how the tool appears and behaves for the LLM client.
47
 
48
+ <Tip>
49
+ Functions with `*args` or `**kwargs` are not supported as tools. This restriction exists because FastMCP needs to generate a complete parameter schema for the MCP protocol, which isn't possible with variable argument lists.
50
+ </Tip>
51
+
52
  ### Parameters
53
 
54
  #### Annotations
 
93
  # Implementation...
94
  ```
95
 
96
+
97
  You can also use the Field as a default value, though the Annotated approach is preferred:
98
 
99
  ```python
src/fastmcp/prompts/prompt.py CHANGED
@@ -111,6 +111,13 @@ class Prompt(BaseModel):
111
 
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()
 
111
 
112
  if func_name == "<lambda>":
113
  raise ValueError("You must provide a name for lambda functions")
114
+ # Reject functions with *args or **kwargs
115
+ sig = inspect.signature(fn)
116
+ for param in sig.parameters.values():
117
+ if param.kind == inspect.Parameter.VAR_POSITIONAL:
118
+ raise ValueError("Functions with *args are not supported as prompts")
119
+ if param.kind == inspect.Parameter.VAR_KEYWORD:
120
+ raise ValueError("Functions with **kwargs are not supported as prompts")
121
 
122
  type_adapter = get_cached_typeadapter(fn)
123
  parameters = type_adapter.json_schema()
src/fastmcp/resources/template.py CHANGED
@@ -109,6 +109,15 @@ class ResourceTemplate(BaseModel):
109
  if func_name == "<lambda>":
110
  raise ValueError("You must provide a name for lambda functions")
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)
@@ -118,7 +127,7 @@ class ResourceTemplate(BaseModel):
118
  if not uri_params:
119
  raise ValueError("URI template must contain at least one parameter")
120
 
121
- func_params = set(inspect.signature(fn).parameters.keys())
122
  if context_kwarg:
123
  func_params.discard(context_kwarg)
124
 
@@ -126,20 +135,26 @@ class ResourceTemplate(BaseModel):
126
  required_params = {
127
  p
128
  for p in func_params
129
- if inspect.signature(fn).parameters[p].default is inspect.Parameter.empty
 
 
130
  }
131
- if context_kwarg and context_kwarg in required_params:
132
- required_params.discard(context_kwarg)
133
 
 
134
  if not required_params.issubset(uri_params):
135
  raise ValueError(
136
- f"URI parameters {uri_params} must be a subset of the required function arguments: {required_params}"
137
  )
138
 
139
- if not uri_params.issubset(func_params):
140
- raise ValueError(
141
- f"URI parameters {uri_params} must be a subset of the function arguments: {func_params}"
142
- )
 
 
 
 
 
143
 
144
  # Get schema from TypeAdapter - will fail if function isn't properly typed
145
  parameters = TypeAdapter(fn).json_schema()
 
109
  if func_name == "<lambda>":
110
  raise ValueError("You must provide a name for lambda functions")
111
 
112
+ # Reject functions with *args
113
+ # (**kwargs is allowed because the URI will define the parameter names)
114
+ sig = inspect.signature(fn)
115
+ for param in sig.parameters.values():
116
+ if param.kind == inspect.Parameter.VAR_POSITIONAL:
117
+ raise ValueError(
118
+ "Functions with *args are not supported as resource templates"
119
+ )
120
+
121
  # Auto-detect context parameter if not provided
122
  if context_kwarg is None:
123
  context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
 
127
  if not uri_params:
128
  raise ValueError("URI template must contain at least one parameter")
129
 
130
+ func_params = set(sig.parameters.keys())
131
  if context_kwarg:
132
  func_params.discard(context_kwarg)
133
 
 
135
  required_params = {
136
  p
137
  for p in func_params
138
+ if sig.parameters[p].default is inspect.Parameter.empty
139
+ and sig.parameters[p].kind != inspect.Parameter.VAR_KEYWORD
140
+ and p != context_kwarg
141
  }
 
 
142
 
143
+ # Check if required parameters are a subset of the URI parameters
144
  if not required_params.issubset(uri_params):
145
  raise ValueError(
146
+ f"Required function arguments {required_params} must be a subset of the URI parameters {uri_params}"
147
  )
148
 
149
+ # Check if the URI parameters are a subset of the function parameters (skip if **kwargs present)
150
+ if not any(
151
+ param.kind == inspect.Parameter.VAR_KEYWORD
152
+ for param in sig.parameters.values()
153
+ ):
154
+ if not uri_params.issubset(func_params):
155
+ raise ValueError(
156
+ f"URI parameters {uri_params} must be a subset of the function arguments: {func_params}"
157
+ )
158
 
159
  # Get schema from TypeAdapter - will fail if function isn't properly typed
160
  parameters = TypeAdapter(fn).json_schema()
src/fastmcp/tools/tool.py CHANGED
@@ -67,6 +67,14 @@ class Tool(BaseModel):
67
  """Create a Tool from a function."""
68
  from fastmcp import Context
69
 
 
 
 
 
 
 
 
 
70
  func_name = name or fn.__name__
71
 
72
  if func_name == "<lambda>":
 
67
  """Create a Tool from a function."""
68
  from fastmcp import Context
69
 
70
+ # Reject functions with *args or **kwargs
71
+ sig = inspect.signature(fn)
72
+ for param in sig.parameters.values():
73
+ if param.kind == inspect.Parameter.VAR_POSITIONAL:
74
+ raise ValueError("Functions with *args are not supported as tools")
75
+ if param.kind == inspect.Parameter.VAR_KEYWORD:
76
+ raise ValueError("Functions with **kwargs are not supported as tools")
77
+
78
  func_name = name or fn.__name__
79
 
80
  if func_name == "<lambda>":
tests/prompts/test_prompt_manager.py CHANGED
@@ -189,6 +189,30 @@ class TestPromptManager:
189
  with pytest.raises(ValueError, match="Missing required arguments"):
190
  await manager.render_prompt("fn")
191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
 
193
  class TestPromptTags:
194
  """Test functionality related to prompt tags."""
 
189
  with pytest.raises(ValueError, match="Missing required arguments"):
190
  await manager.render_prompt("fn")
191
 
192
+ async def test_prompt_with_varargs_not_allowed(self):
193
+ """Test that a prompt with *args is not allowed."""
194
+
195
+ def fn(*args: int) -> str:
196
+ return f"Hello, {args}!"
197
+
198
+ manager = PromptManager()
199
+ with pytest.raises(
200
+ ValueError, match=r"Functions with \*args are not supported as prompts"
201
+ ):
202
+ manager.add_prompt(Prompt.from_function(fn))
203
+
204
+ async def test_prompt_with_varkwargs_not_allowed(self):
205
+ """Test that a prompt with **kwargs is not allowed."""
206
+
207
+ def fn(**kwargs: int) -> str:
208
+ return f"Hello, {kwargs}!"
209
+
210
+ manager = PromptManager()
211
+ with pytest.raises(
212
+ ValueError, match=r"Functions with \*\*kwargs are not supported as prompts"
213
+ ):
214
+ manager.add_prompt(Prompt.from_function(fn))
215
+
216
 
217
  class TestPromptTags:
218
  """Test functionality related to prompt tags."""
tests/resources/test_resource_template.py CHANGED
@@ -104,7 +104,7 @@ class TestResourceTemplate:
104
  # This should fail - 'unknown' is not a function parameter
105
  with pytest.raises(
106
  ValueError,
107
- match="URI parameters .* must be a subset of the required function arguments",
108
  ):
109
  ResourceTemplate.from_function(
110
  fn=my_func,
@@ -132,7 +132,7 @@ class TestResourceTemplate:
132
  # This should fail - required param is not in URI
133
  with pytest.raises(
134
  ValueError,
135
- match="URI parameters .* must be a subset of the required function arguments",
136
  ):
137
  ResourceTemplate.from_function(
138
  fn=func_with_required,
@@ -157,7 +157,7 @@ class TestResourceTemplate:
157
  # This fails - missing one required param
158
  with pytest.raises(
159
  ValueError,
160
- match="URI parameters .* must be a subset of the required function arguments",
161
  ):
162
  ResourceTemplate.from_function(
163
  fn=multi_required,
@@ -360,6 +360,31 @@ class TestResourceTemplate:
360
  params = template.matches("test://src/path/to/test.py")
361
  assert params == {"prefix": "src", "path": "path/to/test.py"}
362
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
363
 
364
  class TestMatchUriTemplate:
365
  """Test match_uri_template function."""
 
104
  # This should fail - 'unknown' is not a function parameter
105
  with pytest.raises(
106
  ValueError,
107
+ match="Required function arguments .* must be a subset of the URI parameters",
108
  ):
109
  ResourceTemplate.from_function(
110
  fn=my_func,
 
132
  # This should fail - required param is not in URI
133
  with pytest.raises(
134
  ValueError,
135
+ match="Required function arguments .* must be a subset of the URI parameters",
136
  ):
137
  ResourceTemplate.from_function(
138
  fn=func_with_required,
 
157
  # This fails - missing one required param
158
  with pytest.raises(
159
  ValueError,
160
+ match="Required function arguments .* must be a subset of the URI parameters",
161
  ):
162
  ResourceTemplate.from_function(
163
  fn=multi_required,
 
360
  params = template.matches("test://src/path/to/test.py")
361
  assert params == {"prefix": "src", "path": "path/to/test.py"}
362
 
363
+ async def test_function_with_varargs_not_allowed(self):
364
+ def func(x: int, *args: int) -> int:
365
+ return x + sum(args)
366
+
367
+ with pytest.raises(
368
+ ValueError,
369
+ match=r"Functions with \*args are not supported as resource templates",
370
+ ):
371
+ ResourceTemplate.from_function(
372
+ fn=func,
373
+ uri_template="test://{x}/{args*}",
374
+ name="test",
375
+ )
376
+
377
+ async def test_function_with_varkwargs_ok(self):
378
+ def func(x: int, **kwargs: int) -> int:
379
+ return x + sum(kwargs.values())
380
+
381
+ template = ResourceTemplate.from_function(
382
+ fn=func,
383
+ uri_template="test://{x}/{y}/{z}",
384
+ name="test",
385
+ )
386
+ assert template.uri_template == "test://{x}/{y}/{z}"
387
+
388
 
389
  class TestMatchUriTemplate:
390
  """Test match_uri_template function."""
tests/server/test_server_interactions.py CHANGED
@@ -930,7 +930,7 @@ class TestResourceTemplates:
930
 
931
  with pytest.raises(
932
  ValueError,
933
- match="URI parameters .* must be a subset of the required function arguments",
934
  ):
935
 
936
  @mcp.resource("resource://{name}/data")
@@ -958,7 +958,7 @@ class TestResourceTemplates:
958
 
959
  with pytest.raises(
960
  ValueError,
961
- match="URI parameters .* must be a subset of the required function arguments",
962
  ):
963
 
964
  @mcp.resource("resource://{org}/{repo}/data")
@@ -977,6 +977,19 @@ class TestResourceTemplates:
977
  assert isinstance(result[0], TextResourceContents)
978
  assert result[0].text == "Static data"
979
 
 
 
 
 
 
 
 
 
 
 
 
 
 
980
  async def test_template_with_default_params(self):
981
  """Test that a template can have default parameters."""
982
  mcp = FastMCP()
 
930
 
931
  with pytest.raises(
932
  ValueError,
933
+ match="Required function arguments .* must be a subset of the URI parameters",
934
  ):
935
 
936
  @mcp.resource("resource://{name}/data")
 
958
 
959
  with pytest.raises(
960
  ValueError,
961
+ match="Required function arguments .* must be a subset of the URI parameters",
962
  ):
963
 
964
  @mcp.resource("resource://{org}/{repo}/data")
 
977
  assert isinstance(result[0], TextResourceContents)
978
  assert result[0].text == "Static data"
979
 
980
+ async def test_template_with_varkwargs(self):
981
+ """Test that a template can have **kwargs."""
982
+ mcp = FastMCP()
983
+
984
+ @mcp.resource("test://{x}/{y}/{z}")
985
+ def func(**kwargs: int) -> int:
986
+ return sum(kwargs.values())
987
+
988
+ async with Client(mcp) as client:
989
+ result = await client.read_resource(AnyUrl("test://1/2/3"))
990
+ assert isinstance(result[0], TextResourceContents)
991
+ assert result[0].text == "6"
992
+
993
  async def test_template_with_default_params(self):
994
  """Test that a template can have default parameters."""
995
  mcp = FastMCP()
tests/tools/test_tool.py CHANGED
@@ -63,15 +63,15 @@ class TestToolFromFunction:
63
  assert tool.parameters["properties"]["data"]["type"] == "string"
64
  assert isinstance(result[0], ImageContent)
65
 
66
- def test_add_invalid_tool(self):
67
- with pytest.raises(AttributeError):
68
  Tool.from_function(1) # type: ignore
69
 
70
- def test_add_lambda(self):
71
  tool = Tool.from_function(lambda x: x, name="my_tool")
72
  assert tool.name == "my_tool"
73
 
74
- def test_add_lambda_with_no_name(self):
75
  with pytest.raises(
76
  ValueError, match="You must provide a name for lambda functions"
77
  ):
@@ -86,6 +86,69 @@ class TestToolFromFunction:
86
  assert tool.parameters["properties"]["_a"]["type"] == "integer"
87
  assert tool.parameters["properties"]["_b"]["type"] == "integer"
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
  class TestToolJsonParsing:
91
  """Tests for Tool's JSON pre-parsing functionality."""
 
63
  assert tool.parameters["properties"]["data"]["type"] == "string"
64
  assert isinstance(result[0], ImageContent)
65
 
66
+ def test_non_callable_fn(self):
67
+ with pytest.raises(TypeError, match="not a callable object"):
68
  Tool.from_function(1) # type: ignore
69
 
70
+ def test_lambda(self):
71
  tool = Tool.from_function(lambda x: x, name="my_tool")
72
  assert tool.name == "my_tool"
73
 
74
+ def test_lambda_with_no_name(self):
75
  with pytest.raises(
76
  ValueError, match="You must provide a name for lambda functions"
77
  ):
 
86
  assert tool.parameters["properties"]["_a"]["type"] == "integer"
87
  assert tool.parameters["properties"]["_b"]["type"] == "integer"
88
 
89
+ def test_tool_with_varargs_not_allowed(self):
90
+ def func(a: int, b: int, *args: int) -> int:
91
+ """Add two numbers."""
92
+ return a + b
93
+
94
+ with pytest.raises(
95
+ ValueError, match=r"Functions with \*args are not supported as tools"
96
+ ):
97
+ Tool.from_function(func)
98
+
99
+ def test_tool_with_varkwargs_not_allowed(self):
100
+ def func(a: int, b: int, **kwargs: int) -> int:
101
+ """Add two numbers."""
102
+ return a + b
103
+
104
+ with pytest.raises(
105
+ ValueError, match=r"Functions with \*\*kwargs are not supported as tools"
106
+ ):
107
+ Tool.from_function(func)
108
+
109
+ async def test_instance_method(self):
110
+ class MyClass:
111
+ def add(self, x: int, y: int) -> int:
112
+ """Add two numbers."""
113
+ return x + y
114
+
115
+ obj = MyClass()
116
+
117
+ tool = Tool.from_function(obj.add)
118
+ assert tool.name == "add"
119
+ assert tool.description == "Add two numbers."
120
+ assert "self" not in tool.parameters["properties"]
121
+
122
+ async def test_instance_method_with_varargs_not_allowed(self):
123
+ class MyClass:
124
+ def add(self, x: int, y: int, *args: int) -> int:
125
+ """Add two numbers."""
126
+ return x + y
127
+
128
+ obj = MyClass()
129
+
130
+ with pytest.raises(
131
+ ValueError, match=r"Functions with \*args are not supported as tools"
132
+ ):
133
+ Tool.from_function(obj.add)
134
+
135
+ async def test_instance_method_with_varkwargs_not_allowed(self):
136
+ class MyClass:
137
+ def add(self, x: int, y: int, **kwargs: int) -> int:
138
+ """Add two numbers."""
139
+ return x + y
140
+
141
+ obj = MyClass()
142
+
143
+ with pytest.raises(
144
+ ValueError, match=r"Functions with \*\*kwargs are not supported as tools"
145
+ ):
146
+ Tool.from_function(obj.add)
147
+
148
+ async def test_classmethod(self):
149
+ class MyClass:
150
+ x: int = 10
151
+
152
 
153
  class TestToolJsonParsing:
154
  """Tests for Tool's JSON pre-parsing functionality."""
tests/tools/test_tool_manager.py CHANGED
@@ -84,9 +84,9 @@ class TestAddTools:
84
  assert tool.parameters["properties"]["data"]["type"] == "string"
85
  assert isinstance(result[0], ImageContent)
86
 
87
- def test_add_invalid_tool(self):
88
  manager = ToolManager()
89
- with pytest.raises(AttributeError):
90
  manager.add_tool_from_fn(1) # type: ignore
91
 
92
  def test_add_lambda(self):
 
84
  assert tool.parameters["properties"]["data"]["type"] == "string"
85
  assert isinstance(result[0], ImageContent)
86
 
87
+ def test_add_noncallable_tool(self):
88
  manager = ToolManager()
89
+ with pytest.raises(TypeError, match="not a callable object"):
90
  manager.add_tool_from_fn(1) # type: ignore
91
 
92
  def test_add_lambda(self):