fips-agents-cli 0.1.0__py3-none-any.whl → 0.1.2__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.
- fips_agents_cli/cli.py +2 -0
- fips_agents_cli/commands/create.py +1 -2
- fips_agents_cli/commands/generate.py +405 -0
- fips_agents_cli/tools/filesystem.py +3 -4
- fips_agents_cli/tools/generators.py +311 -0
- fips_agents_cli/tools/project.py +1 -2
- fips_agents_cli/tools/validation.py +183 -0
- fips_agents_cli/version.py +1 -1
- {fips_agents_cli-0.1.0.dist-info → fips_agents_cli-0.1.2.dist-info}/METADATA +246 -8
- fips_agents_cli-0.1.2.dist-info/RECORD +18 -0
- {fips_agents_cli-0.1.0.dist-info → fips_agents_cli-0.1.2.dist-info}/licenses/LICENSE +2 -0
- fips_agents_cli-0.1.0.dist-info/RECORD +0 -15
- {fips_agents_cli-0.1.0.dist-info → fips_agents_cli-0.1.2.dist-info}/WHEEL +0 -0
- {fips_agents_cli-0.1.0.dist-info → fips_agents_cli-0.1.2.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,311 @@
|
|
1
|
+
"""Generator utilities for rendering MCP component templates."""
|
2
|
+
|
3
|
+
import ast
|
4
|
+
import json
|
5
|
+
import subprocess
|
6
|
+
from pathlib import Path
|
7
|
+
from typing import Any
|
8
|
+
|
9
|
+
import jinja2
|
10
|
+
import tomlkit
|
11
|
+
from rich.console import Console
|
12
|
+
|
13
|
+
console = Console()
|
14
|
+
|
15
|
+
|
16
|
+
def get_project_info(project_root: Path) -> dict[str, Any]:
|
17
|
+
"""
|
18
|
+
Extract project metadata from pyproject.toml.
|
19
|
+
|
20
|
+
Args:
|
21
|
+
project_root: Path to the project root directory
|
22
|
+
|
23
|
+
Returns:
|
24
|
+
dict: Project metadata including:
|
25
|
+
- name: Project name
|
26
|
+
- module_name: Module name (with underscores)
|
27
|
+
- version: Project version
|
28
|
+
|
29
|
+
Raises:
|
30
|
+
FileNotFoundError: If pyproject.toml doesn't exist
|
31
|
+
ValueError: If pyproject.toml is malformed
|
32
|
+
|
33
|
+
Example:
|
34
|
+
>>> info = get_project_info(Path("/path/to/project"))
|
35
|
+
>>> print(info["name"])
|
36
|
+
'my-mcp-server'
|
37
|
+
"""
|
38
|
+
pyproject_path = project_root / "pyproject.toml"
|
39
|
+
|
40
|
+
if not pyproject_path.exists():
|
41
|
+
raise FileNotFoundError(f"pyproject.toml not found at {pyproject_path}")
|
42
|
+
|
43
|
+
try:
|
44
|
+
with open(pyproject_path) as f:
|
45
|
+
pyproject = tomlkit.parse(f.read())
|
46
|
+
|
47
|
+
project_name = pyproject.get("project", {}).get("name", "unknown")
|
48
|
+
project_version = pyproject.get("project", {}).get("version", "0.1.0")
|
49
|
+
module_name = project_name.replace("-", "_")
|
50
|
+
|
51
|
+
return {
|
52
|
+
"name": project_name,
|
53
|
+
"module_name": module_name,
|
54
|
+
"version": project_version,
|
55
|
+
}
|
56
|
+
|
57
|
+
except Exception as e:
|
58
|
+
raise ValueError(f"Failed to parse pyproject.toml: {e}") from e
|
59
|
+
|
60
|
+
|
61
|
+
def load_template(project_root: Path, component_type: str, template_name: str) -> jinja2.Template:
|
62
|
+
"""
|
63
|
+
Load a Jinja2 template from the project's generator templates.
|
64
|
+
|
65
|
+
Args:
|
66
|
+
project_root: Path to the project root directory
|
67
|
+
component_type: Type of component ('tool', 'resource', 'prompt', 'middleware')
|
68
|
+
template_name: Name of the template file (e.g., 'component.py.j2')
|
69
|
+
|
70
|
+
Returns:
|
71
|
+
jinja2.Template: Loaded Jinja2 template
|
72
|
+
|
73
|
+
Raises:
|
74
|
+
FileNotFoundError: If template file doesn't exist
|
75
|
+
jinja2.TemplateError: If template is malformed
|
76
|
+
|
77
|
+
Example:
|
78
|
+
>>> template = load_template(root, "tool", "component.py.j2")
|
79
|
+
>>> rendered = template.render(component_name="my_tool")
|
80
|
+
"""
|
81
|
+
template_path = (
|
82
|
+
project_root / ".fips-agents-cli" / "generators" / component_type / template_name
|
83
|
+
)
|
84
|
+
|
85
|
+
if not template_path.exists():
|
86
|
+
raise FileNotFoundError(f"Template not found: {template_path}")
|
87
|
+
|
88
|
+
try:
|
89
|
+
with open(template_path) as f:
|
90
|
+
template_content = f.read()
|
91
|
+
|
92
|
+
# Create a Jinja2 environment with the template directory as the loader path
|
93
|
+
template_dir = template_path.parent
|
94
|
+
env = jinja2.Environment(
|
95
|
+
loader=jinja2.FileSystemLoader(str(template_dir)),
|
96
|
+
trim_blocks=True,
|
97
|
+
lstrip_blocks=True,
|
98
|
+
)
|
99
|
+
|
100
|
+
return env.from_string(template_content)
|
101
|
+
|
102
|
+
except jinja2.TemplateError as e:
|
103
|
+
raise jinja2.TemplateError(f"Failed to load template {template_name}: {e}") from e
|
104
|
+
|
105
|
+
|
106
|
+
def load_params_file(params_path: Path) -> list[dict[str, Any]]:
|
107
|
+
"""
|
108
|
+
Load and validate parameter definitions from a JSON file.
|
109
|
+
|
110
|
+
Expected schema:
|
111
|
+
[
|
112
|
+
{
|
113
|
+
"name": "query",
|
114
|
+
"type": "str",
|
115
|
+
"description": "Search query",
|
116
|
+
"required": true,
|
117
|
+
"min_length": 1,
|
118
|
+
"max_length": 100
|
119
|
+
}
|
120
|
+
]
|
121
|
+
|
122
|
+
Args:
|
123
|
+
params_path: Path to the JSON parameter file
|
124
|
+
|
125
|
+
Returns:
|
126
|
+
list: List of parameter definition dictionaries
|
127
|
+
|
128
|
+
Raises:
|
129
|
+
FileNotFoundError: If params file doesn't exist
|
130
|
+
ValueError: If JSON is invalid or schema is incorrect
|
131
|
+
|
132
|
+
Example:
|
133
|
+
>>> params = load_params_file(Path("params.json"))
|
134
|
+
>>> print(params[0]["name"])
|
135
|
+
'query'
|
136
|
+
"""
|
137
|
+
if not params_path.exists():
|
138
|
+
raise FileNotFoundError(f"Parameters file not found: {params_path}")
|
139
|
+
|
140
|
+
try:
|
141
|
+
with open(params_path) as f:
|
142
|
+
params = json.load(f)
|
143
|
+
|
144
|
+
except json.JSONDecodeError as e:
|
145
|
+
raise ValueError(f"Invalid JSON in parameters file: {e}") from e
|
146
|
+
|
147
|
+
# Validate schema
|
148
|
+
if not isinstance(params, list):
|
149
|
+
raise ValueError("Parameters file must contain a JSON array of parameter definitions")
|
150
|
+
|
151
|
+
for i, param in enumerate(params):
|
152
|
+
if not isinstance(param, dict):
|
153
|
+
raise ValueError(f"Parameter {i} must be a JSON object")
|
154
|
+
|
155
|
+
# Check required fields
|
156
|
+
required_fields = ["name", "type", "description"]
|
157
|
+
for field in required_fields:
|
158
|
+
if field not in param:
|
159
|
+
raise ValueError(f"Parameter {i} missing required field: {field}")
|
160
|
+
|
161
|
+
# Validate name is a valid Python identifier
|
162
|
+
if not param["name"].isidentifier():
|
163
|
+
raise ValueError(f"Parameter {i} has invalid name: {param['name']}")
|
164
|
+
|
165
|
+
# Validate type
|
166
|
+
valid_types = [
|
167
|
+
"str",
|
168
|
+
"int",
|
169
|
+
"float",
|
170
|
+
"bool",
|
171
|
+
"list[str]",
|
172
|
+
"list[int]",
|
173
|
+
"list[float]",
|
174
|
+
"Optional[str]",
|
175
|
+
"Optional[int]",
|
176
|
+
"Optional[float]",
|
177
|
+
"Optional[bool]",
|
178
|
+
]
|
179
|
+
if param["type"] not in valid_types:
|
180
|
+
raise ValueError(
|
181
|
+
f"Parameter {i} has invalid type: {param['type']}. "
|
182
|
+
f"Valid types: {', '.join(valid_types)}"
|
183
|
+
)
|
184
|
+
|
185
|
+
return params
|
186
|
+
|
187
|
+
|
188
|
+
def render_component(template: jinja2.Template, variables: dict[str, Any]) -> str:
|
189
|
+
"""
|
190
|
+
Render a Jinja2 template with the provided variables.
|
191
|
+
|
192
|
+
Args:
|
193
|
+
template: Jinja2 template object
|
194
|
+
variables: Dictionary of template variables
|
195
|
+
|
196
|
+
Returns:
|
197
|
+
str: Rendered template as a string
|
198
|
+
|
199
|
+
Raises:
|
200
|
+
jinja2.TemplateError: If rendering fails
|
201
|
+
|
202
|
+
Example:
|
203
|
+
>>> template = load_template(root, "tool", "component.py.j2")
|
204
|
+
>>> code = render_component(template, {"component_name": "my_tool"})
|
205
|
+
"""
|
206
|
+
try:
|
207
|
+
return template.render(**variables)
|
208
|
+
except jinja2.TemplateError as e:
|
209
|
+
raise jinja2.TemplateError(f"Failed to render template: {e}") from e
|
210
|
+
|
211
|
+
|
212
|
+
def validate_python_syntax(code: str) -> tuple[bool, str]:
|
213
|
+
"""
|
214
|
+
Validate Python code syntax using ast.parse().
|
215
|
+
|
216
|
+
Args:
|
217
|
+
code: Python code as a string
|
218
|
+
|
219
|
+
Returns:
|
220
|
+
tuple: (is_valid, error_message)
|
221
|
+
is_valid is True if syntax is valid, False otherwise
|
222
|
+
error_message is empty string if valid, otherwise contains error description
|
223
|
+
|
224
|
+
Example:
|
225
|
+
>>> code = "def my_func():\\n return 42"
|
226
|
+
>>> is_valid, msg = validate_python_syntax(code)
|
227
|
+
>>> print(is_valid)
|
228
|
+
True
|
229
|
+
"""
|
230
|
+
try:
|
231
|
+
ast.parse(code)
|
232
|
+
return True, ""
|
233
|
+
except SyntaxError as e:
|
234
|
+
return False, f"Syntax error at line {e.lineno}: {e.msg}"
|
235
|
+
except Exception as e:
|
236
|
+
return False, f"Failed to validate syntax: {e}"
|
237
|
+
|
238
|
+
|
239
|
+
def write_component_file(content: str, file_path: Path) -> None:
|
240
|
+
"""
|
241
|
+
Write component content to a file, creating parent directories if needed.
|
242
|
+
|
243
|
+
Args:
|
244
|
+
content: File content as a string
|
245
|
+
file_path: Path where the file should be written
|
246
|
+
|
247
|
+
Raises:
|
248
|
+
OSError: If file cannot be written (permissions, etc.)
|
249
|
+
|
250
|
+
Example:
|
251
|
+
>>> write_component_file("print('hello')", Path("src/tools/my_tool.py"))
|
252
|
+
"""
|
253
|
+
try:
|
254
|
+
# Create parent directories if they don't exist
|
255
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
256
|
+
|
257
|
+
# Write the file
|
258
|
+
with open(file_path, "w") as f:
|
259
|
+
f.write(content)
|
260
|
+
|
261
|
+
except OSError as e:
|
262
|
+
raise OSError(f"Failed to write file {file_path}: {e}") from e
|
263
|
+
|
264
|
+
|
265
|
+
def run_component_tests(project_root: Path, test_file: Path) -> tuple[bool, str]:
|
266
|
+
"""
|
267
|
+
Run pytest on a generated test file and capture output.
|
268
|
+
|
269
|
+
Args:
|
270
|
+
project_root: Path to the project root directory
|
271
|
+
test_file: Path to the test file to run (relative or absolute)
|
272
|
+
|
273
|
+
Returns:
|
274
|
+
tuple: (success, output)
|
275
|
+
success is True if tests passed, False if failed
|
276
|
+
output is the pytest output as a string
|
277
|
+
|
278
|
+
Example:
|
279
|
+
>>> success, output = run_component_tests(root, Path("tests/tools/test_my_tool.py"))
|
280
|
+
>>> if success:
|
281
|
+
... print("Tests passed!")
|
282
|
+
"""
|
283
|
+
try:
|
284
|
+
# Make test_file relative to project_root if it's absolute
|
285
|
+
if test_file.is_absolute():
|
286
|
+
try:
|
287
|
+
test_file = test_file.relative_to(project_root)
|
288
|
+
except ValueError:
|
289
|
+
# test_file is not relative to project_root, use as-is
|
290
|
+
pass
|
291
|
+
|
292
|
+
# Run pytest with minimal output
|
293
|
+
result = subprocess.run(
|
294
|
+
["pytest", str(test_file), "-v", "--tb=short"],
|
295
|
+
cwd=str(project_root),
|
296
|
+
capture_output=True,
|
297
|
+
text=True,
|
298
|
+
timeout=30,
|
299
|
+
)
|
300
|
+
|
301
|
+
output = result.stdout + result.stderr
|
302
|
+
success = result.returncode == 0
|
303
|
+
|
304
|
+
return success, output
|
305
|
+
|
306
|
+
except subprocess.TimeoutExpired:
|
307
|
+
return False, "Test execution timed out after 30 seconds"
|
308
|
+
except FileNotFoundError:
|
309
|
+
return False, "pytest not found. Install with: pip install pytest"
|
310
|
+
except Exception as e:
|
311
|
+
return False, f"Failed to run tests: {e}"
|
fips_agents_cli/tools/project.py
CHANGED
@@ -3,7 +3,6 @@
|
|
3
3
|
import re
|
4
4
|
import shutil
|
5
5
|
from pathlib import Path
|
6
|
-
from typing import Optional
|
7
6
|
|
8
7
|
import tomlkit
|
9
8
|
from rich.console import Console
|
@@ -11,7 +10,7 @@ from rich.console import Console
|
|
11
10
|
console = Console()
|
12
11
|
|
13
12
|
|
14
|
-
def validate_project_name(name: str) -> tuple[bool,
|
13
|
+
def validate_project_name(name: str) -> tuple[bool, str | None]:
|
15
14
|
"""
|
16
15
|
Validate project name according to Python package naming conventions.
|
17
16
|
|
@@ -0,0 +1,183 @@
|
|
1
|
+
"""Validation utilities for MCP component generation."""
|
2
|
+
|
3
|
+
import keyword
|
4
|
+
import re
|
5
|
+
from pathlib import Path
|
6
|
+
|
7
|
+
import tomlkit
|
8
|
+
from rich.console import Console
|
9
|
+
|
10
|
+
console = Console()
|
11
|
+
|
12
|
+
|
13
|
+
def find_project_root() -> Path | None:
|
14
|
+
"""
|
15
|
+
Find the project root by walking up from current directory.
|
16
|
+
|
17
|
+
Looks for pyproject.toml with fastmcp dependency to identify MCP server projects.
|
18
|
+
|
19
|
+
Returns:
|
20
|
+
Path: Project root path if found
|
21
|
+
None: If no valid MCP project root is found
|
22
|
+
|
23
|
+
Example:
|
24
|
+
>>> root = find_project_root()
|
25
|
+
>>> if root:
|
26
|
+
... print(f"Found project at {root}")
|
27
|
+
"""
|
28
|
+
current_path = Path.cwd()
|
29
|
+
|
30
|
+
# Walk up the directory tree
|
31
|
+
for parent in [current_path] + list(current_path.parents):
|
32
|
+
pyproject_path = parent / "pyproject.toml"
|
33
|
+
|
34
|
+
if pyproject_path.exists():
|
35
|
+
try:
|
36
|
+
with open(pyproject_path) as f:
|
37
|
+
pyproject = tomlkit.parse(f.read())
|
38
|
+
|
39
|
+
# Check if this is an MCP server project
|
40
|
+
dependencies = pyproject.get("project", {}).get("dependencies", [])
|
41
|
+
|
42
|
+
# Check for fastmcp dependency
|
43
|
+
for dep in dependencies:
|
44
|
+
if isinstance(dep, str) and "fastmcp" in dep.lower():
|
45
|
+
return parent
|
46
|
+
|
47
|
+
except Exception as e:
|
48
|
+
console.print(f"[yellow]⚠[/yellow] Could not parse {pyproject_path}: {e}")
|
49
|
+
continue
|
50
|
+
|
51
|
+
return None
|
52
|
+
|
53
|
+
|
54
|
+
def is_valid_component_name(name: str) -> tuple[bool, str]:
|
55
|
+
"""
|
56
|
+
Validate component name as a valid Python identifier.
|
57
|
+
|
58
|
+
Component names must:
|
59
|
+
- Be valid Python identifiers (snake_case)
|
60
|
+
- Not be Python keywords
|
61
|
+
- Not be empty
|
62
|
+
- Start with a letter or underscore
|
63
|
+
- Contain only letters, numbers, and underscores
|
64
|
+
|
65
|
+
Args:
|
66
|
+
name: The component name to validate
|
67
|
+
|
68
|
+
Returns:
|
69
|
+
tuple: (is_valid, error_message)
|
70
|
+
is_valid is True if valid, False otherwise
|
71
|
+
error_message is empty string if valid, otherwise contains error description
|
72
|
+
|
73
|
+
Examples:
|
74
|
+
>>> is_valid_component_name("my_tool")
|
75
|
+
(True, '')
|
76
|
+
>>> is_valid_component_name("123invalid")
|
77
|
+
(False, 'Component name must start with a letter or underscore')
|
78
|
+
"""
|
79
|
+
if not name:
|
80
|
+
return False, "Component name cannot be empty"
|
81
|
+
|
82
|
+
# Check if it's a valid Python identifier
|
83
|
+
if not name.isidentifier():
|
84
|
+
if name[0].isdigit():
|
85
|
+
return False, "Component name must start with a letter or underscore"
|
86
|
+
return False, (
|
87
|
+
"Component name must be a valid Python identifier (use snake_case: "
|
88
|
+
"letters, numbers, underscores only)"
|
89
|
+
)
|
90
|
+
|
91
|
+
# Check if it's a Python keyword
|
92
|
+
if keyword.iskeyword(name):
|
93
|
+
return False, f"Component name '{name}' is a Python keyword and cannot be used"
|
94
|
+
|
95
|
+
# Recommend snake_case
|
96
|
+
if not re.match(r"^[a-z_][a-z0-9_]*$", name):
|
97
|
+
return False, (
|
98
|
+
"Component name should use snake_case (lowercase letters, numbers, " "underscores only)"
|
99
|
+
)
|
100
|
+
|
101
|
+
return True, ""
|
102
|
+
|
103
|
+
|
104
|
+
def component_exists(project_root: Path, component_type: str, name: str) -> bool:
|
105
|
+
"""
|
106
|
+
Check if a component file already exists.
|
107
|
+
|
108
|
+
Args:
|
109
|
+
project_root: Path to the project root directory
|
110
|
+
component_type: Type of component ('tool', 'resource', 'prompt', 'middleware')
|
111
|
+
name: Component name (will check for {name}.py)
|
112
|
+
|
113
|
+
Returns:
|
114
|
+
bool: True if component file exists, False otherwise
|
115
|
+
|
116
|
+
Example:
|
117
|
+
>>> root = Path("/path/to/project")
|
118
|
+
>>> component_exists(root, "tool", "my_tool")
|
119
|
+
False
|
120
|
+
"""
|
121
|
+
# Map component types to their directory locations
|
122
|
+
component_dirs = {
|
123
|
+
"tool": "tools",
|
124
|
+
"resource": "resources",
|
125
|
+
"prompt": "prompts",
|
126
|
+
"middleware": "middleware",
|
127
|
+
}
|
128
|
+
|
129
|
+
if component_type not in component_dirs:
|
130
|
+
return False
|
131
|
+
|
132
|
+
component_dir = component_dirs[component_type]
|
133
|
+
component_file = project_root / "src" / component_dir / f"{name}.py"
|
134
|
+
|
135
|
+
return component_file.exists()
|
136
|
+
|
137
|
+
|
138
|
+
def validate_generator_templates(project_root: Path, component_type: str) -> tuple[bool, str]:
|
139
|
+
"""
|
140
|
+
Validate that generator templates exist for the component type.
|
141
|
+
|
142
|
+
Args:
|
143
|
+
project_root: Path to the project root directory
|
144
|
+
component_type: Type of component ('tool', 'resource', 'prompt', 'middleware')
|
145
|
+
|
146
|
+
Returns:
|
147
|
+
tuple: (is_valid, error_message)
|
148
|
+
is_valid is True if templates exist, False otherwise
|
149
|
+
error_message is empty string if valid, otherwise contains error description
|
150
|
+
|
151
|
+
Example:
|
152
|
+
>>> root = Path("/path/to/project")
|
153
|
+
>>> is_valid, msg = validate_generator_templates(root, "tool")
|
154
|
+
>>> if is_valid:
|
155
|
+
... print("Templates found!")
|
156
|
+
"""
|
157
|
+
generators_dir = project_root / ".fips-agents-cli" / "generators" / component_type
|
158
|
+
|
159
|
+
if not generators_dir.exists():
|
160
|
+
return False, (
|
161
|
+
f"Generator templates not found for '{component_type}'\n"
|
162
|
+
f"Expected: {generators_dir}\n"
|
163
|
+
"Was this project created with fips-agents create mcp-server?"
|
164
|
+
)
|
165
|
+
|
166
|
+
# Check for required template files
|
167
|
+
component_template = generators_dir / "component.py.j2"
|
168
|
+
test_template = generators_dir / "test.py.j2"
|
169
|
+
|
170
|
+
missing_files = []
|
171
|
+
if not component_template.exists():
|
172
|
+
missing_files.append("component.py.j2")
|
173
|
+
if not test_template.exists():
|
174
|
+
missing_files.append("test.py.j2")
|
175
|
+
|
176
|
+
if missing_files:
|
177
|
+
return False, (
|
178
|
+
f"Missing template files for '{component_type}':\n"
|
179
|
+
f" {', '.join(missing_files)}\n"
|
180
|
+
f"Expected location: {generators_dir}"
|
181
|
+
)
|
182
|
+
|
183
|
+
return True, ""
|
fips_agents_cli/version.py
CHANGED