Coverage for /Users/antonigmitruk/golf/src/golf/core/builder.py: 0%
588 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-08-16 18:46 +0200
« prev ^ index » next coverage.py v7.6.12, created at 2025-08-16 18:46 +0200
1"""Builder for generating FastMCP manifests from parsed components."""
3import json
4import os
5import shutil
6import sys
7from pathlib import Path
8from typing import Any
10import black
11from rich.console import Console
13from golf.auth import is_auth_configured
14from golf.auth.api_key import get_api_key_config
15from golf.core.builder_auth import generate_auth_code, generate_auth_routes
16from golf.core.builder_telemetry import (
17 generate_telemetry_imports,
18)
19from golf.cli.branding import create_build_header, get_status_text, STATUS_ICONS, GOLF_BLUE
20from golf.core.config import Settings
21from golf.core.parser import (
22 ComponentType,
23 ParsedComponent,
24 parse_project,
25)
26from golf.core.transformer import transform_component
28console = Console()
31class ManifestBuilder:
32 """Builds FastMCP manifest from parsed components."""
34 def __init__(self, project_path: Path, settings: Settings) -> None:
35 """Initialize the manifest builder.
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 }
52 def build(self) -> dict[str, Any]:
53 """Build the complete manifest.
55 Returns:
56 FastMCP manifest dictionary
57 """
58 # Parse all components
59 self.components = parse_project(self.project_path)
61 # Process each component type
62 self._process_tools()
63 self._process_resources()
64 self._process_prompts()
66 return self.manifest
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 = []
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"]
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 }
95 # Include required fields if they exist
96 if required_fields:
97 tool_schema["inputSchema"]["required"] = required_fields
99 # Add tool annotations if present
100 if component.annotations:
101 # Merge with existing annotations (keeping title)
102 tool_schema["annotations"].update(component.annotations)
104 # Add the tool to the manifest
105 self.manifest["tools"].append(tool_schema)
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
114 resource_schema = {
115 "uri": component.uri_template,
116 "name": component.name,
117 "description": component.docstring or "",
118 "entry_function": component.entry_function,
119 }
121 # Add the resource to the manifest
122 self.manifest["resources"].append(resource_schema)
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 }
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
145 # Add the prompt to the manifest
146 self.manifest["prompts"].append(prompt_schema)
148 def save_manifest(self, output_path: Path | None = None) -> Path:
149 """Save the manifest to a JSON file.
151 Args:
152 output_path: Path to save the manifest to (defaults to .golf/manifest.json)
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"
163 # Ensure parent directories exist
164 output_path.parent.mkdir(parents=True, exist_ok=True)
166 # Write the manifest to the file
167 with open(output_path, "w") as f:
168 json.dump(self.manifest, f, indent=2)
170 console.print(f"[green]Manifest saved to {output_path}[/green]")
171 return output_path
174def build_manifest(project_path: Path, settings: Settings) -> dict[str, Any]:
175 """Build a FastMCP manifest from parsed components.
177 Args:
178 project_path: Path to the project root
179 settings: Project settings
181 Returns:
182 FastMCP manifest dictionary
183 """
184 # Use the ManifestBuilder class to build the manifest
185 builder = ManifestBuilder(project_path, settings)
186 return builder.build()
189def compute_manifest_diff(old_manifest: dict[str, Any], new_manifest: dict[str, Any]) -> dict[str, Any]:
190 """Compute the difference between two manifests.
192 Args:
193 old_manifest: Previous manifest
194 new_manifest: New manifest
196 Returns:
197 Dictionary describing the changes
198 """
199 diff = {
200 "tools": {"added": [], "removed": [], "changed": []},
201 "resources": {"added": [], "removed": [], "changed": []},
202 "prompts": {"added": [], "removed": [], "changed": []},
203 }
205 # Helper function to extract names from a list of components
206 def extract_names(components: list[dict[str, Any]]) -> set[str]:
207 return {comp["name"] for comp in components}
209 # Compare tools
210 old_tools = extract_names(old_manifest.get("tools", []))
211 new_tools = extract_names(new_manifest.get("tools", []))
212 diff["tools"]["added"] = list(new_tools - old_tools)
213 diff["tools"]["removed"] = list(old_tools - new_tools)
215 # Compare tools that exist in both for changes
216 for new_tool in new_manifest.get("tools", []):
217 if new_tool["name"] in old_tools:
218 # Find the corresponding old tool
219 old_tool = next(
220 (t for t in old_manifest.get("tools", []) if t["name"] == new_tool["name"]),
221 None,
222 )
223 if old_tool and json.dumps(old_tool) != json.dumps(new_tool):
224 diff["tools"]["changed"].append(new_tool["name"])
226 # Compare resources
227 old_resources = extract_names(old_manifest.get("resources", []))
228 new_resources = extract_names(new_manifest.get("resources", []))
229 diff["resources"]["added"] = list(new_resources - old_resources)
230 diff["resources"]["removed"] = list(old_resources - new_resources)
232 # Compare resources that exist in both for changes
233 for new_resource in new_manifest.get("resources", []):
234 if new_resource["name"] in old_resources:
235 # Find the corresponding old resource
236 old_resource = next(
237 (r for r in old_manifest.get("resources", []) if r["name"] == new_resource["name"]),
238 None,
239 )
240 if old_resource and json.dumps(old_resource) != json.dumps(new_resource):
241 diff["resources"]["changed"].append(new_resource["name"])
243 # Compare prompts
244 old_prompts = extract_names(old_manifest.get("prompts", []))
245 new_prompts = extract_names(new_manifest.get("prompts", []))
246 diff["prompts"]["added"] = list(new_prompts - old_prompts)
247 diff["prompts"]["removed"] = list(old_prompts - new_prompts)
249 # Compare prompts that exist in both for changes
250 for new_prompt in new_manifest.get("prompts", []):
251 if new_prompt["name"] in old_prompts:
252 # Find the corresponding old prompt
253 old_prompt = next(
254 (p for p in old_manifest.get("prompts", []) if p["name"] == new_prompt["name"]),
255 None,
256 )
257 if old_prompt and json.dumps(old_prompt) != json.dumps(new_prompt):
258 diff["prompts"]["changed"].append(new_prompt["name"])
260 return diff
263def has_changes(diff: dict[str, Any]) -> bool:
264 """Check if a manifest diff contains any changes.
266 Args:
267 diff: Manifest diff from compute_manifest_diff
269 Returns:
270 True if there are any changes, False otherwise
271 """
272 for category in diff:
273 for change_type in diff[category]:
274 if diff[category][change_type]:
275 return True
277 return False
280class CodeGenerator:
281 """Code generator for FastMCP applications."""
283 def __init__(
284 self,
285 project_path: Path,
286 settings: Settings,
287 output_dir: Path,
288 build_env: str = "prod",
289 copy_env: bool = False,
290 ) -> None:
291 """Initialize the code generator.
293 Args:
294 project_path: Path to the project root
295 settings: Project settings
296 output_dir: Directory to output the generated code
297 build_env: Build environment ('dev' or 'prod')
298 copy_env: Whether to copy environment variables to the built app
299 """
300 self.project_path = project_path
301 self.settings = settings
302 self.output_dir = output_dir
303 self.build_env = build_env
304 self.copy_env = copy_env
305 self.components = {}
306 self.manifest = {}
307 self.shared_files = {}
308 self.import_map = {}
310 def generate(self) -> None:
311 """Generate the FastMCP application code."""
312 # Parse the project and build the manifest
313 with console.status("Analyzing project components..."):
314 self.components = parse_project(self.project_path)
315 self.manifest = build_manifest(self.project_path, self.settings)
317 # Find shared Python files and build import map
318 from golf.core.parser import parse_shared_files
319 self.shared_files = parse_shared_files(self.project_path)
320 self.import_map = build_import_map(self.project_path, self.shared_files)
322 # Create output directory structure
323 with console.status("Creating directory structure..."):
324 self._create_directory_structure()
326 # Generate code for all components
327 tasks = [
328 ("Generating tools", self._generate_tools),
329 ("Generating resources", self._generate_resources),
330 ("Generating prompts", self._generate_prompts),
331 ("Generating server entry point", self._generate_server),
332 ]
334 for description, func in tasks:
335 console.print(get_status_text("generating", description))
336 func()
338 # Get relative path for display
339 try:
340 output_dir_display = self.output_dir.relative_to(Path.cwd())
341 except (ValueError, FileNotFoundError, OSError):
342 # ValueError: paths don't have a common base
343 # FileNotFoundError/OSError: current directory was deleted
344 output_dir_display = self.output_dir
346 # Show success message with output directory
347 console.print()
348 console.print(get_status_text("success", f"Build completed successfully in {output_dir_display}"))
350 def _create_directory_structure(self) -> None:
351 """Create the output directory structure"""
352 # Create main directories
353 dirs = [
354 self.output_dir,
355 self.output_dir / "components",
356 self.output_dir / "components" / "tools",
357 self.output_dir / "components" / "resources",
358 self.output_dir / "components" / "prompts",
359 ]
361 for directory in dirs:
362 directory.mkdir(parents=True, exist_ok=True)
363 # Process shared files directly in the components directory
364 self._process_shared_files()
366 def _process_shared_files(self) -> None:
367 """Process and transform shared Python files in the components directory
368 structure."""
369 # Process all shared files
370 for module_path_str, shared_file in self.shared_files.items():
371 # Convert module path to Path object (e.g., "tools/weather/helpers")
372 module_path = Path(module_path_str)
374 # Determine the component type
375 component_type = None
376 for part in module_path.parts:
377 if part in ["tools", "resources", "prompts"]:
378 component_type = part
379 break
381 if not component_type:
382 continue
384 # Calculate target directory in components structure
385 rel_to_component = module_path.relative_to(component_type)
386 target_dir = self.output_dir / "components" / component_type / rel_to_component.parent
388 # Create directory if it doesn't exist
389 target_dir.mkdir(parents=True, exist_ok=True)
391 # Create the shared file in the target directory (preserve original filename)
392 target_file = target_dir / shared_file.name
394 # Use transformer to process the file
395 transform_component(
396 component=None,
397 output_file=target_file,
398 project_path=self.project_path,
399 import_map=self.import_map,
400 source_file=shared_file,
401 )
403 def _generate_tools(self) -> None:
404 """Generate code for all tools."""
405 tools_dir = self.output_dir / "components" / "tools"
407 for tool in self.components.get(ComponentType.TOOL, []):
408 # Get the tool directory structure
409 rel_path = Path(tool.file_path).relative_to(self.project_path)
410 if not rel_path.is_relative_to(Path(self.settings.tools_dir)):
411 console.print(f"[yellow]Warning: Tool {tool.name} is not in the tools directory[/yellow]")
412 continue
414 try:
415 rel_to_tools = rel_path.relative_to(self.settings.tools_dir)
416 tool_dir = tools_dir / rel_to_tools.parent
417 except ValueError:
418 # Fall back to just using the filename
419 tool_dir = tools_dir
421 tool_dir.mkdir(parents=True, exist_ok=True)
423 # Create the tool file
424 output_file = tool_dir / rel_path.name
425 transform_component(tool, output_file, self.project_path, self.import_map)
427 def _generate_resources(self) -> None:
428 """Generate code for all resources."""
429 resources_dir = self.output_dir / "components" / "resources"
431 for resource in self.components.get(ComponentType.RESOURCE, []):
432 # Get the resource directory structure
433 rel_path = Path(resource.file_path).relative_to(self.project_path)
434 if not rel_path.is_relative_to(Path(self.settings.resources_dir)):
435 console.print(f"[yellow]Warning: Resource {resource.name} is not in the resources directory[/yellow]")
436 continue
438 try:
439 rel_to_resources = rel_path.relative_to(self.settings.resources_dir)
440 resource_dir = resources_dir / rel_to_resources.parent
441 except ValueError:
442 # Fall back to just using the filename
443 resource_dir = resources_dir
445 resource_dir.mkdir(parents=True, exist_ok=True)
447 # Create the resource file
448 output_file = resource_dir / rel_path.name
449 transform_component(resource, output_file, self.project_path, self.import_map)
451 def _generate_prompts(self) -> None:
452 """Generate code for all prompts."""
453 prompts_dir = self.output_dir / "components" / "prompts"
455 for prompt in self.components.get(ComponentType.PROMPT, []):
456 # Get the prompt directory structure
457 rel_path = Path(prompt.file_path).relative_to(self.project_path)
458 if not rel_path.is_relative_to(Path(self.settings.prompts_dir)):
459 console.print(f"[yellow]Warning: Prompt {prompt.name} is not in the prompts directory[/yellow]")
460 continue
462 try:
463 rel_to_prompts = rel_path.relative_to(self.settings.prompts_dir)
464 prompt_dir = prompts_dir / rel_to_prompts.parent
465 except ValueError:
466 # Fall back to just using the filename
467 prompt_dir = prompts_dir
469 prompt_dir.mkdir(parents=True, exist_ok=True)
471 # Create the prompt file
472 output_file = prompt_dir / rel_path.name
473 transform_component(prompt, output_file, self.project_path, self.import_map)
475 def _get_transport_config(self, transport_type: str) -> dict:
476 """Get transport-specific configuration (primarily for endpoint path display).
478 Args:
479 transport_type: The transport type (e.g., 'sse', 'streamable-http', 'stdio')
481 Returns:
482 Dictionary with transport configuration details (endpoint_path)
483 """
484 config = {
485 "endpoint_path": "",
486 }
488 if transport_type == "sse":
489 config["endpoint_path"] = "/sse" # Default SSE path for FastMCP
490 elif transport_type == "stdio":
491 config["endpoint_path"] = "" # No HTTP endpoint
492 else:
493 # Default to streamable-http
494 config["endpoint_path"] = "/mcp/" # Default MCP path for FastMCP
496 return config
498 def _generate_server(self) -> None:
499 """Generate the main server entry point."""
500 server_file = self.output_dir / "server.py"
502 # Get auth components
503 auth_components = generate_auth_code(
504 server_name=self.settings.name,
505 host=self.settings.host,
506 port=self.settings.port,
507 https=False, # This could be configurable in settings
508 opentelemetry_enabled=self.settings.opentelemetry_enabled,
509 transport=self.settings.transport,
510 )
512 # Create imports section
513 imports = [
514 "from fastmcp import FastMCP",
515 "from fastmcp.tools import Tool",
516 "from fastmcp.resources import Resource",
517 "from fastmcp.prompts import Prompt",
518 "import os",
519 "import sys",
520 "from dotenv import load_dotenv",
521 "import logging",
522 "",
523 "# Suppress FastMCP INFO logs",
524 "logging.getLogger('FastMCP').setLevel(logging.ERROR)",
525 "logging.getLogger('mcp').setLevel(logging.ERROR)",
526 "",
527 "# Golf utilities for MCP features (available for tool functions)",
528 "# from golf.utilities import elicit, sample, get_current_context",
529 "",
530 ]
532 # Add auth imports if auth is configured
533 if auth_components.get("has_auth"):
534 imports.extend(auth_components["imports"])
535 imports.append("")
537 # Add OpenTelemetry imports if enabled
538 if self.settings.opentelemetry_enabled:
539 imports.extend(generate_telemetry_imports())
541 # Add metrics imports if enabled
542 if self.settings.metrics_enabled:
543 from golf.core.builder_metrics import (
544 generate_metrics_imports,
545 generate_metrics_instrumentation,
546 generate_session_tracking,
547 )
549 imports.extend(generate_metrics_imports())
550 imports.extend(generate_metrics_instrumentation())
551 imports.extend(generate_session_tracking())
553 # Add health check imports if enabled
554 if self.settings.health_check_enabled:
555 imports.extend(
556 [
557 "from starlette.requests import Request",
558 "from starlette.responses import PlainTextResponse",
559 ]
560 )
562 # Get transport-specific configuration
563 transport_config = self._get_transport_config(self.settings.transport)
564 endpoint_path = transport_config["endpoint_path"]
566 # Track component modules to register
567 component_registrations = []
569 # Import components
570 for component_type in self.components:
571 # Add a section header
572 if component_type == ComponentType.TOOL:
573 imports.append("# Import tools")
574 comp_section = "# Register tools"
575 elif component_type == ComponentType.RESOURCE:
576 imports.append("# Import resources")
577 comp_section = "# Register resources"
578 else:
579 imports.append("# Import prompts")
580 comp_section = "# Register prompts"
582 component_registrations.append(comp_section)
584 for component in self.components[component_type]:
585 # Derive the import path based on component type and file path
586 rel_path = Path(component.file_path).relative_to(self.project_path)
587 module_name = rel_path.stem
589 if component_type == ComponentType.TOOL:
590 try:
591 rel_to_tools = rel_path.relative_to(self.settings.tools_dir)
592 # Handle nested directories properly
593 if rel_to_tools.parent != Path("."):
594 parent_path = str(rel_to_tools.parent).replace("\\", ".").replace("/", ".")
595 import_path = f"components.tools.{parent_path}"
596 else:
597 import_path = "components.tools"
598 except ValueError:
599 import_path = "components.tools"
600 elif component_type == ComponentType.RESOURCE:
601 try:
602 rel_to_resources = rel_path.relative_to(self.settings.resources_dir)
603 # Handle nested directories properly
604 if rel_to_resources.parent != Path("."):
605 parent_path = str(rel_to_resources.parent).replace("\\", ".").replace("/", ".")
606 import_path = f"components.resources.{parent_path}"
607 else:
608 import_path = "components.resources"
609 except ValueError:
610 import_path = "components.resources"
611 else: # PROMPT
612 try:
613 rel_to_prompts = rel_path.relative_to(self.settings.prompts_dir)
614 # Handle nested directories properly
615 if rel_to_prompts.parent != Path("."):
616 parent_path = str(rel_to_prompts.parent).replace("\\", ".").replace("/", ".")
617 import_path = f"components.prompts.{parent_path}"
618 else:
619 import_path = "components.prompts"
620 except ValueError:
621 import_path = "components.prompts"
623 # Clean up the import path
624 import_path = import_path.rstrip(".")
626 # Add the import for the component's module
627 full_module_path = f"{import_path}.{module_name}"
628 imports.append(f"import {full_module_path}")
630 # Add code to register this component
631 if self.settings.opentelemetry_enabled:
632 # Use telemetry instrumentation
633 registration = f"# Register the {component_type.value} '{component.name}' with telemetry"
634 entry_func = (
635 component.entry_function
636 if hasattr(component, "entry_function") and component.entry_function
637 else "export"
638 )
640 registration += (
641 f"\n_wrapped_func = instrument_{component_type.value}("
642 f"{full_module_path}.{entry_func}, '{component.name}')"
643 )
645 if component_type == ComponentType.TOOL:
646 registration += (
647 f"\n_tool = Tool.from_function(_wrapped_func, "
648 f'name="{component.name}", '
649 f'description="{component.docstring or ""}")'
650 )
651 # Add annotations if present
652 if hasattr(component, "annotations") and component.annotations:
653 registration += f".with_annotations({component.annotations})"
654 registration += "\nmcp.add_tool(_tool)"
655 elif component_type == ComponentType.RESOURCE:
656 registration += (
657 f"\n_resource = Resource.from_function(_wrapped_func, "
658 f'uri="{component.uri_template}", name="{component.name}", '
659 f'description="{component.docstring or ""}")\n'
660 f"mcp.add_resource(_resource)"
661 )
662 else: # PROMPT
663 registration += (
664 f"\n_prompt = Prompt.from_function(_wrapped_func, "
665 f'name="{component.name}", '
666 f'description="{component.docstring or ""}")\n'
667 f"mcp.add_prompt(_prompt)"
668 )
669 elif self.settings.metrics_enabled:
670 # Use metrics instrumentation
671 registration = f"# Register the {component_type.value} '{component.name}' with metrics"
672 entry_func = (
673 component.entry_function
674 if hasattr(component, "entry_function") and component.entry_function
675 else "export"
676 )
678 registration += (
679 f"\n_wrapped_func = instrument_{component_type.value}("
680 f"{full_module_path}.{entry_func}, '{component.name}')"
681 )
683 if component_type == ComponentType.TOOL:
684 registration += (
685 f"\n_tool = Tool.from_function(_wrapped_func, "
686 f'name="{component.name}", '
687 f'description="{component.docstring or ""}")'
688 )
689 # Add annotations if present
690 if hasattr(component, "annotations") and component.annotations:
691 registration += f".with_annotations({component.annotations})"
692 registration += "\nmcp.add_tool(_tool)"
693 elif component_type == ComponentType.RESOURCE:
694 registration += (
695 f"\n_resource = Resource.from_function(_wrapped_func, "
696 f'uri="{component.uri_template}", name="{component.name}", '
697 f'description="{component.docstring or ""}")\n'
698 f"mcp.add_resource(_resource)"
699 )
700 else: # PROMPT
701 registration += (
702 f"\n_prompt = Prompt.from_function(_wrapped_func, "
703 f'name="{component.name}", '
704 f'description="{component.docstring or ""}")\n'
705 f"mcp.add_prompt(_prompt)"
706 )
707 else:
708 # Standard registration without telemetry
709 if component_type == ComponentType.TOOL:
710 registration = f"# Register the tool '{component.name}' from {full_module_path}"
712 # Use the entry_function if available, otherwise try the
713 # export variable
714 if hasattr(component, "entry_function") and component.entry_function:
715 registration += (
716 f"\n_tool = Tool.from_function({full_module_path}.{component.entry_function}"
717 )
718 else:
719 registration += f"\n_tool = Tool.from_function({full_module_path}.export"
721 # Add the name parameter
722 registration += f', name="{component.name}"'
724 # Add description from docstring
725 if component.docstring:
726 # Escape any quotes in the docstring
727 escaped_docstring = component.docstring.replace('"', '\\"')
728 registration += f', description="{escaped_docstring}"'
730 registration += ")"
732 # Add annotations if present
733 if hasattr(component, "annotations") and component.annotations:
734 registration += f"\n_tool = _tool.with_annotations({component.annotations})"
736 registration += "\nmcp.add_tool(_tool)"
738 elif component_type == ComponentType.RESOURCE:
739 registration = f"# Register the resource '{component.name}' from {full_module_path}"
741 # Use the entry_function if available, otherwise try the
742 # export variable
743 if hasattr(component, "entry_function") and component.entry_function:
744 registration += (
745 f"\n_resource = Resource.from_function("
746 f"{full_module_path}.{component.entry_function}, "
747 f'uri="{component.uri_template}"'
748 )
749 else:
750 registration += (
751 f"\n_resource = Resource.from_function("
752 f"{full_module_path}.export, "
753 f'uri="{component.uri_template}"'
754 )
756 # Add the name parameter
757 registration += f', name="{component.name}"'
759 # Add description from docstring
760 if component.docstring:
761 # Escape any quotes in the docstring
762 escaped_docstring = component.docstring.replace('"', '\\"')
763 registration += f', description="{escaped_docstring}"'
765 registration += ")\nmcp.add_resource(_resource)"
767 else: # PROMPT
768 registration = f"# Register the prompt '{component.name}' from {full_module_path}"
770 # Use the entry_function if available, otherwise try the
771 # export variable
772 if hasattr(component, "entry_function") and component.entry_function:
773 registration += (
774 f"\n_prompt = Prompt.from_function({full_module_path}.{component.entry_function}"
775 )
776 else:
777 registration += f"\n_prompt = Prompt.from_function({full_module_path}.export"
779 # Add the name parameter
780 registration += f', name="{component.name}"'
782 # Add description from docstring
783 if component.docstring:
784 # Escape any quotes in the docstring
785 escaped_docstring = component.docstring.replace('"', '\\"')
786 registration += f', description="{escaped_docstring}"'
788 registration += ")\nmcp.add_prompt(_prompt)"
790 component_registrations.append(registration)
792 # Add a blank line after each section
793 imports.append("")
794 component_registrations.append("")
796 # Create environment section based on build type - moved after imports
797 env_section = [
798 "",
799 "# Load environment variables from .env file if it exists",
800 "# Note: dotenv will not override existing environment variables by default",
801 "load_dotenv()",
802 "",
803 ]
805 # OpenTelemetry setup code will be handled through imports and lifespan
807 # Add auth setup code if auth is configured
808 auth_setup_code = []
809 if auth_components.get("has_auth"):
810 auth_setup_code = auth_components["setup_code"]
812 # Create FastMCP instance section
813 server_code_lines = ["# Create FastMCP server"]
815 # Build FastMCP constructor arguments
816 mcp_constructor_args = [f'"{self.settings.name}"']
818 # Add auth arguments if configured
819 if auth_components.get("has_auth") and auth_components.get("fastmcp_args"):
820 for key, value in auth_components["fastmcp_args"].items():
821 mcp_constructor_args.append(f"{key}={value}")
823 # Add stateless HTTP parameter if enabled
824 if self.settings.stateless_http:
825 mcp_constructor_args.append("stateless_http=True")
827 # Add OpenTelemetry parameters if enabled
828 if self.settings.opentelemetry_enabled:
829 mcp_constructor_args.append("lifespan=telemetry_lifespan")
831 mcp_instance_line = f"mcp = FastMCP({', '.join(mcp_constructor_args)})"
832 server_code_lines.append(mcp_instance_line)
833 server_code_lines.append("")
835 # Add early telemetry initialization if enabled (before component registration)
836 early_telemetry_init = []
837 if self.settings.opentelemetry_enabled:
838 early_telemetry_init.extend(
839 [
840 "# Initialize telemetry early to ensure instrumentation works",
841 "from golf.telemetry.instrumentation import init_telemetry, set_detailed_tracing",
842 f'init_telemetry("{self.settings.name}")',
843 f"set_detailed_tracing({self.settings.detailed_tracing})",
844 "",
845 ]
846 )
848 # Add metrics initialization if enabled
849 early_metrics_init = []
850 if self.settings.metrics_enabled:
851 from golf.core.builder_metrics import generate_metrics_initialization
853 early_metrics_init.extend(generate_metrics_initialization(self.settings.name))
855 # Main entry point with transport-specific app initialization
856 main_code = [
857 'if __name__ == "__main__":',
858 " from rich.console import Console",
859 " from rich.panel import Panel",
860 " console = Console()",
861 " # Get configuration from environment variables or use defaults",
862 ' host = os.environ.get("HOST", "localhost")',
863 ' port = int(os.environ.get("PORT", 3000))',
864 f' transport_to_run = "{self.settings.transport}"',
865 "",
866 ]
868 main_code.append("")
870 # Transport-specific run methods
871 if self.settings.transport == "sse":
872 # Check if we need middleware for SSE
873 middleware_setup = []
874 middleware_list = []
876 api_key_config = get_api_key_config()
877 if auth_components.get("has_auth") and api_key_config:
878 middleware_setup.append(" from starlette.middleware import Middleware")
879 middleware_list.append("Middleware(ApiKeyMiddleware)")
881 # Add metrics middleware if enabled
882 if self.settings.metrics_enabled:
883 middleware_setup.append(" from starlette.middleware import Middleware")
884 middleware_list.append("Middleware(MetricsMiddleware)")
886 # Add OpenTelemetry middleware if enabled
887 if self.settings.opentelemetry_enabled:
888 middleware_setup.append(" from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware")
889 middleware_setup.append(" from starlette.middleware import Middleware")
890 middleware_list.append("Middleware(OpenTelemetryMiddleware)")
892 if middleware_setup:
893 main_code.extend(middleware_setup)
894 main_code.append(f" middleware = [{', '.join(middleware_list)}]")
895 main_code.append("")
896 main_code.extend(
897 [
898 " # Run SSE server with middleware using FastMCP's run method",
899 f' mcp.run(transport="sse", host=host, port=port, '
900 f'path="{endpoint_path}", log_level="info", '
901 f"middleware=middleware, show_banner=False)",
902 ]
903 )
904 else:
905 main_code.extend(
906 [
907 " # Run SSE server using FastMCP's run method",
908 f' mcp.run(transport="sse", host=host, port=port, '
909 f'path="{endpoint_path}", log_level="info", '
910 f"show_banner=False)",
911 ]
912 )
914 elif self.settings.transport in ["streamable-http", "http"]:
915 # Check if we need middleware for streamable-http
916 middleware_setup = []
917 middleware_list = []
919 api_key_config = get_api_key_config()
920 if auth_components.get("has_auth") and api_key_config:
921 middleware_setup.append(" from starlette.middleware import Middleware")
922 middleware_list.append("Middleware(ApiKeyMiddleware)")
924 # Add metrics middleware if enabled
925 if self.settings.metrics_enabled:
926 middleware_setup.append(" from starlette.middleware import Middleware")
927 middleware_list.append("Middleware(MetricsMiddleware)")
929 # Add OpenTelemetry middleware if enabled
930 if self.settings.opentelemetry_enabled:
931 middleware_setup.append(" from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware")
932 middleware_setup.append(" from starlette.middleware import Middleware")
933 middleware_list.append("Middleware(OpenTelemetryMiddleware)")
935 if middleware_setup:
936 main_code.extend(middleware_setup)
937 main_code.append(f" middleware = [{', '.join(middleware_list)}]")
938 main_code.append("")
939 main_code.extend(
940 [
941 " # Run HTTP server with middleware using FastMCP's run method",
942 f' mcp.run(transport="streamable-http", host=host, '
943 f'port=port, path="{endpoint_path}", log_level="info", '
944 f"middleware=middleware, show_banner=False)",
945 ]
946 )
947 else:
948 main_code.extend(
949 [
950 " # Run HTTP server using FastMCP's run method",
951 f' mcp.run(transport="streamable-http", host=host, '
952 f'port=port, path="{endpoint_path}", log_level="info", '
953 f"show_banner=False)",
954 ]
955 )
956 else:
957 # For stdio transport, use mcp.run()
958 main_code.extend([" # Run with stdio transport", ' mcp.run(transport="stdio", show_banner=False)'])
960 # Add metrics route if enabled
961 metrics_route_code = []
962 if self.settings.metrics_enabled:
963 from golf.core.builder_metrics import generate_metrics_route
965 metrics_route_code = generate_metrics_route(self.settings.metrics_path)
967 # Add health check route if enabled
968 health_check_code = []
969 if self.settings.health_check_enabled:
970 health_check_code = [
971 "# Add health check route",
972 "@mcp.custom_route('" + self.settings.health_check_path + '\', methods=["GET"])',
973 "async def health_check(request: Request) -> PlainTextResponse:",
974 ' """Health check endpoint for Kubernetes and load balancers."""',
975 (f' return PlainTextResponse("{self.settings.health_check_response}")'),
976 "",
977 ]
979 # Combine all sections
980 # Order: imports, env_section, auth_setup, server_code (mcp init),
981 # early_telemetry_init, early_metrics_init, component_registrations,
982 # metrics_route_code, health_check_code, main_code (run block)
983 code = "\n".join(
984 imports
985 + env_section
986 + auth_setup_code
987 + server_code_lines
988 + early_telemetry_init
989 + early_metrics_init
990 + component_registrations
991 + metrics_route_code
992 + health_check_code
993 + main_code
994 )
996 # Format with black
997 try:
998 code = black.format_str(code, mode=black.Mode())
999 except Exception as e:
1000 console.print(f"[yellow]Warning: Could not format server.py: {e}[/yellow]")
1002 # Write to file
1003 with open(server_file, "w") as f:
1004 f.write(code)
1007def build_project(
1008 project_path: Path,
1009 settings: Settings,
1010 output_dir: Path,
1011 build_env: str = "prod",
1012 copy_env: bool = False,
1013) -> None:
1014 """Build a standalone FastMCP application from a GolfMCP project.
1016 Args:
1017 project_path: Path to the project directory
1018 settings: Project settings
1019 output_dir: Output directory for the built application
1020 build_env: Build environment ('dev' or 'prod')
1021 copy_env: Whether to copy environment variables to the built app
1022 """
1023 # Load Golf credentials from .env for build operations (platform registration, etc.)
1024 # This happens regardless of copy_env setting to ensure build process works
1025 from dotenv import load_dotenv
1027 project_env_file = project_path / ".env"
1028 if project_env_file.exists():
1029 # Load GOLF_* variables for build process
1030 load_dotenv(project_env_file, override=False)
1032 # Only log if we actually found the specific Golf platform credentials
1033 has_api_key = "GOLF_API_KEY" in os.environ
1034 has_server_id = "GOLF_SERVER_ID" in os.environ
1035 if has_api_key and has_server_id:
1036 console.print("[dim]Loaded Golf credentials for build operations[/dim]")
1038 # Execute auth.py if it exists (for authentication configuration)
1039 # Also support legacy pre_build.py for backward compatibility
1040 auth_path = project_path / "auth.py"
1041 legacy_path = project_path / "pre_build.py"
1043 config_path = None
1044 if auth_path.exists():
1045 config_path = auth_path
1046 elif legacy_path.exists():
1047 config_path = legacy_path
1048 console.print("[yellow]Warning: pre_build.py is deprecated. Rename to auth.py[/yellow]")
1050 if config_path:
1051 # Save the current directory and path - handle case where cwd might be invalid
1052 try:
1053 original_dir = os.getcwd()
1054 except (FileNotFoundError, OSError):
1055 # Current directory might have been deleted by previous operations,
1056 # use project_path as fallback
1057 original_dir = str(project_path)
1058 os.chdir(original_dir)
1059 original_path = sys.path.copy()
1061 try:
1062 # Change to the project directory and add it to Python path
1063 os.chdir(project_path)
1064 sys.path.insert(0, str(project_path))
1066 # Execute the auth configuration script
1067 with open(config_path) as f:
1068 script_content = f.read()
1070 # Print the first few lines for debugging
1071 "\n".join(script_content.split("\n")[:5]) + "\n..."
1073 # Use exec to run the script as a module
1074 code = compile(script_content, str(config_path), "exec")
1075 exec(code, {})
1077 except Exception as e:
1078 console.print(f"[red]Error executing {config_path.name}: {str(e)}[/red]")
1079 import traceback
1081 console.print(f"[red]{traceback.format_exc()}[/red]")
1083 # Track detailed error for auth.py execution failures
1084 try:
1085 from golf.core.telemetry import track_detailed_error
1087 track_detailed_error(
1088 "build_auth_failed",
1089 e,
1090 context=f"Executing {config_path.name} configuration script",
1091 operation="auth_execution",
1092 additional_props={
1093 "file_path": str(config_path.relative_to(project_path)),
1094 "build_env": build_env,
1095 },
1096 )
1097 except Exception:
1098 # Don't let telemetry errors break the build
1099 pass
1100 finally:
1101 # Always restore original directory and path, even if an exception occurred
1102 try:
1103 os.chdir(original_dir)
1104 sys.path = original_path
1105 except Exception:
1106 # If we can't restore the directory, at least try to reset the path
1107 sys.path = original_path
1109 # Clear the output directory if it exists
1110 if output_dir.exists():
1111 shutil.rmtree(output_dir)
1112 output_dir.mkdir(parents=True, exist_ok=True) # Ensure output_dir exists after clearing
1114 # --- BEGIN Enhanced .env handling ---
1115 env_vars_to_write = {}
1116 env_file_path = output_dir / ".env"
1118 # 1. Load from existing project .env if copy_env is true
1119 if copy_env:
1120 project_env_file = project_path / ".env"
1121 if project_env_file.exists():
1122 try:
1123 from dotenv import dotenv_values
1125 env_vars_to_write.update(dotenv_values(project_env_file))
1126 except ImportError:
1127 console.print(
1128 "[yellow]Warning: python-dotenv is not installed. "
1129 "Cannot read existing .env file for rich merging. "
1130 "Copying directly.[/yellow]"
1131 )
1132 try:
1133 shutil.copy(project_env_file, env_file_path)
1134 # If direct copy happens, re-read for step 2 & 3 to respect
1135 # its content
1136 if env_file_path.exists():
1137 from dotenv import dotenv_values
1139 env_vars_to_write.update(dotenv_values(env_file_path)) # Read what was copied
1140 except Exception as e:
1141 console.print(f"[yellow]Warning: Could not copy project .env file: {e}[/yellow]")
1142 except Exception as e:
1143 console.print(f"[yellow]Warning: Error reading project .env file content: {e}[/yellow]")
1145 # 2. Apply Golf's OTel default exporter setting if OTEL_TRACES_EXPORTER
1146 # is not already set
1147 if (
1148 settings.opentelemetry_enabled
1149 and settings.opentelemetry_default_exporter
1150 and "OTEL_TRACES_EXPORTER" not in env_vars_to_write
1151 ):
1152 env_vars_to_write["OTEL_TRACES_EXPORTER"] = settings.opentelemetry_default_exporter
1154 # 3. Apply Golf's project name as OTEL_SERVICE_NAME if not already set
1155 # (Ensures service name defaults to project name if not specified in user's .env)
1156 if settings.opentelemetry_enabled and settings.name and "OTEL_SERVICE_NAME" not in env_vars_to_write:
1157 env_vars_to_write["OTEL_SERVICE_NAME"] = settings.name
1159 # 4. (Re-)Write the .env file in the output directory if there's anything to write
1160 if env_vars_to_write:
1161 try:
1162 with open(env_file_path, "w") as f:
1163 for key, value in env_vars_to_write.items():
1164 # Ensure values are properly quoted if they contain spaces or special characters
1165 # and handle existing quotes within the value.
1166 if isinstance(value, str):
1167 # Replace backslashes first, then double quotes
1168 processed_value = value.replace("\\", "\\\\") # Escape backslashes
1169 processed_value = processed_value.replace('"', '\\"') # Escape double quotes
1170 if " " in value or "#" in value or "\n" in value or '"' in value or "'" in value:
1171 f.write(f'{key}="{processed_value}"\n')
1172 else:
1173 f.write(f"{key}={processed_value}\n")
1174 else: # For non-string values, write directly
1175 f.write(f"{key}={value}\n")
1176 except Exception as e:
1177 console.print(f"[yellow]Warning: Could not write .env file to output directory: {e}[/yellow]")
1178 # --- END Enhanced .env handling ---
1180 # Show what we're building, with environment info
1181 create_build_header(settings.name, build_env, console)
1183 # Generate the code
1184 generator = CodeGenerator(project_path, settings, output_dir, build_env=build_env, copy_env=copy_env)
1185 generator.generate()
1187 # Platform registration (only for prod builds)
1188 if build_env == "prod":
1189 console.print()
1190 status_msg = f"[{GOLF_BLUE}]{STATUS_ICONS['platform']} Registering with Golf platform and updating resources...[/{GOLF_BLUE}]"
1191 with console.status(status_msg):
1192 import asyncio
1194 try:
1195 from golf.core.platform import register_project_with_platform
1197 success = asyncio.run(
1198 register_project_with_platform(
1199 project_path=project_path,
1200 settings=settings,
1201 components=generator.components,
1202 )
1203 )
1205 if success:
1206 console.print(get_status_text("success", "Platform registration completed"))
1207 # If success is False, the platform module already printed appropriate warnings
1208 except ImportError:
1209 console.print(get_status_text("warning", "Platform registration module not available"))
1210 except Exception as e:
1211 console.print(get_status_text("warning", f"Platform registration failed: {e}"))
1212 console.print("[dim]Tip: Ensure GOLF_API_KEY and GOLF_SERVER_ID are available in your .env file[/dim]")
1214 # Create a simple README
1215 readme_content = f"""# {settings.name}
1217Generated FastMCP application ({build_env} environment).
1219## Running the server
1221```bash
1222cd {output_dir.name}
1223python server.py
1224```
1226This is a standalone FastMCP server generated by GolfMCP.
1227"""
1229 with open(output_dir / "README.md", "w") as f:
1230 f.write(readme_content)
1232 # Always copy the auth module so it's available
1233 auth_dir = output_dir / "golf" / "auth"
1234 auth_dir.mkdir(parents=True, exist_ok=True)
1236 # Create __init__.py with needed exports
1237 with open(auth_dir / "__init__.py", "w") as f:
1238 f.write(
1239 """\"\"\"Auth module for GolfMCP.\"\"\"
1241# Legacy ProviderConfig removed in Golf 0.2.x - use modern auth configurations
1242# Legacy OAuth imports removed in Golf 0.2.x - use FastMCP 2.11+ auth providers
1243from golf.auth.helpers import get_provider_token, extract_token_from_header, get_api_key, set_api_key
1244from golf.auth.api_key import configure_api_key, get_api_key_config
1245from golf.auth.factory import create_auth_provider
1246from golf.auth.providers import RemoteAuthConfig, JWTAuthConfig, StaticTokenConfig, OAuthServerConfig
1247"""
1248 )
1250 # Copy auth modules required for Golf 0.2.x
1251 for module in ["helpers.py", "api_key.py", "factory.py", "providers.py"]:
1252 src_file = Path(__file__).parent.parent.parent / "golf" / "auth" / module
1253 dst_file = auth_dir / module
1255 if src_file.exists():
1256 shutil.copy(src_file, dst_file)
1257 else:
1258 console.print(f"[yellow]Warning: Could not find {src_file} to copy[/yellow]")
1260 # Copy telemetry module if OpenTelemetry is enabled
1261 if settings.opentelemetry_enabled:
1262 telemetry_dir = output_dir / "golf" / "telemetry"
1263 telemetry_dir.mkdir(parents=True, exist_ok=True)
1265 # Copy telemetry __init__.py
1266 src_init = Path(__file__).parent.parent.parent / "golf" / "telemetry" / "__init__.py"
1267 dst_init = telemetry_dir / "__init__.py"
1268 if src_init.exists():
1269 shutil.copy(src_init, dst_init)
1271 # Copy instrumentation module
1272 src_instrumentation = Path(__file__).parent.parent.parent / "golf" / "telemetry" / "instrumentation.py"
1273 dst_instrumentation = telemetry_dir / "instrumentation.py"
1274 if src_instrumentation.exists():
1275 shutil.copy(src_instrumentation, dst_instrumentation)
1276 else:
1277 console.print("[yellow]Warning: Could not find telemetry instrumentation module[/yellow]")
1279 # Check if auth routes need to be added
1280 if is_auth_configured() or get_api_key_config():
1281 auth_routes_code = generate_auth_routes()
1283 server_file = output_dir / "server.py"
1284 if server_file.exists():
1285 with open(server_file) as f:
1286 server_code_content = f.read()
1288 # Add auth routes before the main block
1289 app_marker = 'if __name__ == "__main__":'
1290 app_pos = server_code_content.find(app_marker)
1291 if app_pos != -1:
1292 modified_code = (
1293 server_code_content[:app_pos] + auth_routes_code + "\n\n" + server_code_content[app_pos:]
1294 )
1296 # Format with black before writing
1297 try:
1298 final_code_to_write = black.format_str(modified_code, mode=black.Mode())
1299 except Exception as e:
1300 console.print(
1301 f"[yellow]Warning: Could not format server.py after auth routes injection: {e}[/yellow]"
1302 )
1303 final_code_to_write = modified_code
1305 with open(server_file, "w") as f:
1306 f.write(final_code_to_write)
1307 else:
1308 console.print(
1309 f"[yellow]Warning: Could not find main block marker '{app_marker}' in {server_file} to inject auth routes.[/yellow]"
1310 )
1313# Legacy function removed - replaced by parse_shared_files in parser module
1316# Updated to handle any shared file, not just common.py files
1317def build_import_map(project_path: Path, shared_files: dict[str, Path]) -> dict[str, str]:
1318 """Build a mapping of import paths to their new locations in the build output.
1320 This maps from original relative import paths to absolute import paths
1321 in the components directory structure.
1323 Args:
1324 project_path: Path to the project root
1325 shared_files: Dictionary mapping module paths to shared file paths
1326 """
1327 import_map = {}
1329 for module_path_str, file_path in shared_files.items():
1330 # Convert module path to Path object (e.g., "tools/weather/helpers" -> Path("tools/weather/helpers"))
1331 module_path = Path(module_path_str)
1333 # Get the component type (tools, resources, prompts)
1334 component_type = None
1335 for part in module_path.parts:
1336 if part in ["tools", "resources", "prompts"]:
1337 component_type = part
1338 break
1340 if not component_type:
1341 continue
1343 # Calculate the relative path within the component type
1344 try:
1345 rel_to_component = module_path.relative_to(component_type)
1346 # Create the new import path
1347 if str(rel_to_component) == ".":
1348 # This shouldn't happen for individual files, but handle it
1349 new_path = f"components.{component_type}"
1350 else:
1351 # Replace path separators with dots
1352 path_parts = str(rel_to_component).replace("\\", "/").split("/")
1353 new_path = f"components.{component_type}.{'.'.join(path_parts)}"
1355 # Map the specific shared module
1356 # e.g., "tools/weather/helpers" -> "components.tools.weather.helpers"
1357 import_map[module_path_str] = new_path
1359 # Also map the directory path for relative imports
1360 # e.g., "tools/weather" -> "components.tools.weather"
1361 dir_path_str = str(module_path.parent)
1362 if dir_path_str != "." and dir_path_str not in import_map:
1363 dir_rel_to_component = module_path.parent.relative_to(component_type)
1364 if str(dir_rel_to_component) == ".":
1365 dir_new_path = f"components.{component_type}"
1366 else:
1367 dir_path_parts = str(dir_rel_to_component).replace("\\", "/").split("/")
1368 dir_new_path = f"components.{component_type}.{'.'.join(dir_path_parts)}"
1369 import_map[dir_path_str] = dir_new_path
1371 except ValueError:
1372 continue
1374 return import_map