golf-mcp 0.2.16__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. golf/__init__.py +1 -0
  2. golf/auth/__init__.py +277 -0
  3. golf/auth/api_key.py +73 -0
  4. golf/auth/factory.py +360 -0
  5. golf/auth/helpers.py +175 -0
  6. golf/auth/providers.py +586 -0
  7. golf/auth/registry.py +256 -0
  8. golf/cli/__init__.py +1 -0
  9. golf/cli/branding.py +191 -0
  10. golf/cli/main.py +377 -0
  11. golf/commands/__init__.py +5 -0
  12. golf/commands/build.py +81 -0
  13. golf/commands/init.py +290 -0
  14. golf/commands/run.py +137 -0
  15. golf/core/__init__.py +1 -0
  16. golf/core/builder.py +1884 -0
  17. golf/core/builder_auth.py +209 -0
  18. golf/core/builder_metrics.py +221 -0
  19. golf/core/builder_telemetry.py +99 -0
  20. golf/core/config.py +199 -0
  21. golf/core/parser.py +1085 -0
  22. golf/core/telemetry.py +492 -0
  23. golf/core/transformer.py +231 -0
  24. golf/examples/__init__.py +0 -0
  25. golf/examples/basic/.env.example +4 -0
  26. golf/examples/basic/README.md +133 -0
  27. golf/examples/basic/auth.py +76 -0
  28. golf/examples/basic/golf.json +5 -0
  29. golf/examples/basic/prompts/welcome.py +27 -0
  30. golf/examples/basic/resources/current_time.py +34 -0
  31. golf/examples/basic/resources/info.py +28 -0
  32. golf/examples/basic/resources/weather/city.py +46 -0
  33. golf/examples/basic/resources/weather/client.py +48 -0
  34. golf/examples/basic/resources/weather/current.py +36 -0
  35. golf/examples/basic/resources/weather/forecast.py +36 -0
  36. golf/examples/basic/tools/calculator.py +94 -0
  37. golf/examples/basic/tools/say/hello.py +65 -0
  38. golf/metrics/__init__.py +10 -0
  39. golf/metrics/collector.py +320 -0
  40. golf/metrics/registry.py +12 -0
  41. golf/telemetry/__init__.py +23 -0
  42. golf/telemetry/instrumentation.py +1402 -0
  43. golf/utilities/__init__.py +12 -0
  44. golf/utilities/context.py +53 -0
  45. golf/utilities/elicitation.py +170 -0
  46. golf/utilities/sampling.py +221 -0
  47. golf_mcp-0.2.16.dist-info/METADATA +262 -0
  48. golf_mcp-0.2.16.dist-info/RECORD +52 -0
  49. golf_mcp-0.2.16.dist-info/WHEEL +5 -0
  50. golf_mcp-0.2.16.dist-info/entry_points.txt +2 -0
  51. golf_mcp-0.2.16.dist-info/licenses/LICENSE +201 -0
  52. golf_mcp-0.2.16.dist-info/top_level.txt +1 -0
golf/core/parser.py ADDED
@@ -0,0 +1,1085 @@
1
+ """Python file parser for extracting tools, resources, and prompts using AST."""
2
+
3
+ import ast
4
+ import re
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from rich.console import Console
11
+
12
+ console = Console()
13
+
14
+
15
+ class ComponentType(str, Enum):
16
+ """Type of component discovered by the parser."""
17
+
18
+ TOOL = "tool"
19
+ RESOURCE = "resource"
20
+ PROMPT = "prompt"
21
+ ROUTE = "route"
22
+ UNKNOWN = "unknown"
23
+
24
+
25
+ @dataclass
26
+ class ParsedComponent:
27
+ """Represents a parsed MCP component (tool, resource, or prompt)."""
28
+
29
+ name: str # Derived from file path or explicit name
30
+ type: ComponentType
31
+ file_path: Path
32
+ module_path: str
33
+ docstring: str | None = None
34
+ input_schema: dict[str, Any] | None = None
35
+ output_schema: dict[str, Any] | None = None
36
+ uri_template: str | None = None # For resources
37
+ parameters: list[str] | None = None # For resources with URI params
38
+ parent_module: str | None = None # For nested components
39
+ entry_function: str | None = None # Store the name of the function to use
40
+ annotations: dict[str, Any] | None = None # Tool annotations for MCP hints
41
+
42
+
43
+ class AstParser:
44
+ """AST-based parser for extracting MCP components from Python files."""
45
+
46
+ def __init__(self, project_root: Path) -> None:
47
+ """Initialize the parser.
48
+
49
+ Args:
50
+ project_root: Root directory of the project
51
+ """
52
+ self.project_root = project_root
53
+ self.components: dict[str, ParsedComponent] = {}
54
+
55
+ def parse_directory(self, directory: Path) -> list[ParsedComponent]:
56
+ """Parse all Python files in a directory recursively."""
57
+ components = []
58
+
59
+ for file_path in directory.glob("**/*.py"):
60
+ # Skip __pycache__ and other hidden directories
61
+ if "__pycache__" in file_path.parts or any(part.startswith(".") for part in file_path.parts):
62
+ continue
63
+
64
+ try:
65
+ file_components = self.parse_file(file_path)
66
+ components.extend(file_components)
67
+ except Exception as e:
68
+ relative_path = file_path.relative_to(self.project_root)
69
+ console.print(f"[bold red]Error parsing {relative_path}:[/bold red] {e}")
70
+
71
+ return components
72
+
73
+ def parse_file(self, file_path: Path) -> list[ParsedComponent]:
74
+ """Parse a single Python file using AST to extract MCP components."""
75
+ # Handle common.py files
76
+ if file_path.name == "common.py":
77
+ # Register as a known shared module but don't return as a component
78
+ return []
79
+
80
+ # Skip __init__.py files for direct parsing
81
+ if file_path.name == "__init__.py":
82
+ return []
83
+
84
+ # Determine component type based on directory structure
85
+ rel_path = file_path.relative_to(self.project_root)
86
+ parent_dir = rel_path.parts[0] if rel_path.parts else None
87
+
88
+ component_type = ComponentType.UNKNOWN
89
+ if parent_dir == "tools":
90
+ component_type = ComponentType.TOOL
91
+ elif parent_dir == "resources":
92
+ component_type = ComponentType.RESOURCE
93
+ elif parent_dir == "prompts":
94
+ component_type = ComponentType.PROMPT
95
+
96
+ if component_type == ComponentType.UNKNOWN:
97
+ return [] # Not in a recognized directory
98
+
99
+ # Read the file content and parse it with AST
100
+ with open(file_path, encoding="utf-8") as f:
101
+ file_content = f.read()
102
+
103
+ try:
104
+ tree = ast.parse(file_content)
105
+ except SyntaxError as e:
106
+ raise ValueError(f"Syntax error in {file_path}: {e}")
107
+
108
+ # Find the entry function - look for "export = function_name" pattern,
109
+ # or any top-level function (like "run") as a fallback
110
+ entry_function = None
111
+ export_target = None
112
+
113
+ # Look for export = function_name assignment
114
+ for node in tree.body:
115
+ if isinstance(node, ast.Assign):
116
+ for target in node.targets:
117
+ if isinstance(target, ast.Name) and target.id == "export" and isinstance(node.value, ast.Name):
118
+ export_target = node.value.id
119
+ break
120
+
121
+ # Find all top-level functions
122
+ functions = []
123
+ for node in tree.body:
124
+ if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
125
+ functions.append(node)
126
+ # If this function matches our export target, it's our entry function
127
+ if export_target and node.name == export_target:
128
+ entry_function = node
129
+
130
+ # Check for the run function as a fallback
131
+ run_function = None
132
+ for func in functions:
133
+ if func.name == "run":
134
+ run_function = func
135
+
136
+ # If we have an export but didn't find the target function, warn
137
+ if export_target and not entry_function:
138
+ console.print(f"[yellow]Warning: Export target '{export_target}' not found in {file_path}[/yellow]")
139
+
140
+ # Use the export target function if found, otherwise fall back to run
141
+ entry_function = entry_function or run_function
142
+
143
+ # If no valid function found, skip this file
144
+ if not entry_function:
145
+ return []
146
+
147
+ # Extract component description prioritizing function over module docstring
148
+ description = self._extract_component_description(tree, entry_function, file_path)
149
+
150
+ # Create component
151
+ component = ParsedComponent(
152
+ name="", # Will be set later
153
+ type=component_type,
154
+ file_path=file_path,
155
+ module_path=file_path.relative_to(self.project_root).as_posix(),
156
+ docstring=description,
157
+ entry_function=export_target or "run", # Store the name of the entry function
158
+ )
159
+
160
+ # Process the entry function
161
+ self._process_entry_function(component, entry_function, tree, file_path)
162
+
163
+ # Process other component-specific information
164
+ if component_type == ComponentType.TOOL:
165
+ self._process_tool(component, tree)
166
+ elif component_type == ComponentType.RESOURCE:
167
+ self._process_resource(component, tree)
168
+ elif component_type == ComponentType.PROMPT:
169
+ self._process_prompt(component, tree)
170
+
171
+ # Set component name based on file path
172
+ component.name = self._derive_component_name(file_path, component_type)
173
+
174
+ # Set parent module if it's in a nested structure
175
+ if len(rel_path.parts) > 2: # More than just "tools/file.py"
176
+ parent_parts = rel_path.parts[1:-1] # Skip the root category and the file itself
177
+ if parent_parts:
178
+ component.parent_module = ".".join(parent_parts)
179
+
180
+ return [component]
181
+
182
+ def _extract_component_description(
183
+ self, tree: ast.Module, entry_function: ast.FunctionDef | ast.AsyncFunctionDef, file_path: Path
184
+ ) -> str:
185
+ """Extract component description prioritizing function over module docstring.
186
+
187
+ Args:
188
+ tree: The AST module
189
+ entry_function: The entry function node
190
+ file_path: Path to the file being parsed
191
+
192
+ Returns:
193
+ The description string from function or module docstring
194
+
195
+ Raises:
196
+ ValueError: If neither function nor module docstring is found
197
+ """
198
+ function_docstring = None
199
+ module_docstring = ast.get_docstring(tree)
200
+
201
+ # Extract function docstring if entry function exists
202
+ if entry_function:
203
+ function_docstring = ast.get_docstring(entry_function)
204
+
205
+ # Prefer function docstring, fall back to module docstring
206
+ description = function_docstring or module_docstring
207
+
208
+ if not description:
209
+ raise ValueError(
210
+ f"Missing docstring in {file_path}. "
211
+ f"Add either a function docstring to your exported function "
212
+ f"or a module docstring at the top of the file."
213
+ )
214
+
215
+ return description
216
+
217
+ def _process_entry_function(
218
+ self,
219
+ component: ParsedComponent,
220
+ func_node: ast.FunctionDef | ast.AsyncFunctionDef,
221
+ tree: ast.Module,
222
+ file_path: Path,
223
+ ) -> None:
224
+ """Process the entry function to extract parameters and return type."""
225
+ # Check for return annotation - STRICT requirement
226
+ if func_node.returns is None:
227
+ raise ValueError(f"Missing return annotation for {func_node.name} function in {file_path}")
228
+
229
+ # Extract parameter names for basic info
230
+ parameters = []
231
+ for arg in func_node.args.args:
232
+ # Skip self, cls, ctx parameters
233
+ if arg.arg not in ("self", "cls", "ctx"):
234
+ parameters.append(arg.arg)
235
+
236
+ # Store parameters
237
+ component.parameters = parameters
238
+
239
+ # Extract schemas using runtime inspection (safer and more accurate)
240
+ try:
241
+ self._extract_schemas_at_runtime(component, file_path)
242
+ except Exception as e:
243
+ console.print(f"[yellow]Warning: Could not extract schemas from {file_path}: {e}[/yellow]")
244
+ # Continue without schemas - better than failing the build
245
+
246
+ def _extract_schemas_at_runtime(self, component: ParsedComponent, file_path: Path) -> None:
247
+ """Extract input/output schemas by importing and inspecting the
248
+ actual function."""
249
+ import importlib.util
250
+ import sys
251
+
252
+ # Convert file path to module name
253
+ rel_path = file_path.relative_to(self.project_root)
254
+ module_name = str(rel_path.with_suffix("")).replace("/", ".")
255
+
256
+ # Temporarily add project root to sys.path
257
+ project_root_str = str(self.project_root)
258
+ if project_root_str not in sys.path:
259
+ sys.path.insert(0, project_root_str)
260
+ cleanup_path = True
261
+ else:
262
+ cleanup_path = False
263
+
264
+ try:
265
+ # Import the module
266
+ spec = importlib.util.spec_from_file_location(module_name, file_path)
267
+ if spec is None or spec.loader is None:
268
+ return
269
+
270
+ module = importlib.util.module_from_spec(spec)
271
+ spec.loader.exec_module(module)
272
+
273
+ # Get the entry function
274
+ if not hasattr(module, component.entry_function):
275
+ return
276
+
277
+ func = getattr(module, component.entry_function)
278
+
279
+ # Extract input schema from function signature
280
+ component.input_schema = self._extract_input_schema(func)
281
+
282
+ # Extract output schema from return type annotation
283
+ component.output_schema = self._extract_output_schema(func)
284
+
285
+ finally:
286
+ # Clean up sys.path
287
+ if cleanup_path and project_root_str in sys.path:
288
+ sys.path.remove(project_root_str)
289
+
290
+ def _extract_input_schema(self, func: Any) -> dict[str, Any] | None:
291
+ """Extract input schema from function signature using runtime inspection."""
292
+ import inspect
293
+ from typing import get_type_hints
294
+
295
+ try:
296
+ sig = inspect.signature(func)
297
+ type_hints = get_type_hints(func, include_extras=True)
298
+
299
+ properties = {}
300
+ required = []
301
+
302
+ for param_name, param in sig.parameters.items():
303
+ # Skip special parameters
304
+ if param_name in ("self", "cls", "ctx"):
305
+ continue
306
+
307
+ # Get type hint
308
+ if param_name not in type_hints:
309
+ continue
310
+
311
+ type_hint = type_hints[param_name]
312
+
313
+ # Extract schema for this parameter
314
+ param_schema = self._extract_param_schema_from_hint(type_hint, param_name)
315
+ if param_schema:
316
+ # Clean the schema to remove problematic objects
317
+ cleaned_schema = self._clean_schema(param_schema)
318
+ if cleaned_schema:
319
+ properties[param_name] = cleaned_schema
320
+
321
+ # Check if required (no default value)
322
+ if param.default is param.empty:
323
+ required.append(param_name)
324
+
325
+ if properties:
326
+ return {
327
+ "type": "object",
328
+ "properties": properties,
329
+ "required": required,
330
+ }
331
+
332
+ except Exception as e:
333
+ console.print(f"[yellow]Warning: Could not extract input schema: {e}[/yellow]")
334
+
335
+ return None
336
+
337
+ def _extract_output_schema(self, func: Any) -> dict[str, Any] | None:
338
+ """Extract output schema from return type annotation."""
339
+ from typing import get_type_hints
340
+
341
+ try:
342
+ type_hints = get_type_hints(func, include_extras=True)
343
+ return_type = type_hints.get("return")
344
+
345
+ if return_type is None:
346
+ return None
347
+
348
+ # If it's a Pydantic BaseModel, extract schema manually
349
+ if hasattr(return_type, "model_fields"):
350
+ return self._extract_pydantic_model_schema(return_type)
351
+
352
+ # For other types, create a simple schema
353
+ return self._type_to_schema(return_type)
354
+
355
+ except Exception as e:
356
+ console.print(f"[yellow]Warning: Could not extract output schema: {e}[/yellow]")
357
+
358
+ return None
359
+
360
+ def _extract_pydantic_model_schema(self, model_class: Any) -> dict[str, Any]:
361
+ """Extract schema from Pydantic model by inspecting fields directly."""
362
+ try:
363
+ schema = {"type": "object", "properties": {}, "required": []}
364
+
365
+ if hasattr(model_class, "model_fields"):
366
+ for field_name, field_info in model_class.model_fields.items():
367
+ # Extract field type
368
+ field_type = field_info.annotation if hasattr(field_info, "annotation") else None
369
+ if field_type:
370
+ field_schema = self._type_to_schema(field_type)
371
+
372
+ # Add description if available
373
+ if hasattr(field_info, "description") and field_info.description:
374
+ field_schema["description"] = field_info.description
375
+
376
+ # Add title
377
+ field_schema["title"] = field_name.replace("_", " ").title()
378
+
379
+ # Add default if available
380
+ if hasattr(field_info, "default") and field_info.default is not None:
381
+ try:
382
+ # Only add if it's JSON serializable
383
+ import json
384
+
385
+ json.dumps(field_info.default)
386
+ field_schema["default"] = field_info.default
387
+ except:
388
+ pass
389
+
390
+ schema["properties"][field_name] = field_schema
391
+
392
+ # Check if required
393
+ if hasattr(field_info, "is_required") and field_info.is_required():
394
+ schema["required"].append(field_name)
395
+ elif not hasattr(field_info, "default") or field_info.default is None:
396
+ # Assume required if no default
397
+ schema["required"].append(field_name)
398
+
399
+ return schema
400
+
401
+ except Exception as e:
402
+ console.print(f"[yellow]Warning: Could not extract Pydantic model schema: {e}[/yellow]")
403
+ return {"type": "object"}
404
+
405
+ def _clean_schema(self, schema: Any) -> dict[str, Any]:
406
+ """Clean up a schema to remove non-JSON-serializable objects."""
407
+ import json
408
+
409
+ def clean_object(obj: Any) -> Any:
410
+ if obj is None:
411
+ return None
412
+ elif isinstance(obj, (str, int, float, bool)):
413
+ return obj
414
+ elif isinstance(obj, dict):
415
+ cleaned = {}
416
+ for k, v in obj.items():
417
+ # Skip problematic keys
418
+ if k in ["definitions", "$defs", "allOf", "anyOf", "oneOf"]:
419
+ continue
420
+ cleaned_v = clean_object(v)
421
+ if cleaned_v is not None:
422
+ cleaned[k] = cleaned_v
423
+ return cleaned if cleaned else None
424
+ elif isinstance(obj, list):
425
+ cleaned = []
426
+ for item in obj:
427
+ cleaned_item = clean_object(item)
428
+ if cleaned_item is not None:
429
+ cleaned.append(cleaned_item)
430
+ return cleaned if cleaned else None
431
+ else:
432
+ # For any other type, test JSON serializability
433
+ try:
434
+ json.dumps(obj)
435
+ return obj
436
+ except (TypeError, ValueError):
437
+ # If it's not JSON serializable, try to get a string representation
438
+ if hasattr(obj, "__name__"):
439
+ return obj.__name__
440
+ elif hasattr(obj, "__str__"):
441
+ try:
442
+ str_val = str(obj)
443
+ if str_val and str_val != repr(obj):
444
+ return str_val
445
+ except:
446
+ pass
447
+ return None
448
+
449
+ cleaned = clean_object(schema)
450
+ return cleaned if cleaned else {"type": "object"}
451
+
452
+ def _extract_param_schema_from_hint(self, type_hint: Any, param_name: str) -> dict[str, Any] | None:
453
+ """Extract parameter schema from type hint (including Annotated types)."""
454
+ from typing import get_args, get_origin
455
+
456
+ # Handle Annotated types
457
+ if get_origin(type_hint) is not None:
458
+ origin = get_origin(type_hint)
459
+ args = get_args(type_hint)
460
+
461
+ # Check for Annotated[Type, Field(...)]
462
+ if hasattr(origin, "__name__") and origin.__name__ == "Annotated" and len(args) >= 2:
463
+ base_type = args[0]
464
+ metadata = args[1:]
465
+
466
+ # Start with base type schema
467
+ schema = self._type_to_schema(base_type)
468
+
469
+ # Extract Field metadata
470
+ for meta in metadata:
471
+ if hasattr(meta, "description") and meta.description:
472
+ schema["description"] = meta.description
473
+ if hasattr(meta, "title") and meta.title:
474
+ schema["title"] = meta.title
475
+ if hasattr(meta, "default") and meta.default is not None:
476
+ schema["default"] = meta.default
477
+ # Add other Field constraints as needed
478
+
479
+ return schema
480
+
481
+ # For non-Annotated types, just convert the type
482
+ return self._type_to_schema(type_hint)
483
+
484
+ def _type_to_schema(self, type_hint: object) -> dict[str, Any]:
485
+ """Convert a Python type to JSON schema."""
486
+ from typing import get_args, get_origin
487
+ import types
488
+
489
+ # Handle None/NoneType
490
+ if type_hint is type(None):
491
+ return {"type": "null"}
492
+
493
+ # Handle basic types
494
+ if type_hint is str:
495
+ return {"type": "string"}
496
+ elif type_hint is int:
497
+ return {"type": "integer"}
498
+ elif type_hint is float:
499
+ return {"type": "number"}
500
+ elif type_hint is bool:
501
+ return {"type": "boolean"}
502
+ elif type_hint is list:
503
+ return {"type": "array"}
504
+ elif type_hint is dict:
505
+ return {"type": "object"}
506
+
507
+ # Handle generic types
508
+ origin = get_origin(type_hint)
509
+ if origin is not None:
510
+ args = get_args(type_hint)
511
+
512
+ if origin is list:
513
+ if args:
514
+ item_schema = self._type_to_schema(args[0])
515
+ return {"type": "array", "items": item_schema}
516
+ return {"type": "array"}
517
+
518
+ elif origin is dict:
519
+ return {"type": "object"}
520
+
521
+ elif (
522
+ origin is types.UnionType
523
+ or (hasattr(types, "UnionType") and origin is types.UnionType)
524
+ or str(origin).startswith("typing.Union")
525
+ ):
526
+ # Handle Union types (including Optional)
527
+ non_none_types = [arg for arg in args if arg is not type(None)]
528
+ if len(non_none_types) == 1:
529
+ # This is Optional[Type]
530
+ return self._type_to_schema(non_none_types[0])
531
+ # For complex unions, default to object
532
+ return {"type": "object"}
533
+
534
+ # For unknown types, try to use Pydantic schema if available
535
+ if hasattr(type_hint, "model_json_schema"):
536
+ schema = type_hint.model_json_schema()
537
+ return self._clean_schema(schema)
538
+
539
+ # Default fallback
540
+ return {"type": "object"}
541
+
542
+ def _process_tool(self, component: ParsedComponent, tree: ast.Module) -> None:
543
+ """Process a tool component to extract input/output schemas and annotations."""
544
+ # Look for Input and Output classes in the AST
545
+ input_class = None
546
+ output_class = None
547
+ annotations = None
548
+
549
+ for node in tree.body:
550
+ if isinstance(node, ast.ClassDef):
551
+ if node.name == "Input":
552
+ input_class = node
553
+ elif node.name == "Output":
554
+ output_class = node
555
+ # Look for annotations assignment
556
+ elif isinstance(node, ast.Assign):
557
+ for target in node.targets:
558
+ if isinstance(target, ast.Name) and target.id == "annotations":
559
+ if isinstance(node.value, ast.Dict):
560
+ annotations = self._extract_dict_from_ast(node.value)
561
+ break
562
+
563
+ # Process Input class if found
564
+ if input_class:
565
+ # Check if it inherits from BaseModel
566
+ for base in input_class.bases:
567
+ if isinstance(base, ast.Name) and base.id == "BaseModel":
568
+ component.input_schema = self._extract_pydantic_schema_from_ast(input_class)
569
+ break
570
+
571
+ # Process Output class if found
572
+ if output_class:
573
+ # Check if it inherits from BaseModel
574
+ for base in output_class.bases:
575
+ if isinstance(base, ast.Name) and base.id == "BaseModel":
576
+ component.output_schema = self._extract_pydantic_schema_from_ast(output_class)
577
+ break
578
+
579
+ # Store annotations if found
580
+ if annotations:
581
+ component.annotations = annotations
582
+
583
+ def _process_resource(self, component: ParsedComponent, tree: ast.Module) -> None:
584
+ """Process a resource component to extract URI template."""
585
+ # Look for resource_uri assignment in the AST
586
+ for node in tree.body:
587
+ if isinstance(node, ast.Assign):
588
+ for target in node.targets:
589
+ if (
590
+ isinstance(target, ast.Name)
591
+ and target.id == "resource_uri"
592
+ and isinstance(node.value, ast.Constant)
593
+ ):
594
+ uri_template = node.value.value
595
+ component.uri_template = uri_template
596
+
597
+ # Extract URI parameters (parts in {})
598
+ uri_params = re.findall(r"{([^}]+)}", uri_template)
599
+ if uri_params:
600
+ component.parameters = uri_params
601
+ break
602
+
603
+ def _process_prompt(self, component: ParsedComponent, tree: ast.Module) -> None:
604
+ """Process a prompt component (no special processing needed)."""
605
+ pass
606
+
607
+ def _derive_component_name(self, file_path: Path, component_type: ComponentType) -> str:
608
+ """Derive a component name from its file path according to the spec.
609
+
610
+ Following the spec: <filename> + ("_" + "_".join(PathRev) if PathRev else "")
611
+ where PathRev is the reversed list of parent directories under the category.
612
+ """
613
+ rel_path = file_path.relative_to(self.project_root)
614
+
615
+ # Find which category directory this is in
616
+ category_idx = -1
617
+ for i, part in enumerate(rel_path.parts):
618
+ if part in ["tools", "resources", "prompts"]:
619
+ category_idx = i
620
+ break
621
+
622
+ if category_idx == -1:
623
+ return ""
624
+
625
+ # Get the filename without extension
626
+ filename = rel_path.stem
627
+
628
+ # Get parent directories between category and file
629
+ parent_dirs = list(rel_path.parts[category_idx + 1 : -1])
630
+
631
+ # Reverse parent dirs according to spec
632
+ parent_dirs.reverse()
633
+
634
+ # Form the ID according to spec
635
+ if parent_dirs:
636
+ return f"{filename}_{'_'.join(parent_dirs)}"
637
+ else:
638
+ return filename
639
+
640
+ def _extract_pydantic_schema_from_ast(self, class_node: ast.ClassDef) -> dict[str, Any]:
641
+ """Extract a JSON schema from an AST class definition.
642
+
643
+ This is a simplified version that extracts basic field information.
644
+ For complex annotations, a more sophisticated approach would be needed.
645
+ """
646
+ schema = {"type": "object", "properties": {}, "required": []}
647
+
648
+ for node in class_node.body:
649
+ if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
650
+ field_name = node.target.id
651
+
652
+ # Extract type annotation as string
653
+ annotation = ""
654
+ if isinstance(node.annotation, ast.Name):
655
+ annotation = node.annotation.id
656
+ elif isinstance(node.annotation, ast.Subscript):
657
+ # Simple handling of things like List[str]
658
+ annotation = ast.unparse(node.annotation)
659
+ else:
660
+ annotation = ast.unparse(node.annotation)
661
+
662
+ # Create property definition using improved type extraction
663
+ if isinstance(node.annotation, ast.Subscript):
664
+ # Use the improved complex type extraction
665
+ type_schema = self._extract_complex_type_schema(node.annotation)
666
+ if isinstance(type_schema, dict) and "type" in type_schema:
667
+ prop = type_schema.copy()
668
+ prop["title"] = field_name.replace("_", " ").title()
669
+ else:
670
+ prop = {
671
+ "type": self._type_hint_to_json_type(annotation),
672
+ "title": field_name.replace("_", " ").title(),
673
+ }
674
+ elif isinstance(node.annotation, ast.Name):
675
+ prop = {
676
+ "type": self._type_hint_to_json_type(node.annotation.id),
677
+ "title": field_name.replace("_", " ").title(),
678
+ }
679
+ else:
680
+ prop = {
681
+ "type": self._type_hint_to_json_type(annotation),
682
+ "title": field_name.replace("_", " ").title(),
683
+ }
684
+
685
+ # Extract default value if present
686
+ if node.value is not None:
687
+ if isinstance(node.value, ast.Constant):
688
+ # Simple constant default
689
+ prop["default"] = node.value.value
690
+ elif (
691
+ isinstance(node.value, ast.Call)
692
+ and isinstance(node.value.func, ast.Name)
693
+ and node.value.func.id == "Field"
694
+ ):
695
+ # Field object - extract its parameters
696
+ for keyword in node.value.keywords:
697
+ if keyword.arg == "default" or keyword.arg == "default_factory":
698
+ if isinstance(keyword.value, ast.Constant):
699
+ prop["default"] = keyword.value.value
700
+ elif keyword.arg == "description":
701
+ if isinstance(keyword.value, ast.Constant):
702
+ prop["description"] = keyword.value.value
703
+ elif keyword.arg == "title":
704
+ if isinstance(keyword.value, ast.Constant):
705
+ prop["title"] = keyword.value.value
706
+
707
+ # Check for position default argument
708
+ # (Field(..., "description"))
709
+ if node.value.args:
710
+ for i, arg in enumerate(node.value.args):
711
+ if i == 0 and isinstance(arg, ast.Constant) and arg.value != Ellipsis:
712
+ prop["default"] = arg.value
713
+ elif i == 1 and isinstance(arg, ast.Constant):
714
+ prop["description"] = arg.value
715
+
716
+ # Add to properties
717
+ schema["properties"][field_name] = prop
718
+
719
+ # Check if required (no default value or Field(...))
720
+ is_required = True
721
+ if node.value is not None:
722
+ if isinstance(node.value, ast.Constant):
723
+ is_required = False
724
+ elif (
725
+ isinstance(node.value, ast.Call)
726
+ and isinstance(node.value.func, ast.Name)
727
+ and node.value.func.id == "Field"
728
+ ):
729
+ # Field has default if it doesn't use ... or if it has a
730
+ # default keyword
731
+ has_ellipsis = False
732
+ has_default = False
733
+
734
+ if node.value.args and isinstance(node.value.args[0], ast.Constant):
735
+ has_ellipsis = node.value.args[0].value is Ellipsis
736
+
737
+ for keyword in node.value.keywords:
738
+ if keyword.arg == "default" or keyword.arg == "default_factory":
739
+ has_default = True
740
+
741
+ is_required = has_ellipsis and not has_default
742
+
743
+ if is_required:
744
+ schema["required"].append(field_name)
745
+
746
+ return schema
747
+
748
+ def _type_hint_to_json_type(self, type_hint: str) -> str:
749
+ """Convert a Python type hint to a JSON schema type.
750
+
751
+ This handles complex types and edge cases better than the original version.
752
+ """
753
+ # Handle None type
754
+ if type_hint.lower() in ["none", "nonetype"]:
755
+ return "null"
756
+
757
+ # Handle basic types first
758
+ type_map = {
759
+ "str": "string",
760
+ "int": "integer",
761
+ "float": "number",
762
+ "bool": "boolean",
763
+ "list": "array",
764
+ "dict": "object",
765
+ "any": "object", # Any maps to object
766
+ }
767
+
768
+ # Exact matches for simple types
769
+ lower_hint = type_hint.lower()
770
+ if lower_hint in type_map:
771
+ return type_map[lower_hint]
772
+
773
+ # Handle common complex patterns
774
+ if "list[" in type_hint or "List[" in type_hint:
775
+ return "array"
776
+ elif "dict[" in type_hint or "Dict[" in type_hint:
777
+ return "object"
778
+ elif "union[" in type_hint or "Union[" in type_hint:
779
+ # For Union types, try to extract the first non-None type
780
+ if "none" in lower_hint or "nonetype" in lower_hint:
781
+ # This is Optional[SomeType] - extract the SomeType
782
+ for basic_type in type_map:
783
+ if basic_type in lower_hint:
784
+ return type_map[basic_type]
785
+ return "object" # Fallback for complex unions
786
+ elif "optional[" in type_hint or "Optional[" in type_hint:
787
+ # Extract the wrapped type from Optional[Type]
788
+ for basic_type in type_map:
789
+ if basic_type in lower_hint:
790
+ return type_map[basic_type]
791
+ return "object"
792
+
793
+ # Handle some common pydantic/typing types
794
+ if any(keyword in lower_hint for keyword in ["basemodel", "model"]):
795
+ return "object"
796
+
797
+ # Check for numeric patterns
798
+ if any(num_type in lower_hint for num_type in ["int", "integer", "number"]):
799
+ return "integer"
800
+ elif any(num_type in lower_hint for num_type in ["float", "double", "decimal"]):
801
+ return "number"
802
+ elif any(str_type in lower_hint for str_type in ["str", "string", "text"]):
803
+ return "string"
804
+ elif any(bool_type in lower_hint for bool_type in ["bool", "boolean"]):
805
+ return "boolean"
806
+
807
+ # Default to object for unknown complex types, string for simple unknowns
808
+ if "[" in type_hint or "." in type_hint:
809
+ return "object"
810
+ else:
811
+ return "string"
812
+
813
+ def _extract_dict_from_ast(self, dict_node: ast.Dict) -> dict[str, Any]:
814
+ """Extract a dictionary from an AST Dict node.
815
+
816
+ This handles simple literal dictionaries with string keys and
817
+ boolean/string/number values.
818
+ """
819
+ result = {}
820
+
821
+ for key, value in zip(dict_node.keys, dict_node.values, strict=False):
822
+ # Extract the key
823
+ if isinstance(key, ast.Constant) and isinstance(key.value, str):
824
+ key_str = key.value
825
+ elif isinstance(key, ast.Str): # For older Python versions
826
+ key_str = key.s
827
+ else:
828
+ # Skip non-string keys
829
+ continue
830
+
831
+ # Extract the value
832
+ if isinstance(value, ast.Constant):
833
+ # Handles strings, numbers, booleans, None
834
+ result[key_str] = value.value
835
+ elif isinstance(value, ast.Str): # For older Python versions
836
+ result[key_str] = value.s
837
+ elif isinstance(value, ast.Num): # For older Python versions
838
+ result[key_str] = value.n
839
+ elif isinstance(value, ast.NameConstant): # For older Python versions (True/False/None)
840
+ result[key_str] = value.value
841
+ elif isinstance(value, ast.Name):
842
+ # Handle True/False/None as names
843
+ if value.id in ("True", "False", "None"):
844
+ result[key_str] = {"True": True, "False": False, "None": None}[value.id]
845
+ # We could add more complex value handling here if needed
846
+
847
+ return result
848
+
849
+ def _extract_complex_type_schema(self, subscript: ast.Subscript) -> dict[str, Any]:
850
+ """Extract schema from complex types like list[str], dict[str, Any], etc."""
851
+ if isinstance(subscript.value, ast.Name):
852
+ base_type = subscript.value.id
853
+
854
+ if base_type == "list":
855
+ # Handle list[ItemType]
856
+ if isinstance(subscript.slice, ast.Name):
857
+ item_type = self._type_hint_to_json_type(subscript.slice.id)
858
+ return {"type": "array", "items": {"type": item_type}}
859
+ elif isinstance(subscript.slice, ast.Subscript):
860
+ # Nested subscript like list[dict[str, Any]]
861
+ item_schema = self._extract_complex_type_schema(subscript.slice)
862
+ return {"type": "array", "items": item_schema}
863
+ else:
864
+ # Complex item type, try to parse it
865
+ item_type_str = ast.unparse(subscript.slice)
866
+ if "dict" in item_type_str.lower():
867
+ return {"type": "array", "items": {"type": "object"}}
868
+ else:
869
+ item_type = self._type_hint_to_json_type(item_type_str)
870
+ return {"type": "array", "items": {"type": item_type}}
871
+
872
+ elif base_type == "dict":
873
+ return {"type": "object"}
874
+
875
+ elif base_type in ["Optional", "Union"]:
876
+ # Handle Optional[Type] or Union[Type, None]
877
+ return self._handle_optional_type(subscript)
878
+
879
+ # Fallback
880
+ type_str = ast.unparse(subscript)
881
+ return {"type": self._type_hint_to_json_type(type_str)}
882
+
883
+ def _handle_union_type(self, union_node: ast.BinOp) -> dict[str, Any]:
884
+ """Handle union types like str | None."""
885
+ # For now, just extract the first non-None type
886
+ left_type = self._extract_type_from_node(union_node.left)
887
+ right_type = self._extract_type_from_node(union_node.right)
888
+
889
+ # If one side is None, return the other type
890
+ if isinstance(right_type, str) and right_type == "null":
891
+ return left_type if isinstance(left_type, dict) else {"type": left_type}
892
+ elif isinstance(left_type, str) and left_type == "null":
893
+ return right_type if isinstance(right_type, dict) else {"type": right_type}
894
+
895
+ # Otherwise, return the first type
896
+ return left_type if isinstance(left_type, dict) else {"type": left_type}
897
+
898
+ def _handle_optional_type(self, subscript: ast.Subscript) -> dict[str, Any]:
899
+ """Handle Optional[Type] annotations."""
900
+ if isinstance(subscript.slice, ast.Name):
901
+ base_type = self._type_hint_to_json_type(subscript.slice.id)
902
+ return {"type": base_type}
903
+ elif isinstance(subscript.slice, ast.Subscript):
904
+ return self._extract_complex_type_schema(subscript.slice)
905
+ else:
906
+ type_str = ast.unparse(subscript.slice)
907
+ return {"type": self._type_hint_to_json_type(type_str)}
908
+
909
+ def _is_parameter_required(self, position: int, defaults: list, total_args: int) -> bool:
910
+ """Check if a function parameter is required (has no default value)."""
911
+ if position >= total_args or position < 0:
912
+ return True # Default to required if position is out of range
913
+
914
+ # If there are no defaults, all parameters are required
915
+ if not defaults:
916
+ return True
917
+
918
+ # Defaults apply to the last N parameters where N = len(defaults)
919
+ # So if we have 4 args and 2 defaults, defaults apply to args[2] and args[3]
920
+ args_with_defaults = len(defaults)
921
+ first_default_position = total_args - args_with_defaults
922
+
923
+ # If this parameter's position is before the first default position,
924
+ # it's required
925
+ return position < first_default_position
926
+
927
+ def _extract_return_type_schema(self, return_annotation: ast.AST, tree: ast.Module) -> dict[str, Any] | None:
928
+ """Extract schema from function return type annotation."""
929
+ if isinstance(return_annotation, ast.Name):
930
+ # Simple type like str, int, or a class name
931
+ if return_annotation.id in ["str", "int", "float", "bool", "list", "dict"]:
932
+ return {"type": self._type_hint_to_json_type(return_annotation.id)}
933
+ else:
934
+ # Assume it's a Pydantic model class - look for it in the module
935
+ return self._find_class_schema(return_annotation.id, tree)
936
+
937
+ elif isinstance(return_annotation, ast.Subscript):
938
+ # Complex type like list[dict], Optional[MyClass], etc.
939
+ return self._extract_complex_type_schema(return_annotation)
940
+
941
+ else:
942
+ # Other complex types
943
+ type_str = ast.unparse(return_annotation)
944
+ return {"type": self._type_hint_to_json_type(type_str)}
945
+
946
+ def _find_class_schema(self, class_name: str, tree: ast.Module) -> dict[str, Any] | None:
947
+ """Find a class definition in the module and extract its schema."""
948
+ for node in tree.body:
949
+ if isinstance(node, ast.ClassDef) and node.name == class_name:
950
+ # Check if it inherits from BaseModel
951
+ for base in node.bases:
952
+ if isinstance(base, ast.Name) and base.id == "BaseModel":
953
+ return self._extract_pydantic_schema_from_ast(node)
954
+
955
+ return None
956
+
957
+
958
+ def parse_project(project_path: Path) -> dict[ComponentType, list[ParsedComponent]]:
959
+ """Parse a GolfMCP project to extract all components."""
960
+ parser = AstParser(project_path)
961
+
962
+ components: dict[ComponentType, list[ParsedComponent]] = {
963
+ ComponentType.TOOL: [],
964
+ ComponentType.RESOURCE: [],
965
+ ComponentType.PROMPT: [],
966
+ }
967
+
968
+ # Parse each directory
969
+ for comp_type, dir_name in [
970
+ (ComponentType.TOOL, "tools"),
971
+ (ComponentType.RESOURCE, "resources"),
972
+ (ComponentType.PROMPT, "prompts"),
973
+ ]:
974
+ dir_path = project_path / dir_name
975
+ if dir_path.exists() and dir_path.is_dir():
976
+ dir_components = parser.parse_directory(dir_path)
977
+ components[comp_type].extend([c for c in dir_components if c.type == comp_type])
978
+
979
+ # Check for ID collisions
980
+ all_ids = []
981
+ for comp_type, comps in components.items():
982
+ for comp in comps:
983
+ if comp.name in all_ids:
984
+ raise ValueError(f"ID collision detected: {comp.name} is used by multiple components")
985
+ all_ids.append(comp.name)
986
+
987
+ return components
988
+
989
+
990
+ def parse_common_files(project_path: Path) -> dict[str, Path]:
991
+ """Find all common.py files in the project.
992
+
993
+ Args:
994
+ project_path: Path to the project root
995
+
996
+ Returns:
997
+ Dictionary mapping directory paths to common.py file paths
998
+ """
999
+ common_files = {}
1000
+
1001
+ # Search for common.py files in tools, resources, and prompts directories
1002
+ for dir_name in ["tools", "resources", "prompts"]:
1003
+ base_dir = project_path / dir_name
1004
+ if not base_dir.exists() or not base_dir.is_dir():
1005
+ continue
1006
+
1007
+ # Find all common.py files (recursively)
1008
+ for common_file in base_dir.glob("**/common.py"):
1009
+ # Skip files in __pycache__ or other hidden directories
1010
+ if "__pycache__" in common_file.parts or any(part.startswith(".") for part in common_file.parts):
1011
+ continue
1012
+
1013
+ # Get the parent directory as the module path
1014
+ module_path = str(common_file.parent.relative_to(project_path))
1015
+ common_files[module_path] = common_file
1016
+
1017
+ return common_files
1018
+
1019
+
1020
+ def _is_golf_component_file(file_path: Path) -> bool:
1021
+ """Check if a Python file is a Golf component (has export or resource_uri).
1022
+
1023
+ Args:
1024
+ file_path: Path to the Python file to check
1025
+
1026
+ Returns:
1027
+ True if the file appears to be a Golf component, False otherwise
1028
+ """
1029
+ try:
1030
+ with open(file_path, encoding="utf-8") as f:
1031
+ content = f.read()
1032
+
1033
+ # Parse the file to check for Golf component patterns
1034
+ tree = ast.parse(content)
1035
+
1036
+ # Look for 'export' or 'resource_uri' variable assignments
1037
+ for node in ast.walk(tree):
1038
+ if isinstance(node, ast.Assign):
1039
+ for target in node.targets:
1040
+ if isinstance(target, ast.Name):
1041
+ if target.id in ("export", "resource_uri"):
1042
+ return True
1043
+
1044
+ return False
1045
+
1046
+ except (SyntaxError, OSError, UnicodeDecodeError):
1047
+ # If we can't parse the file, assume it's not a component
1048
+ return False
1049
+
1050
+
1051
+ def parse_shared_files(project_path: Path) -> dict[str, Path]:
1052
+ """Find all shared Python files in the project (non-component .py files).
1053
+
1054
+ Args:
1055
+ project_path: Path to the project root
1056
+
1057
+ Returns:
1058
+ Dictionary mapping module paths to shared file paths
1059
+ """
1060
+ shared_files = {}
1061
+
1062
+ # Search for all .py files in tools, resources, and prompts directories
1063
+ for dir_name in ["tools", "resources", "prompts"]:
1064
+ base_dir = project_path / dir_name
1065
+ if not base_dir.exists() or not base_dir.is_dir():
1066
+ continue
1067
+
1068
+ # Find all .py files (recursively)
1069
+ for py_file in base_dir.glob("**/*.py"):
1070
+ # Skip files in __pycache__ or other hidden directories
1071
+ if "__pycache__" in py_file.parts or any(part.startswith(".") for part in py_file.parts):
1072
+ continue
1073
+
1074
+ # Skip files that are Golf components (have export or resource_uri)
1075
+ if _is_golf_component_file(py_file):
1076
+ continue
1077
+
1078
+ # Calculate the module path for this shared file
1079
+ # For example: tools/weather/helpers.py -> tools/weather/helpers
1080
+ relative_path = py_file.relative_to(project_path)
1081
+ module_path = str(relative_path.with_suffix("")) # Remove .py extension
1082
+
1083
+ shared_files[module_path] = py_file
1084
+
1085
+ return shared_files