Repository: jlowin/fastmcp (python)

Issue:
@mpc.tool with *args and **kwargs gives unexpected results (args is empty and kwargs swallows everything)
### Description

The `@mcp.tool()` decorator on a function definition with `*args` and `**kwargs` does not do the expected thing.

In the resulting function, `args` is always set to an empty array: `[]` and kwargs becomes a dictionary that contains `args` as a key and `kwargs` as another key:

For example, like this:

```json
{
  "name": "foo",
  "result": "Pattern result here",
  "args": [],
  "kwargs": {
    "args": [
      "x",
      9
    ],
    "kwargs": "{\"foo\": nil, \"bar\": true, \"baz\": 88}"
  }
}
```

### Example Code

```Python
# server.py
from fastmcp import FastMCP

mcp = FastMCP("Demo 🚀")

@mcp.tool()
def add(a: int, b: int) -> int:
    """Add two numbers"""
    return a + b

@mcp.tool()
def test_args(name: str, *args, **kwargs) -> dict:
    return {
      "name": name,
      "result": "Pattern result here",
      "args": args,
      "kwargs": kwargs,
    }

if __name__ == "__main__":
    mcp.run()
```

### Version Information

```Text
FastMCP version:                                                             2.2.7
MCP version:                                                                 1.7.1
Python version:                                                             3.12.9
Platform:                                             macOS-15.4.1-arm64-arm-64bit
FastMCP root path: /Users/kayvan/src/fabric-mcp/.venv/lib/python3.12/site-packages
```

### Additional Context

Examined the results using MCP inspector:

![Image](https://github.com/user-attachments/assets/08fdd939-3479-4874-87a3-d064207a5874)

Pull request:
Handle *args/**kwargs appropriately for various components
Closes #313 

This PR:
- prevents registering tools with *args / **kwargs (because no sensible parameter schema can be generated)
- prevents registering templates with *args but allows **kwargs (because parameter names are inferred from the URI template)
- prevents registering prompts with *args / **kwargs

Patch:
diff --git a/docs/servers/prompts.mdx b/docs/servers/prompts.mdx
index 9f27620..75d0418 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.
+<Tip>
+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.
+</Tip>
 
 ### 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 b3e1c1d..2ccea8d 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.
 
+<Tip>
+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.
+</Tip>
+
 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 fb1847b..66c6c98 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.
 
+<Tip>
+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.
+</Tip>
+
 ### 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 39c3469..801bab6 100644
--- a/src/fastmcp/prompts/prompt.py
+++ b/src/fastmcp/prompts/prompt.py
@@ -111,6 +111,13 @@ class Prompt(BaseModel):
 
         if func_name == "<lambda>":
             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 f789076..04fae97 100644
--- a/src/fastmcp/resources/template.py
+++ b/src/fastmcp/resources/template.py
@@ -109,6 +109,15 @@ class ResourceTemplate(BaseModel):
         if func_name == "<lambda>":
             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 1d1a09c..fbe5113 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 == "<lambda>":


Test patch:
diff --git a/tests/prompts/test_prompt_manager.py b/tests/prompts/test_prompt_manager.py
index 25e3a99..c00d27e 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 878b80e..563876f 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 af3cd8f..3489bf8 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 a12953e..bc1e452 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 6cc44a3..d0997d0 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):