golf-mcp 0.1.14__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
@@ -18,6 +18,7 @@ class ComponentType(str, Enum):
18
18
  TOOL = "tool"
19
19
  RESOURCE = "resource"
20
20
  PROMPT = "prompt"
21
+ ROUTE = "route"
21
22
  UNKNOWN = "unknown"
22
23
 
23
24
 
@@ -36,6 +37,7 @@ class ParsedComponent:
36
37
  parameters: list[str] | None = None # For resources with URI params
37
38
  parent_module: str | None = None # For nested components
38
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
39
41
 
40
42
 
41
43
  class AstParser:
@@ -197,36 +199,364 @@ class AstParser:
197
199
  file_path: Path,
198
200
  ) -> None:
199
201
  """Process the entry function to extract parameters and return type."""
200
- # Extract function docstring
201
- ast.get_docstring(func_node)
202
-
203
- # Extract parameter names and annotations
204
- parameters = []
205
- for arg in func_node.args.args:
206
- # Skip self, cls parameters
207
- if arg.arg in ("self", "cls"):
208
- continue
209
-
210
- # Skip ctx parameter - GolfMCP will inject this
211
- if arg.arg == "ctx":
212
- continue
213
-
214
- parameters.append(arg.arg)
215
-
216
202
  # Check for return annotation - STRICT requirement
217
203
  if func_node.returns is None:
218
204
  raise ValueError(
219
205
  f"Missing return annotation for {func_node.name} function in {file_path}"
220
206
  )
221
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
+
222
215
  # Store parameters
223
216
  component.parameters = parameters
224
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
+
225
554
  def _process_tool(self, component: ParsedComponent, tree: ast.Module) -> None:
226
- """Process a tool component to extract input/output schemas."""
555
+ """Process a tool component to extract input/output schemas and annotations."""
227
556
  # Look for Input and Output classes in the AST
228
557
  input_class = None
229
558
  output_class = None
559
+ annotations = None
230
560
 
231
561
  for node in tree.body:
232
562
  if isinstance(node, ast.ClassDef):
@@ -234,6 +564,13 @@ class AstParser:
234
564
  input_class = node
235
565
  elif node.name == "Output":
236
566
  output_class = node
567
+ # Look for annotations assignment
568
+ elif isinstance(node, ast.Assign):
569
+ for target in node.targets:
570
+ if isinstance(target, ast.Name) and target.id == "annotations":
571
+ if isinstance(node.value, ast.Dict):
572
+ annotations = self._extract_dict_from_ast(node.value)
573
+ break
237
574
 
238
575
  # Process Input class if found
239
576
  if input_class:
@@ -255,6 +592,10 @@ class AstParser:
255
592
  )
256
593
  break
257
594
 
595
+ # Store annotations if found
596
+ if annotations:
597
+ component.annotations = annotations
598
+
258
599
  def _process_resource(self, component: ParsedComponent, tree: ast.Module) -> None:
259
600
  """Process a resource component to extract URI template."""
260
601
  # Look for resource_uri assignment in the AST
@@ -281,7 +622,7 @@ class AstParser:
281
622
  ) -> str:
282
623
  """Derive a component name from its file path according to the spec.
283
624
 
284
- Following the spec: <filename> + ("-" + "-".join(PathRev) if PathRev else "")
625
+ Following the spec: <filename> + ("_" + "_".join(PathRev) if PathRev else "")
285
626
  where PathRev is the reversed list of parent directories under the category.
286
627
  """
287
628
  rel_path = file_path.relative_to(self.project_root)
@@ -307,7 +648,7 @@ class AstParser:
307
648
 
308
649
  # Form the ID according to spec
309
650
  if parent_dirs:
310
- return f"{filename}-{'-'.join(parent_dirs)}"
651
+ return f"{filename}_{'_'.join(parent_dirs)}"
311
652
  else:
312
653
  return filename
313
654
 
@@ -335,11 +676,28 @@ class AstParser:
335
676
  else:
336
677
  annotation = ast.unparse(node.annotation)
337
678
 
338
- # Create property definition
339
- prop = {
340
- "type": self._type_hint_to_json_type(annotation),
341
- "title": field_name.replace("_", " ").title(),
342
- }
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
+ }
343
701
 
344
702
  # Extract default value if present
345
703
  if node.value is not None:
@@ -417,9 +775,13 @@ class AstParser:
417
775
  def _type_hint_to_json_type(self, type_hint: str) -> str:
418
776
  """Convert a Python type hint to a JSON schema type.
419
777
 
420
- This is a simplified version. A more sophisticated approach would
421
- handle complex types correctly.
778
+ This handles complex types and edge cases better than the original version.
422
779
  """
780
+ # Handle None type
781
+ if type_hint.lower() in ["none", "nonetype"]:
782
+ return "null"
783
+
784
+ # Handle basic types first
423
785
  type_map = {
424
786
  "str": "string",
425
787
  "int": "integer",
@@ -427,15 +789,206 @@ class AstParser:
427
789
  "bool": "boolean",
428
790
  "list": "array",
429
791
  "dict": "object",
792
+ "any": "object", # Any maps to object
430
793
  }
431
794
 
432
- # Handle simple types
433
- for py_type, json_type in type_map.items():
434
- if py_type in type_hint.lower():
435
- return json_type
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"
839
+
840
+ def _extract_dict_from_ast(self, dict_node: ast.Dict) -> dict[str, Any]:
841
+ """Extract a dictionary from an AST Dict node.
842
+
843
+ This handles simple literal dictionaries with string keys and
844
+ boolean/string/number values.
845
+ """
846
+ result = {}
847
+
848
+ for key, value in zip(dict_node.keys, dict_node.values, strict=False):
849
+ # Extract the key
850
+ if isinstance(key, ast.Constant) and isinstance(key.value, str):
851
+ key_str = key.value
852
+ elif isinstance(key, ast.Str): # For older Python versions
853
+ key_str = key.s
854
+ else:
855
+ # Skip non-string keys
856
+ continue
857
+
858
+ # Extract the value
859
+ if isinstance(value, ast.Constant):
860
+ # Handles strings, numbers, booleans, None
861
+ result[key_str] = value.value
862
+ elif isinstance(value, ast.Str): # For older Python versions
863
+ result[key_str] = value.s
864
+ elif isinstance(value, ast.Num): # For older Python versions
865
+ result[key_str] = value.n
866
+ elif isinstance(
867
+ value, ast.NameConstant
868
+ ): # For older Python versions (True/False/None)
869
+ result[key_str] = value.value
870
+ elif isinstance(value, ast.Name):
871
+ # Handle True/False/None as names
872
+ if value.id in ("True", "False", "None"):
873
+ result[key_str] = {"True": True, "False": False, "None": None}[
874
+ value.id
875
+ ]
876
+ # We could add more complex value handling here if needed
877
+
878
+ return result
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)
436
990
 
437
- # Default to string for unknown types
438
- return "string"
991
+ return None
439
992
 
440
993
 
441
994
  def parse_project(project_path: Path) -> dict[ComponentType, list[ParsedComponent]]: