golf-mcp 0.1.10__py3-none-any.whl → 0.1.12__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/auth/__init__.py +38 -26
- golf/auth/api_key.py +16 -23
- golf/auth/helpers.py +68 -54
- golf/auth/oauth.py +340 -277
- golf/auth/provider.py +58 -53
- golf/cli/__init__.py +1 -1
- golf/cli/main.py +202 -82
- golf/commands/__init__.py +1 -1
- golf/commands/build.py +31 -25
- golf/commands/init.py +119 -80
- golf/commands/run.py +14 -13
- golf/core/__init__.py +1 -1
- golf/core/builder.py +478 -353
- golf/core/builder_auth.py +115 -107
- golf/core/builder_telemetry.py +12 -9
- golf/core/config.py +62 -46
- golf/core/parser.py +174 -136
- golf/core/telemetry.py +169 -69
- golf/core/transformer.py +53 -55
- golf/examples/__init__.py +0 -1
- golf/examples/api_key/pre_build.py +2 -2
- golf/examples/api_key/tools/issues/create.py +35 -36
- golf/examples/api_key/tools/issues/list.py +42 -37
- golf/examples/api_key/tools/repos/list.py +50 -29
- golf/examples/api_key/tools/search/code.py +50 -37
- golf/examples/api_key/tools/users/get.py +21 -20
- golf/examples/basic/pre_build.py +4 -4
- golf/examples/basic/prompts/welcome.py +6 -7
- golf/examples/basic/resources/current_time.py +10 -9
- golf/examples/basic/resources/info.py +6 -5
- golf/examples/basic/resources/weather/common.py +16 -10
- golf/examples/basic/resources/weather/current.py +15 -11
- golf/examples/basic/resources/weather/forecast.py +15 -11
- golf/examples/basic/tools/github_user.py +19 -21
- golf/examples/basic/tools/hello.py +10 -6
- golf/examples/basic/tools/payments/charge.py +34 -25
- golf/examples/basic/tools/payments/common.py +8 -6
- golf/examples/basic/tools/payments/refund.py +29 -25
- golf/telemetry/__init__.py +6 -6
- golf/telemetry/instrumentation.py +781 -276
- {golf_mcp-0.1.10.dist-info → golf_mcp-0.1.12.dist-info}/METADATA +1 -1
- golf_mcp-0.1.12.dist-info/RECORD +55 -0
- {golf_mcp-0.1.10.dist-info → golf_mcp-0.1.12.dist-info}/WHEEL +1 -1
- golf_mcp-0.1.10.dist-info/RECORD +0 -55
- {golf_mcp-0.1.10.dist-info → golf_mcp-0.1.12.dist-info}/entry_points.txt +0 -0
- {golf_mcp-0.1.10.dist-info → golf_mcp-0.1.12.dist-info}/licenses/LICENSE +0 -0
- {golf_mcp-0.1.10.dist-info → golf_mcp-0.1.12.dist-info}/top_level.txt +0 -0
golf/core/parser.py
CHANGED
|
@@ -5,7 +5,7 @@ import re
|
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
from enum import Enum
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from typing import Any
|
|
8
|
+
from typing import Any
|
|
9
9
|
|
|
10
10
|
from rich.console import Console
|
|
11
11
|
|
|
@@ -14,7 +14,7 @@ console = Console()
|
|
|
14
14
|
|
|
15
15
|
class ComponentType(str, Enum):
|
|
16
16
|
"""Type of component discovered by the parser."""
|
|
17
|
-
|
|
17
|
+
|
|
18
18
|
TOOL = "tool"
|
|
19
19
|
RESOURCE = "resource"
|
|
20
20
|
PROMPT = "prompt"
|
|
@@ -24,65 +24,69 @@ class ComponentType(str, Enum):
|
|
|
24
24
|
@dataclass
|
|
25
25
|
class ParsedComponent:
|
|
26
26
|
"""Represents a parsed MCP component (tool, resource, or prompt)."""
|
|
27
|
-
|
|
27
|
+
|
|
28
28
|
name: str # Derived from file path or explicit name
|
|
29
29
|
type: ComponentType
|
|
30
30
|
file_path: Path
|
|
31
31
|
module_path: str
|
|
32
|
-
docstring:
|
|
33
|
-
input_schema:
|
|
34
|
-
output_schema:
|
|
35
|
-
uri_template:
|
|
36
|
-
parameters:
|
|
37
|
-
parent_module:
|
|
38
|
-
entry_function:
|
|
32
|
+
docstring: str | None = None
|
|
33
|
+
input_schema: dict[str, Any] | None = None
|
|
34
|
+
output_schema: dict[str, Any] | None = None
|
|
35
|
+
uri_template: str | None = None # For resources
|
|
36
|
+
parameters: list[str] | None = None # For resources with URI params
|
|
37
|
+
parent_module: str | None = None # For nested components
|
|
38
|
+
entry_function: str | None = None # Store the name of the function to use
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
class AstParser:
|
|
42
42
|
"""AST-based parser for extracting MCP components from Python files."""
|
|
43
|
-
|
|
44
|
-
def __init__(self, project_root: Path):
|
|
43
|
+
|
|
44
|
+
def __init__(self, project_root: Path) -> None:
|
|
45
45
|
"""Initialize the parser.
|
|
46
|
-
|
|
46
|
+
|
|
47
47
|
Args:
|
|
48
48
|
project_root: Root directory of the project
|
|
49
49
|
"""
|
|
50
50
|
self.project_root = project_root
|
|
51
|
-
self.components:
|
|
52
|
-
|
|
53
|
-
def parse_directory(self, directory: Path) ->
|
|
51
|
+
self.components: dict[str, ParsedComponent] = {}
|
|
52
|
+
|
|
53
|
+
def parse_directory(self, directory: Path) -> list[ParsedComponent]:
|
|
54
54
|
"""Parse all Python files in a directory recursively."""
|
|
55
55
|
components = []
|
|
56
|
-
|
|
56
|
+
|
|
57
57
|
for file_path in directory.glob("**/*.py"):
|
|
58
58
|
# Skip __pycache__ and other hidden directories
|
|
59
|
-
if "__pycache__" in file_path.parts or any(
|
|
59
|
+
if "__pycache__" in file_path.parts or any(
|
|
60
|
+
part.startswith(".") for part in file_path.parts
|
|
61
|
+
):
|
|
60
62
|
continue
|
|
61
|
-
|
|
63
|
+
|
|
62
64
|
try:
|
|
63
65
|
file_components = self.parse_file(file_path)
|
|
64
66
|
components.extend(file_components)
|
|
65
67
|
except Exception as e:
|
|
66
68
|
relative_path = file_path.relative_to(self.project_root)
|
|
67
|
-
console.print(
|
|
68
|
-
|
|
69
|
+
console.print(
|
|
70
|
+
f"[bold red]Error parsing {relative_path}:[/bold red] {e}"
|
|
71
|
+
)
|
|
72
|
+
|
|
69
73
|
return components
|
|
70
|
-
|
|
71
|
-
def parse_file(self, file_path: Path) ->
|
|
74
|
+
|
|
75
|
+
def parse_file(self, file_path: Path) -> list[ParsedComponent]:
|
|
72
76
|
"""Parse a single Python file using AST to extract MCP components."""
|
|
73
77
|
# Handle common.py files
|
|
74
78
|
if file_path.name == "common.py":
|
|
75
79
|
# Register as a known shared module but don't return as a component
|
|
76
80
|
return []
|
|
77
|
-
|
|
81
|
+
|
|
78
82
|
# Skip __init__.py files for direct parsing
|
|
79
83
|
if file_path.name == "__init__.py":
|
|
80
84
|
return []
|
|
81
|
-
|
|
85
|
+
|
|
82
86
|
# Determine component type based on directory structure
|
|
83
87
|
rel_path = file_path.relative_to(self.project_root)
|
|
84
88
|
parent_dir = rel_path.parts[0] if rel_path.parts else None
|
|
85
|
-
|
|
89
|
+
|
|
86
90
|
component_type = ComponentType.UNKNOWN
|
|
87
91
|
if parent_dir == "tools":
|
|
88
92
|
component_type = ComponentType.TOOL
|
|
@@ -90,29 +94,29 @@ class AstParser:
|
|
|
90
94
|
component_type = ComponentType.RESOURCE
|
|
91
95
|
elif parent_dir == "prompts":
|
|
92
96
|
component_type = ComponentType.PROMPT
|
|
93
|
-
|
|
97
|
+
|
|
94
98
|
if component_type == ComponentType.UNKNOWN:
|
|
95
99
|
return [] # Not in a recognized directory
|
|
96
|
-
|
|
100
|
+
|
|
97
101
|
# Read the file content and parse it with AST
|
|
98
|
-
with open(file_path,
|
|
102
|
+
with open(file_path, encoding="utf-8") as f:
|
|
99
103
|
file_content = f.read()
|
|
100
|
-
|
|
104
|
+
|
|
101
105
|
try:
|
|
102
106
|
tree = ast.parse(file_content)
|
|
103
107
|
except SyntaxError as e:
|
|
104
108
|
raise ValueError(f"Syntax error in {file_path}: {e}")
|
|
105
|
-
|
|
109
|
+
|
|
106
110
|
# Extract module docstring
|
|
107
111
|
module_docstring = ast.get_docstring(tree)
|
|
108
112
|
if not module_docstring:
|
|
109
113
|
raise ValueError(f"Missing module docstring in {file_path}")
|
|
110
|
-
|
|
111
|
-
# Find the entry function - look for "export = function_name" pattern,
|
|
114
|
+
|
|
115
|
+
# Find the entry function - look for "export = function_name" pattern,
|
|
112
116
|
# or any top-level function (like "run") as a fallback
|
|
113
117
|
entry_function = None
|
|
114
118
|
export_target = None
|
|
115
|
-
|
|
119
|
+
|
|
116
120
|
# Look for export = function_name assignment
|
|
117
121
|
for node in tree.body:
|
|
118
122
|
if isinstance(node, ast.Assign):
|
|
@@ -121,33 +125,35 @@ class AstParser:
|
|
|
121
125
|
if isinstance(node.value, ast.Name):
|
|
122
126
|
export_target = node.value.id
|
|
123
127
|
break
|
|
124
|
-
|
|
128
|
+
|
|
125
129
|
# Find all top-level functions
|
|
126
130
|
functions = []
|
|
127
131
|
for node in tree.body:
|
|
128
|
-
if isinstance(node,
|
|
132
|
+
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
129
133
|
functions.append(node)
|
|
130
134
|
# If this function matches our export target, it's our entry function
|
|
131
135
|
if export_target and node.name == export_target:
|
|
132
136
|
entry_function = node
|
|
133
|
-
|
|
137
|
+
|
|
134
138
|
# Check for the run function as a fallback
|
|
135
139
|
run_function = None
|
|
136
140
|
for func in functions:
|
|
137
141
|
if func.name == "run":
|
|
138
142
|
run_function = func
|
|
139
|
-
|
|
143
|
+
|
|
140
144
|
# If we have an export but didn't find the target function, warn
|
|
141
145
|
if export_target and not entry_function:
|
|
142
|
-
console.print(
|
|
143
|
-
|
|
146
|
+
console.print(
|
|
147
|
+
f"[yellow]Warning: Export target '{export_target}' not found in {file_path}[/yellow]"
|
|
148
|
+
)
|
|
149
|
+
|
|
144
150
|
# Use the export target function if found, otherwise fall back to run
|
|
145
151
|
entry_function = entry_function or run_function
|
|
146
|
-
|
|
152
|
+
|
|
147
153
|
# If no valid function found, skip this file
|
|
148
154
|
if not entry_function:
|
|
149
155
|
return []
|
|
150
|
-
|
|
156
|
+
|
|
151
157
|
# Create component
|
|
152
158
|
component = ParsedComponent(
|
|
153
159
|
name="", # Will be set later
|
|
@@ -155,12 +161,13 @@ class AstParser:
|
|
|
155
161
|
file_path=file_path,
|
|
156
162
|
module_path=file_path.relative_to(self.project_root).as_posix(),
|
|
157
163
|
docstring=module_docstring,
|
|
158
|
-
entry_function=export_target
|
|
164
|
+
entry_function=export_target
|
|
165
|
+
or "run", # Store the name of the entry function
|
|
159
166
|
)
|
|
160
|
-
|
|
167
|
+
|
|
161
168
|
# Process the entry function
|
|
162
169
|
self._process_entry_function(component, entry_function, tree, file_path)
|
|
163
|
-
|
|
170
|
+
|
|
164
171
|
# Process other component-specific information
|
|
165
172
|
if component_type == ComponentType.TOOL:
|
|
166
173
|
self._process_tool(component, tree)
|
|
@@ -168,72 +175,86 @@ class AstParser:
|
|
|
168
175
|
self._process_resource(component, tree)
|
|
169
176
|
elif component_type == ComponentType.PROMPT:
|
|
170
177
|
self._process_prompt(component, tree)
|
|
171
|
-
|
|
178
|
+
|
|
172
179
|
# Set component name based on file path
|
|
173
180
|
component.name = self._derive_component_name(file_path, component_type)
|
|
174
|
-
|
|
181
|
+
|
|
175
182
|
# Set parent module if it's in a nested structure
|
|
176
183
|
if len(rel_path.parts) > 2: # More than just "tools/file.py"
|
|
177
|
-
parent_parts = rel_path.parts[
|
|
184
|
+
parent_parts = rel_path.parts[
|
|
185
|
+
1:-1
|
|
186
|
+
] # Skip the root category and the file itself
|
|
178
187
|
if parent_parts:
|
|
179
188
|
component.parent_module = ".".join(parent_parts)
|
|
180
|
-
|
|
189
|
+
|
|
181
190
|
return [component]
|
|
182
|
-
|
|
183
|
-
def _process_entry_function(
|
|
191
|
+
|
|
192
|
+
def _process_entry_function(
|
|
193
|
+
self,
|
|
194
|
+
component: ParsedComponent,
|
|
195
|
+
func_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
196
|
+
tree: ast.Module,
|
|
197
|
+
file_path: Path,
|
|
198
|
+
) -> None:
|
|
184
199
|
"""Process the entry function to extract parameters and return type."""
|
|
185
200
|
# Extract function docstring
|
|
186
|
-
|
|
187
|
-
|
|
201
|
+
ast.get_docstring(func_node)
|
|
202
|
+
|
|
188
203
|
# Extract parameter names and annotations
|
|
189
204
|
parameters = []
|
|
190
205
|
for arg in func_node.args.args:
|
|
191
206
|
# Skip self, cls parameters
|
|
192
207
|
if arg.arg in ("self", "cls"):
|
|
193
208
|
continue
|
|
194
|
-
|
|
209
|
+
|
|
195
210
|
# Skip ctx parameter - GolfMCP will inject this
|
|
196
211
|
if arg.arg == "ctx":
|
|
197
212
|
continue
|
|
198
|
-
|
|
213
|
+
|
|
199
214
|
parameters.append(arg.arg)
|
|
200
|
-
|
|
215
|
+
|
|
201
216
|
# Check for return annotation - STRICT requirement
|
|
202
217
|
if func_node.returns is None:
|
|
203
|
-
raise ValueError(
|
|
204
|
-
|
|
218
|
+
raise ValueError(
|
|
219
|
+
f"Missing return annotation for {func_node.name} function in {file_path}"
|
|
220
|
+
)
|
|
221
|
+
|
|
205
222
|
# Store parameters
|
|
206
223
|
component.parameters = parameters
|
|
207
|
-
|
|
224
|
+
|
|
208
225
|
def _process_tool(self, component: ParsedComponent, tree: ast.Module) -> None:
|
|
209
226
|
"""Process a tool component to extract input/output schemas."""
|
|
210
227
|
# Look for Input and Output classes in the AST
|
|
211
228
|
input_class = None
|
|
212
229
|
output_class = None
|
|
213
|
-
|
|
230
|
+
|
|
214
231
|
for node in tree.body:
|
|
215
232
|
if isinstance(node, ast.ClassDef):
|
|
216
233
|
if node.name == "Input":
|
|
217
234
|
input_class = node
|
|
218
235
|
elif node.name == "Output":
|
|
219
236
|
output_class = node
|
|
220
|
-
|
|
237
|
+
|
|
221
238
|
# Process Input class if found
|
|
222
239
|
if input_class:
|
|
223
240
|
# Check if it inherits from BaseModel
|
|
224
241
|
for base in input_class.bases:
|
|
225
242
|
if isinstance(base, ast.Name) and base.id == "BaseModel":
|
|
226
|
-
component.input_schema = self._extract_pydantic_schema_from_ast(
|
|
243
|
+
component.input_schema = self._extract_pydantic_schema_from_ast(
|
|
244
|
+
input_class
|
|
245
|
+
)
|
|
227
246
|
break
|
|
228
|
-
|
|
247
|
+
|
|
229
248
|
# Process Output class if found
|
|
230
249
|
if output_class:
|
|
231
250
|
# Check if it inherits from BaseModel
|
|
232
251
|
for base in output_class.bases:
|
|
233
252
|
if isinstance(base, ast.Name) and base.id == "BaseModel":
|
|
234
|
-
component.output_schema = self._extract_pydantic_schema_from_ast(
|
|
253
|
+
component.output_schema = self._extract_pydantic_schema_from_ast(
|
|
254
|
+
output_class
|
|
255
|
+
)
|
|
235
256
|
break
|
|
236
|
-
|
|
257
|
+
|
|
237
258
|
def _process_resource(self, component: ParsedComponent, tree: ast.Module) -> None:
|
|
238
259
|
"""Process a resource component to extract URI template."""
|
|
239
260
|
# Look for resource_uri assignment in the AST
|
|
@@ -244,68 +265,66 @@ class AstParser:
|
|
|
244
265
|
if isinstance(node.value, ast.Constant):
|
|
245
266
|
uri_template = node.value.value
|
|
246
267
|
component.uri_template = uri_template
|
|
247
|
-
|
|
268
|
+
|
|
248
269
|
# Extract URI parameters (parts in {})
|
|
249
270
|
uri_params = re.findall(r"{([^}]+)}", uri_template)
|
|
250
271
|
if uri_params:
|
|
251
272
|
component.parameters = uri_params
|
|
252
273
|
break
|
|
253
|
-
|
|
274
|
+
|
|
254
275
|
def _process_prompt(self, component: ParsedComponent, tree: ast.Module) -> None:
|
|
255
276
|
"""Process a prompt component (no special processing needed)."""
|
|
256
277
|
pass
|
|
257
|
-
|
|
258
|
-
def _derive_component_name(
|
|
278
|
+
|
|
279
|
+
def _derive_component_name(
|
|
280
|
+
self, file_path: Path, component_type: ComponentType
|
|
281
|
+
) -> str:
|
|
259
282
|
"""Derive a component name from its file path according to the spec.
|
|
260
|
-
|
|
283
|
+
|
|
261
284
|
Following the spec: <filename> + ("-" + "-".join(PathRev) if PathRev else "")
|
|
262
285
|
where PathRev is the reversed list of parent directories under the category.
|
|
263
286
|
"""
|
|
264
287
|
rel_path = file_path.relative_to(self.project_root)
|
|
265
|
-
|
|
288
|
+
|
|
266
289
|
# Find which category directory this is in
|
|
267
|
-
category = None
|
|
268
290
|
category_idx = -1
|
|
269
291
|
for i, part in enumerate(rel_path.parts):
|
|
270
292
|
if part in ["tools", "resources", "prompts"]:
|
|
271
|
-
category = part
|
|
272
293
|
category_idx = i
|
|
273
294
|
break
|
|
274
|
-
|
|
295
|
+
|
|
275
296
|
if category_idx == -1:
|
|
276
297
|
return ""
|
|
277
|
-
|
|
298
|
+
|
|
278
299
|
# Get the filename without extension
|
|
279
300
|
filename = rel_path.stem
|
|
280
|
-
|
|
301
|
+
|
|
281
302
|
# Get parent directories between category and file
|
|
282
|
-
parent_dirs = list(rel_path.parts[category_idx+1
|
|
283
|
-
|
|
303
|
+
parent_dirs = list(rel_path.parts[category_idx + 1 : -1])
|
|
304
|
+
|
|
284
305
|
# Reverse parent dirs according to spec
|
|
285
306
|
parent_dirs.reverse()
|
|
286
|
-
|
|
307
|
+
|
|
287
308
|
# Form the ID according to spec
|
|
288
309
|
if parent_dirs:
|
|
289
310
|
return f"{filename}-{'-'.join(parent_dirs)}"
|
|
290
311
|
else:
|
|
291
312
|
return filename
|
|
292
|
-
|
|
293
|
-
def _extract_pydantic_schema_from_ast(
|
|
313
|
+
|
|
314
|
+
def _extract_pydantic_schema_from_ast(
|
|
315
|
+
self, class_node: ast.ClassDef
|
|
316
|
+
) -> dict[str, Any]:
|
|
294
317
|
"""Extract a JSON schema from an AST class definition.
|
|
295
|
-
|
|
318
|
+
|
|
296
319
|
This is a simplified version that extracts basic field information.
|
|
297
320
|
For complex annotations, a more sophisticated approach would be needed.
|
|
298
321
|
"""
|
|
299
|
-
schema = {
|
|
300
|
-
|
|
301
|
-
"properties": {},
|
|
302
|
-
"required": []
|
|
303
|
-
}
|
|
304
|
-
|
|
322
|
+
schema = {"type": "object", "properties": {}, "required": []}
|
|
323
|
+
|
|
305
324
|
for node in class_node.body:
|
|
306
325
|
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
|
307
326
|
field_name = node.target.id
|
|
308
|
-
|
|
327
|
+
|
|
309
328
|
# Extract type annotation as string
|
|
310
329
|
annotation = ""
|
|
311
330
|
if isinstance(node.annotation, ast.Name):
|
|
@@ -315,24 +334,29 @@ class AstParser:
|
|
|
315
334
|
annotation = ast.unparse(node.annotation)
|
|
316
335
|
else:
|
|
317
336
|
annotation = ast.unparse(node.annotation)
|
|
318
|
-
|
|
337
|
+
|
|
319
338
|
# Create property definition
|
|
320
339
|
prop = {
|
|
321
340
|
"type": self._type_hint_to_json_type(annotation),
|
|
322
|
-
"title": field_name.replace(
|
|
341
|
+
"title": field_name.replace("_", " ").title(),
|
|
323
342
|
}
|
|
324
|
-
|
|
343
|
+
|
|
325
344
|
# Extract default value if present
|
|
326
345
|
if node.value is not None:
|
|
327
346
|
if isinstance(node.value, ast.Constant):
|
|
328
347
|
# Simple constant default
|
|
329
348
|
prop["default"] = node.value.value
|
|
330
|
-
elif (
|
|
331
|
-
|
|
332
|
-
|
|
349
|
+
elif (
|
|
350
|
+
isinstance(node.value, ast.Call)
|
|
351
|
+
and isinstance(node.value.func, ast.Name)
|
|
352
|
+
and node.value.func.id == "Field"
|
|
353
|
+
):
|
|
333
354
|
# Field object - extract its parameters
|
|
334
355
|
for keyword in node.value.keywords:
|
|
335
|
-
if
|
|
356
|
+
if (
|
|
357
|
+
keyword.arg == "default"
|
|
358
|
+
or keyword.arg == "default_factory"
|
|
359
|
+
):
|
|
336
360
|
if isinstance(keyword.value, ast.Constant):
|
|
337
361
|
prop["default"] = keyword.value.value
|
|
338
362
|
elif keyword.arg == "description":
|
|
@@ -341,47 +365,58 @@ class AstParser:
|
|
|
341
365
|
elif keyword.arg == "title":
|
|
342
366
|
if isinstance(keyword.value, ast.Constant):
|
|
343
367
|
prop["title"] = keyword.value.value
|
|
344
|
-
|
|
368
|
+
|
|
345
369
|
# Check for position default argument (Field(..., "description"))
|
|
346
370
|
if node.value.args:
|
|
347
371
|
for i, arg in enumerate(node.value.args):
|
|
348
|
-
if
|
|
372
|
+
if (
|
|
373
|
+
i == 0
|
|
374
|
+
and isinstance(arg, ast.Constant)
|
|
375
|
+
and arg.value != Ellipsis
|
|
376
|
+
):
|
|
349
377
|
prop["default"] = arg.value
|
|
350
378
|
elif i == 1 and isinstance(arg, ast.Constant):
|
|
351
379
|
prop["description"] = arg.value
|
|
352
|
-
|
|
380
|
+
|
|
353
381
|
# Add to properties
|
|
354
382
|
schema["properties"][field_name] = prop
|
|
355
|
-
|
|
383
|
+
|
|
356
384
|
# Check if required (no default value or Field(...))
|
|
357
385
|
is_required = True
|
|
358
386
|
if node.value is not None:
|
|
359
387
|
if isinstance(node.value, ast.Constant):
|
|
360
388
|
is_required = False
|
|
361
|
-
elif (
|
|
362
|
-
|
|
363
|
-
|
|
389
|
+
elif (
|
|
390
|
+
isinstance(node.value, ast.Call)
|
|
391
|
+
and isinstance(node.value.func, ast.Name)
|
|
392
|
+
and node.value.func.id == "Field"
|
|
393
|
+
):
|
|
364
394
|
# Field has default if it doesn't use ... or if it has a default keyword
|
|
365
395
|
has_ellipsis = False
|
|
366
396
|
has_default = False
|
|
367
|
-
|
|
368
|
-
if node.value.args and isinstance(
|
|
397
|
+
|
|
398
|
+
if node.value.args and isinstance(
|
|
399
|
+
node.value.args[0], ast.Constant
|
|
400
|
+
):
|
|
369
401
|
has_ellipsis = node.value.args[0].value is Ellipsis
|
|
370
|
-
|
|
402
|
+
|
|
371
403
|
for keyword in node.value.keywords:
|
|
372
|
-
if
|
|
404
|
+
if (
|
|
405
|
+
keyword.arg == "default"
|
|
406
|
+
or keyword.arg == "default_factory"
|
|
407
|
+
):
|
|
373
408
|
has_default = True
|
|
374
|
-
|
|
409
|
+
|
|
375
410
|
is_required = has_ellipsis and not has_default
|
|
376
|
-
|
|
411
|
+
|
|
377
412
|
if is_required:
|
|
378
413
|
schema["required"].append(field_name)
|
|
379
|
-
|
|
414
|
+
|
|
380
415
|
return schema
|
|
381
|
-
|
|
416
|
+
|
|
382
417
|
def _type_hint_to_json_type(self, type_hint: str) -> str:
|
|
383
418
|
"""Convert a Python type hint to a JSON schema type.
|
|
384
|
-
|
|
419
|
+
|
|
385
420
|
This is a simplified version. A more sophisticated approach would
|
|
386
421
|
handle complex types correctly.
|
|
387
422
|
"""
|
|
@@ -393,76 +428,79 @@ class AstParser:
|
|
|
393
428
|
"list": "array",
|
|
394
429
|
"dict": "object",
|
|
395
430
|
}
|
|
396
|
-
|
|
431
|
+
|
|
397
432
|
# Handle simple types
|
|
398
433
|
for py_type, json_type in type_map.items():
|
|
399
434
|
if py_type in type_hint.lower():
|
|
400
435
|
return json_type
|
|
401
|
-
|
|
436
|
+
|
|
402
437
|
# Default to string for unknown types
|
|
403
438
|
return "string"
|
|
404
439
|
|
|
405
440
|
|
|
406
|
-
def parse_project(project_path: Path) ->
|
|
441
|
+
def parse_project(project_path: Path) -> dict[ComponentType, list[ParsedComponent]]:
|
|
407
442
|
"""Parse a GolfMCP project to extract all components."""
|
|
408
443
|
parser = AstParser(project_path)
|
|
409
|
-
|
|
410
|
-
components:
|
|
444
|
+
|
|
445
|
+
components: dict[ComponentType, list[ParsedComponent]] = {
|
|
411
446
|
ComponentType.TOOL: [],
|
|
412
447
|
ComponentType.RESOURCE: [],
|
|
413
|
-
ComponentType.PROMPT: []
|
|
448
|
+
ComponentType.PROMPT: [],
|
|
414
449
|
}
|
|
415
|
-
|
|
450
|
+
|
|
416
451
|
# Parse each directory
|
|
417
452
|
for comp_type, dir_name in [
|
|
418
453
|
(ComponentType.TOOL, "tools"),
|
|
419
454
|
(ComponentType.RESOURCE, "resources"),
|
|
420
|
-
(ComponentType.PROMPT, "prompts")
|
|
455
|
+
(ComponentType.PROMPT, "prompts"),
|
|
421
456
|
]:
|
|
422
457
|
dir_path = project_path / dir_name
|
|
423
458
|
if dir_path.exists() and dir_path.is_dir():
|
|
424
459
|
dir_components = parser.parse_directory(dir_path)
|
|
425
|
-
components[comp_type].extend(
|
|
426
|
-
|
|
460
|
+
components[comp_type].extend(
|
|
461
|
+
[c for c in dir_components if c.type == comp_type]
|
|
462
|
+
)
|
|
463
|
+
|
|
427
464
|
# Check for ID collisions
|
|
428
465
|
all_ids = []
|
|
429
466
|
for comp_type, comps in components.items():
|
|
430
467
|
for comp in comps:
|
|
431
468
|
if comp.name in all_ids:
|
|
432
|
-
raise ValueError(
|
|
469
|
+
raise ValueError(
|
|
470
|
+
f"ID collision detected: {comp.name} is used by multiple components"
|
|
471
|
+
)
|
|
433
472
|
all_ids.append(comp.name)
|
|
434
|
-
|
|
435
|
-
return components
|
|
436
|
-
|
|
437
|
-
|
|
438
473
|
|
|
474
|
+
return components
|
|
439
475
|
|
|
440
476
|
|
|
441
|
-
def parse_common_files(project_path: Path) ->
|
|
477
|
+
def parse_common_files(project_path: Path) -> dict[str, Path]:
|
|
442
478
|
"""Find all common.py files in the project.
|
|
443
|
-
|
|
479
|
+
|
|
444
480
|
Args:
|
|
445
481
|
project_path: Path to the project root
|
|
446
|
-
|
|
482
|
+
|
|
447
483
|
Returns:
|
|
448
484
|
Dictionary mapping directory paths to common.py file paths
|
|
449
485
|
"""
|
|
450
486
|
common_files = {}
|
|
451
|
-
|
|
487
|
+
|
|
452
488
|
# Search for common.py files in tools, resources, and prompts directories
|
|
453
489
|
for dir_name in ["tools", "resources", "prompts"]:
|
|
454
490
|
base_dir = project_path / dir_name
|
|
455
491
|
if not base_dir.exists() or not base_dir.is_dir():
|
|
456
492
|
continue
|
|
457
|
-
|
|
493
|
+
|
|
458
494
|
# Find all common.py files (recursively)
|
|
459
495
|
for common_file in base_dir.glob("**/common.py"):
|
|
460
496
|
# Skip files in __pycache__ or other hidden directories
|
|
461
|
-
if "__pycache__" in common_file.parts or any(
|
|
497
|
+
if "__pycache__" in common_file.parts or any(
|
|
498
|
+
part.startswith(".") for part in common_file.parts
|
|
499
|
+
):
|
|
462
500
|
continue
|
|
463
|
-
|
|
501
|
+
|
|
464
502
|
# Get the parent directory as the module path
|
|
465
503
|
module_path = str(common_file.parent.relative_to(project_path))
|
|
466
504
|
common_files[module_path] = common_file
|
|
467
|
-
|
|
468
|
-
return common_files
|
|
505
|
+
|
|
506
|
+
return common_files
|