golf-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of golf-mcp might be problematic. Click here for more details.

Files changed (41) hide show
  1. golf/__init__.py +1 -0
  2. golf/auth/__init__.py +109 -0
  3. golf/auth/helpers.py +56 -0
  4. golf/auth/oauth.py +798 -0
  5. golf/auth/provider.py +110 -0
  6. golf/cli/__init__.py +1 -0
  7. golf/cli/main.py +223 -0
  8. golf/commands/__init__.py +3 -0
  9. golf/commands/build.py +78 -0
  10. golf/commands/init.py +197 -0
  11. golf/commands/run.py +68 -0
  12. golf/core/__init__.py +1 -0
  13. golf/core/builder.py +1169 -0
  14. golf/core/builder_auth.py +157 -0
  15. golf/core/builder_telemetry.py +208 -0
  16. golf/core/config.py +205 -0
  17. golf/core/parser.py +509 -0
  18. golf/core/transformer.py +168 -0
  19. golf/examples/__init__.py +1 -0
  20. golf/examples/basic/.env +3 -0
  21. golf/examples/basic/.env.example +3 -0
  22. golf/examples/basic/README.md +117 -0
  23. golf/examples/basic/golf.json +9 -0
  24. golf/examples/basic/pre_build.py +28 -0
  25. golf/examples/basic/prompts/welcome.py +30 -0
  26. golf/examples/basic/resources/current_time.py +41 -0
  27. golf/examples/basic/resources/info.py +27 -0
  28. golf/examples/basic/resources/weather/common.py +48 -0
  29. golf/examples/basic/resources/weather/current.py +32 -0
  30. golf/examples/basic/resources/weather/forecast.py +32 -0
  31. golf/examples/basic/tools/github_user.py +67 -0
  32. golf/examples/basic/tools/hello.py +29 -0
  33. golf/examples/basic/tools/payments/charge.py +50 -0
  34. golf/examples/basic/tools/payments/common.py +34 -0
  35. golf/examples/basic/tools/payments/refund.py +50 -0
  36. golf_mcp-0.1.0.dist-info/METADATA +78 -0
  37. golf_mcp-0.1.0.dist-info/RECORD +41 -0
  38. golf_mcp-0.1.0.dist-info/WHEEL +5 -0
  39. golf_mcp-0.1.0.dist-info/entry_points.txt +2 -0
  40. golf_mcp-0.1.0.dist-info/licenses/LICENSE +201 -0
  41. golf_mcp-0.1.0.dist-info/top_level.txt +1 -0
golf/core/builder.py ADDED
@@ -0,0 +1,1169 @@
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, Dict, List, Optional, Set
9
+
10
+ import black
11
+ from rich.console import Console
12
+ from rich.progress import Progress, SpinnerColumn, TextColumn
13
+ from rich.panel import Panel
14
+
15
+ from golf.core.config import Settings
16
+ from golf.core.parser import (
17
+ ComponentType,
18
+ ParsedComponent,
19
+ parse_project,
20
+ )
21
+ from golf.core.transformer import transform_component
22
+ from golf.core.builder_auth import generate_auth_code, generate_auth_routes
23
+ from golf.auth import get_auth_config
24
+ from golf.auth import get_access_token
25
+ from golf.core.builder_telemetry import (
26
+ generate_otel_lifespan_code,
27
+ generate_otel_instrumentation_code,
28
+ get_otel_dependencies
29
+ )
30
+
31
+ console = Console()
32
+
33
+
34
+ class ManifestBuilder:
35
+ """Builds FastMCP manifest from parsed components."""
36
+
37
+ def __init__(self, project_path: Path, settings: Settings):
38
+ """Initialize the manifest builder.
39
+
40
+ Args:
41
+ project_path: Path to the project root
42
+ settings: Project settings
43
+ """
44
+ self.project_path = project_path
45
+ self.settings = settings
46
+ self.components: Dict[ComponentType, List[ParsedComponent]] = {}
47
+ self.manifest: Dict[str, Any] = {
48
+ "name": settings.name,
49
+ "description": settings.description or "",
50
+ "tools": [],
51
+ "resources": [],
52
+ "prompts": []
53
+ }
54
+
55
+ def build(self) -> Dict[str, Any]:
56
+ """Build the complete manifest.
57
+
58
+ Returns:
59
+ FastMCP manifest dictionary
60
+ """
61
+ # Parse all components
62
+ self.components = parse_project(self.project_path)
63
+
64
+ # Process each component type
65
+ self._process_tools()
66
+ self._process_resources()
67
+ self._process_prompts()
68
+
69
+ return self.manifest
70
+
71
+ def _process_tools(self) -> None:
72
+ """Process all tool components and add them to the manifest."""
73
+ for component in self.components[ComponentType.TOOL]:
74
+ # Extract the properties directly from the Input schema if it exists
75
+ input_properties = {}
76
+ required_fields = []
77
+
78
+ if component.input_schema and "properties" in component.input_schema:
79
+ input_properties = component.input_schema["properties"]
80
+ # Get required fields if they exist
81
+ if "required" in component.input_schema:
82
+ required_fields = component.input_schema["required"]
83
+
84
+ # Create a flattened tool schema matching FastMCP documentation examples
85
+ tool_schema = {
86
+ "name": component.name,
87
+ "description": component.docstring or "",
88
+ "inputSchema": {
89
+ "type": "object",
90
+ "properties": input_properties,
91
+ "additionalProperties": False,
92
+ "$schema": "http://json-schema.org/draft-07/schema#"
93
+ },
94
+ "annotations": {
95
+ "title": component.name.replace('-', ' ').title()
96
+ },
97
+ "entry_function": component.entry_function
98
+ }
99
+
100
+ # Include required fields if they exist
101
+ if required_fields:
102
+ tool_schema["inputSchema"]["required"] = required_fields
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 the run function
128
+ # to get the actual messages, so we just register it by name
129
+ prompt_schema = {
130
+ "name": component.name,
131
+ "description": component.docstring or "",
132
+ "entry_function": component.entry_function
133
+ }
134
+
135
+ # If the prompt has parameters, include them
136
+ if component.parameters:
137
+ arguments = []
138
+ for param in component.parameters:
139
+ arguments.append({
140
+ "name": param,
141
+ "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: Optional[Path] = 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(
190
+ old_manifest: Dict[str, Any], new_manifest: Dict[str, Any]
191
+ ) -> Dict[str, Any]:
192
+ """Compute the difference between two manifests.
193
+
194
+ Args:
195
+ old_manifest: Previous manifest
196
+ new_manifest: New manifest
197
+
198
+ Returns:
199
+ Dictionary describing the changes
200
+ """
201
+ diff = {
202
+ "tools": {
203
+ "added": [],
204
+ "removed": [],
205
+ "changed": []
206
+ },
207
+ "resources": {
208
+ "added": [],
209
+ "removed": [],
210
+ "changed": []
211
+ },
212
+ "prompts": {
213
+ "added": [],
214
+ "removed": [],
215
+ "changed": []
216
+ }
217
+ }
218
+
219
+ # Helper function to extract names from a list of components
220
+ def extract_names(components: List[Dict[str, Any]]) -> Set[str]:
221
+ return {comp["name"] for comp in components}
222
+
223
+ # Compare tools
224
+ old_tools = extract_names(old_manifest.get("tools", []))
225
+ new_tools = extract_names(new_manifest.get("tools", []))
226
+ diff["tools"]["added"] = list(new_tools - old_tools)
227
+ diff["tools"]["removed"] = list(old_tools - new_tools)
228
+
229
+ # Compare tools that exist in both for changes
230
+ for new_tool in new_manifest.get("tools", []):
231
+ if new_tool["name"] in old_tools:
232
+ # Find the corresponding old tool
233
+ old_tool = next((t for t in old_manifest.get("tools", []) if t["name"] == new_tool["name"]), None)
234
+ if old_tool and json.dumps(old_tool) != json.dumps(new_tool):
235
+ diff["tools"]["changed"].append(new_tool["name"])
236
+
237
+ # Compare resources
238
+ old_resources = extract_names(old_manifest.get("resources", []))
239
+ new_resources = extract_names(new_manifest.get("resources", []))
240
+ diff["resources"]["added"] = list(new_resources - old_resources)
241
+ diff["resources"]["removed"] = list(old_resources - new_resources)
242
+
243
+ # Compare resources that exist in both for changes
244
+ for new_resource in new_manifest.get("resources", []):
245
+ if new_resource["name"] in old_resources:
246
+ # Find the corresponding old resource
247
+ old_resource = next((r for r in old_manifest.get("resources", []) if r["name"] == new_resource["name"]), None)
248
+ if old_resource and json.dumps(old_resource) != json.dumps(new_resource):
249
+ diff["resources"]["changed"].append(new_resource["name"])
250
+
251
+ # Compare prompts
252
+ old_prompts = extract_names(old_manifest.get("prompts", []))
253
+ new_prompts = extract_names(new_manifest.get("prompts", []))
254
+ diff["prompts"]["added"] = list(new_prompts - old_prompts)
255
+ diff["prompts"]["removed"] = list(old_prompts - new_prompts)
256
+
257
+ # Compare prompts that exist in both for changes
258
+ for new_prompt in new_manifest.get("prompts", []):
259
+ if new_prompt["name"] in old_prompts:
260
+ # Find the corresponding old prompt
261
+ old_prompt = next((p for p in old_manifest.get("prompts", []) if p["name"] == new_prompt["name"]), None)
262
+ if old_prompt and json.dumps(old_prompt) != json.dumps(new_prompt):
263
+ diff["prompts"]["changed"].append(new_prompt["name"])
264
+
265
+ return diff
266
+
267
+
268
+ def has_changes(diff: Dict[str, Any]) -> bool:
269
+ """Check if a manifest diff contains any changes.
270
+
271
+ Args:
272
+ diff: Manifest diff from compute_manifest_diff
273
+
274
+ Returns:
275
+ True if there are any changes, False otherwise
276
+ """
277
+ for category in diff:
278
+ for change_type in diff[category]:
279
+ if diff[category][change_type]:
280
+ return True
281
+
282
+ return False
283
+
284
+
285
+ class CodeGenerator:
286
+ """Code generator for FastMCP applications."""
287
+
288
+ def __init__(self, project_path: Path, settings: Settings, output_dir: Path, build_env: str = "prod", copy_env: bool = False):
289
+ """Initialize the code generator.
290
+
291
+ Args:
292
+ project_path: Path to the project root
293
+ settings: Project settings
294
+ output_dir: Directory to output the generated code
295
+ build_env: Build environment ('dev' or 'prod')
296
+ copy_env: Whether to copy environment variables to the built app
297
+ """
298
+ self.project_path = project_path
299
+ self.settings = settings
300
+ self.output_dir = output_dir
301
+ self.build_env = build_env
302
+ self.copy_env = copy_env
303
+ self.components = {}
304
+ self.manifest = {}
305
+ self.common_files = {}
306
+ self.import_map = {}
307
+
308
+ def generate(self) -> None:
309
+ """Generate the FastMCP application code."""
310
+ # Parse the project and build the manifest
311
+ with console.status("Analyzing project components..."):
312
+ self.components = parse_project(self.project_path)
313
+ self.manifest = build_manifest(self.project_path, self.settings)
314
+
315
+ # Find common.py files and build import map
316
+ self.common_files = find_common_files(self.project_path, self.components)
317
+ self.import_map = build_import_map(self.project_path, self.common_files)
318
+
319
+ # Create output directory structure
320
+ with console.status("Creating directory structure..."):
321
+ self._create_directory_structure()
322
+
323
+ # Generate code for all components
324
+ with Progress(
325
+ SpinnerColumn(),
326
+ TextColumn("[bold green]Generating {task.description}"),
327
+ console=console,
328
+ ) as progress:
329
+ tasks = [
330
+ ("tools", self._generate_tools),
331
+ ("resources", self._generate_resources),
332
+ ("prompts", self._generate_prompts),
333
+ ("server entry point", self._generate_server),
334
+ ]
335
+
336
+ for description, func in tasks:
337
+ task = progress.add_task(description, total=1)
338
+ func()
339
+ progress.update(task, completed=1)
340
+
341
+ # Get relative path for display
342
+ try:
343
+ output_dir_display = self.output_dir.relative_to(Path.cwd())
344
+ except ValueError:
345
+ output_dir_display = self.output_dir
346
+
347
+ # Show success message with output directory
348
+ console.print(f"[bold green]✓[/bold green] Build completed successfully in [bold]{output_dir_display}[/bold]")
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 common.py files directly in the components directory
364
+ self._process_common_files()
365
+
366
+ def _process_common_files(self) -> None:
367
+ """Process and transform common.py files in the components directory structure."""
368
+ # Reuse the already fetched common_files instead of calling the function again
369
+ for dir_path_str, common_file in self.common_files.items():
370
+ # Convert string path to Path object
371
+ dir_path = Path(dir_path_str)
372
+
373
+ # Determine the component type
374
+ component_type = None
375
+ for part in dir_path.parts:
376
+ if part in ["tools", "resources", "prompts"]:
377
+ component_type = part
378
+ break
379
+
380
+ if not component_type:
381
+ continue
382
+
383
+ # Calculate target directory in components structure
384
+ rel_to_component = dir_path.relative_to(component_type)
385
+ target_dir = self.output_dir / "components" / component_type / rel_to_component
386
+
387
+ # Create directory if it doesn't exist
388
+ target_dir.mkdir(parents=True, exist_ok=True)
389
+
390
+ # Create the common.py file in the target directory
391
+ target_file = target_dir / "common.py"
392
+
393
+ # Use transformer to process the file
394
+ transform_component(
395
+ component=None,
396
+ output_file=target_file,
397
+ project_path=self.project_path,
398
+ import_map=self.import_map,
399
+ source_file=common_file
400
+ )
401
+
402
+ def _generate_tools(self) -> None:
403
+ """Generate code for all tools."""
404
+ tools_dir = self.output_dir / "components" / "tools"
405
+
406
+ for tool in self.components.get(ComponentType.TOOL, []):
407
+ # Get the tool directory structure
408
+ rel_path = Path(tool.file_path).relative_to(self.project_path)
409
+ if not rel_path.is_relative_to(Path(self.settings.tools_dir)):
410
+ console.print(f"[yellow]Warning: Tool {tool.name} is not in the tools directory[/yellow]")
411
+ continue
412
+
413
+ try:
414
+ rel_to_tools = rel_path.relative_to(self.settings.tools_dir)
415
+ tool_dir = tools_dir / rel_to_tools.parent
416
+ except ValueError:
417
+ # Fall back to just using the filename
418
+ tool_dir = tools_dir
419
+
420
+ tool_dir.mkdir(parents=True, exist_ok=True)
421
+
422
+ # Create the tool file
423
+ output_file = tool_dir / rel_path.name
424
+ transform_component(
425
+ tool,
426
+ output_file,
427
+ self.project_path,
428
+ self.import_map
429
+ )
430
+
431
+ def _generate_resources(self) -> None:
432
+ """Generate code for all resources."""
433
+ resources_dir = self.output_dir / "components" / "resources"
434
+
435
+ for resource in self.components.get(ComponentType.RESOURCE, []):
436
+ # Get the resource directory structure
437
+ rel_path = Path(resource.file_path).relative_to(self.project_path)
438
+ if not rel_path.is_relative_to(Path(self.settings.resources_dir)):
439
+ console.print(f"[yellow]Warning: Resource {resource.name} is not in the resources directory[/yellow]")
440
+ continue
441
+
442
+ try:
443
+ rel_to_resources = rel_path.relative_to(self.settings.resources_dir)
444
+ resource_dir = resources_dir / rel_to_resources.parent
445
+ except ValueError:
446
+ # Fall back to just using the filename
447
+ resource_dir = resources_dir
448
+
449
+ resource_dir.mkdir(parents=True, exist_ok=True)
450
+
451
+ # Create the resource file
452
+ output_file = resource_dir / rel_path.name
453
+ transform_component(
454
+ resource,
455
+ output_file,
456
+ self.project_path,
457
+ self.import_map
458
+ )
459
+
460
+ def _generate_prompts(self) -> None:
461
+ """Generate code for all prompts."""
462
+ prompts_dir = self.output_dir / "components" / "prompts"
463
+
464
+ for prompt in self.components.get(ComponentType.PROMPT, []):
465
+ # Get the prompt directory structure
466
+ rel_path = Path(prompt.file_path).relative_to(self.project_path)
467
+ if not rel_path.is_relative_to(Path(self.settings.prompts_dir)):
468
+ console.print(f"[yellow]Warning: Prompt {prompt.name} is not in the prompts directory[/yellow]")
469
+ continue
470
+
471
+ try:
472
+ rel_to_prompts = rel_path.relative_to(self.settings.prompts_dir)
473
+ prompt_dir = prompts_dir / rel_to_prompts.parent
474
+ except ValueError:
475
+ # Fall back to just using the filename
476
+ prompt_dir = prompts_dir
477
+
478
+ prompt_dir.mkdir(parents=True, exist_ok=True)
479
+
480
+ # Create the prompt file
481
+ output_file = prompt_dir / rel_path.name
482
+ transform_component(
483
+ prompt,
484
+ output_file,
485
+ self.project_path,
486
+ self.import_map
487
+ )
488
+
489
+ def _get_transport_config(self, transport_type: str) -> dict:
490
+ """Get transport-specific configuration (primarily for endpoint path display).
491
+
492
+ Args:
493
+ transport_type: The transport type (e.g., 'sse', 'streamable-http', 'stdio')
494
+
495
+ Returns:
496
+ Dictionary with transport configuration details (endpoint_path)
497
+ """
498
+ config = {
499
+ "endpoint_path": "",
500
+ }
501
+
502
+ if transport_type == "sse":
503
+ config["endpoint_path"] = "/sse" # Default SSE path for FastMCP
504
+ elif transport_type == "stdio":
505
+ config["endpoint_path"] = "" # No HTTP endpoint
506
+ else:
507
+ # Default to streamable-http
508
+ config["endpoint_path"] = "/mcp" # Default MCP path for FastMCP
509
+
510
+ return config
511
+
512
+ def _generate_server(self) -> None:
513
+ """Generate the main server entry point."""
514
+ server_file = self.output_dir / "server.py"
515
+
516
+ # Create imports section
517
+ imports = [
518
+ "from fastmcp import FastMCP",
519
+ "import os",
520
+ "import sys",
521
+ "from dotenv import load_dotenv",
522
+ ""
523
+ ]
524
+
525
+ # For imports
526
+ if self.settings.opentelemetry_enabled:
527
+ imports.extend([
528
+ "# OpenTelemetry imports",
529
+ "from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware",
530
+ "from starlette.middleware import Middleware",
531
+ # otel_lifespan function will be defined from generate_otel_lifespan_code
532
+ ])
533
+ imports.append("") # Add blank line after all component type imports or OTel imports
534
+
535
+ # Add imports section for different transport methods
536
+ if self.settings.transport == "sse":
537
+ imports.append("import uvicorn")
538
+ imports.append("from fastmcp.server.http import create_sse_app")
539
+ elif self.settings.transport != "stdio":
540
+ imports.append("import uvicorn")
541
+
542
+ # Create a new FastMCP instance for the server
543
+ server_code_lines = ["# Create FastMCP server"]
544
+ mcp_constructor_args = [f'"{self.settings.name}"']
545
+
546
+ mcp_instance_line = f"mcp = FastMCP({', '.join(mcp_constructor_args)})"
547
+ server_code_lines.append(mcp_instance_line)
548
+ server_code_lines.append("")
549
+
550
+ # Get transport-specific configuration
551
+ transport_config = self._get_transport_config(self.settings.transport)
552
+ endpoint_path = transport_config["endpoint_path"]
553
+
554
+ # Track component modules to register
555
+ component_registrations = []
556
+
557
+ # Import components
558
+ for component_type in self.components:
559
+ # Add a section header
560
+ if component_type == ComponentType.TOOL:
561
+ imports.append("# Import tools")
562
+ comp_section = "# Register tools"
563
+ elif component_type == ComponentType.RESOURCE:
564
+ imports.append("# Import resources")
565
+ comp_section = "# Register resources"
566
+ else:
567
+ imports.append("# Import prompts")
568
+ comp_section = "# Register prompts"
569
+
570
+ component_registrations.append(comp_section)
571
+
572
+ for component in self.components[component_type]:
573
+ # Derive the import path based on component type and file path
574
+ rel_path = Path(component.file_path).relative_to(self.project_path)
575
+ module_name = rel_path.stem
576
+
577
+ if component_type == ComponentType.TOOL:
578
+ try:
579
+ rel_to_tools = rel_path.relative_to(self.settings.tools_dir)
580
+ # Handle nested directories properly
581
+ if rel_to_tools.parent != Path("."):
582
+ parent_path = str(rel_to_tools.parent).replace("\\", ".").replace("/", ".")
583
+ import_path = f"components.tools.{parent_path}"
584
+ else:
585
+ import_path = "components.tools"
586
+ except ValueError:
587
+ import_path = "components.tools"
588
+ elif component_type == ComponentType.RESOURCE:
589
+ try:
590
+ rel_to_resources = rel_path.relative_to(self.settings.resources_dir)
591
+ # Handle nested directories properly
592
+ if rel_to_resources.parent != Path("."):
593
+ parent_path = str(rel_to_resources.parent).replace("\\", ".").replace("/", ".")
594
+ import_path = f"components.resources.{parent_path}"
595
+ else:
596
+ import_path = "components.resources"
597
+ except ValueError:
598
+ import_path = "components.resources"
599
+ else: # PROMPT
600
+ try:
601
+ rel_to_prompts = rel_path.relative_to(self.settings.prompts_dir)
602
+ # Handle nested directories properly
603
+ if rel_to_prompts.parent != Path("."):
604
+ parent_path = str(rel_to_prompts.parent).replace("\\", ".").replace("/", ".")
605
+ import_path = f"components.prompts.{parent_path}"
606
+ else:
607
+ import_path = "components.prompts"
608
+ except ValueError:
609
+ import_path = "components.prompts"
610
+
611
+ # Clean up the import path
612
+ import_path = import_path.rstrip(".")
613
+
614
+ # Add the import for the component's module
615
+ full_module_path = f"{import_path}.{module_name}"
616
+ imports.append(f"import {full_module_path}")
617
+
618
+ # Add code to register this component
619
+ if component_type == ComponentType.TOOL:
620
+ registration = f"# Register the tool '{component.name}' from {full_module_path}"
621
+
622
+ # Use the entry_function if available, otherwise try the export variable
623
+ if hasattr(component, "entry_function") and component.entry_function:
624
+ registration += f"\nmcp.add_tool({full_module_path}.{component.entry_function}"
625
+ else:
626
+ registration += f"\nmcp.add_tool({full_module_path}.export"
627
+
628
+ # Add description from docstring
629
+ if component.docstring:
630
+ # Escape any quotes in the docstring
631
+ escaped_docstring = component.docstring.replace("\"", "\\\"")
632
+ registration += f", description=\"{escaped_docstring}\""
633
+ registration += ")"
634
+
635
+ elif component_type == ComponentType.RESOURCE:
636
+ registration = f"# Register the resource '{component.name}' from {full_module_path}"
637
+
638
+ # Use the entry_function if available, otherwise try the export variable
639
+ if hasattr(component, "entry_function") and component.entry_function:
640
+ registration += f"\nmcp.add_resource_fn({full_module_path}.{component.entry_function}, uri=\"{component.uri_template}\""
641
+ else:
642
+ registration += f"\nmcp.add_resource_fn({full_module_path}.export, uri=\"{component.uri_template}\""
643
+
644
+ # Add description from docstring
645
+ if component.docstring:
646
+ # Escape any quotes in the docstring
647
+ escaped_docstring = component.docstring.replace("\"", "\\\"")
648
+ registration += f", description=\"{escaped_docstring}\""
649
+ registration += ")"
650
+
651
+ else: # PROMPT
652
+ registration = f"# Register the prompt '{component.name}' from {full_module_path}"
653
+
654
+ # Use the entry_function if available, otherwise try the export variable
655
+ if hasattr(component, "entry_function") and component.entry_function:
656
+ registration += f"\nmcp.add_prompt({full_module_path}.{component.entry_function}"
657
+ else:
658
+ registration += f"\nmcp.add_prompt({full_module_path}.export"
659
+
660
+ # Add description from docstring
661
+ if component.docstring:
662
+ # Escape any quotes in the docstring
663
+ escaped_docstring = component.docstring.replace("\"", "\\\"")
664
+ registration += f", description=\"{escaped_docstring}\""
665
+ registration += ")"
666
+
667
+ component_registrations.append(registration)
668
+
669
+ # Add a blank line after each section
670
+ imports.append("")
671
+ component_registrations.append("")
672
+
673
+ # Create environment section based on build type - moved after imports
674
+ env_section = [
675
+ "",
676
+ "# Load environment variables from .env file if it exists",
677
+ "# Note: dotenv will not override existing environment variables by default",
678
+ "load_dotenv()",
679
+ ""
680
+ ]
681
+
682
+ # After env_section, add OpenTelemetry lifespan code
683
+ otel_definitions_code = []
684
+ otel_instrumentation_application_code = [] # For instrumentation that runs after mcp is set up
685
+
686
+ if self.settings.opentelemetry_enabled:
687
+ otel_definitions_code.append(generate_otel_lifespan_code(
688
+ default_exporter=self.settings.opentelemetry_default_exporter,
689
+ project_name=self.settings.name
690
+ ))
691
+ otel_definitions_code.append("") # Add blank line
692
+
693
+ # Prepare instrumentation code to be added after component registration
694
+ otel_instrumentation_application_code.append("# Apply OpenTelemetry Instrumentation")
695
+ otel_instrumentation_application_code.append(generate_otel_instrumentation_code())
696
+ otel_instrumentation_application_code.append("")
697
+
698
+ # Main entry point with transport-specific app initialization
699
+ main_code = [
700
+ "if __name__ == \"__main__\":",
701
+ " from rich.console import Console",
702
+ " from rich.panel import Panel",
703
+ " console = Console()",
704
+ " # Get configuration from environment variables or use defaults",
705
+ " host = os.environ.get(\"HOST\", \"127.0.0.1\")",
706
+ " port = int(os.environ.get(\"PORT\", 3000))",
707
+ f" transport_to_run = \"{self.settings.transport}\"",
708
+ ""
709
+ ]
710
+
711
+ # Add startup message
712
+ if self.settings.transport != "stdio":
713
+ main_code.append(f' console.print(Panel.fit(f"[bold green]{{mcp.name}}[/bold green]\\n[dim]Running on http://{{host}}:{{port}}{endpoint_path} with transport \\"{{transport_to_run}}\\" (environment: {self.build_env})[/dim]", border_style="green"))')
714
+ else:
715
+ main_code.append(f' console.print(Panel.fit(f"[bold green]{{mcp.name}}[/bold green]\\n[dim]Running with transport \\"{{transport_to_run}}\\" (environment: {self.build_env})[/dim]", border_style="green"))')
716
+
717
+ main_code.append("")
718
+
719
+ # Transport-specific run methods
720
+ if self.settings.transport == "sse":
721
+ main_code.extend([
722
+ " # For SSE, FastMCP's run method handles auth integration better",
723
+ " print(f\"[Server Runner] Using mcp.run() for SSE transport with host={host}, port={port}\", file=sys.stderr)",
724
+ " mcp.run(transport=\"sse\", host=host, port=port, log_level=\"debug\")"
725
+ ])
726
+ elif self.settings.transport == "streamable-http":
727
+ main_code.extend([
728
+ " # Create HTTP app and run with uvicorn",
729
+ " print(f\"[Server Runner] Starting streamable-http transport with host={host}, port={port}\", file=sys.stderr)",
730
+ " app = mcp.http_app()",
731
+ " uvicorn.run(app, host=host, port=port, log_level=\"debug\")"
732
+ ])
733
+ else:
734
+ # For stdio transport, use mcp.run()
735
+ main_code.extend([
736
+ " # Run with stdio transport",
737
+ " print(f\"[Server Runner] Starting stdio transport\", file=sys.stderr)",
738
+ " mcp.run(transport=\"stdio\")"
739
+ ])
740
+
741
+ # Combine all sections - move env_section to right location
742
+ # Order: imports, env_section, otel_definitions (lifespan func), server_code (mcp init),
743
+ # component_registrations, otel_instrumentation (wrappers), main_code (run block)
744
+ code = "\n".join(
745
+ imports +
746
+ env_section +
747
+ otel_definitions_code +
748
+ server_code_lines + # Add back the server_code_lines with constructor
749
+ component_registrations +
750
+ otel_instrumentation_application_code + # Added instrumentation here
751
+ main_code
752
+ )
753
+
754
+ # Format with black
755
+ try:
756
+ code = black.format_str(code, mode=black.Mode())
757
+ except Exception as e:
758
+ console.print(f"[yellow]Warning: Could not format server.py: {e}[/yellow]")
759
+
760
+ # Write to file
761
+ with open(server_file, "w") as f:
762
+ f.write(code)
763
+
764
+
765
+ def build_project(
766
+ project_path: Path,
767
+ settings: Settings,
768
+ output_dir: Path,
769
+ build_env: str = "prod",
770
+ copy_env: bool = False
771
+ ) -> None:
772
+ """Build a standalone FastMCP application from a GolfMCP project.
773
+
774
+ Args:
775
+ project_path: Path to the project directory
776
+ settings: Project settings
777
+ output_dir: Output directory for the built application
778
+ build_env: Build environment ('dev' or 'prod')
779
+ copy_env: Whether to copy environment variables to the built app
780
+ """
781
+ # Execute pre_build.py if it exists
782
+ pre_build_path = project_path / "pre_build.py"
783
+ if pre_build_path.exists():
784
+ try:
785
+ # Save the current directory and path
786
+ original_dir = os.getcwd()
787
+ original_path = sys.path.copy()
788
+
789
+ # Change to the project directory and add it to Python path
790
+ os.chdir(project_path)
791
+ sys.path.insert(0, str(project_path))
792
+
793
+ # Execute the pre_build script
794
+ with open(pre_build_path) as f:
795
+ script_content = f.read()
796
+
797
+ # Print the first few lines for debugging
798
+ preview = "\n".join(script_content.split("\n")[:5]) + "\n..."
799
+
800
+ # Use exec to run the script as a module
801
+ code = compile(script_content, str(pre_build_path), 'exec')
802
+ exec(code, {})
803
+
804
+ # Check if auth was configured by the script
805
+ provider, scopes = get_auth_config()
806
+
807
+ # Restore original directory and path
808
+ os.chdir(original_dir)
809
+ sys.path = original_path
810
+
811
+ except Exception as e:
812
+ console.print(f"[red]Error executing pre_build.py: {str(e)}[/red]")
813
+ import traceback
814
+ console.print(f"[red]{traceback.format_exc()}[/red]")
815
+
816
+ # Clear the output directory if it exists
817
+ if output_dir.exists():
818
+ shutil.rmtree(output_dir)
819
+ output_dir.mkdir(parents=True, exist_ok=True) # Ensure output_dir exists after clearing
820
+
821
+ # If dev build and copy_env flag is true, copy .env file from project root to output_dir
822
+ if copy_env: # The build_env string ('dev'/'prod') check can be done in the CLI layer that sets copy_env
823
+ project_env_file = project_path / ".env"
824
+ if project_env_file.exists():
825
+ try:
826
+ shutil.copy(project_env_file, output_dir / ".env")
827
+ except Exception as e:
828
+ console.print(f"[yellow]Warning: Could not copy project .env file: {e}[/yellow]")
829
+
830
+ # Show what we're building, with environment info
831
+ console.print(f"[bold]Building [green]{settings.name}[/green] ({build_env} environment)[/bold]")
832
+
833
+ # Generate the code
834
+ generator = CodeGenerator(
835
+ project_path,
836
+ settings,
837
+ output_dir,
838
+ build_env=build_env,
839
+ copy_env=copy_env
840
+ )
841
+ generator.generate()
842
+
843
+ # Create a simple README
844
+ readme_content = f"""# {settings.name}
845
+
846
+ Generated FastMCP application ({build_env} environment).
847
+
848
+ ## Running the server
849
+
850
+ ```bash
851
+ cd {output_dir.name}
852
+ python server.py
853
+ ```
854
+
855
+ This is a standalone FastMCP server generated by GolfMCP.
856
+ """
857
+
858
+ with open(output_dir / "README.md", "w") as f:
859
+ f.write(readme_content)
860
+
861
+ # Copy pyproject.toml with required dependencies
862
+ base_dependencies = [
863
+ "fastmcp>=2.0.0",
864
+ "uvicorn>=0.20.0",
865
+ "pydantic>=2.0.0",
866
+ "python-dotenv>=1.0.0",
867
+ ]
868
+
869
+ # Add OpenTelemetry dependencies if enabled
870
+ if settings.opentelemetry_enabled:
871
+ base_dependencies.extend(get_otel_dependencies())
872
+
873
+ # Add authentication dependencies if enabled, before generating pyproject_content
874
+ provider_config, required_scopes = get_auth_config() # Ensure this is called to check for auth
875
+ if provider_config:
876
+ base_dependencies.extend([
877
+ "pyjwt>=2.0.0",
878
+ "httpx>=0.20.0",
879
+ ])
880
+
881
+ # Create the dependencies string
882
+ dependencies_str = ",\n ".join([f'"{dep}"' for dep in base_dependencies])
883
+
884
+ pyproject_content = f"""[build-system]
885
+ requires = ["setuptools>=61.0"]
886
+ build-backend = "setuptools.build_meta"
887
+
888
+ [project]
889
+ name = "generated-fastmcp-app"
890
+ version = "0.1.0"
891
+ description = "Generated FastMCP Application"
892
+ requires-python = ">=3.10"
893
+ dependencies = [
894
+ {dependencies_str}
895
+ ]
896
+ """
897
+
898
+ with open(output_dir / "pyproject.toml", "w") as f:
899
+ f.write(pyproject_content)
900
+
901
+
902
+ # Always copy the auth module so it's available
903
+ auth_dir = output_dir / "golf" / "auth"
904
+ auth_dir.mkdir(parents=True, exist_ok=True)
905
+
906
+ # Create __init__.py with needed exports
907
+ with open(auth_dir / "__init__.py", "w") as f:
908
+ f.write("""\"\"\"Auth module for GolfMCP.\"\"\"
909
+
910
+ from golf.auth.provider import ProviderConfig
911
+ from golf.auth.oauth import GolfOAuthProvider, create_callback_handler
912
+ from golf.auth.helpers import get_access_token, get_provider_token, extract_token_from_header
913
+ """)
914
+
915
+ # Copy provider, oauth, and helper modules
916
+ for module in ["provider.py", "oauth.py", "helpers.py"]:
917
+ src_file = Path(__file__).parent.parent.parent / "golf" / "auth" / module
918
+ dst_file = auth_dir / module
919
+
920
+ if src_file.exists():
921
+ shutil.copy(src_file, dst_file)
922
+ else:
923
+ console.print(f"[yellow]Warning: Could not find {src_file} to copy[/yellow]")
924
+
925
+ # Now handle the auth integration if configured
926
+ if provider_config:
927
+
928
+ # Generate the auth code to inject into server.py
929
+ # The existing call to generate_auth_code.
930
+ # We need to ensure the arguments passed are sensible.
931
+ # server.py determines issuer_url at runtime. generate_auth_code
932
+ # likely uses host/port/https to construct its own version or parts of it.
933
+
934
+ # Determine protocol for https flag based on runtime logic similar to server.py
935
+ # This is a bit of a guess as settings doesn't explicitly store protocol for generate_auth_code
936
+ # A small inconsistency here if server.py's runtime logic for issuer_url differs significantly
937
+ # from what generate_auth_code expects/builds.
938
+ # For now, let's assume False is okay, or it's handled internally by generate_auth_code
939
+ # based on typical dev environments.
940
+ is_https_proto = False # Default, adjust if settings provide this info for build time
941
+
942
+ auth_code_str = generate_auth_code( # Renamed to auth_code_str to avoid confusion
943
+ server_name=settings.name,
944
+ host=settings.host,
945
+ port=settings.port
946
+ )
947
+ else:
948
+ # If auth is not configured, create a basic FastMCP instantiation string
949
+ # This string will then be processed for OTel args like the auth_code_str would be
950
+ auth_code_str = f"mcp = FastMCP('{settings.name}')"
951
+
952
+ # ---- Centralized OpenTelemetry Argument Injection ----
953
+ if settings.opentelemetry_enabled:
954
+ temp_mcp_lines = auth_code_str.split('\n')
955
+ final_mcp_lines = []
956
+ otel_args_injected = False
957
+ for line_content in temp_mcp_lines:
958
+ if "mcp = FastMCP(" in line_content and ")" in line_content and not otel_args_injected:
959
+ open_paren_pos = line_content.find("(")
960
+ close_paren_pos = line_content.rfind(")")
961
+ if open_paren_pos != -1 and close_paren_pos != -1 and open_paren_pos < close_paren_pos:
962
+ existing_args_str = line_content[open_paren_pos+1:close_paren_pos].strip()
963
+ otel_args_to_add = []
964
+ if "lifespan=" not in existing_args_str:
965
+ otel_args_to_add.append("lifespan=otel_lifespan")
966
+ if settings.transport != "stdio" and "middleware=" not in existing_args_str:
967
+ otel_args_to_add.append("middleware=[Middleware(OpenTelemetryMiddleware)]")
968
+
969
+ if otel_args_to_add:
970
+ new_args_str = existing_args_str
971
+ if new_args_str and not new_args_str.endswith(','):
972
+ new_args_str += ", "
973
+ new_args_str += ", ".join(otel_args_to_add)
974
+ new_line = f"{line_content[:open_paren_pos+1]}{new_args_str}{line_content[close_paren_pos:]}"
975
+ final_mcp_lines.append(new_line)
976
+ otel_args_injected = True
977
+ continue
978
+ final_mcp_lines.append(line_content)
979
+
980
+ if otel_args_injected:
981
+ auth_code_str = "\n".join(final_mcp_lines)
982
+ elif otel_args_to_add: # Only warn if we actually tried to add something
983
+ console.print(f"[yellow]Warning: Could not automatically inject OpenTelemetry lifespan/middleware into FastMCP constructor. Review server.py.[/yellow]")
984
+ # ---- END Centralized OpenTelemetry Argument Injection ----
985
+
986
+ # ---- MODIFICATION TO auth_code_str for _set_active_golf_oauth_provider (if auth was enabled) ----
987
+ if provider_config: # Only run this if auth was actually processed by generate_auth_code
988
+ auth_routes_code = generate_auth_routes()
989
+
990
+ server_file = output_dir / "server.py"
991
+ if server_file.exists():
992
+ with open(server_file, "r") as f:
993
+ server_code_content = f.read()
994
+
995
+ create_marker = '# Create FastMCP server'
996
+ # The original logic replaces the FastMCP instantiation part.
997
+ # So we use the modified auth_code_str here.
998
+ create_pos = server_code_content.find(create_marker)
999
+ if create_pos != -1: # Ensure marker is found
1000
+ create_pos += len(create_marker) # Move past the marker text itself
1001
+ create_next_line = server_code_content.find('\n', create_pos) + 1
1002
+ # Assuming the original mcp = FastMCP(...) line is what auth_code_str replaces
1003
+ # Find the end of the line that starts with "mcp = FastMCP("
1004
+ mcp_line_start_search = server_code_content.find("mcp = FastMCP(", create_next_line)
1005
+ if mcp_line_start_search != -1:
1006
+ mcp_line_end = server_code_content.find('\n', mcp_line_start_search)
1007
+ if mcp_line_end == -1: mcp_line_end = len(server_code_content) # if it's the last line
1008
+
1009
+ modified_code = (
1010
+ server_code_content[:create_next_line] +
1011
+ auth_code_str + # Use the modified auth code string
1012
+ server_code_content[mcp_line_end:]
1013
+ )
1014
+ else: # Fallback if "mcp = FastMCP(" line isn't found as expected
1015
+ console.print(f"[yellow]Warning: Could not precisely find 'mcp = FastMCP(...)' line for replacement by auth_code in {server_file}. Appending auth_code instead.[/yellow]")
1016
+ # This part of the logic was to replace mcp = FastMCP(...)
1017
+ # If the generate_auth_code ALREADY includes the mcp = FastMCP(...) line,
1018
+ # then the original injection logic might be different.
1019
+ # The example server.py shows that the auth_code INCLUDES the mcp = FastMCP(...) line.
1020
+ # The original code in builder.py:
1021
+ # create_next_line = server_code.find('\n', create_pos) + 1
1022
+ # mcp_line_end = server_code.find('\n', create_next_line)
1023
+ # This implies it replaces ONE line after '# Create FastMCP server'
1024
+ # This needs to be robust. If auth_code_str contains the `mcp = FastMCP(...)` line itself,
1025
+ # then this replacement logic is correct.
1026
+
1027
+ # The server.py example from `new/dist` implies that auth_code effectively *is* the
1028
+ # whole block from "import os" for auth settings down to and including "mcp = FastMCP(...)".
1029
+ # The original `_generate_server` creates a very minimal `mcp = FastMCP(...)`.
1030
+ # The `build_project` then overwrites this with the richer `auth_code` block.
1031
+ # Let's assume `auth_code_str_modified` should replace from `create_next_line` up to
1032
+ # where the original `mcp = FastMCP(...)` definition ended.
1033
+
1034
+ # Re-evaluating the injection for auth_code_str_modified.
1035
+ # The server.py is first generated by _generate_server.
1036
+ # Then, if auth is enabled, this part of build_project MODIFIES it.
1037
+ # It finds '# Create FastMCP server', then replaces the *next line* (which is `mcp = FastMCP(...)` from _generate_server)
1038
+ # with the entire `auth_code_str_modified`.
1039
+
1040
+ # Original line to find/replace: `mcp = FastMCP("{self.settings.name}")`
1041
+ # OR if telemetry was on `mcp = FastMCP("{self.settings.name}", lifespan=otel_lifespan)`
1042
+ # The replacement logic must be robust to find the line created by _generate_server
1043
+
1044
+ # Let's find the line starting with "mcp = FastMCP(" that _generate_server created
1045
+ original_mcp_instantiation_pattern = "mcp = FastMCP("
1046
+ start_replace_idx = server_code_content.find(original_mcp_instantiation_pattern)
1047
+
1048
+ if start_replace_idx != -1:
1049
+ # We need to find the complete statement, including any continuation lines
1050
+ line_start = server_code_content.rfind('\n', 0, start_replace_idx) + 1
1051
+
1052
+ # Find the closing parenthesis, handling potential multi-line calls
1053
+ opening_paren_pos = server_code_content.find('(', start_replace_idx)
1054
+ if opening_paren_pos != -1:
1055
+ # Count open parentheses to handle nested ones correctly
1056
+ paren_count = 1
1057
+ pos = opening_paren_pos + 1
1058
+ while pos < len(server_code_content) and paren_count > 0:
1059
+ if server_code_content[pos] == '(':
1060
+ paren_count += 1
1061
+ elif server_code_content[pos] == ')':
1062
+ paren_count -= 1
1063
+ pos += 1
1064
+
1065
+ closing_paren_pos = pos - 1 if paren_count == 0 else -1
1066
+
1067
+ if closing_paren_pos != -1:
1068
+ # Find the end of the statement (newline after the closing parenthesis)
1069
+ next_newline = server_code_content.find('\n', closing_paren_pos)
1070
+ if next_newline != -1:
1071
+ end_replace_idx = next_newline + 1
1072
+ else:
1073
+ end_replace_idx = len(server_code_content)
1074
+
1075
+ # Replace the entire statement with the auth code
1076
+ modified_code = (
1077
+ server_code_content[:line_start] +
1078
+ auth_code_str +
1079
+ server_code_content[end_replace_idx:]
1080
+ )
1081
+ else:
1082
+ console.print(f"[red]Error: Could not find closing parenthesis for FastMCP constructor in {server_file}. Auth injection may fail.[/red]")
1083
+ modified_code = server_code_content
1084
+ else:
1085
+ console.print(f"[red]Error: Could not find opening parenthesis for FastMCP constructor in {server_file}. Auth injection may fail.[/red]")
1086
+ modified_code = server_code_content
1087
+
1088
+ else: # create_marker not found (This case should ideally not happen if _generate_server works)
1089
+ console.print(f"[red]Could not find injection marker '{create_marker}' in {server_file}. Auth injection failed.[/red]")
1090
+ modified_code = server_code_content # No change
1091
+
1092
+ app_marker = 'if __name__ == "__main__":'
1093
+ app_pos = modified_code.find(app_marker)
1094
+ if app_pos != -1: # Ensure marker is found
1095
+ modified_code = (
1096
+ modified_code[:app_pos] +
1097
+ auth_routes_code + "\n\n" + # Ensure auth routes are injected
1098
+ modified_code[app_pos:]
1099
+ )
1100
+ else:
1101
+ console.print(f"[yellow]Warning: Could not find main block marker '{app_marker}' in {server_file} to inject auth routes.[/yellow]")
1102
+
1103
+ # Format with black before writing
1104
+ try:
1105
+ final_code_to_write = black.format_str(modified_code, mode=black.Mode())
1106
+ except Exception as e:
1107
+ console.print(f"[yellow]Warning: Could not format server.py after auth injection: {e}[/yellow]")
1108
+ final_code_to_write = modified_code # Write unformatted if black fails
1109
+
1110
+ with open(server_file, "w") as f:
1111
+ f.write(final_code_to_write)
1112
+
1113
+ else: # server_file does not exist
1114
+ console.print(f"[red]Error: {server_file} does not exist for auth modification. Ensure _generate_server runs first.[/red]")
1115
+
1116
+
1117
+ # Renamed function - was find_shared_modules
1118
+ def find_common_files(project_path: Path, components: Dict[ComponentType, List[ParsedComponent]]) -> Dict[str, Path]:
1119
+ """Find all common.py files used by components."""
1120
+ # We'll use the parser's functionality to find common files directly
1121
+ from golf.core.parser import parse_common_files
1122
+ common_files = parse_common_files(project_path)
1123
+
1124
+ # Return the found files without debug messages
1125
+ return common_files
1126
+
1127
+
1128
+ # Updated parameter name from shared_modules to common_files
1129
+ def build_import_map(project_path: Path, common_files: Dict[str, Path]) -> Dict[str, str]:
1130
+ """Build a mapping of import paths to their new locations in the build output.
1131
+
1132
+ This maps from original relative import paths to absolute import paths
1133
+ in the components directory structure.
1134
+ """
1135
+ import_map = {}
1136
+
1137
+ for dir_path_str, file_path in common_files.items():
1138
+ # Convert string path to Path object
1139
+ dir_path = Path(dir_path_str)
1140
+
1141
+ # Get the component type (tools, resources, prompts)
1142
+ component_type = None
1143
+ for part in dir_path.parts:
1144
+ if part in ["tools", "resources", "prompts"]:
1145
+ component_type = part
1146
+ break
1147
+
1148
+ if not component_type:
1149
+ continue
1150
+
1151
+ # Calculate the relative path within the component type
1152
+ try:
1153
+ rel_to_component = dir_path.relative_to(component_type)
1154
+ # Create the new import path
1155
+ new_path = f"components.{component_type}.{rel_to_component}".replace("/", ".")
1156
+ # Fix any double dots
1157
+ new_path = new_path.replace("..", ".")
1158
+
1159
+ # Map both the directory and the common file
1160
+ orig_module = dir_path_str
1161
+ import_map[orig_module] = new_path
1162
+
1163
+ # Also map the specific common module
1164
+ common_module = f"{dir_path_str}/common"
1165
+ import_map[common_module] = f"{new_path}.common"
1166
+ except ValueError:
1167
+ continue
1168
+
1169
+ return import_map