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):