Skip to content

Commit 6d587fd

Browse files
authored
Merge pull request #73 from golf-mcp/asch/tool-annotations
Asch/tool annotations
2 parents b71aeae + 392512b commit 6d587fd

File tree

10 files changed

+967
-30
lines changed

10 files changed

+967
-30
lines changed

.github/SECURITY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
| Version | Supported |
66
| ------- | ------------------ |
7+
| 0.1.15 | :white_check_mark: |
78
| 0.1.14 | :white_check_mark: |
89

910

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.1.14"
7+
version = "0.1.15"
88
description = "Framework for building MCP servers"
99
authors = [
1010
{name = "Antoni Gmitruk", email = "antoni@golf.dev"}
@@ -64,7 +64,7 @@ golf = ["examples/**/*"]
6464

6565
[tool.poetry]
6666
name = "golf-mcp"
67-
version = "0.1.14"
67+
version = "0.1.15"
6868
description = "Framework for building MCP servers with zero boilerplate"
6969
authors = ["Antoni Gmitruk <antoni@golf.dev>"]
7070
license = "Apache-2.0"

src/golf/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.1.14"
1+
__version__ = "0.1.15"

src/golf/cli/main.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ def build_dev(
162162
e,
163163
context="Development build with environment variables",
164164
operation="build_dev",
165-
additional_props={"environment": "dev", "copy_env": True}
165+
additional_props={"environment": "dev", "copy_env": True},
166166
)
167167
raise
168168

@@ -214,7 +214,7 @@ def build_prod(
214214
e,
215215
context="Production build without environment variables",
216216
operation="build_prod",
217-
additional_props={"environment": "prod", "copy_env": False}
217+
additional_props={"environment": "prod", "copy_env": False},
218218
)
219219
raise
220220

@@ -275,15 +275,13 @@ def run(
275275

276276
build_project(project_root, settings, dist_dir)
277277
except Exception as e:
278-
console.print(
279-
f"[bold red]Error building project:[/bold red] {str(e)}"
280-
)
278+
console.print(f"[bold red]Error building project:[/bold red] {str(e)}")
281279
track_detailed_error(
282280
"cli_run_failed",
283281
e,
284282
context="Auto-build before running server",
285283
operation="auto_build_before_run",
286-
additional_props={"auto_build": True}
284+
additional_props={"auto_build": True},
287285
)
288286
raise
289287
else:
@@ -326,11 +324,11 @@ def run(
326324
# 2: General interrupt/graceful shutdown
327325
shutdown_type = {
328326
130: "UserInterrupt",
329-
143: "GracefulShutdown",
327+
143: "GracefulShutdown",
330328
137: "ForcedShutdown",
331-
2: "Interrupt"
329+
2: "Interrupt",
332330
}.get(return_code, "GracefulShutdown")
333-
331+
334332
track_event(
335333
"cli_run_shutdown",
336334
{
@@ -362,7 +360,7 @@ def run(
362360
e,
363361
context="Server execution or startup failure",
364362
operation="run_server_execution",
365-
additional_props={"has_dist_dir": dist_dir.exists() if dist_dir else False}
363+
additional_props={"has_dist_dir": dist_dir.exists() if dist_dir else False},
366364
)
367365
raise
368366

src/golf/commands/run.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,21 @@ def run_server(
7070
elif process.returncode == 130:
7171
console.print("[yellow]Server stopped by user interrupt (Ctrl+C)[/yellow]")
7272
elif process.returncode == 143:
73-
console.print("[yellow]Server stopped by SIGTERM (graceful shutdown)[/yellow]")
73+
console.print(
74+
"[yellow]Server stopped by SIGTERM (graceful shutdown)[/yellow]"
75+
)
7476
elif process.returncode == 137:
75-
console.print("[yellow]Server stopped by SIGKILL (forced shutdown)[/yellow]")
77+
console.print(
78+
"[yellow]Server stopped by SIGKILL (forced shutdown)[/yellow]"
79+
)
7680
elif process.returncode in [1, 2]:
77-
console.print(f"[red]Server exited with error code {process.returncode}[/red]")
81+
console.print(
82+
f"[red]Server exited with error code {process.returncode}[/red]"
83+
)
7884
else:
79-
console.print(f"[orange]Server exited with code {process.returncode}[/orange]")
85+
console.print(
86+
f"[orange]Server exited with code {process.returncode}[/orange]"
87+
)
8088

8189
return process.returncode
8290
except KeyboardInterrupt:

src/golf/core/builder.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@ def _process_tools(self) -> None:
9797
if required_fields:
9898
tool_schema["inputSchema"]["required"] = required_fields
9999

100+
# Add tool annotations if present
101+
if component.annotations:
102+
# Merge with existing annotations (keeping title)
103+
tool_schema["annotations"].update(component.annotations)
104+
100105
# Add the tool to the manifest
101106
self.manifest["tools"].append(tool_schema)
102107

@@ -662,7 +667,11 @@ def _generate_server(self) -> None:
662667
registration += f"\n_wrapped_func = instrument_{component_type.value}({full_module_path}.{entry_func}, '{component.name}')"
663668

664669
if component_type == ComponentType.TOOL:
665-
registration += f'\nmcp.add_tool(_wrapped_func, name="{component.name}", description="{component.docstring or ""}")'
670+
registration += f'\nmcp.add_tool(_wrapped_func, name="{component.name}", description="{component.docstring or ""}"'
671+
# Add annotations if present
672+
if hasattr(component, "annotations") and component.annotations:
673+
registration += f", annotations={component.annotations}"
674+
registration += ")"
666675
elif component_type == ComponentType.RESOURCE:
667676
registration += f'\nmcp.add_resource_fn(_wrapped_func, uri="{component.uri_template}", name="{component.name}", description="{component.docstring or ""}")'
668677
else: # PROMPT
@@ -689,6 +698,11 @@ def _generate_server(self) -> None:
689698
# Escape any quotes in the docstring
690699
escaped_docstring = component.docstring.replace('"', '\\"')
691700
registration += f', description="{escaped_docstring}"'
701+
702+
# Add annotations if present
703+
if hasattr(component, "annotations") and component.annotations:
704+
registration += f", annotations={component.annotations}"
705+
692706
registration += ")"
693707

694708
elif component_type == ComponentType.RESOURCE:
@@ -711,6 +725,7 @@ def _generate_server(self) -> None:
711725
# Escape any quotes in the docstring
712726
escaped_docstring = component.docstring.replace('"', '\\"')
713727
registration += f', description="{escaped_docstring}"'
728+
714729
registration += ")"
715730

716731
else: # PROMPT
@@ -735,6 +750,7 @@ def _generate_server(self) -> None:
735750
# Escape any quotes in the docstring
736751
escaped_docstring = component.docstring.replace('"', '\\"')
737752
registration += f', description="{escaped_docstring}"'
753+
738754
registration += ")"
739755

740756
component_registrations.append(registration)
@@ -950,10 +966,11 @@ def build_project(
950966
import traceback
951967

952968
console.print(f"[red]{traceback.format_exc()}[/red]")
953-
969+
954970
# Track detailed error for pre_build.py execution failures
955971
try:
956972
from golf.core.telemetry import track_detailed_error
973+
957974
track_detailed_error(
958975
"build_pre_build_failed",
959976
e,
@@ -962,7 +979,7 @@ def build_project(
962979
additional_props={
963980
"file_path": str(pre_build_path.relative_to(project_path)),
964981
"build_env": build_env,
965-
}
982+
},
966983
)
967984
except Exception:
968985
# Don't let telemetry errors break the build

src/golf/core/parser.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ class ParsedComponent:
3636
parameters: list[str] | None = None # For resources with URI params
3737
parent_module: str | None = None # For nested components
3838
entry_function: str | None = None # Store the name of the function to use
39+
annotations: dict[str, Any] | None = None # Tool annotations for MCP hints
3940

4041

4142
class AstParser:
@@ -223,17 +224,25 @@ def _process_entry_function(
223224
component.parameters = parameters
224225

225226
def _process_tool(self, component: ParsedComponent, tree: ast.Module) -> None:
226-
"""Process a tool component to extract input/output schemas."""
227+
"""Process a tool component to extract input/output schemas and annotations."""
227228
# Look for Input and Output classes in the AST
228229
input_class = None
229230
output_class = None
231+
annotations = None
230232

231233
for node in tree.body:
232234
if isinstance(node, ast.ClassDef):
233235
if node.name == "Input":
234236
input_class = node
235237
elif node.name == "Output":
236238
output_class = node
239+
# Look for annotations assignment
240+
elif isinstance(node, ast.Assign):
241+
for target in node.targets:
242+
if isinstance(target, ast.Name) and target.id == "annotations":
243+
if isinstance(node.value, ast.Dict):
244+
annotations = self._extract_dict_from_ast(node.value)
245+
break
237246

238247
# Process Input class if found
239248
if input_class:
@@ -255,6 +264,10 @@ def _process_tool(self, component: ParsedComponent, tree: ast.Module) -> None:
255264
)
256265
break
257266

267+
# Store annotations if found
268+
if annotations:
269+
component.annotations = annotations
270+
258271
def _process_resource(self, component: ParsedComponent, tree: ast.Module) -> None:
259272
"""Process a resource component to extract URI template."""
260273
# Look for resource_uri assignment in the AST
@@ -437,6 +450,46 @@ def _type_hint_to_json_type(self, type_hint: str) -> str:
437450
# Default to string for unknown types
438451
return "string"
439452

453+
def _extract_dict_from_ast(self, dict_node: ast.Dict) -> dict[str, Any]:
454+
"""Extract a dictionary from an AST Dict node.
455+
456+
This handles simple literal dictionaries with string keys and
457+
boolean/string/number values.
458+
"""
459+
result = {}
460+
461+
for key, value in zip(dict_node.keys, dict_node.values, strict=False):
462+
# Extract the key
463+
if isinstance(key, ast.Constant) and isinstance(key.value, str):
464+
key_str = key.value
465+
elif isinstance(key, ast.Str): # For older Python versions
466+
key_str = key.s
467+
else:
468+
# Skip non-string keys
469+
continue
470+
471+
# Extract the value
472+
if isinstance(value, ast.Constant):
473+
# Handles strings, numbers, booleans, None
474+
result[key_str] = value.value
475+
elif isinstance(value, ast.Str): # For older Python versions
476+
result[key_str] = value.s
477+
elif isinstance(value, ast.Num): # For older Python versions
478+
result[key_str] = value.n
479+
elif isinstance(
480+
value, ast.NameConstant
481+
): # For older Python versions (True/False/None)
482+
result[key_str] = value.value
483+
elif isinstance(value, ast.Name):
484+
# Handle True/False/None as names
485+
if value.id in ("True", "False", "None"):
486+
result[key_str] = {"True": True, "False": False, "None": None}[
487+
value.id
488+
]
489+
# We could add more complex value handling here if needed
490+
491+
return result
492+
440493

441494
def parse_project(project_path: Path) -> dict[ComponentType, list[ParsedComponent]]:
442495
"""Parse a GolfMCP project to extract all components."""

src/golf/core/telemetry.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -366,30 +366,32 @@ def track_detailed_error(
366366
# Get the last few frames (most relevant) and sanitize them
367367
relevant_frames = tb_lines[-3:] if len(tb_lines) > 3 else tb_lines
368368
sanitized_trace = []
369-
369+
370370
for frame in relevant_frames:
371371
# Sanitize file paths in stack trace
372372
sanitized_frame = _sanitize_error_message(frame.strip())
373373
# Further sanitize common traceback patterns
374374
sanitized_frame = sanitized_frame.replace('File "[PATH]', 'File "[PATH]')
375375
sanitized_trace.append(sanitized_frame)
376-
376+
377377
properties["stack_trace"] = " | ".join(sanitized_trace)
378-
378+
379379
# Add the specific line that caused the error if available
380-
if hasattr(error, '__traceback__') and error.__traceback__:
380+
if hasattr(error, "__traceback__") and error.__traceback__:
381381
tb = error.__traceback__
382382
while tb.tb_next:
383383
tb = tb.tb_next
384384
properties["error_line"] = tb.tb_lineno
385-
385+
386386
except Exception:
387387
# Don't fail if we can't capture stack trace
388388
pass
389389

390390
# Add system context for debugging
391391
try:
392-
properties["python_executable"] = _sanitize_error_message(platform.python_implementation())
392+
properties["python_executable"] = _sanitize_error_message(
393+
platform.python_implementation()
394+
)
393395
properties["platform_detail"] = platform.platform()[:50] # Limit length
394396
except Exception:
395397
pass
@@ -398,16 +400,25 @@ def track_detailed_error(
398400
if additional_props:
399401
# Only include safe additional properties
400402
safe_additional_keys = {
401-
"exit_code", "shutdown_type", "environment", "template",
402-
"build_env", "transport", "component_count", "file_path",
403-
"component_type", "validation_error", "config_error"
403+
"exit_code",
404+
"shutdown_type",
405+
"environment",
406+
"template",
407+
"build_env",
408+
"transport",
409+
"component_count",
410+
"file_path",
411+
"component_type",
412+
"validation_error",
413+
"config_error",
404414
}
405415
for key, value in additional_props.items():
406416
if key in safe_additional_keys:
407417
properties[key] = value
408418

409419
track_event(event_name, properties)
410420

421+
411422
def _sanitize_error_message(message: str) -> str:
412423
"""Sanitize error messages to remove sensitive information."""
413424
import re

0 commit comments

Comments
 (0)