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.
- golf/__init__.py +1 -0
- golf/auth/__init__.py +109 -0
- golf/auth/helpers.py +56 -0
- golf/auth/oauth.py +798 -0
- golf/auth/provider.py +110 -0
- golf/cli/__init__.py +1 -0
- golf/cli/main.py +223 -0
- golf/commands/__init__.py +3 -0
- golf/commands/build.py +78 -0
- golf/commands/init.py +197 -0
- golf/commands/run.py +68 -0
- golf/core/__init__.py +1 -0
- golf/core/builder.py +1169 -0
- golf/core/builder_auth.py +157 -0
- golf/core/builder_telemetry.py +208 -0
- golf/core/config.py +205 -0
- golf/core/parser.py +509 -0
- golf/core/transformer.py +168 -0
- golf/examples/__init__.py +1 -0
- golf/examples/basic/.env +3 -0
- golf/examples/basic/.env.example +3 -0
- golf/examples/basic/README.md +117 -0
- golf/examples/basic/golf.json +9 -0
- golf/examples/basic/pre_build.py +28 -0
- golf/examples/basic/prompts/welcome.py +30 -0
- golf/examples/basic/resources/current_time.py +41 -0
- golf/examples/basic/resources/info.py +27 -0
- golf/examples/basic/resources/weather/common.py +48 -0
- golf/examples/basic/resources/weather/current.py +32 -0
- golf/examples/basic/resources/weather/forecast.py +32 -0
- golf/examples/basic/tools/github_user.py +67 -0
- golf/examples/basic/tools/hello.py +29 -0
- golf/examples/basic/tools/payments/charge.py +50 -0
- golf/examples/basic/tools/payments/common.py +34 -0
- golf/examples/basic/tools/payments/refund.py +50 -0
- golf_mcp-0.1.0.dist-info/METADATA +78 -0
- golf_mcp-0.1.0.dist-info/RECORD +41 -0
- golf_mcp-0.1.0.dist-info/WHEEL +5 -0
- golf_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- golf_mcp-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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
|