Repository: jlowin/fastmcp

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)


Discussion:
I don’t expect args/kwargs to be supported at all, since every parameter must be broadcast to the MCP client. I think it would be better to error if a varargs function is decorated and guide users to accept parameters explicitly then splat them to their helper? Alternatively we could broadcast literally “args” and “kwargs” as an untyped tuple and dict but I think that’s likely to give poor results
That works. I like the idea of just causing an error/exception in that case.



Patch:
diff --git a/docs/servers/prompts.mdx b/docs/servers/prompts.mdx
index 9f276209..75d0418a 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 b3e1c1de..2ccea8d8 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 fb1847b6..66c6c982 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 39c34694..801bab62 100644
--- a/src/fastmcp/prompts/prompt.py
+++ b/src/fastmcp/prompts/prompt.py
@@ -111,6 +111,13 @@ def from_function(
 
         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 f789076d..04fae970 100644
--- a/src/fastmcp/resources/template.py
+++ b/src/fastmcp/resources/template.py
@@ -109,6 +109,15 @@ def from_function(
         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 @@ def from_function(
         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 @@ def from_function(
         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 1d1a09c8..fbe51130 100644
--- a/src/fastmcp/tools/tool.py
+++ b/src/fastmcp/tools/tool.py
@@ -67,6 +67,14 @@ def from_function(
         """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 25e3a99e..c00d27e4 100644
--- a/tests/prompts/test_prompt_manager.py
+++ b/tests/prompts/test_prompt_manager.py
@@ -189,6 +189,30 @@ def fn(name: str) -> str:
         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 878b80e4..563876f2 100644
--- a/tests/resources/test_resource_template.py
+++ b/tests/resources/test_resource_template.py
@@ -104,7 +104,7 @@ def my_func(key: str, value: int) -> dict:
         # 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 @@ def func_with_required(
         # 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 @@ def multi_required(param1: str, param2: int, optional: str = "default") -> dict:
         # 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 @@ def identity(prefix: str, path: str) -> str:
         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 af3cd8f9..3489bf86 100644
--- a/tests/server/test_server_interactions.py
+++ b/tests/server/test_server_interactions.py
@@ -930,7 +930,7 @@ async def test_resource_mismatched_params(self):
 
         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 @@ async def test_resource_multiple_mismatched_params(self):
 
         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 @@ def get_static_data() -> str:
             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 a12953e4..bc1e452c 100644
--- a/tests/tools/test_tool.py
+++ b/tests/tools/test_tool.py
@@ -63,15 +63,15 @@ def image_tool(data: bytes) -> Image:
         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 @@ def add(_a: int, _b: int) -> int:
         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 6cc44a36..d0997d05 100644
--- a/tests/tools/test_tool_manager.py
+++ b/tests/tools/test_tool_manager.py
@@ -84,9 +84,9 @@ def image_tool(data: bytes) -> Image:
         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):