From c449f1d697d35bba94669bc3f5471c8df63ca375 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Sun, 4 May 2025 15:00:26 -0400
Subject: [PATCH] Handle args/kwargs appropriately for various objects
---
docs/servers/prompts.mdx | 4 ++
docs/servers/resources.mdx | 4 ++
docs/servers/tools.mdx | 6 +-
src/fastmcp/prompts/prompt.py | 7 +++
src/fastmcp/resources/template.py | 33 ++++++++---
src/fastmcp/tools/tool.py | 8 +++
tests/prompts/test_prompt_manager.py | 24 ++++++++
tests/resources/test_resource_template.py | 31 +++++++++-
tests/server/test_server_interactions.py | 17 +++++-
tests/tools/test_tool.py | 71 +++++++++++++++++++++--
tests/tools/test_tool_manager.py | 4 +-
11 files changed, 188 insertions(+), 21 deletions(-)
diff --git a/docs/servers/prompts.mdx b/docs/servers/prompts.mdx
index 9f276209a..75d0418a5 100644
--- a/docs/servers/prompts.mdx
+++ b/docs/servers/prompts.mdx
@@ -53,6 +53,9 @@ def generate_code_request(language: str, task_description: str) -> UserMessage:
* **Inferred Metadata:** By default:
* Prompt Name: Taken from the function name (`ask_about_topic`).
* Prompt Description: Taken from the function's docstring.
+
+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.
+
### Return Values
@@ -105,6 +108,7 @@ def generate_content_request(
return prompt
```
+
### Required vs. Optional Parameters
Parameters in your function signature are considered **required** unless they have a default value.
diff --git a/docs/servers/resources.mdx b/docs/servers/resources.mdx
index b3e1c1dee..2ccea8d84 100644
--- a/docs/servers/resources.mdx
+++ b/docs/servers/resources.mdx
@@ -239,6 +239,10 @@ Resource templates share most configuration options with regular resources (name
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.
+
+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.
+
+
Here is a complete example that shows how to define two resource templates:
```python
diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx
index fb1847b68..66c6c982f 100644
--- a/docs/servers/tools.mdx
+++ b/docs/servers/tools.mdx
@@ -43,9 +43,12 @@ When this tool is registered, FastMCP automatically:
- Generates an input schema based on the function's parameters and type annotations.
- Handles parameter validation and error reporting.
-
The way you define your Python function dictates how the tool appears and behaves for the LLM client.
+
+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.
+
+
### Parameters
#### Annotations
@@ -90,6 +93,7 @@ def process_image(
# Implementation...
```
+
You can also use the Field as a default value, though the Annotated approach is preferred:
```python
diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py
index 39c346942..801bab62c 100644
--- a/src/fastmcp/prompts/prompt.py
+++ b/src/fastmcp/prompts/prompt.py
@@ -111,6 +111,13 @@ class Prompt(BaseModel):
if func_name == "":
raise ValueError("You must provide a name for lambda functions")
+ # Reject functions with *args or **kwargs
+ sig = inspect.signature(fn)
+ for param in sig.parameters.values():
+ if param.kind == inspect.Parameter.VAR_POSITIONAL:
+ raise ValueError("Functions with *args are not supported as prompts")
+ if param.kind == inspect.Parameter.VAR_KEYWORD:
+ raise ValueError("Functions with **kwargs are not supported as prompts")
type_adapter = get_cached_typeadapter(fn)
parameters = type_adapter.json_schema()
diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py
index f789076d9..3c37296aa 100644
--- a/src/fastmcp/resources/template.py
+++ b/src/fastmcp/resources/template.py
@@ -109,6 +109,15 @@ class ResourceTemplate(BaseModel):
if func_name == "":
raise ValueError("You must provide a name for lambda functions")
+ # Reject functions with *args
+ # (**kwargs is allowed because the URI will define the parameter names)
+ sig = inspect.signature(fn)
+ for param in sig.parameters.values():
+ if param.kind == inspect.Parameter.VAR_POSITIONAL:
+ raise ValueError(
+ "Functions with *args are not supported as resource templates"
+ )
+
# Auto-detect context parameter if not provided
if context_kwarg is None:
context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
@@ -118,7 +127,7 @@ class ResourceTemplate(BaseModel):
if not uri_params:
raise ValueError("URI template must contain at least one parameter")
- func_params = set(inspect.signature(fn).parameters.keys())
+ func_params = set(sig.parameters.keys())
if context_kwarg:
func_params.discard(context_kwarg)
@@ -126,20 +135,26 @@ class ResourceTemplate(BaseModel):
required_params = {
p
for p in func_params
- if inspect.signature(fn).parameters[p].default is inspect.Parameter.empty
+ if sig.parameters[p].default is inspect.Parameter.empty
+ and sig.parameters[p].kind != inspect.Parameter.VAR_KEYWORD
+ and p != context_kwarg
}
- if context_kwarg and context_kwarg in required_params:
- required_params.discard(context_kwarg)
+ # Check if required parameters are a subset of the URI parameters
if not required_params.issubset(uri_params):
raise ValueError(
- f"URI parameters {uri_params} must be a subset of the required function arguments: {required_params}"
+ f"Required function arguments {required_params} must be a subset of the URI parameters {uri_params}"
)
- if not uri_params.issubset(func_params):
- raise ValueError(
- f"URI parameters {uri_params} must be a subset of the function arguments: {func_params}"
- )
+ # CHeck if the URI parameters are a subset of the function parameters (skip if **kwargs present)
+ if not any(
+ param.kind == inspect.Parameter.VAR_KEYWORD
+ for param in sig.parameters.values()
+ ):
+ if not uri_params.issubset(func_params):
+ raise ValueError(
+ f"URI parameters {uri_params} must be a subset of the function arguments: {func_params}"
+ )
# Get schema from TypeAdapter - will fail if function isn't properly typed
parameters = TypeAdapter(fn).json_schema()
diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py
index 1d1a09c8b..fbe51130c 100644
--- a/src/fastmcp/tools/tool.py
+++ b/src/fastmcp/tools/tool.py
@@ -67,6 +67,14 @@ class Tool(BaseModel):
"""Create a Tool from a function."""
from fastmcp import Context
+ # Reject functions with *args or **kwargs
+ sig = inspect.signature(fn)
+ for param in sig.parameters.values():
+ if param.kind == inspect.Parameter.VAR_POSITIONAL:
+ raise ValueError("Functions with *args are not supported as tools")
+ if param.kind == inspect.Parameter.VAR_KEYWORD:
+ raise ValueError("Functions with **kwargs are not supported as tools")
+
func_name = name or fn.__name__
if func_name == "":
diff --git a/tests/prompts/test_prompt_manager.py b/tests/prompts/test_prompt_manager.py
index 25e3a99e1..c00d27e49 100644
--- a/tests/prompts/test_prompt_manager.py
+++ b/tests/prompts/test_prompt_manager.py
@@ -189,6 +189,30 @@ class TestPromptManager:
with pytest.raises(ValueError, match="Missing required arguments"):
await manager.render_prompt("fn")
+ async def test_prompt_with_varargs_not_allowed(self):
+ """Test that a prompt with *args is not allowed."""
+
+ def fn(*args: int) -> str:
+ return f"Hello, {args}!"
+
+ manager = PromptManager()
+ with pytest.raises(
+ ValueError, match=r"Functions with \*args are not supported as prompts"
+ ):
+ manager.add_prompt(Prompt.from_function(fn))
+
+ async def test_prompt_with_varkwargs_not_allowed(self):
+ """Test that a prompt with **kwargs is not allowed."""
+
+ def fn(**kwargs: int) -> str:
+ return f"Hello, {kwargs}!"
+
+ manager = PromptManager()
+ with pytest.raises(
+ ValueError, match=r"Functions with \*\*kwargs are not supported as prompts"
+ ):
+ manager.add_prompt(Prompt.from_function(fn))
+
class TestPromptTags:
"""Test functionality related to prompt tags."""
diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py
index 878b80e4d..563876f25 100644
--- a/tests/resources/test_resource_template.py
+++ b/tests/resources/test_resource_template.py
@@ -104,7 +104,7 @@ class TestResourceTemplate:
# This should fail - 'unknown' is not a function parameter
with pytest.raises(
ValueError,
- match="URI parameters .* must be a subset of the required function arguments",
+ match="Required function arguments .* must be a subset of the URI parameters",
):
ResourceTemplate.from_function(
fn=my_func,
@@ -132,7 +132,7 @@ class TestResourceTemplate:
# This should fail - required param is not in URI
with pytest.raises(
ValueError,
- match="URI parameters .* must be a subset of the required function arguments",
+ match="Required function arguments .* must be a subset of the URI parameters",
):
ResourceTemplate.from_function(
fn=func_with_required,
@@ -157,7 +157,7 @@ class TestResourceTemplate:
# This fails - missing one required param
with pytest.raises(
ValueError,
- match="URI parameters .* must be a subset of the required function arguments",
+ match="Required function arguments .* must be a subset of the URI parameters",
):
ResourceTemplate.from_function(
fn=multi_required,
@@ -360,6 +360,31 @@ class TestResourceTemplate:
params = template.matches("test://src/path/to/test.py")
assert params == {"prefix": "src", "path": "path/to/test.py"}
+ async def test_function_with_varargs_not_allowed(self):
+ def func(x: int, *args: int) -> int:
+ return x + sum(args)
+
+ with pytest.raises(
+ ValueError,
+ match=r"Functions with \*args are not supported as resource templates",
+ ):
+ ResourceTemplate.from_function(
+ fn=func,
+ uri_template="test://{x}/{args*}",
+ name="test",
+ )
+
+ async def test_function_with_varkwargs_ok(self):
+ def func(x: int, **kwargs: int) -> int:
+ return x + sum(kwargs.values())
+
+ template = ResourceTemplate.from_function(
+ fn=func,
+ uri_template="test://{x}/{y}/{z}",
+ name="test",
+ )
+ assert template.uri_template == "test://{x}/{y}/{z}"
+
class TestMatchUriTemplate:
"""Test match_uri_template function."""
diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py
index af3cd8f94..3489bf86c 100644
--- a/tests/server/test_server_interactions.py
+++ b/tests/server/test_server_interactions.py
@@ -930,7 +930,7 @@ class TestResourceTemplates:
with pytest.raises(
ValueError,
- match="URI parameters .* must be a subset of the required function arguments",
+ match="Required function arguments .* must be a subset of the URI parameters",
):
@mcp.resource("resource://{name}/data")
@@ -958,7 +958,7 @@ class TestResourceTemplates:
with pytest.raises(
ValueError,
- match="URI parameters .* must be a subset of the required function arguments",
+ match="Required function arguments .* must be a subset of the URI parameters",
):
@mcp.resource("resource://{org}/{repo}/data")
@@ -977,6 +977,19 @@ class TestResourceTemplates:
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Static data"
+ async def test_template_with_varkwargs(self):
+ """Test that a template can have **kwargs."""
+ mcp = FastMCP()
+
+ @mcp.resource("test://{x}/{y}/{z}")
+ def func(**kwargs: int) -> int:
+ return sum(kwargs.values())
+
+ async with Client(mcp) as client:
+ result = await client.read_resource(AnyUrl("test://1/2/3"))
+ assert isinstance(result[0], TextResourceContents)
+ assert result[0].text == "6"
+
async def test_template_with_default_params(self):
"""Test that a template can have default parameters."""
mcp = FastMCP()
diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py
index a12953e41..bc1e452cf 100644
--- a/tests/tools/test_tool.py
+++ b/tests/tools/test_tool.py
@@ -63,15 +63,15 @@ class TestToolFromFunction:
assert tool.parameters["properties"]["data"]["type"] == "string"
assert isinstance(result[0], ImageContent)
- def test_add_invalid_tool(self):
- with pytest.raises(AttributeError):
+ def test_non_callable_fn(self):
+ with pytest.raises(TypeError, match="not a callable object"):
Tool.from_function(1) # type: ignore
- def test_add_lambda(self):
+ def test_lambda(self):
tool = Tool.from_function(lambda x: x, name="my_tool")
assert tool.name == "my_tool"
- def test_add_lambda_with_no_name(self):
+ def test_lambda_with_no_name(self):
with pytest.raises(
ValueError, match="You must provide a name for lambda functions"
):
@@ -86,6 +86,69 @@ class TestToolFromFunction:
assert tool.parameters["properties"]["_a"]["type"] == "integer"
assert tool.parameters["properties"]["_b"]["type"] == "integer"
+ def test_tool_with_varargs_not_allowed(self):
+ def func(a: int, b: int, *args: int) -> int:
+ """Add two numbers."""
+ return a + b
+
+ with pytest.raises(
+ ValueError, match=r"Functions with \*args are not supported as tools"
+ ):
+ Tool.from_function(func)
+
+ def test_tool_with_varkwargs_not_allowed(self):
+ def func(a: int, b: int, **kwargs: int) -> int:
+ """Add two numbers."""
+ return a + b
+
+ with pytest.raises(
+ ValueError, match=r"Functions with \*\*kwargs are not supported as tools"
+ ):
+ Tool.from_function(func)
+
+ async def test_instance_method(self):
+ class MyClass:
+ def add(self, x: int, y: int) -> int:
+ """Add two numbers."""
+ return x + y
+
+ obj = MyClass()
+
+ tool = Tool.from_function(obj.add)
+ assert tool.name == "add"
+ assert tool.description == "Add two numbers."
+ assert "self" not in tool.parameters["properties"]
+
+ async def test_instance_method_with_varargs_not_allowed(self):
+ class MyClass:
+ def add(self, x: int, y: int, *args: int) -> int:
+ """Add two numbers."""
+ return x + y
+
+ obj = MyClass()
+
+ with pytest.raises(
+ ValueError, match=r"Functions with \*args are not supported as tools"
+ ):
+ Tool.from_function(obj.add)
+
+ async def test_instance_method_with_varkwargs_not_allowed(self):
+ class MyClass:
+ def add(self, x: int, y: int, **kwargs: int) -> int:
+ """Add two numbers."""
+ return x + y
+
+ obj = MyClass()
+
+ with pytest.raises(
+ ValueError, match=r"Functions with \*\*kwargs are not supported as tools"
+ ):
+ Tool.from_function(obj.add)
+
+ async def test_classmethod(self):
+ class MyClass:
+ x: int = 10
+
class TestToolJsonParsing:
"""Tests for Tool's JSON pre-parsing functionality."""
diff --git a/tests/tools/test_tool_manager.py b/tests/tools/test_tool_manager.py
index 6cc44a36b..d0997d050 100644
--- a/tests/tools/test_tool_manager.py
+++ b/tests/tools/test_tool_manager.py
@@ -84,9 +84,9 @@ class TestAddTools:
assert tool.parameters["properties"]["data"]["type"] == "string"
assert isinstance(result[0], ImageContent)
- def test_add_invalid_tool(self):
+ def test_add_noncallable_tool(self):
manager = ToolManager()
- with pytest.raises(AttributeError):
+ with pytest.raises(TypeError, match="not a callable object"):
manager.add_tool_from_fn(1) # type: ignore
def test_add_lambda(self):