diff --git a/src/mcp/server/mcpserver/tools/base.py b/src/mcp/server/mcpserver/tools/base.py index 4a8bed792e..991895ed5b 100644 --- a/src/mcp/server/mcpserver/tools/base.py +++ b/src/mcp/server/mcpserver/tools/base.py @@ -102,6 +102,7 @@ def from_function( fn, skip_names=skip_names, structured_output=structured_output, + allow_var_params=False, ) parameters = func_arg_metadata.arg_model.model_json_schema(by_alias=True) diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index 0ffac07c4e..80aa6fda16 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -276,6 +276,7 @@ def func_metadata( func: Callable[..., Any], skip_names: Sequence[str] = (), structured_output: bool | None = None, + allow_var_params: bool = True, ) -> FuncMetadata: """Given a function, return metadata including a Pydantic model representing its signature. @@ -293,6 +294,11 @@ def func_metadata( func: The function to convert to a Pydantic model skip_names: A list of parameter names to skip. These will not be included in the model. + allow_var_params: When False, a `*args` or `**kwargs` parameter raises + `InvalidSignature`; a variadic parameter has no scalar schema and would + otherwise be modelled as an unusable normal field. Tools pass False; + resource templates leave it True since they use `**kwargs` for + runtime-determined URI variables. structured_output: Controls whether the tool's output is structured or unstructured - If None, auto-detects based on the function's return type annotation - If True, creates a structured tool (return type annotation permitting) @@ -329,6 +335,12 @@ def func_metadata( for param in params.values(): if param.name.startswith("_"): # pragma: no cover raise InvalidSignature(f"Parameter {param.name} of {func.__name__} cannot start with '_'") + if not allow_var_params and param.kind in ( + inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD, + ): + variadic = "*args" if param.kind is inspect.Parameter.VAR_POSITIONAL else "**kwargs" + raise InvalidSignature(f"Function {func.__name__} cannot have a {variadic} parameter") if param.name in skip_names: continue diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index dba0637ded..1fe90101d1 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -1520,3 +1520,36 @@ def fn() -> StepA | StepB: ... # pragma: no branch meta = func_metadata(fn) assert meta.output_schema is None + + +def test_var_positional_param_is_rejected_for_tools(): + """A tool signature with *args has no scalar schema and is rejected at registration.""" + + def func_with_var_args(a: int, *args: int) -> int: # pragma: no cover + return a + + with pytest.raises(InvalidSignature) as exc_info: + func_metadata(func_with_var_args, allow_var_params=False) + assert "*args" in str(exc_info.value) + + +def test_var_keyword_param_is_rejected_for_tools(): + """A tool signature with **kwargs has no scalar schema and is rejected at registration.""" + + def func_with_var_kwargs(a: int, **kwargs: int) -> int: # pragma: no cover + return a + + with pytest.raises(InvalidSignature) as exc_info: + func_metadata(func_with_var_kwargs, allow_var_params=False) + assert "**kwargs" in str(exc_info.value) + + +def test_var_keyword_param_is_allowed_by_default(): + """Resource templates keep using **kwargs for runtime-determined URI variables, so the + default (allow_var_params=True) must not reject a variadic parameter.""" + + def func_with_var_kwargs(**kwargs: str) -> str: # pragma: no cover + return "" + + meta = func_metadata(func_with_var_kwargs) + assert isinstance(meta, FuncMetadata)