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.
Files changed (52) hide show
  1. golf/__init__.py +1 -0
  2. golf/auth/__init__.py +277 -0
  3. golf/auth/api_key.py +73 -0
  4. golf/auth/factory.py +360 -0
  5. golf/auth/helpers.py +175 -0
  6. golf/auth/providers.py +586 -0
  7. golf/auth/registry.py +256 -0
  8. golf/cli/__init__.py +1 -0
  9. golf/cli/branding.py +191 -0
  10. golf/cli/main.py +377 -0
  11. golf/commands/__init__.py +5 -0
  12. golf/commands/build.py +81 -0
  13. golf/commands/init.py +290 -0
  14. golf/commands/run.py +137 -0
  15. golf/core/__init__.py +1 -0
  16. golf/core/builder.py +1884 -0
  17. golf/core/builder_auth.py +209 -0
  18. golf/core/builder_metrics.py +221 -0
  19. golf/core/builder_telemetry.py +99 -0
  20. golf/core/config.py +199 -0
  21. golf/core/parser.py +1085 -0
  22. golf/core/telemetry.py +492 -0
  23. golf/core/transformer.py +231 -0
  24. golf/examples/__init__.py +0 -0
  25. golf/examples/basic/.env.example +4 -0
  26. golf/examples/basic/README.md +133 -0
  27. golf/examples/basic/auth.py +76 -0
  28. golf/examples/basic/golf.json +5 -0
  29. golf/examples/basic/prompts/welcome.py +27 -0
  30. golf/examples/basic/resources/current_time.py +34 -0
  31. golf/examples/basic/resources/info.py +28 -0
  32. golf/examples/basic/resources/weather/city.py +46 -0
  33. golf/examples/basic/resources/weather/client.py +48 -0
  34. golf/examples/basic/resources/weather/current.py +36 -0
  35. golf/examples/basic/resources/weather/forecast.py +36 -0
  36. golf/examples/basic/tools/calculator.py +94 -0
  37. golf/examples/basic/tools/say/hello.py +65 -0
  38. golf/metrics/__init__.py +10 -0
  39. golf/metrics/collector.py +320 -0
  40. golf/metrics/registry.py +12 -0
  41. golf/telemetry/__init__.py +23 -0
  42. golf/telemetry/instrumentation.py +1402 -0
  43. golf/utilities/__init__.py +12 -0
  44. golf/utilities/context.py +53 -0
  45. golf/utilities/elicitation.py +170 -0
  46. golf/utilities/sampling.py +221 -0
  47. golf_mcp-0.2.16.dist-info/METADATA +262 -0
  48. golf_mcp-0.2.16.dist-info/RECORD +52 -0
  49. golf_mcp-0.2.16.dist-info/WHEEL +5 -0
  50. golf_mcp-0.2.16.dist-info/entry_points.txt +2 -0
  51. golf_mcp-0.2.16.dist-info/licenses/LICENSE +201 -0
  52. 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