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/builder.py
ADDED
|
@@ -0,0 +1,1884 @@
|
|
|
1
|
+
"""Builder for generating FastMCP manifests from parsed components."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import black
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
from golf.auth import is_auth_configured
|
|
14
|
+
from golf.auth.api_key import get_api_key_config
|
|
15
|
+
from golf.core.builder_auth import generate_auth_code, generate_auth_routes
|
|
16
|
+
from golf.core.builder_telemetry import (
|
|
17
|
+
generate_telemetry_imports,
|
|
18
|
+
)
|
|
19
|
+
from golf.cli.branding import create_build_header, get_status_text
|
|
20
|
+
from golf.core.config import Settings
|
|
21
|
+
from golf.core.parser import (
|
|
22
|
+
ComponentType,
|
|
23
|
+
ParsedComponent,
|
|
24
|
+
parse_project,
|
|
25
|
+
)
|
|
26
|
+
from golf.core.transformer import transform_component
|
|
27
|
+
|
|
28
|
+
console = Console()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ManifestBuilder:
|
|
32
|
+
"""Builds FastMCP manifest from parsed components."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, project_path: Path, settings: Settings) -> None:
|
|
35
|
+
"""Initialize the manifest builder.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
project_path: Path to the project root
|
|
39
|
+
settings: Project settings
|
|
40
|
+
"""
|
|
41
|
+
self.project_path = project_path
|
|
42
|
+
self.settings = settings
|
|
43
|
+
self.components: dict[ComponentType, list[ParsedComponent]] = {}
|
|
44
|
+
self.manifest: dict[str, Any] = {
|
|
45
|
+
"name": settings.name,
|
|
46
|
+
"description": settings.description or "",
|
|
47
|
+
"tools": [],
|
|
48
|
+
"resources": [],
|
|
49
|
+
"prompts": [],
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
def build(self) -> dict[str, Any]:
|
|
53
|
+
"""Build the complete manifest.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
FastMCP manifest dictionary
|
|
57
|
+
"""
|
|
58
|
+
# Parse all components
|
|
59
|
+
self.components = parse_project(self.project_path)
|
|
60
|
+
|
|
61
|
+
# Process each component type
|
|
62
|
+
self._process_tools()
|
|
63
|
+
self._process_resources()
|
|
64
|
+
self._process_prompts()
|
|
65
|
+
|
|
66
|
+
return self.manifest
|
|
67
|
+
|
|
68
|
+
def _process_tools(self) -> None:
|
|
69
|
+
"""Process all tool components and add them to the manifest."""
|
|
70
|
+
for component in self.components[ComponentType.TOOL]:
|
|
71
|
+
# Extract the properties directly from the Input schema if it exists
|
|
72
|
+
input_properties = {}
|
|
73
|
+
required_fields = []
|
|
74
|
+
|
|
75
|
+
if component.input_schema and "properties" in component.input_schema:
|
|
76
|
+
input_properties = component.input_schema["properties"]
|
|
77
|
+
# Get required fields if they exist
|
|
78
|
+
if "required" in component.input_schema:
|
|
79
|
+
required_fields = component.input_schema["required"]
|
|
80
|
+
|
|
81
|
+
# Create a flattened tool schema matching FastMCP documentation examples
|
|
82
|
+
tool_schema = {
|
|
83
|
+
"name": component.name,
|
|
84
|
+
"description": component.docstring or "",
|
|
85
|
+
"inputSchema": {
|
|
86
|
+
"type": "object",
|
|
87
|
+
"properties": input_properties,
|
|
88
|
+
"additionalProperties": False,
|
|
89
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
90
|
+
},
|
|
91
|
+
"annotations": {"title": component.name.replace("-", " ").title()},
|
|
92
|
+
"entry_function": component.entry_function,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# Include required fields if they exist
|
|
96
|
+
if required_fields:
|
|
97
|
+
tool_schema["inputSchema"]["required"] = required_fields
|
|
98
|
+
|
|
99
|
+
# Add tool annotations if present
|
|
100
|
+
if component.annotations:
|
|
101
|
+
# Merge with existing annotations (keeping title)
|
|
102
|
+
tool_schema["annotations"].update(component.annotations)
|
|
103
|
+
|
|
104
|
+
# Add the tool to the manifest
|
|
105
|
+
self.manifest["tools"].append(tool_schema)
|
|
106
|
+
|
|
107
|
+
def _process_resources(self) -> None:
|
|
108
|
+
"""Process all resource components and add them to the manifest."""
|
|
109
|
+
for component in self.components[ComponentType.RESOURCE]:
|
|
110
|
+
if not component.uri_template:
|
|
111
|
+
console.print(f"[yellow]Warning: Resource {component.name} has no URI template[/yellow]")
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
resource_schema = {
|
|
115
|
+
"uri": component.uri_template,
|
|
116
|
+
"name": component.name,
|
|
117
|
+
"description": component.docstring or "",
|
|
118
|
+
"entry_function": component.entry_function,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# Add the resource to the manifest
|
|
122
|
+
self.manifest["resources"].append(resource_schema)
|
|
123
|
+
|
|
124
|
+
def _process_prompts(self) -> None:
|
|
125
|
+
"""Process all prompt components and add them to the manifest."""
|
|
126
|
+
for component in self.components[ComponentType.PROMPT]:
|
|
127
|
+
# For prompts, the handler will have to load the module and execute
|
|
128
|
+
# the run function
|
|
129
|
+
# to get the actual messages, so we just register it by name
|
|
130
|
+
prompt_schema = {
|
|
131
|
+
"name": component.name,
|
|
132
|
+
"description": component.docstring or "",
|
|
133
|
+
"entry_function": component.entry_function,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
# If the prompt has parameters, include them
|
|
137
|
+
if component.parameters:
|
|
138
|
+
arguments = []
|
|
139
|
+
for param in component.parameters:
|
|
140
|
+
arguments.append(
|
|
141
|
+
{"name": param, "required": True} # Default to required
|
|
142
|
+
)
|
|
143
|
+
prompt_schema["arguments"] = arguments
|
|
144
|
+
|
|
145
|
+
# Add the prompt to the manifest
|
|
146
|
+
self.manifest["prompts"].append(prompt_schema)
|
|
147
|
+
|
|
148
|
+
def save_manifest(self, output_path: Path | None = None) -> Path:
|
|
149
|
+
"""Save the manifest to a JSON file.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
output_path: Path to save the manifest to (defaults to .golf/manifest.json)
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Path where the manifest was saved
|
|
156
|
+
"""
|
|
157
|
+
if not output_path:
|
|
158
|
+
# Create .golf directory if it doesn't exist
|
|
159
|
+
golf_dir = self.project_path / ".golf"
|
|
160
|
+
golf_dir.mkdir(exist_ok=True)
|
|
161
|
+
output_path = golf_dir / "manifest.json"
|
|
162
|
+
|
|
163
|
+
# Ensure parent directories exist
|
|
164
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
|
|
166
|
+
# Write the manifest to the file
|
|
167
|
+
with open(output_path, "w") as f:
|
|
168
|
+
json.dump(self.manifest, f, indent=2)
|
|
169
|
+
|
|
170
|
+
console.print(f"[green]Manifest saved to {output_path}[/green]")
|
|
171
|
+
return output_path
|
|
172
|
+
|
|
173
|
+
def _get_fastmcp_version(self) -> str | None:
|
|
174
|
+
"""Get the installed FastMCP version.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
FastMCP version string (e.g., "2.12.0") or None if not available
|
|
178
|
+
"""
|
|
179
|
+
try:
|
|
180
|
+
import fastmcp
|
|
181
|
+
|
|
182
|
+
return fastmcp.__version__
|
|
183
|
+
except (ImportError, AttributeError):
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
def _is_fastmcp_version_gte(self, target_version: str) -> bool:
|
|
187
|
+
"""Check if installed FastMCP version is >= target version.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
target_version: Version string to compare against (e.g., "2.12.0")
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
True if FastMCP version >= target_version, False otherwise
|
|
194
|
+
"""
|
|
195
|
+
try:
|
|
196
|
+
from packaging import version
|
|
197
|
+
|
|
198
|
+
current_version = self._get_fastmcp_version()
|
|
199
|
+
if current_version is None:
|
|
200
|
+
# Default to older behavior for safety
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
return version.parse(current_version) >= version.parse(target_version)
|
|
204
|
+
except (ImportError, ValueError):
|
|
205
|
+
# Default to older behavior for safety
|
|
206
|
+
return False
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def build_manifest(project_path: Path, settings: Settings) -> dict[str, Any]:
|
|
210
|
+
"""Build a FastMCP manifest from parsed components.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
project_path: Path to the project root
|
|
214
|
+
settings: Project settings
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
FastMCP manifest dictionary
|
|
218
|
+
"""
|
|
219
|
+
# Use the ManifestBuilder class to build the manifest
|
|
220
|
+
builder = ManifestBuilder(project_path, settings)
|
|
221
|
+
return builder.build()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def compute_manifest_diff(old_manifest: dict[str, Any], new_manifest: dict[str, Any]) -> dict[str, Any]:
|
|
225
|
+
"""Compute the difference between two manifests.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
old_manifest: Previous manifest
|
|
229
|
+
new_manifest: New manifest
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
Dictionary describing the changes
|
|
233
|
+
"""
|
|
234
|
+
diff = {
|
|
235
|
+
"tools": {"added": [], "removed": [], "changed": []},
|
|
236
|
+
"resources": {"added": [], "removed": [], "changed": []},
|
|
237
|
+
"prompts": {"added": [], "removed": [], "changed": []},
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
# Helper function to extract names from a list of components
|
|
241
|
+
def extract_names(components: list[dict[str, Any]]) -> set[str]:
|
|
242
|
+
return {comp["name"] for comp in components}
|
|
243
|
+
|
|
244
|
+
# Compare tools
|
|
245
|
+
old_tools = extract_names(old_manifest.get("tools", []))
|
|
246
|
+
new_tools = extract_names(new_manifest.get("tools", []))
|
|
247
|
+
diff["tools"]["added"] = list(new_tools - old_tools)
|
|
248
|
+
diff["tools"]["removed"] = list(old_tools - new_tools)
|
|
249
|
+
|
|
250
|
+
# Compare tools that exist in both for changes
|
|
251
|
+
for new_tool in new_manifest.get("tools", []):
|
|
252
|
+
if new_tool["name"] in old_tools:
|
|
253
|
+
# Find the corresponding old tool
|
|
254
|
+
old_tool = next(
|
|
255
|
+
(t for t in old_manifest.get("tools", []) if t["name"] == new_tool["name"]),
|
|
256
|
+
None,
|
|
257
|
+
)
|
|
258
|
+
if old_tool and json.dumps(old_tool) != json.dumps(new_tool):
|
|
259
|
+
diff["tools"]["changed"].append(new_tool["name"])
|
|
260
|
+
|
|
261
|
+
# Compare resources
|
|
262
|
+
old_resources = extract_names(old_manifest.get("resources", []))
|
|
263
|
+
new_resources = extract_names(new_manifest.get("resources", []))
|
|
264
|
+
diff["resources"]["added"] = list(new_resources - old_resources)
|
|
265
|
+
diff["resources"]["removed"] = list(old_resources - new_resources)
|
|
266
|
+
|
|
267
|
+
# Compare resources that exist in both for changes
|
|
268
|
+
for new_resource in new_manifest.get("resources", []):
|
|
269
|
+
if new_resource["name"] in old_resources:
|
|
270
|
+
# Find the corresponding old resource
|
|
271
|
+
old_resource = next(
|
|
272
|
+
(r for r in old_manifest.get("resources", []) if r["name"] == new_resource["name"]),
|
|
273
|
+
None,
|
|
274
|
+
)
|
|
275
|
+
if old_resource and json.dumps(old_resource) != json.dumps(new_resource):
|
|
276
|
+
diff["resources"]["changed"].append(new_resource["name"])
|
|
277
|
+
|
|
278
|
+
# Compare prompts
|
|
279
|
+
old_prompts = extract_names(old_manifest.get("prompts", []))
|
|
280
|
+
new_prompts = extract_names(new_manifest.get("prompts", []))
|
|
281
|
+
diff["prompts"]["added"] = list(new_prompts - old_prompts)
|
|
282
|
+
diff["prompts"]["removed"] = list(old_prompts - new_prompts)
|
|
283
|
+
|
|
284
|
+
# Compare prompts that exist in both for changes
|
|
285
|
+
for new_prompt in new_manifest.get("prompts", []):
|
|
286
|
+
if new_prompt["name"] in old_prompts:
|
|
287
|
+
# Find the corresponding old prompt
|
|
288
|
+
old_prompt = next(
|
|
289
|
+
(p for p in old_manifest.get("prompts", []) if p["name"] == new_prompt["name"]),
|
|
290
|
+
None,
|
|
291
|
+
)
|
|
292
|
+
if old_prompt and json.dumps(old_prompt) != json.dumps(new_prompt):
|
|
293
|
+
diff["prompts"]["changed"].append(new_prompt["name"])
|
|
294
|
+
|
|
295
|
+
return diff
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def has_changes(diff: dict[str, Any]) -> bool:
|
|
299
|
+
"""Check if a manifest diff contains any changes.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
diff: Manifest diff from compute_manifest_diff
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
True if there are any changes, False otherwise
|
|
306
|
+
"""
|
|
307
|
+
for category in diff:
|
|
308
|
+
for change_type in diff[category]:
|
|
309
|
+
if diff[category][change_type]:
|
|
310
|
+
return True
|
|
311
|
+
|
|
312
|
+
return False
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class CodeGenerator:
|
|
316
|
+
"""Code generator for FastMCP applications."""
|
|
317
|
+
|
|
318
|
+
def __init__(
|
|
319
|
+
self,
|
|
320
|
+
project_path: Path,
|
|
321
|
+
settings: Settings,
|
|
322
|
+
output_dir: Path,
|
|
323
|
+
build_env: str = "prod",
|
|
324
|
+
copy_env: bool = False,
|
|
325
|
+
) -> None:
|
|
326
|
+
"""Initialize the code generator.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
project_path: Path to the project root
|
|
330
|
+
settings: Project settings
|
|
331
|
+
output_dir: Directory to output the generated code
|
|
332
|
+
build_env: Build environment ('dev' or 'prod')
|
|
333
|
+
copy_env: Whether to copy environment variables to the built app
|
|
334
|
+
"""
|
|
335
|
+
self.project_path = project_path
|
|
336
|
+
self.settings = settings
|
|
337
|
+
self.output_dir = output_dir
|
|
338
|
+
self.build_env = build_env
|
|
339
|
+
self.copy_env = copy_env
|
|
340
|
+
self.components = {}
|
|
341
|
+
self.manifest = {}
|
|
342
|
+
self.shared_files = {}
|
|
343
|
+
self.import_map = {}
|
|
344
|
+
self._root_files_cache = None # Cache for discovered root files
|
|
345
|
+
|
|
346
|
+
def _get_cached_root_files(self) -> dict[str, Path]:
|
|
347
|
+
"""Get cached root files, discovering them only once."""
|
|
348
|
+
if self._root_files_cache is None:
|
|
349
|
+
self._root_files_cache = discover_root_files(self.project_path)
|
|
350
|
+
return self._root_files_cache
|
|
351
|
+
|
|
352
|
+
def generate(self) -> None:
|
|
353
|
+
"""Generate the FastMCP application code."""
|
|
354
|
+
# Parse the project and build the manifest
|
|
355
|
+
with console.status("Analyzing project components..."):
|
|
356
|
+
self.components = parse_project(self.project_path)
|
|
357
|
+
self.manifest = build_manifest(self.project_path, self.settings)
|
|
358
|
+
|
|
359
|
+
# Find shared Python files and build import map
|
|
360
|
+
from golf.core.parser import parse_shared_files
|
|
361
|
+
|
|
362
|
+
self.shared_files = parse_shared_files(self.project_path)
|
|
363
|
+
self.import_map = build_import_map(self.project_path, self.shared_files)
|
|
364
|
+
|
|
365
|
+
# Create output directory structure
|
|
366
|
+
with console.status("Creating directory structure..."):
|
|
367
|
+
self._create_directory_structure()
|
|
368
|
+
|
|
369
|
+
# Generate code for all components
|
|
370
|
+
tasks = [
|
|
371
|
+
("Generating tools", self._generate_tools),
|
|
372
|
+
("Generating resources", self._generate_resources),
|
|
373
|
+
("Generating prompts", self._generate_prompts),
|
|
374
|
+
("Generating server entry point", self._generate_server),
|
|
375
|
+
]
|
|
376
|
+
|
|
377
|
+
for description, func in tasks:
|
|
378
|
+
console.print(get_status_text("generating", description))
|
|
379
|
+
func()
|
|
380
|
+
|
|
381
|
+
# Get relative path for display
|
|
382
|
+
try:
|
|
383
|
+
output_dir_display = self.output_dir.relative_to(Path.cwd())
|
|
384
|
+
except (ValueError, FileNotFoundError, OSError):
|
|
385
|
+
# ValueError: paths don't have a common base
|
|
386
|
+
# FileNotFoundError/OSError: current directory was deleted
|
|
387
|
+
output_dir_display = self.output_dir
|
|
388
|
+
|
|
389
|
+
# Show success message with output directory
|
|
390
|
+
console.print()
|
|
391
|
+
console.print(get_status_text("success", f"Build completed successfully in {output_dir_display}"))
|
|
392
|
+
|
|
393
|
+
def _generate_root_file_imports(self) -> list[str]:
|
|
394
|
+
"""Generate import statements for automatically discovered root files."""
|
|
395
|
+
root_file_imports = []
|
|
396
|
+
discovered_files = self._get_cached_root_files()
|
|
397
|
+
|
|
398
|
+
if discovered_files:
|
|
399
|
+
root_file_imports.append("# Import root-level Python files")
|
|
400
|
+
|
|
401
|
+
for filename in sorted(discovered_files.keys()):
|
|
402
|
+
module_name = Path(filename).stem # env.py -> env
|
|
403
|
+
root_file_imports.append(f"import {module_name}")
|
|
404
|
+
|
|
405
|
+
root_file_imports.append("") # Blank line
|
|
406
|
+
|
|
407
|
+
return root_file_imports
|
|
408
|
+
|
|
409
|
+
def _get_root_file_modules(self) -> set[str]:
|
|
410
|
+
"""Get set of root file module names for import transformation."""
|
|
411
|
+
discovered_files = self._get_cached_root_files()
|
|
412
|
+
return {Path(filename).stem for filename in discovered_files.keys()}
|
|
413
|
+
|
|
414
|
+
def _create_directory_structure(self) -> None:
|
|
415
|
+
"""Create the output directory structure"""
|
|
416
|
+
# Create main directories
|
|
417
|
+
dirs = [
|
|
418
|
+
self.output_dir,
|
|
419
|
+
self.output_dir / "components",
|
|
420
|
+
self.output_dir / "components" / "tools",
|
|
421
|
+
self.output_dir / "components" / "resources",
|
|
422
|
+
self.output_dir / "components" / "prompts",
|
|
423
|
+
]
|
|
424
|
+
|
|
425
|
+
for directory in dirs:
|
|
426
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
427
|
+
# Process shared files directly in the components directory
|
|
428
|
+
self._process_shared_files()
|
|
429
|
+
|
|
430
|
+
def _process_shared_files(self) -> None:
|
|
431
|
+
"""Process and transform shared Python files in the components directory
|
|
432
|
+
structure."""
|
|
433
|
+
# Process all shared files
|
|
434
|
+
for module_path_str, shared_file in self.shared_files.items():
|
|
435
|
+
# Convert module path to Path object (e.g., "tools/weather/helpers")
|
|
436
|
+
module_path = Path(module_path_str)
|
|
437
|
+
|
|
438
|
+
# Determine the component type
|
|
439
|
+
component_type = None
|
|
440
|
+
for part in module_path.parts:
|
|
441
|
+
if part in ["tools", "resources", "prompts"]:
|
|
442
|
+
component_type = part
|
|
443
|
+
break
|
|
444
|
+
|
|
445
|
+
if not component_type:
|
|
446
|
+
continue
|
|
447
|
+
|
|
448
|
+
# Calculate target directory in components structure
|
|
449
|
+
rel_to_component = module_path.relative_to(component_type)
|
|
450
|
+
target_dir = self.output_dir / "components" / component_type / rel_to_component.parent
|
|
451
|
+
|
|
452
|
+
# Create directory if it doesn't exist
|
|
453
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
454
|
+
|
|
455
|
+
# Create the shared file in the target directory (preserve original filename)
|
|
456
|
+
target_file = target_dir / shared_file.name
|
|
457
|
+
|
|
458
|
+
# Use transformer to process the file
|
|
459
|
+
root_file_modules = self._get_root_file_modules()
|
|
460
|
+
transform_component(
|
|
461
|
+
component=None,
|
|
462
|
+
output_file=target_file,
|
|
463
|
+
project_path=self.project_path,
|
|
464
|
+
import_map=self.import_map,
|
|
465
|
+
source_file=shared_file,
|
|
466
|
+
root_file_modules=root_file_modules,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
def _generate_tools(self) -> None:
|
|
470
|
+
"""Generate code for all tools."""
|
|
471
|
+
tools_dir = self.output_dir / "components" / "tools"
|
|
472
|
+
|
|
473
|
+
for tool in self.components.get(ComponentType.TOOL, []):
|
|
474
|
+
# Get the tool directory structure
|
|
475
|
+
rel_path = Path(tool.file_path).relative_to(self.project_path)
|
|
476
|
+
if not rel_path.is_relative_to(Path(self.settings.tools_dir)):
|
|
477
|
+
console.print(f"[yellow]Warning: Tool {tool.name} is not in the tools directory[/yellow]")
|
|
478
|
+
continue
|
|
479
|
+
|
|
480
|
+
try:
|
|
481
|
+
rel_to_tools = rel_path.relative_to(self.settings.tools_dir)
|
|
482
|
+
tool_dir = tools_dir / rel_to_tools.parent
|
|
483
|
+
except ValueError:
|
|
484
|
+
# Fall back to just using the filename
|
|
485
|
+
tool_dir = tools_dir
|
|
486
|
+
|
|
487
|
+
tool_dir.mkdir(parents=True, exist_ok=True)
|
|
488
|
+
|
|
489
|
+
# Create the tool file
|
|
490
|
+
output_file = tool_dir / rel_path.name
|
|
491
|
+
root_file_modules = self._get_root_file_modules()
|
|
492
|
+
transform_component(
|
|
493
|
+
tool, output_file, self.project_path, self.import_map, root_file_modules=root_file_modules
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
def _generate_resources(self) -> None:
|
|
497
|
+
"""Generate code for all resources."""
|
|
498
|
+
resources_dir = self.output_dir / "components" / "resources"
|
|
499
|
+
|
|
500
|
+
for resource in self.components.get(ComponentType.RESOURCE, []):
|
|
501
|
+
# Get the resource directory structure
|
|
502
|
+
rel_path = Path(resource.file_path).relative_to(self.project_path)
|
|
503
|
+
if not rel_path.is_relative_to(Path(self.settings.resources_dir)):
|
|
504
|
+
console.print(f"[yellow]Warning: Resource {resource.name} is not in the resources directory[/yellow]")
|
|
505
|
+
continue
|
|
506
|
+
|
|
507
|
+
try:
|
|
508
|
+
rel_to_resources = rel_path.relative_to(self.settings.resources_dir)
|
|
509
|
+
resource_dir = resources_dir / rel_to_resources.parent
|
|
510
|
+
except ValueError:
|
|
511
|
+
# Fall back to just using the filename
|
|
512
|
+
resource_dir = resources_dir
|
|
513
|
+
|
|
514
|
+
resource_dir.mkdir(parents=True, exist_ok=True)
|
|
515
|
+
|
|
516
|
+
# Create the resource file
|
|
517
|
+
output_file = resource_dir / rel_path.name
|
|
518
|
+
root_file_modules = self._get_root_file_modules()
|
|
519
|
+
transform_component(
|
|
520
|
+
resource, output_file, self.project_path, self.import_map, root_file_modules=root_file_modules
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
def _generate_prompts(self) -> None:
|
|
524
|
+
"""Generate code for all prompts."""
|
|
525
|
+
prompts_dir = self.output_dir / "components" / "prompts"
|
|
526
|
+
|
|
527
|
+
for prompt in self.components.get(ComponentType.PROMPT, []):
|
|
528
|
+
# Get the prompt directory structure
|
|
529
|
+
rel_path = Path(prompt.file_path).relative_to(self.project_path)
|
|
530
|
+
if not rel_path.is_relative_to(Path(self.settings.prompts_dir)):
|
|
531
|
+
console.print(f"[yellow]Warning: Prompt {prompt.name} is not in the prompts directory[/yellow]")
|
|
532
|
+
continue
|
|
533
|
+
|
|
534
|
+
try:
|
|
535
|
+
rel_to_prompts = rel_path.relative_to(self.settings.prompts_dir)
|
|
536
|
+
prompt_dir = prompts_dir / rel_to_prompts.parent
|
|
537
|
+
except ValueError:
|
|
538
|
+
# Fall back to just using the filename
|
|
539
|
+
prompt_dir = prompts_dir
|
|
540
|
+
|
|
541
|
+
prompt_dir.mkdir(parents=True, exist_ok=True)
|
|
542
|
+
|
|
543
|
+
# Create the prompt file
|
|
544
|
+
output_file = prompt_dir / rel_path.name
|
|
545
|
+
root_file_modules = self._get_root_file_modules()
|
|
546
|
+
transform_component(
|
|
547
|
+
prompt, output_file, self.project_path, self.import_map, root_file_modules=root_file_modules
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
def _get_transport_config(self, transport_type: str) -> dict:
|
|
551
|
+
"""Get transport-specific configuration (primarily for endpoint path display).
|
|
552
|
+
|
|
553
|
+
Args:
|
|
554
|
+
transport_type: The transport type (e.g., 'sse', 'streamable-http', 'stdio')
|
|
555
|
+
|
|
556
|
+
Returns:
|
|
557
|
+
Dictionary with transport configuration details (endpoint_path)
|
|
558
|
+
"""
|
|
559
|
+
config = {
|
|
560
|
+
"endpoint_path": "",
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if transport_type == "sse":
|
|
564
|
+
config["endpoint_path"] = "/sse" # Default SSE path for FastMCP
|
|
565
|
+
elif transport_type == "stdio":
|
|
566
|
+
config["endpoint_path"] = "" # No HTTP endpoint
|
|
567
|
+
else:
|
|
568
|
+
# Default to streamable-http
|
|
569
|
+
config["endpoint_path"] = "/mcp/" # Default MCP path for FastMCP
|
|
570
|
+
|
|
571
|
+
return config
|
|
572
|
+
|
|
573
|
+
def _is_resource_template(self, component: ParsedComponent) -> bool:
|
|
574
|
+
"""Check if a resource component is a template (has URI parameters).
|
|
575
|
+
|
|
576
|
+
Args:
|
|
577
|
+
component: The parsed component to check
|
|
578
|
+
|
|
579
|
+
Returns:
|
|
580
|
+
True if the resource has URI parameters, False otherwise
|
|
581
|
+
"""
|
|
582
|
+
return (
|
|
583
|
+
component.type == ComponentType.RESOURCE
|
|
584
|
+
and component.parameters is not None
|
|
585
|
+
and len(component.parameters) > 0
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
def _get_fastmcp_version(self) -> str | None:
|
|
589
|
+
"""Get the installed FastMCP version.
|
|
590
|
+
|
|
591
|
+
Returns:
|
|
592
|
+
FastMCP version string (e.g., "2.12.0") or None if not available
|
|
593
|
+
"""
|
|
594
|
+
try:
|
|
595
|
+
import fastmcp
|
|
596
|
+
|
|
597
|
+
return fastmcp.__version__
|
|
598
|
+
except (ImportError, AttributeError):
|
|
599
|
+
return None
|
|
600
|
+
|
|
601
|
+
def _is_fastmcp_version_gte(self, target_version: str) -> bool:
|
|
602
|
+
"""Check if installed FastMCP version is >= target version.
|
|
603
|
+
|
|
604
|
+
Args:
|
|
605
|
+
target_version: Version string to compare against (e.g., "2.12.0")
|
|
606
|
+
|
|
607
|
+
Returns:
|
|
608
|
+
True if FastMCP version >= target_version, False otherwise
|
|
609
|
+
"""
|
|
610
|
+
try:
|
|
611
|
+
from packaging import version
|
|
612
|
+
|
|
613
|
+
current_version = self._get_fastmcp_version()
|
|
614
|
+
if current_version is None:
|
|
615
|
+
# Default to older behavior for safety
|
|
616
|
+
return False
|
|
617
|
+
|
|
618
|
+
return version.parse(current_version) >= version.parse(target_version)
|
|
619
|
+
except (ImportError, ValueError):
|
|
620
|
+
# Default to older behavior for safety
|
|
621
|
+
return False
|
|
622
|
+
|
|
623
|
+
def _generate_startup_section(self, project_path: Path) -> list[str]:
|
|
624
|
+
"""Generate code section for startup.py execution during server runtime."""
|
|
625
|
+
startup_path = project_path / "startup.py"
|
|
626
|
+
|
|
627
|
+
if not startup_path.exists():
|
|
628
|
+
return []
|
|
629
|
+
|
|
630
|
+
return [
|
|
631
|
+
"",
|
|
632
|
+
"# Execute startup script for loading secrets and initialization",
|
|
633
|
+
"import importlib.util",
|
|
634
|
+
"import sys",
|
|
635
|
+
"import os",
|
|
636
|
+
"from pathlib import Path",
|
|
637
|
+
"",
|
|
638
|
+
"# Look for startup.py in the same directory as this server.py",
|
|
639
|
+
"startup_path = Path(__file__).parent / 'startup.py'",
|
|
640
|
+
"if startup_path.exists():",
|
|
641
|
+
" try:",
|
|
642
|
+
" # Save original environment for restoration",
|
|
643
|
+
" try:",
|
|
644
|
+
" original_dir = os.getcwd()",
|
|
645
|
+
" except (FileNotFoundError, OSError):",
|
|
646
|
+
" # Use server directory as fallback",
|
|
647
|
+
" original_dir = str(Path(__file__).parent)",
|
|
648
|
+
" os.chdir(original_dir)",
|
|
649
|
+
" original_path = sys.path.copy()",
|
|
650
|
+
" ",
|
|
651
|
+
" # Set context for startup script execution",
|
|
652
|
+
" script_dir = str(startup_path.parent)",
|
|
653
|
+
" os.chdir(script_dir)",
|
|
654
|
+
" sys.path.insert(0, script_dir)",
|
|
655
|
+
" ",
|
|
656
|
+
" # Debug output for startup script development",
|
|
657
|
+
" if os.environ.get('GOLF_DEBUG'):",
|
|
658
|
+
" print(f'Executing startup script: {startup_path}')",
|
|
659
|
+
" print(f'Working directory: {os.getcwd()}')",
|
|
660
|
+
" print(f'Python path: {sys.path[:3]}...')", # Show first 3 entries
|
|
661
|
+
" ",
|
|
662
|
+
" # Load and execute startup script",
|
|
663
|
+
" spec = importlib.util.spec_from_file_location('startup', startup_path)",
|
|
664
|
+
" if spec and spec.loader:",
|
|
665
|
+
" startup_module = importlib.util.module_from_spec(spec)",
|
|
666
|
+
" spec.loader.exec_module(startup_module)",
|
|
667
|
+
" else:",
|
|
668
|
+
" print('Warning: Could not load startup.py', file=sys.stderr)",
|
|
669
|
+
" ",
|
|
670
|
+
" except Exception as e:",
|
|
671
|
+
" import traceback",
|
|
672
|
+
" print(f'Warning: Startup script execution failed: {e}', file=sys.stderr)",
|
|
673
|
+
" print(traceback.format_exc(), file=sys.stderr)",
|
|
674
|
+
" # Continue server startup despite script failure",
|
|
675
|
+
" ",
|
|
676
|
+
" finally:",
|
|
677
|
+
" # Always restore original environment",
|
|
678
|
+
" try:",
|
|
679
|
+
" os.chdir(original_dir)",
|
|
680
|
+
" sys.path[:] = original_path",
|
|
681
|
+
" except Exception:",
|
|
682
|
+
" # If directory restoration fails, at least fix the path",
|
|
683
|
+
" sys.path[:] = original_path",
|
|
684
|
+
"",
|
|
685
|
+
]
|
|
686
|
+
|
|
687
|
+
def _generate_syspath_section(self) -> list[str]:
|
|
688
|
+
"""Generate sys.path setup for absolute root file imports."""
|
|
689
|
+
discovered_files = self._get_cached_root_files()
|
|
690
|
+
if not discovered_files:
|
|
691
|
+
return []
|
|
692
|
+
|
|
693
|
+
return [
|
|
694
|
+
"",
|
|
695
|
+
"# Enable absolute imports for root files",
|
|
696
|
+
"import sys",
|
|
697
|
+
"from pathlib import Path",
|
|
698
|
+
"",
|
|
699
|
+
"# Add build root to Python path for global root file access",
|
|
700
|
+
"_build_root = str(Path(__file__).parent)",
|
|
701
|
+
"if _build_root not in sys.path:",
|
|
702
|
+
" sys.path.insert(0, _build_root)",
|
|
703
|
+
"",
|
|
704
|
+
]
|
|
705
|
+
|
|
706
|
+
def _generate_readiness_section(self, project_path: Path) -> list[str]:
|
|
707
|
+
"""Generate code section for readiness.py execution during server runtime."""
|
|
708
|
+
readiness_path = project_path / "readiness.py"
|
|
709
|
+
|
|
710
|
+
if not readiness_path.exists():
|
|
711
|
+
# Only generate default readiness if health checks are explicitly enabled
|
|
712
|
+
if not self.settings.health_check_enabled:
|
|
713
|
+
return []
|
|
714
|
+
return [
|
|
715
|
+
"# Default readiness check - no custom readiness.py found",
|
|
716
|
+
"@mcp.custom_route('/ready', methods=[\"GET\"])",
|
|
717
|
+
"async def readiness_check(request: Request) -> JSONResponse:",
|
|
718
|
+
' """Readiness check endpoint for Kubernetes and load balancers."""',
|
|
719
|
+
' return JSONResponse({"status": "pass"}, status_code=200)',
|
|
720
|
+
"",
|
|
721
|
+
]
|
|
722
|
+
|
|
723
|
+
return [
|
|
724
|
+
"# Custom readiness check from readiness.py",
|
|
725
|
+
"from readiness import check as readiness_check_func",
|
|
726
|
+
"@mcp.custom_route('/ready', methods=[\"GET\"])",
|
|
727
|
+
"async def readiness_check(request: Request):",
|
|
728
|
+
' """Readiness check endpoint for Kubernetes and load balancers."""',
|
|
729
|
+
" result = readiness_check_func()",
|
|
730
|
+
" if isinstance(result, dict):",
|
|
731
|
+
" return JSONResponse(result)",
|
|
732
|
+
" return result",
|
|
733
|
+
"",
|
|
734
|
+
]
|
|
735
|
+
|
|
736
|
+
def _generate_health_section(self, project_path: Path) -> list[str]:
|
|
737
|
+
"""Generate code section for health.py execution during server runtime."""
|
|
738
|
+
health_path = project_path / "health.py"
|
|
739
|
+
|
|
740
|
+
if not health_path.exists():
|
|
741
|
+
# Check if legacy health configuration is used
|
|
742
|
+
if self.settings.health_check_enabled:
|
|
743
|
+
return [
|
|
744
|
+
"# Legacy health check configuration (deprecated)",
|
|
745
|
+
"@mcp.custom_route('" + self.settings.health_check_path + '\', methods=["GET"])',
|
|
746
|
+
"async def health_check(request: Request) -> PlainTextResponse:",
|
|
747
|
+
' """Health check endpoint for Kubernetes and load balancers."""',
|
|
748
|
+
f' return PlainTextResponse("{self.settings.health_check_response}")',
|
|
749
|
+
"",
|
|
750
|
+
]
|
|
751
|
+
else:
|
|
752
|
+
# If health checks are disabled, return empty (no default health check)
|
|
753
|
+
return []
|
|
754
|
+
|
|
755
|
+
return [
|
|
756
|
+
"# Custom health check from health.py",
|
|
757
|
+
"from health import check as health_check_func",
|
|
758
|
+
"@mcp.custom_route('/health', methods=[\"GET\"])",
|
|
759
|
+
"async def health_check(request: Request):",
|
|
760
|
+
' """Health check endpoint for Kubernetes and load balancers."""',
|
|
761
|
+
" result = health_check_func()",
|
|
762
|
+
" if isinstance(result, dict):",
|
|
763
|
+
" return JSONResponse(result)",
|
|
764
|
+
" return result",
|
|
765
|
+
"",
|
|
766
|
+
]
|
|
767
|
+
|
|
768
|
+
def _generate_check_function_helper(self) -> list[str]:
|
|
769
|
+
"""Generate helper function to call custom check functions."""
|
|
770
|
+
return [
|
|
771
|
+
"# Helper function to call custom check functions",
|
|
772
|
+
"async def _call_check_function(check_type: str) -> JSONResponse:",
|
|
773
|
+
' """Call custom check function and handle errors gracefully."""',
|
|
774
|
+
" import importlib.util",
|
|
775
|
+
" import traceback",
|
|
776
|
+
" from pathlib import Path",
|
|
777
|
+
" from datetime import datetime",
|
|
778
|
+
" ",
|
|
779
|
+
" try:",
|
|
780
|
+
" # Load the custom check module",
|
|
781
|
+
" module_path = Path(__file__).parent / f'{check_type}.py'",
|
|
782
|
+
" if not module_path.exists():",
|
|
783
|
+
' return JSONResponse({"status": "pass"}, status_code=200)',
|
|
784
|
+
" ",
|
|
785
|
+
" spec = importlib.util.spec_from_file_location(f'{check_type}_check', module_path)",
|
|
786
|
+
" if spec and spec.loader:",
|
|
787
|
+
" module = importlib.util.module_from_spec(spec)",
|
|
788
|
+
" spec.loader.exec_module(module)",
|
|
789
|
+
" ",
|
|
790
|
+
" # Call the check function if it exists",
|
|
791
|
+
" if hasattr(module, 'check'):",
|
|
792
|
+
" result = module.check()",
|
|
793
|
+
" ",
|
|
794
|
+
" # Handle different return types",
|
|
795
|
+
" if isinstance(result, dict):",
|
|
796
|
+
" # User returned structured response",
|
|
797
|
+
" status_code = result.get('status_code', 200)",
|
|
798
|
+
" response_data = {k: v for k, v in result.items() if k != 'status_code'}",
|
|
799
|
+
" elif isinstance(result, bool):",
|
|
800
|
+
" # User returned simple boolean",
|
|
801
|
+
" status_code = 200 if result else 503",
|
|
802
|
+
" response_data = {",
|
|
803
|
+
' "status": "pass" if result else "fail",',
|
|
804
|
+
' "timestamp": datetime.utcnow().isoformat()',
|
|
805
|
+
" }",
|
|
806
|
+
" elif result is None:",
|
|
807
|
+
" # User returned nothing - assume success",
|
|
808
|
+
" status_code = 200",
|
|
809
|
+
' response_data = {"status": "pass"}',
|
|
810
|
+
" else:",
|
|
811
|
+
" # User returned something else - treat as success message",
|
|
812
|
+
" status_code = 200",
|
|
813
|
+
" response_data = {",
|
|
814
|
+
' "status": "pass",',
|
|
815
|
+
' "message": str(result)',
|
|
816
|
+
" }",
|
|
817
|
+
" ",
|
|
818
|
+
" return JSONResponse(response_data, status_code=status_code)",
|
|
819
|
+
" else:",
|
|
820
|
+
" return JSONResponse(",
|
|
821
|
+
' {"status": "fail", "error": f"No check() function found in {check_type}.py"},',
|
|
822
|
+
" status_code=503",
|
|
823
|
+
" )",
|
|
824
|
+
" ",
|
|
825
|
+
" except Exception as e:",
|
|
826
|
+
" # Log error and return failure response",
|
|
827
|
+
" import sys",
|
|
828
|
+
' print(f"Error calling {check_type} check function: {e}", file=sys.stderr)',
|
|
829
|
+
" print(traceback.format_exc(), file=sys.stderr)",
|
|
830
|
+
" return JSONResponse({",
|
|
831
|
+
' "status": "fail",',
|
|
832
|
+
' "error": f"Error calling {check_type} check function: {str(e)}"',
|
|
833
|
+
" }, status_code=503)",
|
|
834
|
+
"",
|
|
835
|
+
]
|
|
836
|
+
|
|
837
|
+
def _generate_server(self) -> None:
|
|
838
|
+
"""Generate the main server entry point."""
|
|
839
|
+
server_file = self.output_dir / "server.py"
|
|
840
|
+
|
|
841
|
+
# Get auth components
|
|
842
|
+
auth_components = generate_auth_code(
|
|
843
|
+
server_name=self.settings.name,
|
|
844
|
+
host=self.settings.host,
|
|
845
|
+
port=self.settings.port,
|
|
846
|
+
https=False, # This could be configurable in settings
|
|
847
|
+
opentelemetry_enabled=self.settings.opentelemetry_enabled,
|
|
848
|
+
transport=self.settings.transport,
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
# Create imports section
|
|
852
|
+
imports = [
|
|
853
|
+
"from fastmcp import FastMCP",
|
|
854
|
+
"from fastmcp.tools import Tool",
|
|
855
|
+
"from fastmcp.resources import Resource, ResourceTemplate",
|
|
856
|
+
"from fastmcp.prompts import Prompt",
|
|
857
|
+
"import os",
|
|
858
|
+
"import sys",
|
|
859
|
+
"from dotenv import load_dotenv",
|
|
860
|
+
"import logging",
|
|
861
|
+
"",
|
|
862
|
+
"# Suppress FastMCP INFO logs",
|
|
863
|
+
"logging.getLogger('FastMCP').setLevel(logging.ERROR)",
|
|
864
|
+
"logging.getLogger('mcp').setLevel(logging.ERROR)",
|
|
865
|
+
"",
|
|
866
|
+
"# Golf utilities for MCP features (available for tool functions)",
|
|
867
|
+
"# from golf.utilities import elicit, sample, get_current_context",
|
|
868
|
+
"",
|
|
869
|
+
]
|
|
870
|
+
|
|
871
|
+
# Add imports for root files
|
|
872
|
+
root_file_imports = self._generate_root_file_imports()
|
|
873
|
+
if root_file_imports:
|
|
874
|
+
imports.extend(root_file_imports)
|
|
875
|
+
|
|
876
|
+
# Add auth imports if auth is configured
|
|
877
|
+
if auth_components.get("has_auth"):
|
|
878
|
+
imports.extend(auth_components["imports"])
|
|
879
|
+
imports.append("")
|
|
880
|
+
|
|
881
|
+
# Add OpenTelemetry imports if enabled
|
|
882
|
+
if self.settings.opentelemetry_enabled:
|
|
883
|
+
imports.extend(generate_telemetry_imports())
|
|
884
|
+
|
|
885
|
+
# Add metrics imports if enabled
|
|
886
|
+
if self.settings.metrics_enabled:
|
|
887
|
+
from golf.core.builder_metrics import (
|
|
888
|
+
generate_metrics_imports,
|
|
889
|
+
generate_metrics_instrumentation,
|
|
890
|
+
generate_session_tracking,
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
imports.extend(generate_metrics_imports())
|
|
894
|
+
imports.extend(generate_metrics_instrumentation())
|
|
895
|
+
imports.extend(generate_session_tracking())
|
|
896
|
+
|
|
897
|
+
# Add health check imports only when we generate default endpoints
|
|
898
|
+
readiness_exists = (self.project_path / "readiness.py").exists()
|
|
899
|
+
health_exists = (self.project_path / "health.py").exists()
|
|
900
|
+
|
|
901
|
+
# Only import starlette when we generate default endpoints (not when custom files exist)
|
|
902
|
+
will_generate_default_readiness = not readiness_exists and self.settings.health_check_enabled
|
|
903
|
+
will_generate_default_health = not health_exists and self.settings.health_check_enabled
|
|
904
|
+
|
|
905
|
+
if will_generate_default_readiness or will_generate_default_health:
|
|
906
|
+
imports.append("from starlette.requests import Request")
|
|
907
|
+
|
|
908
|
+
# Determine response types needed for default endpoints
|
|
909
|
+
response_types = []
|
|
910
|
+
if will_generate_default_readiness:
|
|
911
|
+
response_types.append("JSONResponse")
|
|
912
|
+
if will_generate_default_health:
|
|
913
|
+
response_types.append("PlainTextResponse")
|
|
914
|
+
|
|
915
|
+
if response_types:
|
|
916
|
+
imports.append(f"from starlette.responses import {', '.join(response_types)}")
|
|
917
|
+
|
|
918
|
+
# Import Request and JSONResponse for custom check routes (they need both)
|
|
919
|
+
elif readiness_exists or health_exists:
|
|
920
|
+
imports.append("from starlette.requests import Request")
|
|
921
|
+
imports.append("from starlette.responses import JSONResponse")
|
|
922
|
+
|
|
923
|
+
# Get transport-specific configuration
|
|
924
|
+
transport_config = self._get_transport_config(self.settings.transport)
|
|
925
|
+
endpoint_path = transport_config["endpoint_path"]
|
|
926
|
+
|
|
927
|
+
# Track component modules to register
|
|
928
|
+
component_registrations = []
|
|
929
|
+
|
|
930
|
+
# Import components
|
|
931
|
+
for component_type in self.components:
|
|
932
|
+
# Add a section header
|
|
933
|
+
if component_type == ComponentType.TOOL:
|
|
934
|
+
imports.append("# Import tools")
|
|
935
|
+
comp_section = "# Register tools"
|
|
936
|
+
elif component_type == ComponentType.RESOURCE:
|
|
937
|
+
imports.append("# Import resources")
|
|
938
|
+
comp_section = "# Register resources"
|
|
939
|
+
else:
|
|
940
|
+
imports.append("# Import prompts")
|
|
941
|
+
comp_section = "# Register prompts"
|
|
942
|
+
|
|
943
|
+
component_registrations.append(comp_section)
|
|
944
|
+
|
|
945
|
+
for component in self.components[component_type]:
|
|
946
|
+
# Derive the import path based on component type and file path
|
|
947
|
+
rel_path = Path(component.file_path).relative_to(self.project_path)
|
|
948
|
+
module_name = rel_path.stem
|
|
949
|
+
|
|
950
|
+
if component_type == ComponentType.TOOL:
|
|
951
|
+
try:
|
|
952
|
+
rel_to_tools = rel_path.relative_to(self.settings.tools_dir)
|
|
953
|
+
# Handle nested directories properly
|
|
954
|
+
if rel_to_tools.parent != Path("."):
|
|
955
|
+
parent_path = str(rel_to_tools.parent).replace("\\", ".").replace("/", ".")
|
|
956
|
+
import_path = f"components.tools.{parent_path}"
|
|
957
|
+
else:
|
|
958
|
+
import_path = "components.tools"
|
|
959
|
+
except ValueError:
|
|
960
|
+
import_path = "components.tools"
|
|
961
|
+
elif component_type == ComponentType.RESOURCE:
|
|
962
|
+
try:
|
|
963
|
+
rel_to_resources = rel_path.relative_to(self.settings.resources_dir)
|
|
964
|
+
# Handle nested directories properly
|
|
965
|
+
if rel_to_resources.parent != Path("."):
|
|
966
|
+
parent_path = str(rel_to_resources.parent).replace("\\", ".").replace("/", ".")
|
|
967
|
+
import_path = f"components.resources.{parent_path}"
|
|
968
|
+
else:
|
|
969
|
+
import_path = "components.resources"
|
|
970
|
+
except ValueError:
|
|
971
|
+
import_path = "components.resources"
|
|
972
|
+
else: # PROMPT
|
|
973
|
+
try:
|
|
974
|
+
rel_to_prompts = rel_path.relative_to(self.settings.prompts_dir)
|
|
975
|
+
# Handle nested directories properly
|
|
976
|
+
if rel_to_prompts.parent != Path("."):
|
|
977
|
+
parent_path = str(rel_to_prompts.parent).replace("\\", ".").replace("/", ".")
|
|
978
|
+
import_path = f"components.prompts.{parent_path}"
|
|
979
|
+
else:
|
|
980
|
+
import_path = "components.prompts"
|
|
981
|
+
except ValueError:
|
|
982
|
+
import_path = "components.prompts"
|
|
983
|
+
|
|
984
|
+
# Clean up the import path
|
|
985
|
+
import_path = import_path.rstrip(".")
|
|
986
|
+
|
|
987
|
+
# Add the import for the component's module
|
|
988
|
+
full_module_path = f"{import_path}.{module_name}"
|
|
989
|
+
imports.append(f"import {full_module_path}")
|
|
990
|
+
|
|
991
|
+
# Add code to register this component
|
|
992
|
+
if self.settings.opentelemetry_enabled:
|
|
993
|
+
# Use telemetry instrumentation
|
|
994
|
+
registration = f"# Register the {component_type.value} '{component.name}' with telemetry"
|
|
995
|
+
entry_func = (
|
|
996
|
+
component.entry_function
|
|
997
|
+
if hasattr(component, "entry_function") and component.entry_function
|
|
998
|
+
else "export"
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
registration += (
|
|
1002
|
+
f"\n_wrapped_func = instrument_{component_type.value}("
|
|
1003
|
+
f"{full_module_path}.{entry_func}, '{component.name}')"
|
|
1004
|
+
)
|
|
1005
|
+
|
|
1006
|
+
if component_type == ComponentType.TOOL:
|
|
1007
|
+
registration += (
|
|
1008
|
+
f"\n_tool = Tool.from_function(_wrapped_func, "
|
|
1009
|
+
f'name="{component.name}", '
|
|
1010
|
+
f"description={repr(component.docstring or '')})"
|
|
1011
|
+
)
|
|
1012
|
+
# Add annotations if present
|
|
1013
|
+
if hasattr(component, "annotations") and component.annotations:
|
|
1014
|
+
registration += f".with_annotations({component.annotations})"
|
|
1015
|
+
registration += "\nmcp.add_tool(_tool)"
|
|
1016
|
+
elif component_type == ComponentType.RESOURCE:
|
|
1017
|
+
if self._is_resource_template(component):
|
|
1018
|
+
registration += (
|
|
1019
|
+
f"\n_template = ResourceTemplate.from_function(_wrapped_func, "
|
|
1020
|
+
f'uri_template="{component.uri_template}", name="{component.name}", '
|
|
1021
|
+
f"description={repr(component.docstring or '')})\n"
|
|
1022
|
+
f"mcp.add_template(_template)"
|
|
1023
|
+
)
|
|
1024
|
+
else:
|
|
1025
|
+
registration += (
|
|
1026
|
+
f"\n_resource = Resource.from_function(_wrapped_func, "
|
|
1027
|
+
f'uri="{component.uri_template}", name="{component.name}", '
|
|
1028
|
+
f"description={repr(component.docstring or '')})\n"
|
|
1029
|
+
f"mcp.add_resource(_resource)"
|
|
1030
|
+
)
|
|
1031
|
+
else: # PROMPT
|
|
1032
|
+
registration += (
|
|
1033
|
+
f"\n_prompt = Prompt.from_function(_wrapped_func, "
|
|
1034
|
+
f'name="{component.name}", '
|
|
1035
|
+
f"description={repr(component.docstring or '')})\n"
|
|
1036
|
+
f"mcp.add_prompt(_prompt)"
|
|
1037
|
+
)
|
|
1038
|
+
elif self.settings.metrics_enabled:
|
|
1039
|
+
# Use metrics instrumentation
|
|
1040
|
+
registration = f"# Register the {component_type.value} '{component.name}' with metrics"
|
|
1041
|
+
entry_func = (
|
|
1042
|
+
component.entry_function
|
|
1043
|
+
if hasattr(component, "entry_function") and component.entry_function
|
|
1044
|
+
else "export"
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
registration += (
|
|
1048
|
+
f"\n_wrapped_func = instrument_{component_type.value}("
|
|
1049
|
+
f"{full_module_path}.{entry_func}, '{component.name}')"
|
|
1050
|
+
)
|
|
1051
|
+
|
|
1052
|
+
if component_type == ComponentType.TOOL:
|
|
1053
|
+
registration += (
|
|
1054
|
+
f"\n_tool = Tool.from_function(_wrapped_func, "
|
|
1055
|
+
f'name="{component.name}", '
|
|
1056
|
+
f"description={repr(component.docstring or '')})"
|
|
1057
|
+
)
|
|
1058
|
+
# Add annotations if present
|
|
1059
|
+
if hasattr(component, "annotations") and component.annotations:
|
|
1060
|
+
registration += f".with_annotations({component.annotations})"
|
|
1061
|
+
registration += "\nmcp.add_tool(_tool)"
|
|
1062
|
+
elif component_type == ComponentType.RESOURCE:
|
|
1063
|
+
if self._is_resource_template(component):
|
|
1064
|
+
registration += (
|
|
1065
|
+
f"\n_template = ResourceTemplate.from_function(_wrapped_func, "
|
|
1066
|
+
f'uri_template="{component.uri_template}", name="{component.name}", '
|
|
1067
|
+
f"description={repr(component.docstring or '')})\n"
|
|
1068
|
+
f"mcp.add_template(_template)"
|
|
1069
|
+
)
|
|
1070
|
+
else:
|
|
1071
|
+
registration += (
|
|
1072
|
+
f"\n_resource = Resource.from_function(_wrapped_func, "
|
|
1073
|
+
f'uri="{component.uri_template}", name="{component.name}", '
|
|
1074
|
+
f"description={repr(component.docstring or '')})\n"
|
|
1075
|
+
f"mcp.add_resource(_resource)"
|
|
1076
|
+
)
|
|
1077
|
+
else: # PROMPT
|
|
1078
|
+
registration += (
|
|
1079
|
+
f"\n_prompt = Prompt.from_function(_wrapped_func, "
|
|
1080
|
+
f'name="{component.name}", '
|
|
1081
|
+
f"description={repr(component.docstring or '')})\n"
|
|
1082
|
+
f"mcp.add_prompt(_prompt)"
|
|
1083
|
+
)
|
|
1084
|
+
else:
|
|
1085
|
+
# Standard registration without telemetry
|
|
1086
|
+
if component_type == ComponentType.TOOL:
|
|
1087
|
+
registration = f"# Register the tool '{component.name}' from {full_module_path}"
|
|
1088
|
+
|
|
1089
|
+
# Use the entry_function if available, otherwise try the
|
|
1090
|
+
# export variable
|
|
1091
|
+
if hasattr(component, "entry_function") and component.entry_function:
|
|
1092
|
+
registration += (
|
|
1093
|
+
f"\n_tool = Tool.from_function({full_module_path}.{component.entry_function}"
|
|
1094
|
+
)
|
|
1095
|
+
else:
|
|
1096
|
+
registration += f"\n_tool = Tool.from_function({full_module_path}.export"
|
|
1097
|
+
|
|
1098
|
+
# Add the name parameter
|
|
1099
|
+
registration += f', name="{component.name}"'
|
|
1100
|
+
|
|
1101
|
+
# Add description from docstring
|
|
1102
|
+
if component.docstring:
|
|
1103
|
+
# Use repr() for proper escaping of quotes, newlines, etc.
|
|
1104
|
+
registration += f", description={repr(component.docstring)}"
|
|
1105
|
+
|
|
1106
|
+
registration += ")"
|
|
1107
|
+
|
|
1108
|
+
# Add annotations if present
|
|
1109
|
+
if hasattr(component, "annotations") and component.annotations:
|
|
1110
|
+
registration += f"\n_tool = _tool.with_annotations({component.annotations})"
|
|
1111
|
+
|
|
1112
|
+
registration += "\nmcp.add_tool(_tool)"
|
|
1113
|
+
|
|
1114
|
+
elif component_type == ComponentType.RESOURCE:
|
|
1115
|
+
if self._is_resource_template(component):
|
|
1116
|
+
registration = (
|
|
1117
|
+
f"# Register the resource template '{component.name}' from {full_module_path}"
|
|
1118
|
+
)
|
|
1119
|
+
|
|
1120
|
+
# Use the entry_function if available, otherwise try the
|
|
1121
|
+
# export variable
|
|
1122
|
+
if hasattr(component, "entry_function") and component.entry_function:
|
|
1123
|
+
registration += (
|
|
1124
|
+
f"\n_template = ResourceTemplate.from_function("
|
|
1125
|
+
f"{full_module_path}.{component.entry_function}, "
|
|
1126
|
+
f'uri_template="{component.uri_template}"'
|
|
1127
|
+
)
|
|
1128
|
+
else:
|
|
1129
|
+
registration += (
|
|
1130
|
+
f"\n_template = ResourceTemplate.from_function("
|
|
1131
|
+
f"{full_module_path}.export, "
|
|
1132
|
+
f'uri_template="{component.uri_template}"'
|
|
1133
|
+
)
|
|
1134
|
+
|
|
1135
|
+
# Add the name parameter
|
|
1136
|
+
registration += f', name="{component.name}"'
|
|
1137
|
+
|
|
1138
|
+
# Add description from docstring
|
|
1139
|
+
if component.docstring:
|
|
1140
|
+
# Escape any quotes in the docstring
|
|
1141
|
+
escaped_docstring = component.docstring.replace('"', '\\"')
|
|
1142
|
+
registration += f', description="{escaped_docstring}"'
|
|
1143
|
+
|
|
1144
|
+
registration += ")\nmcp.add_template(_template)"
|
|
1145
|
+
else:
|
|
1146
|
+
registration = f"# Register the resource '{component.name}' from {full_module_path}"
|
|
1147
|
+
|
|
1148
|
+
# Use the entry_function if available, otherwise try the
|
|
1149
|
+
# export variable
|
|
1150
|
+
if hasattr(component, "entry_function") and component.entry_function:
|
|
1151
|
+
registration += (
|
|
1152
|
+
f"\n_resource = Resource.from_function("
|
|
1153
|
+
f"{full_module_path}.{component.entry_function}, "
|
|
1154
|
+
f'uri="{component.uri_template}"'
|
|
1155
|
+
)
|
|
1156
|
+
else:
|
|
1157
|
+
registration += (
|
|
1158
|
+
f"\n_resource = Resource.from_function("
|
|
1159
|
+
f"{full_module_path}.export, "
|
|
1160
|
+
f'uri="{component.uri_template}"'
|
|
1161
|
+
)
|
|
1162
|
+
|
|
1163
|
+
# Add the name parameter
|
|
1164
|
+
registration += f', name="{component.name}"'
|
|
1165
|
+
|
|
1166
|
+
# Add description from docstring
|
|
1167
|
+
if component.docstring:
|
|
1168
|
+
# Escape any quotes in the docstring
|
|
1169
|
+
escaped_docstring = component.docstring.replace('"', '\\"')
|
|
1170
|
+
registration += f', description="{escaped_docstring}"'
|
|
1171
|
+
|
|
1172
|
+
registration += ")\nmcp.add_resource(_resource)"
|
|
1173
|
+
|
|
1174
|
+
else: # PROMPT
|
|
1175
|
+
registration = f"# Register the prompt '{component.name}' from {full_module_path}"
|
|
1176
|
+
|
|
1177
|
+
# Use the entry_function if available, otherwise try the
|
|
1178
|
+
# export variable
|
|
1179
|
+
if hasattr(component, "entry_function") and component.entry_function:
|
|
1180
|
+
registration += (
|
|
1181
|
+
f"\n_prompt = Prompt.from_function({full_module_path}.{component.entry_function}"
|
|
1182
|
+
)
|
|
1183
|
+
else:
|
|
1184
|
+
registration += f"\n_prompt = Prompt.from_function({full_module_path}.export"
|
|
1185
|
+
|
|
1186
|
+
# Add the name parameter
|
|
1187
|
+
registration += f', name="{component.name}"'
|
|
1188
|
+
|
|
1189
|
+
# Add description from docstring
|
|
1190
|
+
if component.docstring:
|
|
1191
|
+
# Use repr() for proper escaping of quotes, newlines, etc.
|
|
1192
|
+
registration += f", description={repr(component.docstring)}"
|
|
1193
|
+
|
|
1194
|
+
registration += ")\nmcp.add_prompt(_prompt)"
|
|
1195
|
+
|
|
1196
|
+
component_registrations.append(registration)
|
|
1197
|
+
|
|
1198
|
+
# Add a blank line after each section
|
|
1199
|
+
imports.append("")
|
|
1200
|
+
component_registrations.append("")
|
|
1201
|
+
|
|
1202
|
+
# Create environment section based on build type - moved after imports
|
|
1203
|
+
env_section = [
|
|
1204
|
+
"",
|
|
1205
|
+
"# Load environment variables from .env file if it exists",
|
|
1206
|
+
"# Note: dotenv will not override existing environment variables by default",
|
|
1207
|
+
"load_dotenv()",
|
|
1208
|
+
"",
|
|
1209
|
+
]
|
|
1210
|
+
|
|
1211
|
+
# Generate syspath section
|
|
1212
|
+
syspath_section = self._generate_syspath_section()
|
|
1213
|
+
|
|
1214
|
+
# Generate startup section
|
|
1215
|
+
startup_section = self._generate_startup_section(self.project_path)
|
|
1216
|
+
|
|
1217
|
+
# OpenTelemetry setup code will be handled through imports and lifespan
|
|
1218
|
+
|
|
1219
|
+
# Add auth setup code if auth is configured
|
|
1220
|
+
auth_setup_code = []
|
|
1221
|
+
if auth_components.get("has_auth"):
|
|
1222
|
+
auth_setup_code = auth_components["setup_code"]
|
|
1223
|
+
|
|
1224
|
+
# Create FastMCP instance section
|
|
1225
|
+
server_code_lines = ["# Create FastMCP server"]
|
|
1226
|
+
|
|
1227
|
+
# Build FastMCP constructor arguments
|
|
1228
|
+
mcp_constructor_args = [f'"{self.settings.name}"']
|
|
1229
|
+
|
|
1230
|
+
# Add auth arguments if configured
|
|
1231
|
+
if auth_components.get("has_auth") and auth_components.get("fastmcp_args"):
|
|
1232
|
+
for key, value in auth_components["fastmcp_args"].items():
|
|
1233
|
+
mcp_constructor_args.append(f"{key}={value}")
|
|
1234
|
+
|
|
1235
|
+
# Add stateless HTTP parameter if enabled
|
|
1236
|
+
if self.settings.stateless_http:
|
|
1237
|
+
mcp_constructor_args.append("stateless_http=True")
|
|
1238
|
+
|
|
1239
|
+
# Add OpenTelemetry parameters if enabled
|
|
1240
|
+
if self.settings.opentelemetry_enabled:
|
|
1241
|
+
mcp_constructor_args.append("lifespan=telemetry_lifespan")
|
|
1242
|
+
|
|
1243
|
+
mcp_instance_line = f"mcp = FastMCP({', '.join(mcp_constructor_args)})"
|
|
1244
|
+
server_code_lines.append(mcp_instance_line)
|
|
1245
|
+
server_code_lines.append("")
|
|
1246
|
+
|
|
1247
|
+
# Add early telemetry initialization if enabled (before component registration)
|
|
1248
|
+
early_telemetry_init = []
|
|
1249
|
+
if self.settings.opentelemetry_enabled:
|
|
1250
|
+
early_telemetry_init.extend(
|
|
1251
|
+
[
|
|
1252
|
+
"# Initialize telemetry early to ensure instrumentation works",
|
|
1253
|
+
"from golf.telemetry.instrumentation import init_telemetry, set_detailed_tracing",
|
|
1254
|
+
f'init_telemetry("{self.settings.name}")',
|
|
1255
|
+
f"set_detailed_tracing({self.settings.detailed_tracing})",
|
|
1256
|
+
"",
|
|
1257
|
+
]
|
|
1258
|
+
)
|
|
1259
|
+
|
|
1260
|
+
# Add metrics initialization if enabled
|
|
1261
|
+
early_metrics_init = []
|
|
1262
|
+
if self.settings.metrics_enabled:
|
|
1263
|
+
from golf.core.builder_metrics import generate_metrics_initialization
|
|
1264
|
+
|
|
1265
|
+
early_metrics_init.extend(generate_metrics_initialization(self.settings.name))
|
|
1266
|
+
|
|
1267
|
+
# Main entry point with transport-specific app initialization
|
|
1268
|
+
main_code = [
|
|
1269
|
+
'if __name__ == "__main__":',
|
|
1270
|
+
" from rich.console import Console",
|
|
1271
|
+
" from rich.panel import Panel",
|
|
1272
|
+
" console = Console()",
|
|
1273
|
+
" # Get configuration from environment variables or use defaults",
|
|
1274
|
+
' host = os.environ.get("HOST", "localhost")',
|
|
1275
|
+
' port = int(os.environ.get("PORT", 3000))',
|
|
1276
|
+
f' transport_to_run = "{self.settings.transport}"',
|
|
1277
|
+
"",
|
|
1278
|
+
]
|
|
1279
|
+
|
|
1280
|
+
main_code.append("")
|
|
1281
|
+
|
|
1282
|
+
# Transport-specific run methods
|
|
1283
|
+
if self.settings.transport == "sse":
|
|
1284
|
+
# Check if we need middleware for SSE
|
|
1285
|
+
middleware_setup = []
|
|
1286
|
+
middleware_list = []
|
|
1287
|
+
|
|
1288
|
+
api_key_config = get_api_key_config()
|
|
1289
|
+
if auth_components.get("has_auth") and api_key_config:
|
|
1290
|
+
middleware_setup.append(" from starlette.middleware import Middleware")
|
|
1291
|
+
middleware_list.append("Middleware(ApiKeyMiddleware)")
|
|
1292
|
+
|
|
1293
|
+
# Add metrics middleware if enabled
|
|
1294
|
+
if self.settings.metrics_enabled:
|
|
1295
|
+
middleware_setup.append(" from starlette.middleware import Middleware")
|
|
1296
|
+
middleware_list.append("Middleware(MetricsMiddleware)")
|
|
1297
|
+
|
|
1298
|
+
# Add OpenTelemetry middleware if enabled
|
|
1299
|
+
if self.settings.opentelemetry_enabled:
|
|
1300
|
+
middleware_setup.append(" from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware")
|
|
1301
|
+
middleware_setup.append(" from starlette.middleware import Middleware")
|
|
1302
|
+
middleware_list.append("Middleware(OpenTelemetryMiddleware)")
|
|
1303
|
+
|
|
1304
|
+
if middleware_setup:
|
|
1305
|
+
main_code.extend(middleware_setup)
|
|
1306
|
+
main_code.append(f" middleware = [{', '.join(middleware_list)}]")
|
|
1307
|
+
main_code.append("")
|
|
1308
|
+
if self._is_fastmcp_version_gte("2.12.0"):
|
|
1309
|
+
main_code.extend(
|
|
1310
|
+
[
|
|
1311
|
+
" # Run SSE server with middleware using FastMCP's run method",
|
|
1312
|
+
' mcp.run(transport="sse", host=host, port=port, '
|
|
1313
|
+
'log_level="info", middleware=middleware, show_banner=False)',
|
|
1314
|
+
]
|
|
1315
|
+
)
|
|
1316
|
+
else:
|
|
1317
|
+
main_code.extend(
|
|
1318
|
+
[
|
|
1319
|
+
" # Run SSE server with middleware using FastMCP's run method",
|
|
1320
|
+
f' mcp.run(transport="sse", host=host, port=port, '
|
|
1321
|
+
f'path="{endpoint_path}", log_level="info", '
|
|
1322
|
+
f"middleware=middleware, show_banner=False)",
|
|
1323
|
+
]
|
|
1324
|
+
)
|
|
1325
|
+
else:
|
|
1326
|
+
if self._is_fastmcp_version_gte("2.12.0"):
|
|
1327
|
+
main_code.extend(
|
|
1328
|
+
[
|
|
1329
|
+
" # Run SSE server using FastMCP's run method",
|
|
1330
|
+
' mcp.run(transport="sse", host=host, port=port, log_level="info", show_banner=False)',
|
|
1331
|
+
]
|
|
1332
|
+
)
|
|
1333
|
+
else:
|
|
1334
|
+
main_code.extend(
|
|
1335
|
+
[
|
|
1336
|
+
" # Run SSE server using FastMCP's run method",
|
|
1337
|
+
f' mcp.run(transport="sse", host=host, port=port, '
|
|
1338
|
+
f'path="{endpoint_path}", log_level="info", '
|
|
1339
|
+
f"show_banner=False)",
|
|
1340
|
+
]
|
|
1341
|
+
)
|
|
1342
|
+
|
|
1343
|
+
elif self.settings.transport in ["streamable-http", "http"]:
|
|
1344
|
+
# Check if we need middleware for streamable-http
|
|
1345
|
+
middleware_setup = []
|
|
1346
|
+
middleware_list = []
|
|
1347
|
+
|
|
1348
|
+
api_key_config = get_api_key_config()
|
|
1349
|
+
if auth_components.get("has_auth") and api_key_config:
|
|
1350
|
+
middleware_setup.append(" from starlette.middleware import Middleware")
|
|
1351
|
+
middleware_list.append("Middleware(ApiKeyMiddleware)")
|
|
1352
|
+
|
|
1353
|
+
# Add metrics middleware if enabled
|
|
1354
|
+
if self.settings.metrics_enabled:
|
|
1355
|
+
middleware_setup.append(" from starlette.middleware import Middleware")
|
|
1356
|
+
middleware_list.append("Middleware(MetricsMiddleware)")
|
|
1357
|
+
|
|
1358
|
+
# Add OpenTelemetry middleware if enabled
|
|
1359
|
+
if self.settings.opentelemetry_enabled:
|
|
1360
|
+
middleware_setup.append(" from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware")
|
|
1361
|
+
middleware_setup.append(" from starlette.middleware import Middleware")
|
|
1362
|
+
middleware_list.append("Middleware(OpenTelemetryMiddleware)")
|
|
1363
|
+
|
|
1364
|
+
if middleware_setup:
|
|
1365
|
+
main_code.extend(middleware_setup)
|
|
1366
|
+
main_code.append(f" middleware = [{', '.join(middleware_list)}]")
|
|
1367
|
+
main_code.append("")
|
|
1368
|
+
if self._is_fastmcp_version_gte("2.12.0"):
|
|
1369
|
+
main_code.extend(
|
|
1370
|
+
[
|
|
1371
|
+
" # Run HTTP server with middleware using FastMCP's run method",
|
|
1372
|
+
' mcp.run(transport="streamable-http", host=host, '
|
|
1373
|
+
'port=port, log_level="info", middleware=middleware, show_banner=False)',
|
|
1374
|
+
]
|
|
1375
|
+
)
|
|
1376
|
+
else:
|
|
1377
|
+
main_code.extend(
|
|
1378
|
+
[
|
|
1379
|
+
" # Run HTTP server with middleware using FastMCP's run method",
|
|
1380
|
+
f' mcp.run(transport="streamable-http", host=host, '
|
|
1381
|
+
f'port=port, path="{endpoint_path}", log_level="info", '
|
|
1382
|
+
f"middleware=middleware, show_banner=False)",
|
|
1383
|
+
]
|
|
1384
|
+
)
|
|
1385
|
+
else:
|
|
1386
|
+
if self._is_fastmcp_version_gte("2.12.0"):
|
|
1387
|
+
main_code.extend(
|
|
1388
|
+
[
|
|
1389
|
+
" # Run HTTP server using FastMCP's run method",
|
|
1390
|
+
' mcp.run(transport="streamable-http", host=host, '
|
|
1391
|
+
'port=port, log_level="info", show_banner=False)',
|
|
1392
|
+
]
|
|
1393
|
+
)
|
|
1394
|
+
else:
|
|
1395
|
+
main_code.extend(
|
|
1396
|
+
[
|
|
1397
|
+
" # Run HTTP server using FastMCP's run method",
|
|
1398
|
+
f' mcp.run(transport="streamable-http", host=host, '
|
|
1399
|
+
f'port=port, path="{endpoint_path}", log_level="info", '
|
|
1400
|
+
f"show_banner=False)",
|
|
1401
|
+
]
|
|
1402
|
+
)
|
|
1403
|
+
else:
|
|
1404
|
+
# For stdio transport, use mcp.run()
|
|
1405
|
+
main_code.extend([" # Run with stdio transport", ' mcp.run(transport="stdio", show_banner=False)'])
|
|
1406
|
+
|
|
1407
|
+
# Add metrics route if enabled
|
|
1408
|
+
metrics_route_code = []
|
|
1409
|
+
if self.settings.metrics_enabled:
|
|
1410
|
+
from golf.core.builder_metrics import generate_metrics_route
|
|
1411
|
+
|
|
1412
|
+
metrics_route_code = generate_metrics_route(self.settings.metrics_path)
|
|
1413
|
+
|
|
1414
|
+
# Generate readiness and health check sections
|
|
1415
|
+
readiness_section = self._generate_readiness_section(self.project_path)
|
|
1416
|
+
health_section = self._generate_health_section(self.project_path)
|
|
1417
|
+
|
|
1418
|
+
# No longer need the check helper function since we use direct imports
|
|
1419
|
+
check_helper_section = []
|
|
1420
|
+
|
|
1421
|
+
# Combine all sections
|
|
1422
|
+
# Order: imports, env_section, syspath_section, startup_section, auth_setup, server_code (mcp init),
|
|
1423
|
+
# early_telemetry_init, early_metrics_init, component_registrations,
|
|
1424
|
+
# metrics_route_code, check_helper_section, readiness_section, health_section, main_code (run block)
|
|
1425
|
+
code = "\n".join(
|
|
1426
|
+
imports
|
|
1427
|
+
+ env_section
|
|
1428
|
+
+ syspath_section
|
|
1429
|
+
+ startup_section
|
|
1430
|
+
+ auth_setup_code
|
|
1431
|
+
+ server_code_lines
|
|
1432
|
+
+ early_telemetry_init
|
|
1433
|
+
+ early_metrics_init
|
|
1434
|
+
+ component_registrations
|
|
1435
|
+
+ metrics_route_code
|
|
1436
|
+
+ check_helper_section
|
|
1437
|
+
+ readiness_section
|
|
1438
|
+
+ health_section
|
|
1439
|
+
+ main_code
|
|
1440
|
+
)
|
|
1441
|
+
|
|
1442
|
+
# Format with black
|
|
1443
|
+
try:
|
|
1444
|
+
code = black.format_str(code, mode=black.Mode())
|
|
1445
|
+
except Exception as e:
|
|
1446
|
+
console.print(f"[yellow]Warning: Could not format server.py: {e}[/yellow]")
|
|
1447
|
+
|
|
1448
|
+
# Write to file
|
|
1449
|
+
with open(server_file, "w") as f:
|
|
1450
|
+
f.write(code)
|
|
1451
|
+
|
|
1452
|
+
|
|
1453
|
+
def build_project(
|
|
1454
|
+
project_path: Path,
|
|
1455
|
+
settings: Settings,
|
|
1456
|
+
output_dir: Path,
|
|
1457
|
+
build_env: str = "prod",
|
|
1458
|
+
copy_env: bool = False,
|
|
1459
|
+
) -> None:
|
|
1460
|
+
"""Build a standalone FastMCP application from a GolfMCP project.
|
|
1461
|
+
|
|
1462
|
+
Args:
|
|
1463
|
+
project_path: Path to the project directory
|
|
1464
|
+
settings: Project settings
|
|
1465
|
+
output_dir: Output directory for the built application
|
|
1466
|
+
build_env: Build environment ('dev' or 'prod')
|
|
1467
|
+
copy_env: Whether to copy environment variables to the built app
|
|
1468
|
+
"""
|
|
1469
|
+
# Load environment variables from .env for build operations
|
|
1470
|
+
from dotenv import load_dotenv
|
|
1471
|
+
|
|
1472
|
+
project_env_file = project_path / ".env"
|
|
1473
|
+
if project_env_file.exists():
|
|
1474
|
+
load_dotenv(project_env_file, override=False)
|
|
1475
|
+
|
|
1476
|
+
# Execute auth.py if it exists (for authentication configuration)
|
|
1477
|
+
# Also support legacy pre_build.py for backward compatibility
|
|
1478
|
+
auth_path = project_path / "auth.py"
|
|
1479
|
+
legacy_path = project_path / "pre_build.py"
|
|
1480
|
+
|
|
1481
|
+
config_path = None
|
|
1482
|
+
if auth_path.exists():
|
|
1483
|
+
config_path = auth_path
|
|
1484
|
+
elif legacy_path.exists():
|
|
1485
|
+
config_path = legacy_path
|
|
1486
|
+
console.print("[yellow]Warning: pre_build.py is deprecated. Rename to auth.py[/yellow]")
|
|
1487
|
+
|
|
1488
|
+
if config_path:
|
|
1489
|
+
# Save the current directory and path - handle case where cwd might be invalid
|
|
1490
|
+
try:
|
|
1491
|
+
original_dir = os.getcwd()
|
|
1492
|
+
except (FileNotFoundError, OSError):
|
|
1493
|
+
# Current directory might have been deleted by previous operations,
|
|
1494
|
+
# use project_path as fallback
|
|
1495
|
+
original_dir = str(project_path)
|
|
1496
|
+
os.chdir(original_dir)
|
|
1497
|
+
original_path = sys.path.copy()
|
|
1498
|
+
|
|
1499
|
+
try:
|
|
1500
|
+
# Change to the project directory and add it to Python path
|
|
1501
|
+
os.chdir(project_path)
|
|
1502
|
+
sys.path.insert(0, str(project_path))
|
|
1503
|
+
|
|
1504
|
+
# Execute the auth configuration script
|
|
1505
|
+
with open(config_path) as f:
|
|
1506
|
+
script_content = f.read()
|
|
1507
|
+
|
|
1508
|
+
# Print the first few lines for debugging
|
|
1509
|
+
"\n".join(script_content.split("\n")[:5]) + "\n..."
|
|
1510
|
+
|
|
1511
|
+
# Use exec to run the script as a module
|
|
1512
|
+
code = compile(script_content, str(config_path), "exec")
|
|
1513
|
+
exec(code, {})
|
|
1514
|
+
|
|
1515
|
+
except Exception as e:
|
|
1516
|
+
console.print(f"[red]Error executing {config_path.name}: {str(e)}[/red]")
|
|
1517
|
+
import traceback
|
|
1518
|
+
|
|
1519
|
+
console.print(f"[red]{traceback.format_exc()}[/red]")
|
|
1520
|
+
|
|
1521
|
+
# Track detailed error for auth.py execution failures
|
|
1522
|
+
try:
|
|
1523
|
+
from golf.core.telemetry import track_detailed_error
|
|
1524
|
+
|
|
1525
|
+
track_detailed_error(
|
|
1526
|
+
"build_auth_failed",
|
|
1527
|
+
e,
|
|
1528
|
+
context=f"Executing {config_path.name} configuration script",
|
|
1529
|
+
operation="auth_execution",
|
|
1530
|
+
additional_props={
|
|
1531
|
+
"file_path": str(config_path.relative_to(project_path)),
|
|
1532
|
+
"build_env": build_env,
|
|
1533
|
+
},
|
|
1534
|
+
)
|
|
1535
|
+
except Exception:
|
|
1536
|
+
# Don't let telemetry errors break the build
|
|
1537
|
+
pass
|
|
1538
|
+
finally:
|
|
1539
|
+
# Always restore original directory and path, even if an exception occurred
|
|
1540
|
+
try:
|
|
1541
|
+
os.chdir(original_dir)
|
|
1542
|
+
sys.path = original_path
|
|
1543
|
+
except Exception:
|
|
1544
|
+
# If we can't restore the directory, at least try to reset the path
|
|
1545
|
+
sys.path = original_path
|
|
1546
|
+
|
|
1547
|
+
# Clear the output directory if it exists
|
|
1548
|
+
if output_dir.exists():
|
|
1549
|
+
shutil.rmtree(output_dir)
|
|
1550
|
+
output_dir.mkdir(parents=True, exist_ok=True) # Ensure output_dir exists after clearing
|
|
1551
|
+
|
|
1552
|
+
# --- BEGIN Enhanced .env handling ---
|
|
1553
|
+
env_vars_to_write = {}
|
|
1554
|
+
env_file_path = output_dir / ".env"
|
|
1555
|
+
|
|
1556
|
+
# 1. Load from existing project .env if copy_env is true
|
|
1557
|
+
if copy_env:
|
|
1558
|
+
project_env_file = project_path / ".env"
|
|
1559
|
+
if project_env_file.exists():
|
|
1560
|
+
try:
|
|
1561
|
+
from dotenv import dotenv_values
|
|
1562
|
+
|
|
1563
|
+
env_vars_to_write.update(dotenv_values(project_env_file))
|
|
1564
|
+
except ImportError:
|
|
1565
|
+
console.print(
|
|
1566
|
+
"[yellow]Warning: python-dotenv is not installed. "
|
|
1567
|
+
"Cannot read existing .env file for rich merging. "
|
|
1568
|
+
"Copying directly.[/yellow]"
|
|
1569
|
+
)
|
|
1570
|
+
try:
|
|
1571
|
+
shutil.copy(project_env_file, env_file_path)
|
|
1572
|
+
# If direct copy happens, re-read for step 2 & 3 to respect
|
|
1573
|
+
# its content
|
|
1574
|
+
if env_file_path.exists():
|
|
1575
|
+
from dotenv import dotenv_values
|
|
1576
|
+
|
|
1577
|
+
env_vars_to_write.update(dotenv_values(env_file_path)) # Read what was copied
|
|
1578
|
+
except Exception as e:
|
|
1579
|
+
console.print(f"[yellow]Warning: Could not copy project .env file: {e}[/yellow]")
|
|
1580
|
+
except Exception as e:
|
|
1581
|
+
console.print(f"[yellow]Warning: Error reading project .env file content: {e}[/yellow]")
|
|
1582
|
+
|
|
1583
|
+
# 2. Apply Golf's OTel default exporter setting if OTEL_TRACES_EXPORTER
|
|
1584
|
+
# is not already set
|
|
1585
|
+
if (
|
|
1586
|
+
settings.opentelemetry_enabled
|
|
1587
|
+
and settings.opentelemetry_default_exporter
|
|
1588
|
+
and "OTEL_TRACES_EXPORTER" not in env_vars_to_write
|
|
1589
|
+
):
|
|
1590
|
+
env_vars_to_write["OTEL_TRACES_EXPORTER"] = settings.opentelemetry_default_exporter
|
|
1591
|
+
|
|
1592
|
+
# 3. Apply Golf's project name as OTEL_SERVICE_NAME if not already set
|
|
1593
|
+
# (Ensures service name defaults to project name if not specified in user's .env)
|
|
1594
|
+
if settings.opentelemetry_enabled and settings.name and "OTEL_SERVICE_NAME" not in env_vars_to_write:
|
|
1595
|
+
env_vars_to_write["OTEL_SERVICE_NAME"] = settings.name
|
|
1596
|
+
|
|
1597
|
+
# 4. (Re-)Write the .env file in the output directory if there's anything to write
|
|
1598
|
+
if env_vars_to_write:
|
|
1599
|
+
try:
|
|
1600
|
+
with open(env_file_path, "w") as f:
|
|
1601
|
+
for key, value in env_vars_to_write.items():
|
|
1602
|
+
# Ensure values are properly quoted if they contain spaces or special characters
|
|
1603
|
+
# and handle existing quotes within the value.
|
|
1604
|
+
if isinstance(value, str):
|
|
1605
|
+
# Replace backslashes first, then double quotes
|
|
1606
|
+
processed_value = value.replace("\\", "\\\\") # Escape backslashes
|
|
1607
|
+
processed_value = processed_value.replace('"', '\\"') # Escape double quotes
|
|
1608
|
+
if " " in value or "#" in value or "\n" in value or '"' in value or "'" in value:
|
|
1609
|
+
f.write(f'{key}="{processed_value}"\n')
|
|
1610
|
+
else:
|
|
1611
|
+
f.write(f"{key}={processed_value}\n")
|
|
1612
|
+
else: # For non-string values, write directly
|
|
1613
|
+
f.write(f"{key}={value}\n")
|
|
1614
|
+
except Exception as e:
|
|
1615
|
+
console.print(f"[yellow]Warning: Could not write .env file to output directory: {e}[/yellow]")
|
|
1616
|
+
# --- END Enhanced .env handling ---
|
|
1617
|
+
|
|
1618
|
+
# Show what we're building, with environment info
|
|
1619
|
+
create_build_header(settings.name, build_env, console)
|
|
1620
|
+
|
|
1621
|
+
# Generate the code
|
|
1622
|
+
generator = CodeGenerator(project_path, settings, output_dir, build_env=build_env, copy_env=copy_env)
|
|
1623
|
+
generator.generate()
|
|
1624
|
+
|
|
1625
|
+
# Copy startup.py to output directory if it exists (after server generation)
|
|
1626
|
+
startup_path = project_path / "startup.py"
|
|
1627
|
+
if startup_path.exists():
|
|
1628
|
+
dest_path = output_dir / "startup.py"
|
|
1629
|
+
shutil.copy2(startup_path, dest_path)
|
|
1630
|
+
console.print(get_status_text("success", "Startup script copied to build directory"))
|
|
1631
|
+
|
|
1632
|
+
# Copy optional check files to build directory
|
|
1633
|
+
readiness_path = project_path / "readiness.py"
|
|
1634
|
+
if readiness_path.exists():
|
|
1635
|
+
shutil.copy2(readiness_path, output_dir)
|
|
1636
|
+
console.print(get_status_text("success", "Readiness script copied to build directory"))
|
|
1637
|
+
|
|
1638
|
+
health_path = project_path / "health.py"
|
|
1639
|
+
if health_path.exists():
|
|
1640
|
+
shutil.copy2(health_path, output_dir)
|
|
1641
|
+
console.print(get_status_text("success", "Health script copied to build directory"))
|
|
1642
|
+
|
|
1643
|
+
# Copy any additional Python files from project root (reuse cached discovery from generator)
|
|
1644
|
+
discovered_root_files = generator._get_cached_root_files()
|
|
1645
|
+
|
|
1646
|
+
for filename, file_path in discovered_root_files.items():
|
|
1647
|
+
dest_path = output_dir / filename
|
|
1648
|
+
try:
|
|
1649
|
+
shutil.copy2(file_path, dest_path)
|
|
1650
|
+
console.print(get_status_text("success", f"Root file {filename} copied to build directory"))
|
|
1651
|
+
except (OSError, shutil.Error) as e:
|
|
1652
|
+
console.print(f"[red]Error copying {filename}: {e}[/red]")
|
|
1653
|
+
|
|
1654
|
+
# Create a simple README
|
|
1655
|
+
readme_content = f"""# {settings.name}
|
|
1656
|
+
|
|
1657
|
+
Generated FastMCP application ({build_env} environment).
|
|
1658
|
+
|
|
1659
|
+
## Running the server
|
|
1660
|
+
|
|
1661
|
+
```bash
|
|
1662
|
+
cd {output_dir.name}
|
|
1663
|
+
python server.py
|
|
1664
|
+
```
|
|
1665
|
+
|
|
1666
|
+
This is a standalone FastMCP server generated by GolfMCP.
|
|
1667
|
+
"""
|
|
1668
|
+
|
|
1669
|
+
with open(output_dir / "README.md", "w") as f:
|
|
1670
|
+
f.write(readme_content)
|
|
1671
|
+
|
|
1672
|
+
# Always copy the auth module so it's available
|
|
1673
|
+
auth_dir = output_dir / "golf" / "auth"
|
|
1674
|
+
auth_dir.mkdir(parents=True, exist_ok=True)
|
|
1675
|
+
|
|
1676
|
+
# Create __init__.py with needed exports
|
|
1677
|
+
with open(auth_dir / "__init__.py", "w") as f:
|
|
1678
|
+
f.write(
|
|
1679
|
+
"""\"\"\"Auth module for GolfMCP.\"\"\"
|
|
1680
|
+
|
|
1681
|
+
# Legacy ProviderConfig removed in Golf 0.2.x - use modern auth configurations
|
|
1682
|
+
# Legacy OAuth imports removed in Golf 0.2.x - use FastMCP 2.11+ auth providers
|
|
1683
|
+
from golf.auth.helpers import extract_token_from_header, get_api_key, set_api_key
|
|
1684
|
+
from golf.auth.api_key import configure_api_key, get_api_key_config
|
|
1685
|
+
from golf.auth.factory import create_auth_provider
|
|
1686
|
+
from golf.auth.providers import RemoteAuthConfig, JWTAuthConfig, StaticTokenConfig, OAuthServerConfig
|
|
1687
|
+
"""
|
|
1688
|
+
)
|
|
1689
|
+
|
|
1690
|
+
# Copy auth modules required for Golf 0.2.x
|
|
1691
|
+
for module in ["helpers.py", "api_key.py", "factory.py", "providers.py"]:
|
|
1692
|
+
src_file = Path(__file__).parent.parent.parent / "golf" / "auth" / module
|
|
1693
|
+
dst_file = auth_dir / module
|
|
1694
|
+
|
|
1695
|
+
if src_file.exists():
|
|
1696
|
+
shutil.copy(src_file, dst_file)
|
|
1697
|
+
else:
|
|
1698
|
+
console.print(f"[yellow]Warning: Could not find {src_file} to copy[/yellow]")
|
|
1699
|
+
|
|
1700
|
+
# Copy telemetry module if OpenTelemetry is enabled
|
|
1701
|
+
if settings.opentelemetry_enabled:
|
|
1702
|
+
telemetry_dir = output_dir / "golf" / "telemetry"
|
|
1703
|
+
telemetry_dir.mkdir(parents=True, exist_ok=True)
|
|
1704
|
+
|
|
1705
|
+
# Copy telemetry __init__.py
|
|
1706
|
+
src_init = Path(__file__).parent.parent.parent / "golf" / "telemetry" / "__init__.py"
|
|
1707
|
+
dst_init = telemetry_dir / "__init__.py"
|
|
1708
|
+
if src_init.exists():
|
|
1709
|
+
shutil.copy(src_init, dst_init)
|
|
1710
|
+
|
|
1711
|
+
# Copy instrumentation module
|
|
1712
|
+
src_instrumentation = Path(__file__).parent.parent.parent / "golf" / "telemetry" / "instrumentation.py"
|
|
1713
|
+
dst_instrumentation = telemetry_dir / "instrumentation.py"
|
|
1714
|
+
if src_instrumentation.exists():
|
|
1715
|
+
shutil.copy(src_instrumentation, dst_instrumentation)
|
|
1716
|
+
else:
|
|
1717
|
+
console.print("[yellow]Warning: Could not find telemetry instrumentation module[/yellow]")
|
|
1718
|
+
|
|
1719
|
+
# Check if auth routes need to be added
|
|
1720
|
+
if is_auth_configured() or get_api_key_config():
|
|
1721
|
+
auth_routes_code = generate_auth_routes()
|
|
1722
|
+
|
|
1723
|
+
server_file = output_dir / "server.py"
|
|
1724
|
+
if server_file.exists():
|
|
1725
|
+
with open(server_file) as f:
|
|
1726
|
+
server_code_content = f.read()
|
|
1727
|
+
|
|
1728
|
+
# Add auth routes before the main block
|
|
1729
|
+
app_marker = 'if __name__ == "__main__":'
|
|
1730
|
+
app_pos = server_code_content.find(app_marker)
|
|
1731
|
+
if app_pos != -1:
|
|
1732
|
+
modified_code = (
|
|
1733
|
+
server_code_content[:app_pos] + auth_routes_code + "\n\n" + server_code_content[app_pos:]
|
|
1734
|
+
)
|
|
1735
|
+
|
|
1736
|
+
# Format with black before writing
|
|
1737
|
+
try:
|
|
1738
|
+
final_code_to_write = black.format_str(modified_code, mode=black.Mode())
|
|
1739
|
+
except Exception as e:
|
|
1740
|
+
console.print(
|
|
1741
|
+
f"[yellow]Warning: Could not format server.py after auth routes injection: {e}[/yellow]"
|
|
1742
|
+
)
|
|
1743
|
+
final_code_to_write = modified_code
|
|
1744
|
+
|
|
1745
|
+
with open(server_file, "w") as f:
|
|
1746
|
+
f.write(final_code_to_write)
|
|
1747
|
+
else:
|
|
1748
|
+
console.print(
|
|
1749
|
+
f"[yellow]Warning: Could not find main block marker '{app_marker}' in {server_file} to inject auth routes.[/yellow]"
|
|
1750
|
+
)
|
|
1751
|
+
|
|
1752
|
+
|
|
1753
|
+
def discover_root_files(project_path: Path) -> dict[str, Path]:
|
|
1754
|
+
"""Automatically discover all Python files in the project root directory.
|
|
1755
|
+
|
|
1756
|
+
This function finds all .py files in the project root, excluding:
|
|
1757
|
+
- Special Golf files (startup.py, health.py, readiness.py, auth.py, server.py)
|
|
1758
|
+
- Component directories (tools/, resources/, prompts/)
|
|
1759
|
+
- Hidden files and common exclusions (__pycache__, .git, etc.)
|
|
1760
|
+
|
|
1761
|
+
Args:
|
|
1762
|
+
project_path: Path to the project root directory
|
|
1763
|
+
|
|
1764
|
+
Returns:
|
|
1765
|
+
Dictionary mapping filenames to their full paths
|
|
1766
|
+
"""
|
|
1767
|
+
discovered_files = {}
|
|
1768
|
+
|
|
1769
|
+
# Files that are handled specially by Golf and should not be auto-copied
|
|
1770
|
+
reserved_files = {
|
|
1771
|
+
"startup.py",
|
|
1772
|
+
"health.py",
|
|
1773
|
+
"readiness.py",
|
|
1774
|
+
"auth.py",
|
|
1775
|
+
"server.py",
|
|
1776
|
+
"pre_build.py", # Legacy auth file
|
|
1777
|
+
"golf.json",
|
|
1778
|
+
"golf.toml", # Config files
|
|
1779
|
+
"__init__.py", # Package files
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
# Find all .py files in the project root (not in subdirectories)
|
|
1783
|
+
try:
|
|
1784
|
+
for file_path in project_path.iterdir():
|
|
1785
|
+
if not file_path.is_file():
|
|
1786
|
+
continue
|
|
1787
|
+
|
|
1788
|
+
filename = file_path.name
|
|
1789
|
+
|
|
1790
|
+
# Skip non-Python files
|
|
1791
|
+
if not filename.endswith(".py"):
|
|
1792
|
+
continue
|
|
1793
|
+
|
|
1794
|
+
# Skip reserved/special files
|
|
1795
|
+
if filename in reserved_files:
|
|
1796
|
+
continue
|
|
1797
|
+
|
|
1798
|
+
# Skip hidden files and temporary files
|
|
1799
|
+
if filename.startswith(".") or filename.startswith("_") or filename.endswith("~"):
|
|
1800
|
+
continue
|
|
1801
|
+
|
|
1802
|
+
# Just verify it's a readable file
|
|
1803
|
+
try:
|
|
1804
|
+
with open(file_path, encoding="utf-8") as f:
|
|
1805
|
+
# Just check if file is readable - don't validate syntax
|
|
1806
|
+
f.read(1) # Read one character to verify readability
|
|
1807
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
1808
|
+
console.print(f"[yellow]Warning: Cannot read {filename}, skipping: {e}[/yellow]")
|
|
1809
|
+
continue
|
|
1810
|
+
|
|
1811
|
+
discovered_files[filename] = file_path
|
|
1812
|
+
|
|
1813
|
+
except OSError as e:
|
|
1814
|
+
console.print(f"[yellow]Warning: Error scanning project directory: {e}[/yellow]")
|
|
1815
|
+
|
|
1816
|
+
if discovered_files:
|
|
1817
|
+
file_list = ", ".join(sorted(discovered_files.keys()))
|
|
1818
|
+
console.print(f"[dim]Found root Python files: {file_list}[/dim]")
|
|
1819
|
+
|
|
1820
|
+
return discovered_files
|
|
1821
|
+
|
|
1822
|
+
|
|
1823
|
+
# Legacy function removed - replaced by parse_shared_files in parser module
|
|
1824
|
+
|
|
1825
|
+
|
|
1826
|
+
# Updated to handle any shared file, not just common.py files
|
|
1827
|
+
def build_import_map(project_path: Path, shared_files: dict[str, Path]) -> dict[str, str]:
|
|
1828
|
+
"""Build a mapping of import paths to their new locations in the build output.
|
|
1829
|
+
|
|
1830
|
+
This maps from original relative import paths to absolute import paths
|
|
1831
|
+
in the components directory structure.
|
|
1832
|
+
|
|
1833
|
+
Args:
|
|
1834
|
+
project_path: Path to the project root
|
|
1835
|
+
shared_files: Dictionary mapping module paths to shared file paths
|
|
1836
|
+
"""
|
|
1837
|
+
import_map = {}
|
|
1838
|
+
|
|
1839
|
+
for module_path_str, file_path in shared_files.items():
|
|
1840
|
+
# Convert module path to Path object (e.g., "tools/weather/helpers" -> Path("tools/weather/helpers"))
|
|
1841
|
+
module_path = Path(module_path_str)
|
|
1842
|
+
|
|
1843
|
+
# Get the component type (tools, resources, prompts)
|
|
1844
|
+
component_type = None
|
|
1845
|
+
for part in module_path.parts:
|
|
1846
|
+
if part in ["tools", "resources", "prompts"]:
|
|
1847
|
+
component_type = part
|
|
1848
|
+
break
|
|
1849
|
+
|
|
1850
|
+
if not component_type:
|
|
1851
|
+
continue
|
|
1852
|
+
|
|
1853
|
+
# Calculate the relative path within the component type
|
|
1854
|
+
try:
|
|
1855
|
+
rel_to_component = module_path.relative_to(component_type)
|
|
1856
|
+
# Create the new import path
|
|
1857
|
+
if str(rel_to_component) == ".":
|
|
1858
|
+
# This shouldn't happen for individual files, but handle it
|
|
1859
|
+
new_path = f"components.{component_type}"
|
|
1860
|
+
else:
|
|
1861
|
+
# Replace path separators with dots
|
|
1862
|
+
path_parts = str(rel_to_component).replace("\\", "/").split("/")
|
|
1863
|
+
new_path = f"components.{component_type}.{'.'.join(path_parts)}"
|
|
1864
|
+
|
|
1865
|
+
# Map the specific shared module
|
|
1866
|
+
# e.g., "tools/weather/helpers" -> "components.tools.weather.helpers"
|
|
1867
|
+
import_map[module_path_str] = new_path
|
|
1868
|
+
|
|
1869
|
+
# Also map the directory path for relative imports
|
|
1870
|
+
# e.g., "tools/weather" -> "components.tools.weather"
|
|
1871
|
+
dir_path_str = str(module_path.parent)
|
|
1872
|
+
if dir_path_str != "." and dir_path_str not in import_map:
|
|
1873
|
+
dir_rel_to_component = module_path.parent.relative_to(component_type)
|
|
1874
|
+
if str(dir_rel_to_component) == ".":
|
|
1875
|
+
dir_new_path = f"components.{component_type}"
|
|
1876
|
+
else:
|
|
1877
|
+
dir_path_parts = str(dir_rel_to_component).replace("\\", "/").split("/")
|
|
1878
|
+
dir_new_path = f"components.{component_type}.{'.'.join(dir_path_parts)}"
|
|
1879
|
+
import_map[dir_path_str] = dir_new_path
|
|
1880
|
+
|
|
1881
|
+
except ValueError:
|
|
1882
|
+
continue
|
|
1883
|
+
|
|
1884
|
+
return import_map
|