Skip to content

Commit 75060fb

Browse files
authored
Merge pull request #129 from golf-mcp/aschlean/add-tool-name-decorators
Aschlean/add tool name decorators
2 parents 659b35e + 9270340 commit 75060fb

File tree

6 files changed

+460
-5
lines changed

6 files changed

+460
-5
lines changed

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "golf-mcp"
7-
version = "0.3.0rc1"
7+
version = "0.3.0rc2"
88
description = "Framework for building MCP servers"
99
authors = [
1010
{name = "Antoni Gmitruk", email = "antoni@golf.dev"}
@@ -66,7 +66,7 @@ golf = ["examples/**/*"]
6666

6767
[tool.poetry]
6868
name = "golf-mcp"
69-
version = "0.3.0rc1"
69+
version = "0.3.0rc2"
7070
description = "Framework for building MCP servers with zero boilerplate"
7171
authors = ["Antoni Gmitruk <antoni@golf.dev>"]
7272
license = "Apache-2.0"

src/golf/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
1-
__version__ = "0.3.0rc1"
1+
__version__ = "0.3.0rc2"
2+
3+
from golf.decorators import prompt, resource, tool
4+
5+
__all__ = ["tool", "resource", "prompt"]

src/golf/core/parser.py

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,12 @@ def parse_file(self, file_path: Path) -> list[ParsedComponent]:
168168
elif component_type == ComponentType.PROMPT:
169169
self._process_prompt(component, tree)
170170

171-
# Set component name based on file path
172-
component.name = self._derive_component_name(file_path, component_type)
171+
# Set component name - use explicit decorator name if available, otherwise derive from path
172+
explicit_name = self._extract_explicit_name_from_decorator(entry_function, component_type)
173+
if explicit_name:
174+
component.name = explicit_name
175+
else:
176+
component.name = self._derive_component_name(file_path, component_type)
173177

174178
# Set parent module if it's in a nested structure
175179
if len(rel_path.parts) > 2: # More than just "tools/file.py"
@@ -214,6 +218,79 @@ def _extract_component_description(
214218

215219
return description
216220

221+
def _extract_explicit_name_from_decorator(
222+
self,
223+
func_node: ast.FunctionDef | ast.AsyncFunctionDef,
224+
component_type: ComponentType,
225+
) -> str | None:
226+
"""Extract explicit name from @tool/@resource/@prompt decorator.
227+
228+
Handles both import patterns:
229+
- @tool(name="x") or @tool("x")
230+
- @golf.tool(name="x") or @golf.tool("x")
231+
232+
Args:
233+
func_node: The function AST node to check
234+
component_type: The type of component being parsed
235+
236+
Returns:
237+
The explicit name if found and valid, None otherwise.
238+
"""
239+
# Map component type to expected decorator name
240+
decorator_names = {
241+
ComponentType.TOOL: "tool",
242+
ComponentType.RESOURCE: "resource",
243+
ComponentType.PROMPT: "prompt",
244+
}
245+
expected_decorator = decorator_names.get(component_type)
246+
if not expected_decorator:
247+
return None
248+
249+
for decorator in func_node.decorator_list:
250+
# Handle @tool(...) or @golf.tool(...)
251+
if isinstance(decorator, ast.Call):
252+
func = decorator.func
253+
254+
# Check for @tool(...) pattern
255+
is_direct_decorator = isinstance(func, ast.Name) and func.id == expected_decorator
256+
257+
# Check for @golf.tool(...) pattern
258+
is_qualified_decorator = (
259+
isinstance(func, ast.Attribute)
260+
and func.attr == expected_decorator
261+
and isinstance(func.value, ast.Name)
262+
and func.value.id == "golf"
263+
)
264+
265+
if is_direct_decorator or is_qualified_decorator:
266+
# Check for positional arg: @tool("name")
267+
if decorator.args:
268+
first_arg = decorator.args[0]
269+
if isinstance(first_arg, ast.Constant) and isinstance(first_arg.value, str):
270+
return first_arg.value
271+
else:
272+
# Non-string or dynamic value
273+
console.print(
274+
"[yellow]Warning: Decorator name must be a string literal, "
275+
"falling back to path-derived name[/yellow]"
276+
)
277+
return None
278+
279+
# Check for keyword arg: @tool(name="name")
280+
for keyword in decorator.keywords:
281+
if keyword.arg == "name":
282+
if isinstance(keyword.value, ast.Constant) and isinstance(keyword.value.value, str):
283+
return keyword.value.value
284+
else:
285+
# Non-string or dynamic value
286+
console.print(
287+
"[yellow]Warning: Decorator name must be a string literal, "
288+
"falling back to path-derived name[/yellow]"
289+
)
290+
return None
291+
292+
return None
293+
217294
def _process_entry_function(
218295
self,
219296
component: ParsedComponent,

src/golf/decorators.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Decorators for Golf MCP components."""
2+
3+
from collections.abc import Callable
4+
from typing import TypeVar
5+
6+
F = TypeVar("F", bound=Callable[..., object])
7+
8+
9+
def tool(name: str) -> Callable[[F], F]:
10+
"""Decorator to set an explicit tool name.
11+
12+
Args:
13+
name: The tool name to use instead of deriving from file path.
14+
15+
Example:
16+
@tool(name="stripe_charge")
17+
async def run(amount: int) -> str:
18+
return f"Charged {amount}"
19+
"""
20+
21+
def decorator(func: F) -> F:
22+
func._golf_name = name # type: ignore[attr-defined]
23+
return func
24+
25+
return decorator
26+
27+
28+
def resource(name: str) -> Callable[[F], F]:
29+
"""Decorator to set an explicit resource name.
30+
31+
Args:
32+
name: The resource name to use instead of deriving from file path.
33+
34+
Example:
35+
@resource(name="user_profile")
36+
async def run(user_id: str) -> dict:
37+
return {"user_id": user_id}
38+
"""
39+
40+
def decorator(func: F) -> F:
41+
func._golf_name = name # type: ignore[attr-defined]
42+
return func
43+
44+
return decorator
45+
46+
47+
def prompt(name: str) -> Callable[[F], F]:
48+
"""Decorator to set an explicit prompt name.
49+
50+
Args:
51+
name: The prompt name to use instead of deriving from file path.
52+
53+
Example:
54+
@prompt(name="greeting")
55+
async def run(name: str) -> list:
56+
return [{"role": "user", "content": f"Hello {name}"}]
57+
"""
58+
59+
def decorator(func: F) -> F:
60+
func._golf_name = name # type: ignore[attr-defined]
61+
return func
62+
63+
return decorator

0 commit comments

Comments
 (0)