golf-mcp 0.1.16__py3-none-any.whl → 0.1.17__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.

Potentially problematic release.


This version of golf-mcp might be problematic. Click here for more details.

golf/core/parser.py CHANGED
@@ -199,31 +199,358 @@ class AstParser:
199
199
  file_path: Path,
200
200
  ) -> None:
201
201
  """Process the entry function to extract parameters and return type."""
202
- # Extract function docstring
203
- ast.get_docstring(func_node)
204
-
205
- # Extract parameter names and annotations
206
- parameters = []
207
- for arg in func_node.args.args:
208
- # Skip self, cls parameters
209
- if arg.arg in ("self", "cls"):
210
- continue
211
-
212
- # Skip ctx parameter - GolfMCP will inject this
213
- if arg.arg == "ctx":
214
- continue
215
-
216
- parameters.append(arg.arg)
217
-
218
202
  # Check for return annotation - STRICT requirement
219
203
  if func_node.returns is None:
220
204
  raise ValueError(
221
205
  f"Missing return annotation for {func_node.name} function in {file_path}"
222
206
  )
223
207
 
208
+ # Extract parameter names for basic info
209
+ parameters = []
210
+ for arg in func_node.args.args:
211
+ # Skip self, cls, ctx parameters
212
+ if arg.arg not in ("self", "cls", "ctx"):
213
+ parameters.append(arg.arg)
214
+
224
215
  # Store parameters
225
216
  component.parameters = parameters
226
217
 
218
+ # Extract schemas using runtime inspection (safer and more accurate)
219
+ try:
220
+ self._extract_schemas_at_runtime(component, file_path)
221
+ except Exception as e:
222
+ console.print(
223
+ f"[yellow]Warning: Could not extract schemas from {file_path}: {e}[/yellow]"
224
+ )
225
+ # Continue without schemas - better than failing the build
226
+
227
+ def _extract_schemas_at_runtime(
228
+ self, component: ParsedComponent, file_path: Path
229
+ ) -> None:
230
+ """Extract input/output schemas by importing and inspecting the actual function."""
231
+ import importlib.util
232
+ import sys
233
+
234
+ # Convert file path to module name
235
+ rel_path = file_path.relative_to(self.project_root)
236
+ module_name = str(rel_path.with_suffix("")).replace("/", ".")
237
+
238
+ # Temporarily add project root to sys.path
239
+ project_root_str = str(self.project_root)
240
+ if project_root_str not in sys.path:
241
+ sys.path.insert(0, project_root_str)
242
+ cleanup_path = True
243
+ else:
244
+ cleanup_path = False
245
+
246
+ try:
247
+ # Import the module
248
+ spec = importlib.util.spec_from_file_location(module_name, file_path)
249
+ if spec is None or spec.loader is None:
250
+ return
251
+
252
+ module = importlib.util.module_from_spec(spec)
253
+ spec.loader.exec_module(module)
254
+
255
+ # Get the entry function
256
+ if not hasattr(module, component.entry_function):
257
+ return
258
+
259
+ func = getattr(module, component.entry_function)
260
+
261
+ # Extract input schema from function signature
262
+ component.input_schema = self._extract_input_schema(func)
263
+
264
+ # Extract output schema from return type annotation
265
+ component.output_schema = self._extract_output_schema(func)
266
+
267
+ finally:
268
+ # Clean up sys.path
269
+ if cleanup_path and project_root_str in sys.path:
270
+ sys.path.remove(project_root_str)
271
+
272
+ def _extract_input_schema(self, func) -> dict[str, Any] | None:
273
+ """Extract input schema from function signature using runtime inspection."""
274
+ import inspect
275
+ from typing import get_type_hints
276
+
277
+ try:
278
+ sig = inspect.signature(func)
279
+ type_hints = get_type_hints(func, include_extras=True)
280
+
281
+ properties = {}
282
+ required = []
283
+
284
+ for param_name, param in sig.parameters.items():
285
+ # Skip special parameters
286
+ if param_name in ("self", "cls", "ctx"):
287
+ continue
288
+
289
+ # Get type hint
290
+ if param_name not in type_hints:
291
+ continue
292
+
293
+ type_hint = type_hints[param_name]
294
+
295
+ # Extract schema for this parameter
296
+ param_schema = self._extract_param_schema_from_hint(
297
+ type_hint, param_name
298
+ )
299
+ if param_schema:
300
+ # Clean the schema to remove problematic objects
301
+ cleaned_schema = self._clean_schema(param_schema)
302
+ if cleaned_schema:
303
+ properties[param_name] = cleaned_schema
304
+
305
+ # Check if required (no default value)
306
+ if param.default is param.empty:
307
+ required.append(param_name)
308
+
309
+ if properties:
310
+ return {
311
+ "type": "object",
312
+ "properties": properties,
313
+ "required": required,
314
+ }
315
+
316
+ except Exception as e:
317
+ console.print(
318
+ f"[yellow]Warning: Could not extract input schema: {e}[/yellow]"
319
+ )
320
+
321
+ return None
322
+
323
+ def _extract_output_schema(self, func) -> dict[str, Any] | None:
324
+ """Extract output schema from return type annotation."""
325
+ from typing import get_type_hints
326
+
327
+ try:
328
+ type_hints = get_type_hints(func, include_extras=True)
329
+ return_type = type_hints.get("return")
330
+
331
+ if return_type is None:
332
+ return None
333
+
334
+ # If it's a Pydantic BaseModel, extract schema manually
335
+ if hasattr(return_type, "model_fields"):
336
+ return self._extract_pydantic_model_schema(return_type)
337
+
338
+ # For other types, create a simple schema
339
+ return self._type_to_schema(return_type)
340
+
341
+ except Exception as e:
342
+ console.print(
343
+ f"[yellow]Warning: Could not extract output schema: {e}[/yellow]"
344
+ )
345
+
346
+ return None
347
+
348
+ def _extract_pydantic_model_schema(self, model_class) -> dict[str, Any]:
349
+ """Extract schema from Pydantic model by inspecting fields directly."""
350
+ try:
351
+ schema = {"type": "object", "properties": {}, "required": []}
352
+
353
+ if hasattr(model_class, "model_fields"):
354
+ for field_name, field_info in model_class.model_fields.items():
355
+ # Extract field type
356
+ field_type = (
357
+ field_info.annotation
358
+ if hasattr(field_info, "annotation")
359
+ else None
360
+ )
361
+ if field_type:
362
+ field_schema = self._type_to_schema(field_type)
363
+
364
+ # Add description if available
365
+ if (
366
+ hasattr(field_info, "description")
367
+ and field_info.description
368
+ ):
369
+ field_schema["description"] = field_info.description
370
+
371
+ # Add title
372
+ field_schema["title"] = field_name.replace("_", " ").title()
373
+
374
+ # Add default if available
375
+ if (
376
+ hasattr(field_info, "default")
377
+ and field_info.default is not None
378
+ ):
379
+ try:
380
+ # Only add if it's JSON serializable
381
+ import json
382
+
383
+ json.dumps(field_info.default)
384
+ field_schema["default"] = field_info.default
385
+ except:
386
+ pass
387
+
388
+ schema["properties"][field_name] = field_schema
389
+
390
+ # Check if required
391
+ if (
392
+ hasattr(field_info, "is_required")
393
+ and field_info.is_required()
394
+ ):
395
+ schema["required"].append(field_name)
396
+ elif (
397
+ not hasattr(field_info, "default")
398
+ or field_info.default is None
399
+ ):
400
+ # Assume required if no default
401
+ schema["required"].append(field_name)
402
+
403
+ return schema
404
+
405
+ except Exception as e:
406
+ console.print(
407
+ f"[yellow]Warning: Could not extract Pydantic model schema: {e}[/yellow]"
408
+ )
409
+ return {"type": "object"}
410
+
411
+ def _clean_schema(self, schema) -> dict[str, Any]:
412
+ """Clean up a schema to remove non-JSON-serializable objects."""
413
+ import json
414
+
415
+ def clean_object(obj):
416
+ if obj is None:
417
+ return None
418
+ elif isinstance(obj, (str, int, float, bool)):
419
+ return obj
420
+ elif isinstance(obj, dict):
421
+ cleaned = {}
422
+ for k, v in obj.items():
423
+ # Skip problematic keys
424
+ if k in ["definitions", "$defs", "allOf", "anyOf", "oneOf"]:
425
+ continue
426
+ cleaned_v = clean_object(v)
427
+ if cleaned_v is not None:
428
+ cleaned[k] = cleaned_v
429
+ return cleaned if cleaned else None
430
+ elif isinstance(obj, list):
431
+ cleaned = []
432
+ for item in obj:
433
+ cleaned_item = clean_object(item)
434
+ if cleaned_item is not None:
435
+ cleaned.append(cleaned_item)
436
+ return cleaned if cleaned else None
437
+ else:
438
+ # For any other type, test JSON serializability
439
+ try:
440
+ json.dumps(obj)
441
+ return obj
442
+ except (TypeError, ValueError):
443
+ # If it's not JSON serializable, try to get a string representation
444
+ if hasattr(obj, "__name__"):
445
+ return obj.__name__
446
+ elif hasattr(obj, "__str__"):
447
+ try:
448
+ str_val = str(obj)
449
+ if str_val and str_val != repr(obj):
450
+ return str_val
451
+ except:
452
+ pass
453
+ return None
454
+
455
+ cleaned = clean_object(schema)
456
+ return cleaned if cleaned else {"type": "object"}
457
+
458
+ def _extract_param_schema_from_hint(
459
+ self, type_hint, param_name: str
460
+ ) -> dict[str, Any] | None:
461
+ """Extract parameter schema from type hint (including Annotated types)."""
462
+ from typing import get_args, get_origin
463
+
464
+ # Handle Annotated types
465
+ if get_origin(type_hint) is not None:
466
+ origin = get_origin(type_hint)
467
+ args = get_args(type_hint)
468
+
469
+ # Check for Annotated[Type, Field(...)]
470
+ if (
471
+ hasattr(origin, "__name__")
472
+ and origin.__name__ == "Annotated"
473
+ and len(args) >= 2
474
+ ):
475
+ base_type = args[0]
476
+ metadata = args[1:]
477
+
478
+ # Start with base type schema
479
+ schema = self._type_to_schema(base_type)
480
+
481
+ # Extract Field metadata
482
+ for meta in metadata:
483
+ if hasattr(meta, "description") and meta.description:
484
+ schema["description"] = meta.description
485
+ if hasattr(meta, "title") and meta.title:
486
+ schema["title"] = meta.title
487
+ if hasattr(meta, "default") and meta.default is not None:
488
+ schema["default"] = meta.default
489
+ # Add other Field constraints as needed
490
+
491
+ return schema
492
+
493
+ # For non-Annotated types, just convert the type
494
+ return self._type_to_schema(type_hint)
495
+
496
+ def _type_to_schema(self, type_hint) -> dict[str, Any]:
497
+ """Convert a Python type to JSON schema."""
498
+ from typing import get_args, get_origin
499
+ import types
500
+
501
+ # Handle None/NoneType
502
+ if type_hint is type(None):
503
+ return {"type": "null"}
504
+
505
+ # Handle basic types
506
+ if type_hint is str:
507
+ return {"type": "string"}
508
+ elif type_hint is int:
509
+ return {"type": "integer"}
510
+ elif type_hint is float:
511
+ return {"type": "number"}
512
+ elif type_hint is bool:
513
+ return {"type": "boolean"}
514
+ elif type_hint is list:
515
+ return {"type": "array"}
516
+ elif type_hint is dict:
517
+ return {"type": "object"}
518
+
519
+ # Handle generic types
520
+ origin = get_origin(type_hint)
521
+ if origin is not None:
522
+ args = get_args(type_hint)
523
+
524
+ if origin is list:
525
+ if args:
526
+ item_schema = self._type_to_schema(args[0])
527
+ return {"type": "array", "items": item_schema}
528
+ return {"type": "array"}
529
+
530
+ elif origin is dict:
531
+ return {"type": "object"}
532
+
533
+ elif (
534
+ origin is types.UnionType
535
+ or (hasattr(types, "UnionType") and origin is types.UnionType)
536
+ or str(origin).startswith("typing.Union")
537
+ ):
538
+ # Handle Union types (including Optional)
539
+ non_none_types = [arg for arg in args if arg is not type(None)]
540
+ if len(non_none_types) == 1:
541
+ # This is Optional[Type]
542
+ return self._type_to_schema(non_none_types[0])
543
+ # For complex unions, default to object
544
+ return {"type": "object"}
545
+
546
+ # For unknown types, try to use Pydantic schema if available
547
+ if hasattr(type_hint, "model_json_schema"):
548
+ schema = type_hint.model_json_schema()
549
+ return self._clean_schema(schema)
550
+
551
+ # Default fallback
552
+ return {"type": "object"}
553
+
227
554
  def _process_tool(self, component: ParsedComponent, tree: ast.Module) -> None:
228
555
  """Process a tool component to extract input/output schemas and annotations."""
229
556
  # Look for Input and Output classes in the AST
@@ -295,7 +622,7 @@ class AstParser:
295
622
  ) -> str:
296
623
  """Derive a component name from its file path according to the spec.
297
624
 
298
- Following the spec: <filename> + ("-" + "-".join(PathRev) if PathRev else "")
625
+ Following the spec: <filename> + ("_" + "_".join(PathRev) if PathRev else "")
299
626
  where PathRev is the reversed list of parent directories under the category.
300
627
  """
301
628
  rel_path = file_path.relative_to(self.project_root)
@@ -321,7 +648,7 @@ class AstParser:
321
648
 
322
649
  # Form the ID according to spec
323
650
  if parent_dirs:
324
- return f"{filename}-{'-'.join(parent_dirs)}"
651
+ return f"{filename}_{'_'.join(parent_dirs)}"
325
652
  else:
326
653
  return filename
327
654
 
@@ -349,11 +676,28 @@ class AstParser:
349
676
  else:
350
677
  annotation = ast.unparse(node.annotation)
351
678
 
352
- # Create property definition
353
- prop = {
354
- "type": self._type_hint_to_json_type(annotation),
355
- "title": field_name.replace("_", " ").title(),
356
- }
679
+ # Create property definition using improved type extraction
680
+ if isinstance(node.annotation, ast.Subscript):
681
+ # Use the improved complex type extraction
682
+ type_schema = self._extract_complex_type_schema(node.annotation)
683
+ if isinstance(type_schema, dict) and "type" in type_schema:
684
+ prop = type_schema.copy()
685
+ prop["title"] = field_name.replace("_", " ").title()
686
+ else:
687
+ prop = {
688
+ "type": self._type_hint_to_json_type(annotation),
689
+ "title": field_name.replace("_", " ").title(),
690
+ }
691
+ elif isinstance(node.annotation, ast.Name):
692
+ prop = {
693
+ "type": self._type_hint_to_json_type(node.annotation.id),
694
+ "title": field_name.replace("_", " ").title(),
695
+ }
696
+ else:
697
+ prop = {
698
+ "type": self._type_hint_to_json_type(annotation),
699
+ "title": field_name.replace("_", " ").title(),
700
+ }
357
701
 
358
702
  # Extract default value if present
359
703
  if node.value is not None:
@@ -431,9 +775,13 @@ class AstParser:
431
775
  def _type_hint_to_json_type(self, type_hint: str) -> str:
432
776
  """Convert a Python type hint to a JSON schema type.
433
777
 
434
- This is a simplified version. A more sophisticated approach would
435
- handle complex types correctly.
778
+ This handles complex types and edge cases better than the original version.
436
779
  """
780
+ # Handle None type
781
+ if type_hint.lower() in ["none", "nonetype"]:
782
+ return "null"
783
+
784
+ # Handle basic types first
437
785
  type_map = {
438
786
  "str": "string",
439
787
  "int": "integer",
@@ -441,15 +789,53 @@ class AstParser:
441
789
  "bool": "boolean",
442
790
  "list": "array",
443
791
  "dict": "object",
792
+ "any": "object", # Any maps to object
444
793
  }
445
794
 
446
- # Handle simple types
447
- for py_type, json_type in type_map.items():
448
- if py_type in type_hint.lower():
449
- return json_type
450
-
451
- # Default to string for unknown types
452
- return "string"
795
+ # Exact matches for simple types
796
+ lower_hint = type_hint.lower()
797
+ if lower_hint in type_map:
798
+ return type_map[lower_hint]
799
+
800
+ # Handle common complex patterns
801
+ if "list[" in type_hint or "List[" in type_hint:
802
+ return "array"
803
+ elif "dict[" in type_hint or "Dict[" in type_hint:
804
+ return "object"
805
+ elif "union[" in type_hint or "Union[" in type_hint:
806
+ # For Union types, try to extract the first non-None type
807
+ if "none" in lower_hint or "nonetype" in lower_hint:
808
+ # This is Optional[SomeType] - extract the SomeType
809
+ for basic_type in type_map:
810
+ if basic_type in lower_hint:
811
+ return type_map[basic_type]
812
+ return "object" # Fallback for complex unions
813
+ elif "optional[" in type_hint or "Optional[" in type_hint:
814
+ # Extract the wrapped type from Optional[Type]
815
+ for basic_type in type_map:
816
+ if basic_type in lower_hint:
817
+ return type_map[basic_type]
818
+ return "object"
819
+
820
+ # Handle some common pydantic/typing types
821
+ if any(keyword in lower_hint for keyword in ["basemodel", "model"]):
822
+ return "object"
823
+
824
+ # Check for numeric patterns
825
+ if any(num_type in lower_hint for num_type in ["int", "integer", "number"]):
826
+ return "integer"
827
+ elif any(num_type in lower_hint for num_type in ["float", "double", "decimal"]):
828
+ return "number"
829
+ elif any(str_type in lower_hint for str_type in ["str", "string", "text"]):
830
+ return "string"
831
+ elif any(bool_type in lower_hint for bool_type in ["bool", "boolean"]):
832
+ return "boolean"
833
+
834
+ # Default to object for unknown complex types, string for simple unknowns
835
+ if "[" in type_hint or "." in type_hint:
836
+ return "object"
837
+ else:
838
+ return "string"
453
839
 
454
840
  def _extract_dict_from_ast(self, dict_node: ast.Dict) -> dict[str, Any]:
455
841
  """Extract a dictionary from an AST Dict node.
@@ -491,6 +877,119 @@ class AstParser:
491
877
 
492
878
  return result
493
879
 
880
+ def _extract_complex_type_schema(self, subscript: ast.Subscript) -> dict[str, Any]:
881
+ """Extract schema from complex types like list[str], dict[str, Any], etc."""
882
+ if isinstance(subscript.value, ast.Name):
883
+ base_type = subscript.value.id
884
+
885
+ if base_type == "list":
886
+ # Handle list[ItemType]
887
+ if isinstance(subscript.slice, ast.Name):
888
+ item_type = self._type_hint_to_json_type(subscript.slice.id)
889
+ return {"type": "array", "items": {"type": item_type}}
890
+ elif isinstance(subscript.slice, ast.Subscript):
891
+ # Nested subscript like list[dict[str, Any]]
892
+ item_schema = self._extract_complex_type_schema(subscript.slice)
893
+ return {"type": "array", "items": item_schema}
894
+ else:
895
+ # Complex item type, try to parse it
896
+ item_type_str = ast.unparse(subscript.slice)
897
+ if "dict" in item_type_str.lower():
898
+ return {"type": "array", "items": {"type": "object"}}
899
+ else:
900
+ item_type = self._type_hint_to_json_type(item_type_str)
901
+ return {"type": "array", "items": {"type": item_type}}
902
+
903
+ elif base_type == "dict":
904
+ return {"type": "object"}
905
+
906
+ elif base_type in ["Optional", "Union"]:
907
+ # Handle Optional[Type] or Union[Type, None]
908
+ return self._handle_optional_type(subscript)
909
+
910
+ # Fallback
911
+ type_str = ast.unparse(subscript)
912
+ return {"type": self._type_hint_to_json_type(type_str)}
913
+
914
+ def _handle_union_type(self, union_node: ast.BinOp) -> dict[str, Any]:
915
+ """Handle union types like str | None."""
916
+ # For now, just extract the first non-None type
917
+ left_type = self._extract_type_from_node(union_node.left)
918
+ right_type = self._extract_type_from_node(union_node.right)
919
+
920
+ # If one side is None, return the other type
921
+ if isinstance(right_type, str) and right_type == "null":
922
+ return left_type if isinstance(left_type, dict) else {"type": left_type}
923
+ elif isinstance(left_type, str) and left_type == "null":
924
+ return right_type if isinstance(right_type, dict) else {"type": right_type}
925
+
926
+ # Otherwise, return the first type
927
+ return left_type if isinstance(left_type, dict) else {"type": left_type}
928
+
929
+ def _handle_optional_type(self, subscript: ast.Subscript) -> dict[str, Any]:
930
+ """Handle Optional[Type] annotations."""
931
+ if isinstance(subscript.slice, ast.Name):
932
+ base_type = self._type_hint_to_json_type(subscript.slice.id)
933
+ return {"type": base_type}
934
+ elif isinstance(subscript.slice, ast.Subscript):
935
+ return self._extract_complex_type_schema(subscript.slice)
936
+ else:
937
+ type_str = ast.unparse(subscript.slice)
938
+ return {"type": self._type_hint_to_json_type(type_str)}
939
+
940
+ def _is_parameter_required(
941
+ self, position: int, defaults: list, total_args: int
942
+ ) -> bool:
943
+ """Check if a function parameter is required (has no default value)."""
944
+ if position >= total_args or position < 0:
945
+ return True # Default to required if position is out of range
946
+
947
+ # If there are no defaults, all parameters are required
948
+ if not defaults:
949
+ return True
950
+
951
+ # Defaults apply to the last N parameters where N = len(defaults)
952
+ # So if we have 4 args and 2 defaults, defaults apply to args[2] and args[3]
953
+ args_with_defaults = len(defaults)
954
+ first_default_position = total_args - args_with_defaults
955
+
956
+ # If this parameter's position is before the first default position, it's required
957
+ return position < first_default_position
958
+
959
+ def _extract_return_type_schema(
960
+ self, return_annotation: ast.AST, tree: ast.Module
961
+ ) -> dict[str, Any] | None:
962
+ """Extract schema from function return type annotation."""
963
+ if isinstance(return_annotation, ast.Name):
964
+ # Simple type like str, int, or a class name
965
+ if return_annotation.id in ["str", "int", "float", "bool", "list", "dict"]:
966
+ return {"type": self._type_hint_to_json_type(return_annotation.id)}
967
+ else:
968
+ # Assume it's a Pydantic model class - look for it in the module
969
+ return self._find_class_schema(return_annotation.id, tree)
970
+
971
+ elif isinstance(return_annotation, ast.Subscript):
972
+ # Complex type like list[dict], Optional[MyClass], etc.
973
+ return self._extract_complex_type_schema(return_annotation)
974
+
975
+ else:
976
+ # Other complex types
977
+ type_str = ast.unparse(return_annotation)
978
+ return {"type": self._type_hint_to_json_type(type_str)}
979
+
980
+ def _find_class_schema(
981
+ self, class_name: str, tree: ast.Module
982
+ ) -> dict[str, Any] | None:
983
+ """Find a class definition in the module and extract its schema."""
984
+ for node in tree.body:
985
+ if isinstance(node, ast.ClassDef) and node.name == class_name:
986
+ # Check if it inherits from BaseModel
987
+ for base in node.bases:
988
+ if isinstance(base, ast.Name) and base.id == "BaseModel":
989
+ return self._extract_pydantic_schema_from_ast(node)
990
+
991
+ return None
992
+
494
993
 
495
994
  def parse_project(project_path: Path) -> dict[ComponentType, list[ParsedComponent]]:
496
995
  """Parse a GolfMCP project to extract all components."""