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/__init__.py +1 -1
- golf/cli/main.py +13 -2
- golf/commands/init.py +63 -1
- golf/core/builder.py +139 -60
- golf/core/config.py +6 -0
- golf/core/parser.py +531 -32
- golf/core/platform.py +180 -0
- golf/core/telemetry.py +28 -8
- golf/examples/api_key/.env.example +1 -5
- golf/examples/api_key/README.md +10 -10
- golf/examples/api_key/golf.json +1 -5
- golf/examples/basic/.env.example +3 -4
- golf/examples/basic/golf.json +1 -5
- golf/telemetry/instrumentation.py +26 -48
- {golf_mcp-0.1.16.dist-info → golf_mcp-0.1.17.dist-info}/METADATA +8 -3
- {golf_mcp-0.1.16.dist-info → golf_mcp-0.1.17.dist-info}/RECORD +20 -19
- {golf_mcp-0.1.16.dist-info → golf_mcp-0.1.17.dist-info}/WHEEL +0 -0
- {golf_mcp-0.1.16.dist-info → golf_mcp-0.1.17.dist-info}/entry_points.txt +0 -0
- {golf_mcp-0.1.16.dist-info → golf_mcp-0.1.17.dist-info}/licenses/LICENSE +0 -0
- {golf_mcp-0.1.16.dist-info → golf_mcp-0.1.17.dist-info}/top_level.txt +0 -0
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> + ("
|
|
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}
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
|
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
|
-
#
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
#
|
|
452
|
-
|
|
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."""
|