Coverage for /Users/antonigmitruk/golf/src/golf/core/builder.py: 0%

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, STATUS_ICONS, GOLF_BLUE

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

174 def build_manifest(project_path: Path, settings: Settings) -> dict[str, Any]:

175 """Build a FastMCP manifest from parsed components.

176

177 Args:

178 project_path: Path to the project root

179 settings: Project settings

180

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()

187

188

189 def compute_manifest_diff(old_manifest: dict[str, Any], new_manifest: dict[str, Any]) -> dict[str, Any]:

190 """Compute the difference between two manifests.

191

192 Args:

193 old_manifest: Previous manifest

194 new_manifest: New manifest

195

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 }

204

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}

208

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)

214

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"])

225

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)

231

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"])

242

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)

248

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"])

259

260 return diff

261

262

263 def has_changes(diff: dict[str, Any]) -> bool:

264 """Check if a manifest diff contains any changes.

265

266 Args:

267 diff: Manifest diff from compute_manifest_diff

268

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

276

277 return False

278

279

280 class CodeGenerator:

281 """Code generator for FastMCP applications."""

282

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.

292

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 = {}

309

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)

316

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)

321

322 # Create output directory structure

323 with console.status("Creating directory structure..."):

324 self._create_directory_structure()

325

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 ]

333

334 for description, func in tasks:

335 console.print(get_status_text("generating", description))

336 func()

337

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

345

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}"))

349

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 ]

360

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()

365

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)

373

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

380

381 if not component_type:

382 continue

383

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

387

388 # Create directory if it doesn't exist

389 target_dir.mkdir(parents=True, exist_ok=True)

390

391 # Create the shared file in the target directory (preserve original filename)

392 target_file = target_dir / shared_file.name

393

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 )

402

403 def _generate_tools(self) -> None:

404 """Generate code for all tools."""

405 tools_dir = self.output_dir / "components" / "tools"

406

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

413

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

420

421 tool_dir.mkdir(parents=True, exist_ok=True)

422

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)

426

427 def _generate_resources(self) -> None:

428 """Generate code for all resources."""

429 resources_dir = self.output_dir / "components" / "resources"

430

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

437

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

444

445 resource_dir.mkdir(parents=True, exist_ok=True)

446

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)

450

451 def _generate_prompts(self) -> None:

452 """Generate code for all prompts."""

453 prompts_dir = self.output_dir / "components" / "prompts"

454

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

461

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

468

469 prompt_dir.mkdir(parents=True, exist_ok=True)

470

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)

474

475 def _get_transport_config(self, transport_type: str) -> dict:

476 """Get transport-specific configuration (primarily for endpoint path display).

477

478 Args:

479 transport_type: The transport type (e.g., 'sse', 'streamable-http', 'stdio')

480

481 Returns:

482 Dictionary with transport configuration details (endpoint_path)

483 """

484 config = {

485 "endpoint_path": "",

486 }

487

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

495

496 return config

497

498 def _generate_server(self) -> None:

499 """Generate the main server entry point."""

500 server_file = self.output_dir / "server.py"

501

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 )

511

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 ]

531

532 # Add auth imports if auth is configured

533 if auth_components.get("has_auth"):

534 imports.extend(auth_components["imports"])

535 imports.append("")

536

537 # Add OpenTelemetry imports if enabled

538 if self.settings.opentelemetry_enabled:

539 imports.extend(generate_telemetry_imports())

540

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 )

548

549 imports.extend(generate_metrics_imports())

550 imports.extend(generate_metrics_instrumentation())

551 imports.extend(generate_session_tracking())

552

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 )

561

562 # Get transport-specific configuration

563 transport_config = self._get_transport_config(self.settings.transport)

564 endpoint_path = transport_config["endpoint_path"]

565

566 # Track component modules to register

567 component_registrations = []

568

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"

581

582 component_registrations.append(comp_section)

583

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

588

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"

622

623 # Clean up the import path

624 import_path = import_path.rstrip(".")

625

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}")

629

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 )

639

640 registration += (

641 f"\n_wrapped_func = instrument_{component_type.value}("

642 f"{full_module_path}.{entry_func}, '{component.name}')"

643 )

644

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 )

677

678 registration += (

679 f"\n_wrapped_func = instrument_{component_type.value}("

680 f"{full_module_path}.{entry_func}, '{component.name}')"

681 )

682

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}"

711

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"

720

721 # Add the name parameter

722 registration += f', name="{component.name}"'

723

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}"'

729

730 registration += ")"

731

732 # Add annotations if present

733 if hasattr(component, "annotations") and component.annotations:

734 registration += f"\n_tool = _tool.with_annotations({component.annotations})"

735

736 registration += "\nmcp.add_tool(_tool)"

737

738 elif component_type == ComponentType.RESOURCE:

739 registration = f"# Register the resource '{component.name}' from {full_module_path}"

740

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 )

755

756 # Add the name parameter

757 registration += f', name="{component.name}"'

758

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}"'

764

765 registration += ")\nmcp.add_resource(_resource)"

766

767 else: # PROMPT

768 registration = f"# Register the prompt '{component.name}' from {full_module_path}"

769

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"

778

779 # Add the name parameter

780 registration += f', name="{component.name}"'

781

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}"'

787

788 registration += ")\nmcp.add_prompt(_prompt)"

789

790 component_registrations.append(registration)

791

792 # Add a blank line after each section

793 imports.append("")

794 component_registrations.append("")

795

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 ]

804

805 # OpenTelemetry setup code will be handled through imports and lifespan

806

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"]

811

812 # Create FastMCP instance section

813 server_code_lines = ["# Create FastMCP server"]

814

815 # Build FastMCP constructor arguments

816 mcp_constructor_args = [f'"{self.settings.name}"']

817

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}")

822

823 # Add stateless HTTP parameter if enabled

824 if self.settings.stateless_http:

825 mcp_constructor_args.append("stateless_http=True")

826

827 # Add OpenTelemetry parameters if enabled

828 if self.settings.opentelemetry_enabled:

829 mcp_constructor_args.append("lifespan=telemetry_lifespan")

830

831 mcp_instance_line = f"mcp = FastMCP({', '.join(mcp_constructor_args)})"

832 server_code_lines.append(mcp_instance_line)

833 server_code_lines.append("")

834

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 )

847

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

852

853 early_metrics_init.extend(generate_metrics_initialization(self.settings.name))

854

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 ]

867

868 main_code.append("")

869

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 = []

875

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)")

880

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)")

885

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)")

891

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 )

913

914 elif self.settings.transport in ["streamable-http", "http"]:

915 # Check if we need middleware for streamable-http

916 middleware_setup = []

917 middleware_list = []

918

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)")

923

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)")

928

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)")

934

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)'])

959

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

964

965 metrics_route_code = generate_metrics_route(self.settings.metrics_path)

966

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 ]

978

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 )

995

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]")

1001

1002 # Write to file

1003 with open(server_file, "w") as f:

1004 f.write(code)

1005

1006

1007 def 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.

1015

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

1026

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)

1031

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]")

1037

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"

1042

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]")

1049

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()

1060

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))

1065

1066 # Execute the auth configuration script

1067 with open(config_path) as f:

1068 script_content = f.read()

1069

1070 # Print the first few lines for debugging

1071 "\n".join(script_content.split("\n")[:5]) + "\n..."

1072

1073 # Use exec to run the script as a module

1074 code = compile(script_content, str(config_path), "exec")

1075 exec(code, {})

1076

1077 except Exception as e:

1078 console.print(f"[red]Error executing {config_path.name}: {str(e)}[/red]")

1079 import traceback

1080

1081 console.print(f"[red]{traceback.format_exc()}[/red]")

1082

1083 # Track detailed error for auth.py execution failures

1084 try:

1085 from golf.core.telemetry import track_detailed_error

1086

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

1108

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

1113

1114 # --- BEGIN Enhanced .env handling ---

1115 env_vars_to_write = {}

1116 env_file_path = output_dir / ".env"

1117

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

1124

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

1138

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]")

1144

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

1153

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

1158

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 ---

1179

1180 # Show what we're building, with environment info

1181 create_build_header(settings.name, build_env, console)

1182

1183 # Generate the code

1184 generator = CodeGenerator(project_path, settings, output_dir, build_env=build_env, copy_env=copy_env)

1185 generator.generate()

1186

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

1193

1194 try:

1195 from golf.core.platform import register_project_with_platform

1196

1197 success = asyncio.run(

1198 register_project_with_platform(

1199 project_path=project_path,

1200 settings=settings,

1201 components=generator.components,

1202 )

1203 )

1204

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]")

1213

1214 # Create a simple README

1215 readme_content = f"""# {settings.name}

1216

1217 Generated FastMCP application ({build_env} environment).

1218

1219## Running the server

1220

1221```bash

1222 cd {output_dir.name}

1223 python server.py

1224```

1225

1226 This is a standalone FastMCP server generated by GolfMCP.

1227"""

1228

1229 with open(output_dir / "README.md", "w") as f:

1230 f.write(readme_content)

1231

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)

1235

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.\"\"\"

1240

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

1243 from golf.auth.helpers import get_provider_token, extract_token_from_header, get_api_key, set_api_key

1244 from golf.auth.api_key import configure_api_key, get_api_key_config

1245 from golf.auth.factory import create_auth_provider

1246 from golf.auth.providers import RemoteAuthConfig, JWTAuthConfig, StaticTokenConfig, OAuthServerConfig

1247"""

1248 )

1249

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

1254

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]")

1259

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)

1264

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)

1270

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]")

1278

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()

1282

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()

1287

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 )

1295

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

1304

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 )

1311

1312

1313# Legacy function removed - replaced by parse_shared_files in parser module

1314

1315

1316# Updated to handle any shared file, not just common.py files

1317 def 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.

1319

1320 This maps from original relative import paths to absolute import paths

1321 in the components directory structure.

1322

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 = {}

1328

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)

1332

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

1339

1340 if not component_type:

1341 continue

1342

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)}"

1354

1355 # Map the specific shared module

1356 # e.g., "tools/weather/helpers" -> "components.tools.weather.helpers"

1357 import_map[module_path_str] = new_path

1358

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

1370

1371 except ValueError:

1372 continue

1373

1374 return import_map