Jeremiah Lowin commited on
Commit
26ff0e9
·
1 Parent(s): 4757dcb

Support "naked" tool decorator usage

Browse files
src/fastmcp/server/server.py CHANGED
@@ -513,53 +513,69 @@ class FastMCP(Generic[LifespanResultT]):
513
 
514
  def tool(
515
  self,
 
 
516
  name: str | None = None,
517
  description: str | None = None,
518
  tags: set[str] | None = None,
519
  annotations: ToolAnnotations | dict[str, Any] | None = None,
520
  exclude_args: list[str] | None = None,
521
- ) -> Callable[[AnyFunction], AnyFunction]:
522
  """Decorator to register a tool.
523
 
524
  Tools can optionally request a Context object by adding a parameter with the
525
  Context type annotation. The context provides access to MCP capabilities like
526
  logging, progress reporting, and resource access.
527
 
 
 
 
 
 
 
 
528
  Args:
529
- name: Optional name for the tool (defaults to function name)
530
  description: Optional description of what the tool does
531
  tags: Optional set of tags for categorizing the tool
532
  annotations: Optional annotations about the tool's behavior
 
 
533
 
534
  Example:
535
- @server.tool()
536
  def my_tool(x: int) -> str:
537
  return str(x)
538
 
539
  @server.tool()
540
- def tool_with_context(x: int, ctx: Context) -> str:
541
- ctx.info(f"Processing {x}")
542
  return str(x)
543
 
544
- @server.tool()
545
- async def async_tool(x: int, context: Context) -> str:
546
- await context.report_progress(50, 100)
547
  return str(x)
548
- """
549
 
550
- # Check if user passed function directly instead of calling decorator
551
- if callable(name):
552
- raise TypeError(
553
- "The @tool decorator was used incorrectly. "
554
- "Did you forget to call it? Use @tool() instead of @tool"
555
- )
 
556
  if isinstance(annotations, dict):
557
  annotations = ToolAnnotations(**annotations)
558
 
559
- def decorator(fn: AnyFunction) -> AnyFunction:
 
 
 
 
 
 
 
560
  tool = Tool.from_function(
561
  fn,
562
- name=name,
563
  description=description,
564
  tags=tags,
565
  annotations=annotations,
@@ -569,7 +585,31 @@ class FastMCP(Generic[LifespanResultT]):
569
  self.add_tool(tool)
570
  return fn
571
 
572
- return decorator
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
573
 
574
  def add_resource(self, resource: Resource, key: str | None = None) -> None:
575
  """Add a resource to the server.
 
513
 
514
  def tool(
515
  self,
516
+ name_or_fn: str | AnyFunction | None = None,
517
+ *,
518
  name: str | None = None,
519
  description: str | None = None,
520
  tags: set[str] | None = None,
521
  annotations: ToolAnnotations | dict[str, Any] | None = None,
522
  exclude_args: list[str] | None = None,
523
+ ) -> Callable[[AnyFunction], AnyFunction] | AnyFunction:
524
  """Decorator to register a tool.
525
 
526
  Tools can optionally request a Context object by adding a parameter with the
527
  Context type annotation. The context provides access to MCP capabilities like
528
  logging, progress reporting, and resource access.
529
 
530
+ This decorator supports multiple calling patterns:
531
+ - @server.tool (without parentheses)
532
+ - @server.tool() (with empty parentheses)
533
+ - @server.tool("custom_name") (with name as first argument)
534
+ - @server.tool(name="custom_name") (with name as keyword argument)
535
+ - server.tool(function, name="custom_name") (direct function call)
536
+
537
  Args:
538
+ name_or_fn: Either a function (when used as @tool), a string name, or None
539
  description: Optional description of what the tool does
540
  tags: Optional set of tags for categorizing the tool
541
  annotations: Optional annotations about the tool's behavior
542
+ exclude_args: Optional list of argument names to exclude from the tool schema
543
+ name: Optional name for the tool (keyword-only, alternative to name_or_fn)
544
 
545
  Example:
546
+ @server.tool
547
  def my_tool(x: int) -> str:
548
  return str(x)
549
 
550
  @server.tool()
551
+ def my_tool(x: int) -> str:
 
552
  return str(x)
553
 
554
+ @server.tool("custom_name")
555
+ def my_tool(x: int) -> str:
 
556
  return str(x)
 
557
 
558
+ @server.tool(name="custom_name")
559
+ def my_tool(x: int) -> str:
560
+ return str(x)
561
+
562
+ # Direct function call
563
+ server.tool(my_function, name="custom_name")
564
+ """
565
  if isinstance(annotations, dict):
566
  annotations = ToolAnnotations(**annotations)
567
 
568
+ # Determine the actual name and function based on the calling pattern
569
+ if callable(name_or_fn):
570
+ # Case 1: @tool (without parens) - function passed directly
571
+ # Case 2: direct call like tool(fn, name="something")
572
+ fn = name_or_fn
573
+ tool_name = name # Use keyword name if provided, otherwise None
574
+
575
+ # Register the tool immediately and return the function
576
  tool = Tool.from_function(
577
  fn,
578
+ name=tool_name,
579
  description=description,
580
  tags=tags,
581
  annotations=annotations,
 
585
  self.add_tool(tool)
586
  return fn
587
 
588
+ elif isinstance(name_or_fn, str):
589
+ # Case 3: @tool("custom_name") - name passed as first argument
590
+ if name is not None:
591
+ raise TypeError(
592
+ "Cannot specify both a name as first argument and as keyword argument. "
593
+ f"Use either @tool('{name_or_fn}') or @tool(name='{name}'), not both."
594
+ )
595
+ tool_name = name_or_fn
596
+ elif name_or_fn is None:
597
+ # Case 4: @tool() or @tool(name="something") - use keyword name
598
+ tool_name = name
599
+ else:
600
+ raise TypeError(
601
+ f"First argument to @tool must be a function, string, or None, got {type(name_or_fn)}"
602
+ )
603
+
604
+ # Return partial for cases where we need to wait for the function
605
+ return partial(
606
+ self.tool,
607
+ name=tool_name,
608
+ description=description,
609
+ tags=tags,
610
+ annotations=annotations,
611
+ exclude_args=exclude_args,
612
+ )
613
 
614
  def add_resource(self, resource: Resource, key: str | None = None) -> None:
615
  """Add a resource to the server.
tests/server/test_server.py CHANGED
@@ -133,14 +133,22 @@ class TestToolDecorator:
133
  result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
134
  assert result[0].text == "3" # type: ignore[attr-defined]
135
 
136
- async def test_tool_decorator_incorrect_usage(self):
 
137
  mcp = FastMCP()
138
 
139
- with pytest.raises(TypeError, match="The @tool decorator was used incorrectly"):
 
 
 
140
 
141
- @mcp.tool # Missing parentheses #type: ignore
142
- def add(x: int, y: int) -> int:
143
- return x + y
 
 
 
 
144
 
145
  async def test_tool_decorator_with_name(self):
146
  mcp = FastMCP()
@@ -306,6 +314,59 @@ class TestToolDecorator:
306
  assert tool.parameters["properties"]["x"]["description"] == "x is an int"
307
  assert tool.parameters["properties"]["y"]["description"] == "y is not an int"
308
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
 
310
  class TestResourceDecorator:
311
  async def test_no_resources_before_decorator(self):
 
133
  result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
134
  assert result[0].text == "3" # type: ignore[attr-defined]
135
 
136
+ async def test_tool_decorator_without_parentheses(self):
137
+ """Test that @tool decorator works without parentheses."""
138
  mcp = FastMCP()
139
 
140
+ # Test the @tool syntax without parentheses
141
+ @mcp.tool
142
+ def add(x: int, y: int) -> int:
143
+ return x + y
144
 
145
+ # Verify the tool was registered correctly
146
+ tools = await mcp.get_tools()
147
+ assert "add" in tools
148
+
149
+ # Verify it can be called
150
+ result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
151
+ assert result[0].text == "3" # type: ignore[attr-defined]
152
 
153
  async def test_tool_decorator_with_name(self):
154
  mcp = FastMCP()
 
314
  assert tool.parameters["properties"]["x"]["description"] == "x is an int"
315
  assert tool.parameters["properties"]["y"]["description"] == "y is not an int"
316
 
317
+ async def test_tool_direct_function_call(self):
318
+ """Test that tools can be registered via direct function call."""
319
+ mcp = FastMCP()
320
+
321
+ def standalone_function(x: int, y: int) -> int:
322
+ """A standalone function to be registered."""
323
+ return x + y
324
+
325
+ # Register it directly using the new syntax
326
+ result_fn = mcp.tool(standalone_function, name="direct_call_tool")
327
+
328
+ # The function should be returned unchanged
329
+ assert result_fn is standalone_function
330
+
331
+ # Verify the tool was registered correctly
332
+ tools = await mcp.get_tools()
333
+ assert "direct_call_tool" in tools
334
+
335
+ # Verify it can be called
336
+ result = await mcp._mcp_call_tool("direct_call_tool", {"x": 5, "y": 3})
337
+ assert result[0].text == "8" # type: ignore[attr-defined]
338
+
339
+ async def test_tool_decorator_with_string_name(self):
340
+ """Test that @tool("custom_name") syntax works correctly."""
341
+ mcp = FastMCP()
342
+
343
+ @mcp.tool("string_named_tool")
344
+ def my_function(x: int) -> str:
345
+ """A function with a string name."""
346
+ return f"Result: {x}"
347
+
348
+ # Verify the tool was registered with the custom name
349
+ tools = await mcp.get_tools()
350
+ assert "string_named_tool" in tools
351
+ assert "my_function" not in tools # Original name should not be registered
352
+
353
+ # Verify it can be called
354
+ result = await mcp._mcp_call_tool("string_named_tool", {"x": 42})
355
+ assert result[0].text == "Result: 42" # type: ignore[attr-defined]
356
+
357
+ async def test_tool_decorator_conflicting_names_error(self):
358
+ """Test that providing both positional and keyword name raises an error."""
359
+ mcp = FastMCP()
360
+
361
+ with pytest.raises(
362
+ TypeError,
363
+ match="Cannot specify both a name as first argument and as keyword argument",
364
+ ):
365
+
366
+ @mcp.tool("positional_name", name="keyword_name")
367
+ def my_function(x: int) -> str:
368
+ return f"Result: {x}"
369
+
370
 
371
  class TestResourceDecorator:
372
  async def test_no_resources_before_decorator(self):