From c08c579c91c14f6e909b541d529f6ab7db4040bc Mon Sep 17 00:00:00 2001 From: aschlean Date: Fri, 16 Jan 2026 18:48:37 +0100 Subject: [PATCH 1/3] feat: add capability name decorators --- src/golf/__init__.py | 4 + src/golf/core/parser.py | 87 ++++++++++++- src/golf/decorators.py | 63 ++++++++++ tests/core/test_parser.py | 255 ++++++++++++++++++++++++++++++++++++++ tests/test_decorators.py | 56 +++++++++ 5 files changed, 463 insertions(+), 2 deletions(-) create mode 100644 src/golf/decorators.py create mode 100644 tests/test_decorators.py diff --git a/src/golf/__init__.py b/src/golf/__init__.py index 04d1c7c5..fa64a72d 100644 --- a/src/golf/__init__.py +++ b/src/golf/__init__.py @@ -1 +1,5 @@ __version__ = "0.2.19" + +from golf.decorators import prompt, resource, tool + +__all__ = ["tool", "resource", "prompt"] diff --git a/src/golf/core/parser.py b/src/golf/core/parser.py index 93ef3161..00ec3d20 100644 --- a/src/golf/core/parser.py +++ b/src/golf/core/parser.py @@ -168,8 +168,12 @@ def parse_file(self, file_path: Path) -> list[ParsedComponent]: elif component_type == ComponentType.PROMPT: self._process_prompt(component, tree) - # Set component name based on file path - component.name = self._derive_component_name(file_path, component_type) + # Set component name - use explicit decorator name if available, otherwise derive from path + explicit_name = self._extract_explicit_name_from_decorator(entry_function, component_type) + if explicit_name: + component.name = explicit_name + else: + component.name = self._derive_component_name(file_path, component_type) # Set parent module if it's in a nested structure if len(rel_path.parts) > 2: # More than just "tools/file.py" @@ -214,6 +218,85 @@ def _extract_component_description( return description + def _extract_explicit_name_from_decorator( + self, + func_node: ast.FunctionDef | ast.AsyncFunctionDef, + component_type: ComponentType, + ) -> str | None: + """Extract explicit name from @tool/@resource/@prompt decorator. + + Handles both import patterns: + - @tool(name="x") or @tool("x") + - @golf.tool(name="x") or @golf.tool("x") + + Args: + func_node: The function AST node to check + component_type: The type of component being parsed + + Returns: + The explicit name if found and valid, None otherwise. + """ + # Map component type to expected decorator name + decorator_names = { + ComponentType.TOOL: "tool", + ComponentType.RESOURCE: "resource", + ComponentType.PROMPT: "prompt", + } + expected_decorator = decorator_names.get(component_type) + if not expected_decorator: + return None + + for decorator in func_node.decorator_list: + # Handle @tool(...) or @golf.tool(...) + if isinstance(decorator, ast.Call): + func = decorator.func + + # Check for @tool(...) pattern + is_direct_decorator = ( + isinstance(func, ast.Name) and func.id == expected_decorator + ) + + # Check for @golf.tool(...) pattern + is_qualified_decorator = ( + isinstance(func, ast.Attribute) + and func.attr == expected_decorator + and isinstance(func.value, ast.Name) + and func.value.id == "golf" + ) + + if is_direct_decorator or is_qualified_decorator: + # Check for positional arg: @tool("name") + if decorator.args: + first_arg = decorator.args[0] + if isinstance(first_arg, ast.Constant) and isinstance( + first_arg.value, str + ): + return first_arg.value + else: + # Non-string or dynamic value + console.print( + "[yellow]Warning: Decorator name must be a string literal, " + "falling back to path-derived name[/yellow]" + ) + return None + + # Check for keyword arg: @tool(name="name") + for keyword in decorator.keywords: + if keyword.arg == "name": + if isinstance(keyword.value, ast.Constant) and isinstance( + keyword.value.value, str + ): + return keyword.value.value + else: + # Non-string or dynamic value + console.print( + "[yellow]Warning: Decorator name must be a string literal, " + "falling back to path-derived name[/yellow]" + ) + return None + + return None + def _process_entry_function( self, component: ParsedComponent, diff --git a/src/golf/decorators.py b/src/golf/decorators.py new file mode 100644 index 00000000..f1ff1b25 --- /dev/null +++ b/src/golf/decorators.py @@ -0,0 +1,63 @@ +"""Decorators for Golf MCP components.""" + +from collections.abc import Callable +from typing import TypeVar + +F = TypeVar("F", bound=Callable[..., object]) + + +def tool(name: str) -> Callable[[F], F]: + """Decorator to set an explicit tool name. + + Args: + name: The tool name to use instead of deriving from file path. + + Example: + @tool(name="stripe_charge") + async def run(amount: int) -> str: + return f"Charged {amount}" + """ + + def decorator(func: F) -> F: + func._golf_name = name # type: ignore[attr-defined] + return func + + return decorator + + +def resource(name: str) -> Callable[[F], F]: + """Decorator to set an explicit resource name. + + Args: + name: The resource name to use instead of deriving from file path. + + Example: + @resource(name="user_profile") + async def run(user_id: str) -> dict: + return {"user_id": user_id} + """ + + def decorator(func: F) -> F: + func._golf_name = name # type: ignore[attr-defined] + return func + + return decorator + + +def prompt(name: str) -> Callable[[F], F]: + """Decorator to set an explicit prompt name. + + Args: + name: The prompt name to use instead of deriving from file path. + + Example: + @prompt(name="greeting") + async def run(name: str) -> list: + return [{"role": "user", "content": f"Hello {name}"}] + """ + + def decorator(func: F) -> F: + func._golf_name = name # type: ignore[attr-defined] + return func + + return decorator diff --git a/tests/core/test_parser.py b/tests/core/test_parser.py index cc6d775d..5a6db0cd 100644 --- a/tests/core/test_parser.py +++ b/tests/core/test_parser.py @@ -933,3 +933,258 @@ def run() -> dict: assert len(components) == 1 assert components[0].docstring == "Custom export function docstring." assert components[0].entry_function == "my_custom_function" + + +class TestExplicitNaming: + """Test cases for explicit component naming via decorators.""" + + def test_tool_with_explicit_name(self, sample_project: Path) -> None: + """Test that @tool(name=...) sets explicit name.""" + tool_file = sample_project / "tools" / "charge.py" + tool_file.write_text( + '''"""Process payment.""" + +from golf import tool + + +@tool(name="stripe_charge") +async def run(amount: int) -> str: + """Charge the card.""" + return f"Charged {amount}" + + +export = run +''' + ) + + parser = AstParser(sample_project) + components = parser.parse_file(tool_file) + + assert len(components) == 1 + assert components[0].name == "stripe_charge" + assert components[0].type == ComponentType.TOOL + + def test_tool_with_positional_name(self, sample_project: Path) -> None: + """Test that @tool("name") positional arg works.""" + tool_file = sample_project / "tools" / "charge.py" + tool_file.write_text( + '''"""Process payment.""" + +from golf import tool + + +@tool("my_charge") +async def run(amount: int) -> str: + """Charge the card.""" + return f"Charged {amount}" + + +export = run +''' + ) + + parser = AstParser(sample_project) + components = parser.parse_file(tool_file) + + assert len(components) == 1 + assert components[0].name == "my_charge" + + def test_tool_with_qualified_import(self, sample_project: Path) -> None: + """Test that @golf.tool(name=...) works.""" + tool_file = sample_project / "tools" / "charge.py" + tool_file.write_text( + '''"""Process payment.""" + +import golf + + +@golf.tool(name="qualified_charge") +async def run(amount: int) -> str: + """Charge the card.""" + return f"Charged {amount}" + + +export = run +''' + ) + + parser = AstParser(sample_project) + components = parser.parse_file(tool_file) + + assert len(components) == 1 + assert components[0].name == "qualified_charge" + + def test_resource_with_explicit_name(self, sample_project: Path) -> None: + """Test that @resource(name=...) sets explicit name.""" + resource_file = sample_project / "resources" / "user.py" + resource_file.write_text( + '''"""User resource.""" + +from golf import resource + +resource_uri = "users://{user_id}" + + +@resource(name="user_profile") +async def run(user_id: str) -> dict: + """Get user profile.""" + return {"user_id": user_id} + + +export = run +''' + ) + + parser = AstParser(sample_project) + components = parser.parse_file(resource_file) + + assert len(components) == 1 + assert components[0].name == "user_profile" + assert components[0].type == ComponentType.RESOURCE + + def test_prompt_with_explicit_name(self, sample_project: Path) -> None: + """Test that @prompt(name=...) sets explicit name.""" + prompt_file = sample_project / "prompts" / "greet.py" + prompt_file.write_text( + '''"""Greeting prompt.""" + +from golf import prompt + + +@prompt(name="hello_greeting") +async def run(name: str) -> list: + """Generate greeting.""" + return [{"role": "user", "content": f"Hello {name}"}] + + +export = run +''' + ) + + parser = AstParser(sample_project) + components = parser.parse_file(prompt_file) + + assert len(components) == 1 + assert components[0].name == "hello_greeting" + assert components[0].type == ComponentType.PROMPT + + def test_tool_without_decorator_uses_path_name(self, sample_project: Path) -> None: + """Test that tools without decorator still use path-derived name.""" + tool_file = sample_project / "tools" / "payments" / "charge.py" + tool_file.parent.mkdir(parents=True, exist_ok=True) + tool_file.write_text( + '''"""Process payment.""" + + +async def run(amount: int) -> str: + """Charge the card.""" + return f"Charged {amount}" + + +export = run +''' + ) + + parser = AstParser(sample_project) + components = parser.parse_file(tool_file) + + assert len(components) == 1 + assert components[0].name == "charge_payments" + + def test_decorator_on_non_entry_function_ignored(self, sample_project: Path) -> None: + """Test that decorator on helper function is ignored.""" + tool_file = sample_project / "tools" / "helper_test.py" + tool_file.write_text( + '''"""Test tool.""" + +from golf import tool + + +@tool(name="should_be_ignored") +def helper(x: int) -> int: + """Helper function.""" + return x * 2 + + +async def run(amount: int) -> str: + """Main function.""" + return f"Result: {helper(amount)}" + + +export = run +''' + ) + + parser = AstParser(sample_project) + components = parser.parse_file(tool_file) + + assert len(components) == 1 + # Should use path-derived name since decorator is not on entry function + assert components[0].name == "helper_test" + + def test_dynamic_name_falls_back_to_path(self, sample_project: Path) -> None: + """Test that non-string name falls back to path-derived name.""" + tool_file = sample_project / "tools" / "dynamic.py" + tool_file.write_text( + '''"""Test tool.""" + +from golf import tool + +NAME = "dynamic_name" + + +@tool(name=NAME) # Variable, not string literal +async def run() -> str: + """Test function.""" + return "result" + + +export = run +''' + ) + + parser = AstParser(sample_project) + components = parser.parse_file(tool_file) + + assert len(components) == 1 + # Should fall back to path-derived name + assert components[0].name == "dynamic" + + def test_explicit_name_collision_detected(self, sample_project: Path) -> None: + """Test that explicit name collisions are detected in parse_project.""" + tool1 = sample_project / "tools" / "tool1.py" + tool1.write_text( + '''"""Tool 1.""" + +from golf import tool + + +@tool(name="same_name") +async def run() -> str: + """Tool 1.""" + return "1" + + +export = run +''' + ) + + tool2 = sample_project / "tools" / "tool2.py" + tool2.write_text( + '''"""Tool 2.""" + +from golf import tool + + +@tool(name="same_name") +async def run() -> str: + """Tool 2.""" + return "2" + + +export = run +''' + ) + + with pytest.raises(ValueError, match="ID collision detected"): + parse_project(sample_project) diff --git a/tests/test_decorators.py b/tests/test_decorators.py new file mode 100644 index 00000000..d6a84a2d --- /dev/null +++ b/tests/test_decorators.py @@ -0,0 +1,56 @@ +"""Tests for Golf decorators module.""" + +from golf import prompt, resource, tool +from golf.decorators import prompt as prompt_dec +from golf.decorators import resource as resource_dec +from golf.decorators import tool as tool_dec + + +class TestDecorators: + """Test the decorator functions.""" + + def test_tool_decorator_sets_golf_name(self) -> None: + """Test that @tool sets _golf_name attribute.""" + + @tool(name="my_tool") + def my_func() -> str: + return "result" + + assert hasattr(my_func, "_golf_name") + assert my_func._golf_name == "my_tool" + + def test_resource_decorator_sets_golf_name(self) -> None: + """Test that @resource sets _golf_name attribute.""" + + @resource(name="my_resource") + def my_func() -> str: + return "result" + + assert hasattr(my_func, "_golf_name") + assert my_func._golf_name == "my_resource" + + def test_prompt_decorator_sets_golf_name(self) -> None: + """Test that @prompt sets _golf_name attribute.""" + + @prompt(name="my_prompt") + def my_func() -> str: + return "result" + + assert hasattr(my_func, "_golf_name") + assert my_func._golf_name == "my_prompt" + + def test_decorator_preserves_function(self) -> None: + """Test that decorator returns the same function.""" + + @tool(name="test") + def original() -> str: + return "original" + + assert original() == "original" + + def test_decorators_exported_from_main_package(self) -> None: + """Test that decorators are accessible from golf package.""" + # These imports should work + assert tool_dec is tool + assert resource_dec is resource + assert prompt_dec is prompt From f653965f7c1df4ff6fb131f6c8a610fca1091f50 Mon Sep 17 00:00:00 2001 From: aschlean Date: Sat, 17 Jan 2026 13:19:32 +0100 Subject: [PATCH 2/3] chore: bump versions --- pyproject.toml | 4 ++-- src/golf/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 433c0617..43b1b125 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "golf-mcp" -version = "0.3.0rc1" +version = "0.3.0rc2" description = "Framework for building MCP servers" authors = [ {name = "Antoni Gmitruk", email = "antoni@golf.dev"} @@ -66,7 +66,7 @@ golf = ["examples/**/*"] [tool.poetry] name = "golf-mcp" -version = "0.3.0rc1" +version = "0.3.0rc2" description = "Framework for building MCP servers with zero boilerplate" authors = ["Antoni Gmitruk "] license = "Apache-2.0" diff --git a/src/golf/__init__.py b/src/golf/__init__.py index 395b9b2a..933014d4 100644 --- a/src/golf/__init__.py +++ b/src/golf/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.3.0rc1" +__version__ = "0.3.0rc2" from golf.decorators import prompt, resource, tool From 9270340e08ac67728b65e5591f4b1735580e5c74 Mon Sep 17 00:00:00 2001 From: aschlean Date: Sat, 17 Jan 2026 13:24:27 +0100 Subject: [PATCH 3/3] chore: lint --- src/golf/__init__.py | 2 +- src/golf/core/parser.py | 12 +++--------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/golf/__init__.py b/src/golf/__init__.py index 933014d4..55c1d0ad 100644 --- a/src/golf/__init__.py +++ b/src/golf/__init__.py @@ -2,4 +2,4 @@ from golf.decorators import prompt, resource, tool -__all__ = ["tool", "resource", "prompt"] \ No newline at end of file +__all__ = ["tool", "resource", "prompt"] diff --git a/src/golf/core/parser.py b/src/golf/core/parser.py index 00ec3d20..a52d8270 100644 --- a/src/golf/core/parser.py +++ b/src/golf/core/parser.py @@ -252,9 +252,7 @@ def _extract_explicit_name_from_decorator( func = decorator.func # Check for @tool(...) pattern - is_direct_decorator = ( - isinstance(func, ast.Name) and func.id == expected_decorator - ) + is_direct_decorator = isinstance(func, ast.Name) and func.id == expected_decorator # Check for @golf.tool(...) pattern is_qualified_decorator = ( @@ -268,9 +266,7 @@ def _extract_explicit_name_from_decorator( # Check for positional arg: @tool("name") if decorator.args: first_arg = decorator.args[0] - if isinstance(first_arg, ast.Constant) and isinstance( - first_arg.value, str - ): + if isinstance(first_arg, ast.Constant) and isinstance(first_arg.value, str): return first_arg.value else: # Non-string or dynamic value @@ -283,9 +279,7 @@ def _extract_explicit_name_from_decorator( # Check for keyword arg: @tool(name="name") for keyword in decorator.keywords: if keyword.arg == "name": - if isinstance(keyword.value, ast.Constant) and isinstance( - keyword.value.value, str - ): + if isinstance(keyword.value, ast.Constant) and isinstance(keyword.value.value, str): return keyword.value.value else: # Non-string or dynamic value