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.
- golf/__init__.py +1 -0
- golf/auth/__init__.py +277 -0
- golf/auth/api_key.py +73 -0
- golf/auth/factory.py +360 -0
- golf/auth/helpers.py +175 -0
- golf/auth/providers.py +586 -0
- golf/auth/registry.py +256 -0
- golf/cli/__init__.py +1 -0
- golf/cli/branding.py +191 -0
- golf/cli/main.py +377 -0
- golf/commands/__init__.py +5 -0
- golf/commands/build.py +81 -0
- golf/commands/init.py +290 -0
- golf/commands/run.py +137 -0
- golf/core/__init__.py +1 -0
- golf/core/builder.py +1884 -0
- golf/core/builder_auth.py +209 -0
- golf/core/builder_metrics.py +221 -0
- golf/core/builder_telemetry.py +99 -0
- golf/core/config.py +199 -0
- golf/core/parser.py +1085 -0
- golf/core/telemetry.py +492 -0
- golf/core/transformer.py +231 -0
- golf/examples/__init__.py +0 -0
- golf/examples/basic/.env.example +4 -0
- golf/examples/basic/README.md +133 -0
- golf/examples/basic/auth.py +76 -0
- golf/examples/basic/golf.json +5 -0
- golf/examples/basic/prompts/welcome.py +27 -0
- golf/examples/basic/resources/current_time.py +34 -0
- golf/examples/basic/resources/info.py +28 -0
- golf/examples/basic/resources/weather/city.py +46 -0
- golf/examples/basic/resources/weather/client.py +48 -0
- golf/examples/basic/resources/weather/current.py +36 -0
- golf/examples/basic/resources/weather/forecast.py +36 -0
- golf/examples/basic/tools/calculator.py +94 -0
- golf/examples/basic/tools/say/hello.py +65 -0
- golf/metrics/__init__.py +10 -0
- golf/metrics/collector.py +320 -0
- golf/metrics/registry.py +12 -0
- golf/telemetry/__init__.py +23 -0
- golf/telemetry/instrumentation.py +1402 -0
- golf/utilities/__init__.py +12 -0
- golf/utilities/context.py +53 -0
- golf/utilities/elicitation.py +170 -0
- golf/utilities/sampling.py +221 -0
- golf_mcp-0.2.16.dist-info/METADATA +262 -0
- golf_mcp-0.2.16.dist-info/RECORD +52 -0
- golf_mcp-0.2.16.dist-info/WHEEL +5 -0
- golf_mcp-0.2.16.dist-info/entry_points.txt +2 -0
- golf_mcp-0.2.16.dist-info/licenses/LICENSE +201 -0
- 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
|