diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 564a0aa5e..86f418488 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -513,53 +513,69 @@ class FastMCP(Generic[LifespanResultT]): def tool( self, + name_or_fn: str | AnyFunction | None = None, + *, name: str | None = None, description: str | None = None, tags: set[str] | None = None, annotations: ToolAnnotations | dict[str, Any] | None = None, exclude_args: list[str] | None = None, - ) -> Callable[[AnyFunction], AnyFunction]: + ) -> Callable[[AnyFunction], AnyFunction] | AnyFunction: """Decorator to register a tool. Tools can optionally request a Context object by adding a parameter with the Context type annotation. The context provides access to MCP capabilities like logging, progress reporting, and resource access. + This decorator supports multiple calling patterns: + - @server.tool (without parentheses) + - @server.tool() (with empty parentheses) + - @server.tool("custom_name") (with name as first argument) + - @server.tool(name="custom_name") (with name as keyword argument) + - server.tool(function, name="custom_name") (direct function call) + Args: - name: Optional name for the tool (defaults to function name) + name_or_fn: Either a function (when used as @tool), a string name, or None description: Optional description of what the tool does tags: Optional set of tags for categorizing the tool annotations: Optional annotations about the tool's behavior + exclude_args: Optional list of argument names to exclude from the tool schema + name: Optional name for the tool (keyword-only, alternative to name_or_fn) Example: - @server.tool() + @server.tool def my_tool(x: int) -> str: return str(x) @server.tool() - def tool_with_context(x: int, ctx: Context) -> str: - ctx.info(f"Processing {x}") + def my_tool(x: int) -> str: return str(x) - @server.tool() - async def async_tool(x: int, context: Context) -> str: - await context.report_progress(50, 100) + @server.tool("custom_name") + def my_tool(x: int) -> str: return str(x) + + @server.tool(name="custom_name") + def my_tool(x: int) -> str: + return str(x) + + # Direct function call + server.tool(my_function, name="custom_name") """ - - # Check if user passed function directly instead of calling decorator - if callable(name): - raise TypeError( - "The @tool decorator was used incorrectly. " - "Did you forget to call it? Use @tool() instead of @tool" - ) if isinstance(annotations, dict): annotations = ToolAnnotations(**annotations) - def decorator(fn: AnyFunction) -> AnyFunction: + # Determine the actual name and function based on the calling pattern + if callable(name_or_fn): + # Case 1: @tool (without parens) - function passed directly + # Case 2: direct call like tool(fn, name="something") + fn = name_or_fn + tool_name = name # Use keyword name if provided, otherwise None + + # Register the tool immediately and return the function tool = Tool.from_function( fn, - name=name, + name=tool_name, description=description, tags=tags, annotations=annotations, @@ -569,7 +585,31 @@ class FastMCP(Generic[LifespanResultT]): self.add_tool(tool) return fn - return decorator + elif isinstance(name_or_fn, str): + # Case 3: @tool("custom_name") - name passed as first argument + if name is not None: + raise TypeError( + "Cannot specify both a name as first argument and as keyword argument. " + f"Use either @tool('{name_or_fn}') or @tool(name='{name}'), not both." + ) + tool_name = name_or_fn + elif name_or_fn is None: + # Case 4: @tool() or @tool(name="something") - use keyword name + tool_name = name + else: + raise TypeError( + f"First argument to @tool must be a function, string, or None, got {type(name_or_fn)}" + ) + + # Return partial for cases where we need to wait for the function + return partial( + self.tool, + name=tool_name, + description=description, + tags=tags, + annotations=annotations, + exclude_args=exclude_args, + ) def add_resource(self, resource: Resource, key: str | None = None) -> None: """Add a resource to the server. diff --git a/tests/server/test_server.py b/tests/server/test_server.py index 006ef4074..198deefd5 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -133,14 +133,22 @@ class TestToolDecorator: result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2}) assert result[0].text == "3" # type: ignore[attr-defined] - async def test_tool_decorator_incorrect_usage(self): + async def test_tool_decorator_without_parentheses(self): + """Test that @tool decorator works without parentheses.""" mcp = FastMCP() - with pytest.raises(TypeError, match="The @tool decorator was used incorrectly"): + # Test the @tool syntax without parentheses + @mcp.tool + def add(x: int, y: int) -> int: + return x + y - @mcp.tool # Missing parentheses #type: ignore - def add(x: int, y: int) -> int: - return x + y + # Verify the tool was registered correctly + tools = await mcp.get_tools() + assert "add" in tools + + # Verify it can be called + result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2}) + assert result[0].text == "3" # type: ignore[attr-defined] async def test_tool_decorator_with_name(self): mcp = FastMCP() @@ -306,6 +314,59 @@ class TestToolDecorator: assert tool.parameters["properties"]["x"]["description"] == "x is an int" assert tool.parameters["properties"]["y"]["description"] == "y is not an int" + async def test_tool_direct_function_call(self): + """Test that tools can be registered via direct function call.""" + mcp = FastMCP() + + def standalone_function(x: int, y: int) -> int: + """A standalone function to be registered.""" + return x + y + + # Register it directly using the new syntax + result_fn = mcp.tool(standalone_function, name="direct_call_tool") + + # The function should be returned unchanged + assert result_fn is standalone_function + + # Verify the tool was registered correctly + tools = await mcp.get_tools() + assert "direct_call_tool" in tools + + # Verify it can be called + result = await mcp._mcp_call_tool("direct_call_tool", {"x": 5, "y": 3}) + assert result[0].text == "8" # type: ignore[attr-defined] + + async def test_tool_decorator_with_string_name(self): + """Test that @tool("custom_name") syntax works correctly.""" + mcp = FastMCP() + + @mcp.tool("string_named_tool") + def my_function(x: int) -> str: + """A function with a string name.""" + return f"Result: {x}" + + # Verify the tool was registered with the custom name + tools = await mcp.get_tools() + assert "string_named_tool" in tools + assert "my_function" not in tools # Original name should not be registered + + # Verify it can be called + result = await mcp._mcp_call_tool("string_named_tool", {"x": 42}) + assert result[0].text == "Result: 42" # type: ignore[attr-defined] + + async def test_tool_decorator_conflicting_names_error(self): + """Test that providing both positional and keyword name raises an error.""" + mcp = FastMCP() + + with pytest.raises( + TypeError, + match="Cannot specify both a name as first argument and as keyword argument", + ): + + @mcp.tool("positional_name", name="keyword_name") + def my_function(x: int) -> str: + return f"Result: {x}" + class TestResourceDecorator: async def test_no_resources_before_decorator(self):