Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -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 <antoni@golf.dev>"]
license = "Apache-2.0"
Expand Down
6 changes: 5 additions & 1 deletion src/golf/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
__version__ = "0.3.0rc1"
__version__ = "0.3.0rc2"

from golf.decorators import prompt, resource, tool

__all__ = ["tool", "resource", "prompt"]
81 changes: 79 additions & 2 deletions src/golf/core/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -214,6 +218,79 @@ 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,
Expand Down
63 changes: 63 additions & 0 deletions src/golf/decorators.py
Original file line number Diff line number Diff line change
@@ -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
Loading