scaffold-ca-python 0.1.4__py3-none-any.whl → 0.2.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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import ast
5
6
  import json
6
7
  import tomllib
7
8
  from pathlib import Path
@@ -11,11 +12,13 @@ import typer
11
12
  import yaml
12
13
  from rich.console import Console
13
14
  from rich.markup import escape
15
+ from rich.syntax import Syntax
14
16
  from rich.tree import Tree
15
17
 
18
+ from scaffold_ca_python.core import mcp_tool_spec
16
19
  from scaffold_ca_python.core.module_builder import ModuleBuilder
17
20
  from scaffold_ca_python.core.name_utils import ScaffoldError, to_snake_case
18
- from scaffold_ca_python.core.project_detector import find_project_root
21
+ from scaffold_ca_python.core.project_detector import find_project_root, resolve_tests_root
19
22
  from scaffold_ca_python.factory import ModuleFactory
20
23
  from scaffold_ca_python.factory.entry_points.ep_agent import EntryPointAgent
21
24
  from scaffold_ca_python.factory.entry_points.ep_generic import EntryPointGeneric
@@ -45,12 +48,11 @@ _GEP_EPILOG = (
45
48
  " * mcp MCP server entry point\n\n"
46
49
  " * generic Plain entry point\n\n"
47
50
  " ==================================================\n\n"
48
- "Notes: --enable-kafka and --enable-mcp-client are agent type only.\n\n"
49
- " ==================================================\n\n"
50
51
  "Examples:\n\n"
51
52
  " * scaffold gep --type restapi\n\n"
52
53
  " * scaffold gep --type agent --enable-kafka\n\n"
53
54
  " * scaffold gep --type mcp\n\n"
55
+ " * scaffold gep --type mcp --add-tool --name create_file --param path:str --returns str\n\n"
54
56
  " * scaffold generate-entry-point --type generic\n"
55
57
  )
56
58
 
@@ -64,6 +66,235 @@ def _load_project_context(project_root: Path) -> ProjectContext:
64
66
  return ProjectContext(name=project_name)
65
67
 
66
68
 
69
+ def _add_tool_impl(
70
+ type_: str,
71
+ swagger: str | None,
72
+ enable_kafka: bool,
73
+ enable_mcp_client: bool,
74
+ with_resources: bool,
75
+ with_prompts: bool,
76
+ name: str | None,
77
+ param: list[str] | None,
78
+ returns: str,
79
+ description: str | None,
80
+ dry_run: bool,
81
+ ) -> None:
82
+ """Add a tool to an existing MCP entry point (T018-T021, T027-T030a).
83
+
84
+ Implements User Story 1 and 2: appends a tool stub to tools.py and a registration
85
+ test to test_tools.py in an existing MCP project, with comprehensive guard rails.
86
+ """
87
+ # T028: Flag/type incompatibility guards (FR-011, FR-012) - before project access
88
+ # Check creation-only flags
89
+ creation_flags = []
90
+ if swagger is not None:
91
+ creation_flags.append("--swagger")
92
+ if enable_kafka:
93
+ creation_flags.append("--enable-kafka")
94
+ if enable_mcp_client:
95
+ creation_flags.append("--enable-mcp-client")
96
+ if with_resources:
97
+ creation_flags.append("--with-resources")
98
+ if with_prompts:
99
+ creation_flags.append("--with-prompts")
100
+
101
+ if creation_flags:
102
+ flag = creation_flags[0]
103
+ console.print(f"[red]Error:[/red] --add-tool is incompatible with {flag}.")
104
+ raise typer.Exit(code=1)
105
+
106
+ # Check type compatibility (FR-012) - handled by T016(a) for --type omitted
107
+ # Here we check non-mcp types
108
+ if type_ != "mcp":
109
+ console.print("[red]Error:[/red] --add-tool is only valid with --type mcp.")
110
+ raise typer.Exit(code=1)
111
+
112
+ # T018: Format-only validation (no filesystem access yet)
113
+ if name is None:
114
+ console.print("[red]Error:[/red] --name is required with --add-tool.")
115
+ raise typer.Exit(code=1)
116
+
117
+ try:
118
+ tool_spec = mcp_tool_spec.build_tool_spec(name, param, returns, description)
119
+ except (ValueError, ScaffoldError) as e:
120
+ console.print(f"[red]Error:[/red] {e}")
121
+ raise typer.Exit(code=1) from None
122
+
123
+ # T019: Resolve project root and check entry point exists
124
+ try:
125
+ project_root = find_project_root()
126
+ except ScaffoldError as e:
127
+ console.print(f"[red]Error:[/red] {e}")
128
+ raise typer.Exit(code=1) from None
129
+
130
+ project_ctx = _load_project_context(project_root)
131
+
132
+ tools_py_path = (
133
+ project_root
134
+ / "src"
135
+ / project_ctx.python_package
136
+ / "infrastructure"
137
+ / "entry_points"
138
+ / "mcp_server"
139
+ / "tools.py"
140
+ )
141
+ test_tools_py_path = (
142
+ resolve_tests_root(project_root) / "infrastructure" / "entry_points" / "mcp_server" / "test_tools.py"
143
+ )
144
+
145
+ if not tools_py_path.exists():
146
+ console.print(
147
+ "[red]Error:[/red] No MCP entry point found at infrastructure/entry_points/mcp_server/.\n"
148
+ "[dim]Hint:[/dim] Run [cyan]scaffold gep --type mcp[/cyan] first to create one."
149
+ )
150
+ raise typer.Exit(code=1)
151
+
152
+ # T020: Read files and check structure
153
+ try:
154
+ original_tools_py = tools_py_path.read_text(encoding="utf-8")
155
+ except OSError as e:
156
+ console.print(f"[red]Error:[/red] Cannot read {tools_py_path}: {e}")
157
+ raise typer.Exit(code=1) from None
158
+
159
+ try:
160
+ original_test_tools_py = test_tools_py_path.read_text(encoding="utf-8")
161
+ except OSError:
162
+ console.print(
163
+ "[red]Error:[/red] tests/infrastructure/entry_points/mcp_server/test_tools.py is missing or "
164
+ "does not have\n"
165
+ "the structure --add-tool needs to append a registration test.\n"
166
+ "[dim]Hint:[/dim] Restore test_tools.py, or regenerate the entry point with "
167
+ "[cyan]scaffold gep --type mcp[/cyan]."
168
+ )
169
+ raise typer.Exit(code=1)
170
+
171
+ # Check bind_tools is last top-level statement
172
+ if not mcp_tool_spec.has_bind_tools_function(original_tools_py):
173
+ console.print(
174
+ "[red]Error:[/red] infrastructure/entry_points/mcp_server/tools.py was generated by an older "
175
+ "scaffold\n"
176
+ "version and does not support --add-tool.\n"
177
+ "[dim]Hint:[/dim] Make [cyan]async def bind_tools[/cyan] the last function in tools.py, or "
178
+ "regenerate the entry point\n"
179
+ "(back up any custom tools first) with [cyan]scaffold gep --type mcp[/cyan]."
180
+ )
181
+ raise typer.Exit(code=1)
182
+
183
+ # Check test file has required structure
184
+ if not mcp_tool_spec.has_test_registration_structure(original_test_tools_py):
185
+ console.print(
186
+ "[red]Error:[/red] tests/infrastructure/entry_points/mcp_server/test_tools.py is missing or "
187
+ "does not have\n"
188
+ "the structure --add-tool needs to append a registration test.\n"
189
+ "[dim]Hint:[/dim] Restore test_tools.py, or regenerate the entry point with "
190
+ "[cyan]scaffold gep --type mcp[/cyan]."
191
+ )
192
+ raise typer.Exit(code=1)
193
+
194
+ # Check for duplicate tool names
195
+ existing_names = mcp_tool_spec.find_existing_tool_names(original_tools_py)
196
+ if tool_spec.name in existing_names:
197
+ if tool_spec.name == "bind_tools":
198
+ console.print(
199
+ f"[red]Error:[/red] '{tool_spec.name}' is reserved by the tool-binding function and "
200
+ "cannot be used as a tool name."
201
+ )
202
+ else:
203
+ console.print(f"[red]Error:[/red] A tool named '{tool_spec.name}' already exists in tools.py.")
204
+ raise typer.Exit(code=1)
205
+
206
+ # T021: Render templates and append
207
+ builder = ModuleBuilder(
208
+ project_root=project_root,
209
+ project_ctx=project_ctx,
210
+ dry_run=dry_run,
211
+ )
212
+
213
+ # Render tool stub (4-space-indented nested function)
214
+ tool_stub_content = builder.render(
215
+ "entry_point/mcp/tool_stub.py.jinja2",
216
+ {
217
+ "name": tool_spec.name,
218
+ "params": [{"name": p.name, "type": p.type} for p in tool_spec.params],
219
+ "returns": tool_spec.returns,
220
+ "docstring": tool_spec.docstring,
221
+ },
222
+ )
223
+
224
+ # Render test stub
225
+ test_stub_content = builder.render(
226
+ "entry_point/mcp/test_tool_stub.py.jinja2",
227
+ {"name": tool_spec.name},
228
+ )
229
+
230
+ # Safety check: ensure appended content will parse
231
+ try:
232
+ ast.parse(original_tools_py + tool_stub_content)
233
+ except SyntaxError:
234
+ console.print(
235
+ "[red]Error:[/red] infrastructure/entry_points/mcp_server/tools.py cannot accept the new tool stub.\n"
236
+ "[dim]Hint:[/dim] Regenerate the entry point with [cyan]scaffold gep --type mcp[/cyan]."
237
+ )
238
+ raise typer.Exit(code=1)
239
+
240
+ # T030a: Check writability before any writes (FR-019)
241
+ import os
242
+
243
+ if not os.access(tools_py_path, os.W_OK):
244
+ console.print(
245
+ f"[red]Error:[/red] Cannot write to {tools_py_path.relative_to(project_root)}.\n"
246
+ "[dim]Hint:[/dim] Check the file's permissions and re-run the command."
247
+ )
248
+ raise typer.Exit(code=1)
249
+
250
+ if not os.access(test_tools_py_path, os.W_OK):
251
+ console.print(
252
+ f"[red]Error:[/red] Cannot write to {test_tools_py_path.relative_to(project_root)}.\n"
253
+ "[dim]Hint:[/dim] Check the file's permissions and re-run the command."
254
+ )
255
+ raise typer.Exit(code=1)
256
+
257
+ # Queue append operations
258
+ builder.append_to_file(tools_py_path, tool_stub_content)
259
+ builder.append_to_file(test_tools_py_path, test_stub_content)
260
+
261
+ # T030a: Persist changes with restore-on-failure (FR-019)
262
+ try:
263
+ modified_paths = builder.persist()
264
+ except OSError as e:
265
+ # Restore original content
266
+ try:
267
+ tools_py_path.write_text(original_tools_py, encoding="utf-8")
268
+ test_tools_py_path.write_text(original_test_tools_py, encoding="utf-8")
269
+ except OSError:
270
+ pass # Best effort restore
271
+
272
+ # Determine which file failed
273
+ error_str = str(e).lower()
274
+ if "test_tools" in error_str or str(test_tools_py_path) in str(e):
275
+ failed_file = "test_tools.py"
276
+ else:
277
+ failed_file = "tools.py"
278
+
279
+ console.print(
280
+ f"[red]Error:[/red] Failed to write {failed_file} ({e}). "
281
+ "tools.py was restored; no changes were made.\n"
282
+ "[dim]Hint:[/dim] Fix the underlying problem (permissions, disk space) and re-run the command."
283
+ )
284
+ raise typer.Exit(code=1) from None
285
+
286
+ if dry_run:
287
+ console.print("\n[yellow]Dry run:[/yellow] Would modify:\n")
288
+ for path in modified_paths:
289
+ console.print(f" • {path.relative_to(project_root)}")
290
+ console.print("\n[yellow]Tool stub preview:[/yellow]")
291
+ console.print(Syntax(tool_stub_content, "python", theme="monokai", line_numbers=False))
292
+ console.print("\n[yellow]Test stub preview:[/yellow]")
293
+ console.print(Syntax(test_stub_content, "python", theme="monokai", line_numbers=False))
294
+ else:
295
+ console.print(f"[green]✓[/green] Added tool '{tool_spec.name}' to MCP entry point.")
296
+
297
+
67
298
  def _generate_entry_point_impl(
68
299
  type_: str,
69
300
  swagger: str | None,
@@ -71,8 +302,30 @@ def _generate_entry_point_impl(
71
302
  enable_mcp_client: bool,
72
303
  with_resources: bool,
73
304
  with_prompts: bool,
305
+ add_tool: bool,
306
+ name: str | None,
307
+ param: list[str] | None,
308
+ returns: str,
309
+ description: str | None,
74
310
  dry_run: bool,
75
311
  ) -> None:
312
+ # T016(b): Dispatch to --add-tool implementation if flag is set
313
+ if add_tool:
314
+ _add_tool_impl(
315
+ type_,
316
+ swagger,
317
+ enable_kafka,
318
+ enable_mcp_client,
319
+ with_resources,
320
+ with_prompts,
321
+ name,
322
+ param,
323
+ returns,
324
+ description,
325
+ dry_run,
326
+ )
327
+ return
328
+
76
329
  # --- Validate type ---
77
330
  if type_ not in _REGISTRY:
78
331
  console.print(f"[red]Error:[/red] Unknown type '{type_}'. Allowed: {', '.join(_REGISTRY.keys())}.")
@@ -287,6 +540,24 @@ def register(app: typer.Typer) -> None:
287
540
  show_default=True,
288
541
  ),
289
542
  ] = False,
543
+ add_tool: Annotated[
544
+ bool,
545
+ typer.Option(
546
+ "--add-tool/--no-add-tool",
547
+ help="Add a tool to an existing MCP entry point (mcp only).",
548
+ rich_help_panel="Options",
549
+ show_default=True,
550
+ ),
551
+ ] = False,
552
+ name: Annotated[str | None, typer.Option("--name", help="Tool name (required with --add-tool).")] = None,
553
+ param: Annotated[
554
+ list[str] | None,
555
+ typer.Option("--param", help="Tool parameter in 'name:type' format (repeatable)."),
556
+ ] = None,
557
+ returns: Annotated[str, typer.Option("--returns", help="Tool return type.")] = "str",
558
+ description: Annotated[
559
+ str | None, typer.Option("--description", help="Tool description for docstring.")
560
+ ] = None,
290
561
  dry_run: Annotated[
291
562
  bool,
292
563
  typer.Option(
@@ -298,9 +569,25 @@ def register(app: typer.Typer) -> None:
298
569
  ] = False,
299
570
  ) -> None:
300
571
  """Scaffold an entry point inside infrastructure/entry_points/."""
572
+ # T016(a): Check --add-tool without --type before the help shortcut
573
+ if add_tool and type_ is None:
574
+ console.print("[red]Error:[/red] --add-tool is only valid with --type mcp.")
575
+ raise typer.Exit(code=1)
576
+
301
577
  if type_ is None:
302
578
  typer.echo(ctx.get_help())
303
579
  raise typer.Exit(0)
304
580
  _generate_entry_point_impl(
305
- type_, swagger, enable_kafka, enable_mcp_client, with_resources, with_prompts, dry_run
581
+ type_,
582
+ swagger,
583
+ enable_kafka,
584
+ enable_mcp_client,
585
+ with_resources,
586
+ with_prompts,
587
+ add_tool,
588
+ name,
589
+ param,
590
+ returns,
591
+ description,
592
+ dry_run,
306
593
  )
@@ -7,7 +7,7 @@ import tempfile
7
7
  from pathlib import Path
8
8
  from typing import TYPE_CHECKING
9
9
 
10
- from scaffold_ca_python.models.file_operation import CreateFile, DeleteFile, FileOperation, InsertAfter
10
+ from scaffold_ca_python.models.file_operation import AppendToFile, CreateFile, DeleteFile, FileOperation, InsertAfter
11
11
 
12
12
  if TYPE_CHECKING:
13
13
  pass
@@ -60,7 +60,7 @@ class FileWriter:
60
60
  for op in operations:
61
61
  if isinstance(op, CreateFile):
62
62
  result.append(op.file.path)
63
- elif isinstance(op, (DeleteFile, InsertAfter)):
63
+ elif isinstance(op, (DeleteFile, InsertAfter, AppendToFile)):
64
64
  result.append(op.path)
65
65
  return result
66
66
 
@@ -110,6 +110,11 @@ class FileWriter:
110
110
  for op in inserts:
111
111
  self._apply_insert_after(op)
112
112
 
113
+ # Process append_to_file mutations in declaration order
114
+ appends: list[AppendToFile] = [op for op in operations if isinstance(op, AppendToFile)]
115
+ for op in appends:
116
+ self._apply_append_to_file(op)
117
+
113
118
  return committed
114
119
 
115
120
  @staticmethod
@@ -126,3 +131,23 @@ class FileWriter:
126
131
  op.path.write_text("".join(lines), encoding="utf-8")
127
132
  return
128
133
  raise ValueError(f"insert_after anchor {op.anchor!r} not found in {op.path}")
134
+
135
+ @staticmethod
136
+ def _apply_append_to_file(op: AppendToFile) -> None:
137
+ """Append *op.content* to the end of *op.path*, or create the file.
138
+
139
+ When the file exists, trailing blank lines are normalized to a single newline
140
+ before appending. When it doesn't exist, it's created with *op.content* as
141
+ initial contents (leading blank lines stripped).
142
+ """
143
+ if op.path.exists():
144
+ # File exists - normalize trailing newlines and append
145
+ original = op.path.read_text(encoding="utf-8")
146
+ # Strip all trailing newlines, then ensure exactly one
147
+ normalized = original.rstrip("\n") + "\n"
148
+ op.path.write_text(normalized + op.content, encoding="utf-8")
149
+ else:
150
+ # File doesn't exist - create it with content (strip leading blank lines)
151
+ op.path.parent.mkdir(parents=True, exist_ok=True)
152
+ content = op.content.lstrip("\n")
153
+ op.path.write_text(content, encoding="utf-8")
@@ -0,0 +1,325 @@
1
+ """MCP tool specification parsing and validation (T012).
2
+
3
+ This module provides utilities for parsing and validating --add-tool CLI arguments,
4
+ including tool names, parameters, type expressions, and duplicate-name detection.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ import keyword
11
+
12
+ from pydantic import BaseModel, field_validator, model_validator
13
+
14
+ from scaffold_ca_python.core.name_utils import ScaffoldError, validate_module_name
15
+
16
+
17
+ class ParamSpec(BaseModel):
18
+ """A single parsed --param name:type value."""
19
+
20
+ name: str
21
+ type: str
22
+
23
+ @field_validator("name")
24
+ @classmethod
25
+ def validate_name(cls, v: str) -> str:
26
+ """Validate parameter name is snake_case and not a hard keyword."""
27
+ # Reuse validate_module_name for snake_case check
28
+ validate_module_name(v)
29
+
30
+ # Check for hard keywords
31
+ if keyword.iskeyword(v):
32
+ msg = f"'{v}' is a Python keyword and cannot be used as a parameter name."
33
+ raise ValueError(msg)
34
+
35
+ return v
36
+
37
+
38
+ class ToolSpec(BaseModel):
39
+ """Parsed, fully-validated representation of a single --add-tool invocation."""
40
+
41
+ name: str
42
+ params: list[ParamSpec]
43
+ returns: str
44
+ docstring: str
45
+
46
+ @field_validator("name")
47
+ @classmethod
48
+ def validate_name(cls, v: str) -> str:
49
+ """Validate tool name is snake_case and not a hard keyword."""
50
+ validate_module_name(v)
51
+
52
+ if keyword.iskeyword(v):
53
+ msg = f"'{v}' is a Python keyword and cannot be used as a tool name."
54
+ raise ValueError(msg)
55
+
56
+ return v
57
+
58
+ @field_validator("returns")
59
+ @classmethod
60
+ def validate_returns_type(cls, v: str) -> str:
61
+ """Validate returns is a valid type expression."""
62
+ if not validate_type_expression(v):
63
+ msg = f"Invalid --returns '{v}'. Not a recognizable type-expression."
64
+ raise ValueError(msg)
65
+ return v
66
+
67
+ @model_validator(mode="after")
68
+ def check_no_duplicate_param_names(self) -> ToolSpec:
69
+ """Reject duplicate parameter names."""
70
+ names = [p.name for p in self.params]
71
+ seen = set()
72
+ for name in names:
73
+ if name in seen:
74
+ msg = f"Duplicate --param name '{name}'. Each parameter name must be unique."
75
+ raise ValueError(msg)
76
+ seen.add(name)
77
+ return self
78
+
79
+
80
+ def parse_param(param_str: str) -> tuple[str, str]:
81
+ """Parse a --param 'name:type' string.
82
+
83
+ Args:
84
+ param_str: String in format 'name:type'
85
+
86
+ Returns:
87
+ Tuple of (name, type)
88
+
89
+ Raises:
90
+ ValueError: If format is invalid, name is not snake_case, name is a hard
91
+ keyword, or type is not a recognizable type-expression
92
+ """
93
+ if ":" not in param_str:
94
+ msg = f"Invalid --param '{param_str}'. Expected 'name:type' format."
95
+ raise ValueError(msg)
96
+
97
+ # Split on first colon only
98
+ name, type_str = param_str.split(":", 1)
99
+
100
+ # Validate name (snake_case)
101
+ try:
102
+ validate_module_name(name)
103
+ except (ValueError, ScaffoldError) as e:
104
+ msg = f"Invalid --param '{param_str}'. Parameter name must be snake_case (e.g., 'path:str')."
105
+ raise ValueError(msg) from e
106
+
107
+ # Check for hard keywords
108
+ if keyword.iskeyword(name):
109
+ msg = f"Invalid --param '{param_str}'. '{name}' is a Python keyword and cannot be used as a parameter name."
110
+ raise ValueError(msg)
111
+
112
+ # Validate type expression
113
+ if not validate_type_expression(type_str):
114
+ msg = f"Invalid --param '{param_str}'. '{type_str}' is not a recognizable type-expression."
115
+ raise ValueError(msg)
116
+
117
+ return name, type_str
118
+
119
+
120
+ def validate_type_expression(type_str: str) -> bool:
121
+ """Check if type_str is a recognizable Python type expression.
122
+
123
+ Uses AST parsing to verify the type string is a valid type expression
124
+ (Name, Subscript, BinOp with |, Attribute, etc.) and not a function call,
125
+ string literal, arithmetic expression, lambda, or comprehension.
126
+
127
+ Args:
128
+ type_str: Type expression string to validate
129
+
130
+ Returns:
131
+ True if valid type expression, False otherwise
132
+ """
133
+ try:
134
+ node = ast.parse(type_str, mode="eval").body
135
+ except SyntaxError:
136
+ return False
137
+
138
+ # Allowed: Name (str, int), Subscript (list[str]), BinOp (str | None),
139
+ # Attribute (typing.Optional), Constant (for Literal values inside [...])
140
+ # Not allowed: Call, Str/Constant at top level, Lambda, comprehensions, arithmetic
141
+ return _is_type_expression_node(node)
142
+
143
+
144
+ def _is_type_expression_node(node: ast.expr) -> bool:
145
+ """Recursively check if AST node represents a valid type expression."""
146
+ if isinstance(node, ast.Name):
147
+ return True
148
+ if isinstance(node, ast.Constant):
149
+ # Allow None and Ellipsis in type expressions
150
+ return node.value is None or node.value is ...
151
+ if isinstance(node, ast.Attribute):
152
+ return True
153
+ if isinstance(node, ast.Subscript):
154
+ # Allow Constant in subscript (for Literal["a", "b"])
155
+ return _is_type_expression_node(node.value) and _is_subscript_arg_valid(node.slice)
156
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
157
+ return _is_type_expression_node(node.left) and _is_type_expression_node(node.right)
158
+ if isinstance(node, ast.Tuple):
159
+ # For tuple[int, ...] syntax
160
+ return all(_is_type_expression_node(elt) if not isinstance(elt, ast.Constant) else True for elt in node.elts)
161
+ return False
162
+
163
+
164
+ def _is_subscript_arg_valid(node: ast.expr) -> bool:
165
+ """Check if subscript argument is valid (Name, Tuple, or Constant for Literal)."""
166
+ if isinstance(node, ast.Constant):
167
+ return True # Allow string literals inside Literal[...]
168
+ if isinstance(node, ast.Tuple):
169
+ return all(_is_subscript_arg_valid(elt) for elt in node.elts)
170
+ return _is_type_expression_node(node)
171
+
172
+
173
+ def find_existing_tool_names(tools_py_source: str) -> set[str]:
174
+ """Find all existing tool names in tools.py.
175
+
176
+ Returns the union of:
177
+ - Every @<x>.tool("name") registered name
178
+ - Every function name defined directly inside bind_tools
179
+ - The reserved name "bind_tools"
180
+
181
+ Args:
182
+ tools_py_source: Source code of tools.py
183
+
184
+ Returns:
185
+ Set of existing tool names
186
+ """
187
+ names: set[str] = {"bind_tools"} # Always reserved
188
+
189
+ try:
190
+ module = ast.parse(tools_py_source)
191
+ except SyntaxError:
192
+ return names
193
+
194
+ # Find @<x>.tool("name") decorators
195
+ for node in ast.walk(module):
196
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
197
+ for decorator in node.decorator_list:
198
+ # Match @mcp.tool("name") or @x.tool("name")
199
+ if (
200
+ isinstance(decorator, ast.Call)
201
+ and isinstance(decorator.func, ast.Attribute)
202
+ and decorator.func.attr == "tool"
203
+ and decorator.args
204
+ and isinstance(decorator.args[0], ast.Constant)
205
+ and isinstance(decorator.args[0].value, str)
206
+ ):
207
+ names.add(decorator.args[0].value)
208
+
209
+ # Find function names inside bind_tools
210
+ for node in ast.walk(module):
211
+ if isinstance(node, ast.AsyncFunctionDef) and node.name == "bind_tools":
212
+ for child in ast.walk(node):
213
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) and child != node:
214
+ names.add(child.name)
215
+
216
+ return names
217
+
218
+
219
+ def has_bind_tools_function(tools_py_source: str) -> bool:
220
+ """Check if tools.py has async def bind_tools as the last top-level statement.
221
+
222
+ Args:
223
+ tools_py_source: Source code of tools.py
224
+
225
+ Returns:
226
+ True if last top-level statement is async def bind_tools, False otherwise
227
+ """
228
+ try:
229
+ module = ast.parse(tools_py_source)
230
+ except SyntaxError:
231
+ return False
232
+
233
+ if not module.body:
234
+ return False
235
+
236
+ last_stmt = module.body[-1]
237
+ return isinstance(last_stmt, ast.AsyncFunctionDef) and last_stmt.name == "bind_tools"
238
+
239
+
240
+ def has_test_registration_structure(test_tools_py_source: str) -> bool:
241
+ """Check if test_tools.py can host an appended registration test.
242
+
243
+ Returns True only if it defines a top-level _make_mock_mcp function and
244
+ imports both bind_tools and asyncio.
245
+
246
+ Args:
247
+ test_tools_py_source: Source code of test_tools.py
248
+
249
+ Returns:
250
+ True if structure is compatible, False otherwise
251
+ """
252
+ try:
253
+ module = ast.parse(test_tools_py_source)
254
+ except SyntaxError:
255
+ return False
256
+
257
+ # Check for _make_mock_mcp function
258
+ has_helper = any(isinstance(node, ast.FunctionDef) and node.name == "_make_mock_mcp" for node in module.body)
259
+
260
+ # Check for required imports
261
+ imported = set()
262
+ for node in module.body:
263
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
264
+ for alias in node.names:
265
+ imported.add(alias.asname or alias.name)
266
+
267
+ has_required_imports = {"bind_tools", "asyncio"} <= imported
268
+
269
+ return has_helper and has_required_imports
270
+
271
+
272
+ def validate_description(description: str | None) -> str:
273
+ """Validate description is safe for docstring embedding.
274
+
275
+ Args:
276
+ description: User-provided description or None
277
+
278
+ Returns:
279
+ Validated description or generic placeholder
280
+
281
+ Raises:
282
+ ValueError: If description contains embedded triple-quote sequence
283
+ """
284
+ if not description or not description.strip():
285
+ return "TODO: describe this tool."
286
+
287
+ if '"""' in description or "'''" in description:
288
+ msg = "--description contains an embedded triple-quote sequence and cannot be used as a docstring."
289
+ raise ValueError(msg)
290
+
291
+ return description
292
+
293
+
294
+ def build_tool_spec(
295
+ name: str,
296
+ param: list[str] | None,
297
+ returns: str | None,
298
+ description: str | None,
299
+ ) -> ToolSpec:
300
+ """Build a validated ToolSpec from CLI arguments.
301
+
302
+ Args:
303
+ name: Tool name (required)
304
+ param: List of 'name:type' strings or None
305
+ returns: Return type expression or None (defaults to "str")
306
+ description: Tool description or None (defaults to placeholder)
307
+
308
+ Returns:
309
+ Validated ToolSpec
310
+
311
+ Raises:
312
+ ValueError: If any validation fails
313
+ """
314
+ params = []
315
+ if param:
316
+ for p in param:
317
+ param_name, param_type = parse_param(p)
318
+ params.append(ParamSpec(name=param_name, type=param_type))
319
+
320
+ return ToolSpec(
321
+ name=name,
322
+ params=params,
323
+ returns=returns or "str",
324
+ docstring=validate_description(description),
325
+ )
@@ -17,7 +17,14 @@ from scaffold_ca_python.core.pyproject_writer import (
17
17
  update_project_scripts,
18
18
  )
19
19
  from scaffold_ca_python.core.template_renderer import TemplateRenderer
20
- from scaffold_ca_python.models.file_operation import CreateFile, DeleteFile, FileOperation, GeneratedFile, InsertAfter
20
+ from scaffold_ca_python.models.file_operation import (
21
+ AppendToFile,
22
+ CreateFile,
23
+ DeleteFile,
24
+ FileOperation,
25
+ GeneratedFile,
26
+ InsertAfter,
27
+ )
21
28
 
22
29
  if TYPE_CHECKING:
23
30
  from scaffold_ca_python.models.context import ModuleContext, ProjectContext
@@ -88,6 +95,16 @@ class ModuleBuilder:
88
95
  """
89
96
  self._operations.append(InsertAfter(path=path, anchor=anchor, content=content))
90
97
 
98
+ def append_to_file(self, path: Path, content: str) -> None:
99
+ """Queue an append-to-file operation.
100
+
101
+ *content* will be appended to the end of *path*. If *path* does not exist,
102
+ it will be created. When the file exists, its trailing blank lines are
103
+ normalized to a single newline before appending. In dry-run mode the path
104
+ is included in the preview list without modifying the file.
105
+ """
106
+ self._operations.append(AppendToFile(path=path, content=content))
107
+
91
108
  def render(self, template_name: str, context: BaseModel | dict[str, Any]) -> str:
92
109
  """Render a template through the shared renderer."""
93
110
  return self._renderer.render(template_name, context)
@@ -44,4 +44,22 @@ class InsertAfter(BaseModel):
44
44
  content: str
45
45
 
46
46
 
47
- FileOperation = Annotated[CreateFile | DeleteFile | InsertAfter, "FileOperation"]
47
+ class AppendToFile(BaseModel):
48
+ """Append *content* to the end of an existing file, or create the file.
49
+
50
+ When *path* exists, its trailing blank lines are normalized to a single
51
+ newline before *content* is appended. When *path* does not exist, it is
52
+ created (including parent directories) with *content* as initial contents.
53
+
54
+ Raises
55
+ ------
56
+ OSError
57
+ When file operations fail (permissions, disk space, etc.)
58
+ """
59
+
60
+ kind: Literal["append_to_file"] = "append_to_file"
61
+ path: Path
62
+ content: str
63
+
64
+
65
+ FileOperation = Annotated[CreateFile | DeleteFile | InsertAfter | AppendToFile, "FileOperation"]
@@ -0,0 +1,8 @@
1
+
2
+
3
+ def test_bind_tools_registers_{{ name }}() -> None:
4
+ """Calling bind_tools registers '{{ name }}' on the mcp instance."""
5
+ mock_mcp, registered = _make_mock_mcp()
6
+ fn = getattr(bind_tools, "__wrapped__", bind_tools)
7
+ asyncio.run(fn(mock_mcp))
8
+ assert "{{ name }}" in registered
@@ -0,0 +1,5 @@
1
+
2
+ @mcp.tool("{{ name }}")
3
+ async def {{ name }}({% for param in params %}{{ param.name }}: {{ param.type }}{% if not loop.last %}, {% endif %}{% endfor %}) -> {{ returns }}:
4
+ """{{ docstring }}"""
5
+ raise NotImplementedError("Tool '{{ name }}' is not yet implemented.")
@@ -64,7 +64,7 @@ exclude_also = [
64
64
  ]
65
65
 
66
66
  [tool.pytest.ini_options]
67
- addopts = "--cov={{ python_package }} --cov-fail-under=80 --import-mode=importlib"
67
+ addopts = "--cov={{ python_package }} --cov-fail-under=75 --import-mode=importlib"
68
68
  testpaths = ["src/tests"]
69
69
  asyncio_default_fixture_loop_scope = "function"
70
70
  filterwarnings = [
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: scaffold-ca-python
3
- Version: 0.1.4
3
+ Version: 0.2.0
4
4
  Summary: Scaffolder to generate Clean Architecture apps
5
5
  Project-URL: Homepage, https://github.com/bancolombia/scaffold-clean-architecture-py
6
6
  Project-URL: Repository, https://github.com/bancolombia/scaffold-clean-architecture-py
@@ -151,6 +151,10 @@ scaffold gep --type restapi
151
151
  scaffold gep --type mcp --with-resources --with-prompts --dry-run
152
152
  ```
153
153
 
154
+ ```bash
155
+ scaffold gep --type mcp --add-tool --name create_file --param path:str --param content:str --returns str --description "Creates a file on disk"
156
+ ```
157
+
154
158
  ### scaffold gh
155
159
 
156
160
  Scaffold a helper utility module and its test stub.
@@ -3,7 +3,7 @@ scaffold_ca_python/cli.py,sha256=A0h0pkoNPy3EE55lApnjRuevhWb_Aa-IB7jekaaiAcM,971
3
3
  scaffold_ca_python/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  scaffold_ca_python/commands/delete_module.py,sha256=DZa4rTVhPpt5-HV6dCZY-qWOV18K9qns6gKCsgjDepg,7124
5
5
  scaffold_ca_python/commands/generate_driven_adapter.py,sha256=ckHdHVUV5T3SSvLwPp_jQYdp-Av9j2AV836So6tMODk,6938
6
- scaffold_ca_python/commands/generate_entry_point.py,sha256=nSEz218Ar3WzkzOZOU_7DJ5KzEwGa7w6h9XVV2WQmOw,11943
6
+ scaffold_ca_python/commands/generate_entry_point.py,sha256=iGO0sqq9B4XGRCgXLe0D3YfTzgeDBqZVXThSylPN05E,22665
7
7
  scaffold_ca_python/commands/generate_helper.py,sha256=i5zaqGvTO6nEvVOGVdOsZoDpJf1cWX7GbGaNcEYLSWY,4470
8
8
  scaffold_ca_python/commands/generate_model.py,sha256=I1dMNoVMNytdMZ5mzBAF1WzJDIHMacxwLOmhH29nTIc,4546
9
9
  scaffold_ca_python/commands/generate_pipeline.py,sha256=EYPM9csDnrYB3bpnC4YSycqI3bAE8bYy1283OEEC5Ts,5537
@@ -12,8 +12,9 @@ scaffold_ca_python/commands/generate_use_case.py,sha256=w6BUdF3uXeo3dNsLN76oSYvQ
12
12
  scaffold_ca_python/commands/update_project.py,sha256=cW9IuAF1yIFkAkzOJdioFv97wr7mRiid08KkqOlFmrM,2845
13
13
  scaffold_ca_python/commands/validate_structure.py,sha256=JF6Cb3IQ9d6VcjaReSHHzoXGSmqyoyMWNFplqSCFCOQ,2846
14
14
  scaffold_ca_python/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
- scaffold_ca_python/core/file_writer.py,sha256=DdgD6h4lx6FhKtc0AJvOIBryHgfTE4d4ZCMOfrEAuAI,5030
16
- scaffold_ca_python/core/module_builder.py,sha256=rcWSWxRX4bsaWLCtCHNTAcVFZiHABA5zTn9rilUojKE,5965
15
+ scaffold_ca_python/core/file_writer.py,sha256=ff_5_UEB6vjzYwr6iLiIF3BWbSwNlZmvlnJVmYsRV8Q,6296
16
+ scaffold_ca_python/core/mcp_tool_spec.py,sha256=1APkvaF-PX-wy5sMXtdU9Areha8h-mlQSXtWzPl1wBU,10609
17
+ scaffold_ca_python/core/module_builder.py,sha256=oRUSpmZA3Q_Xno6e3R3e26eoK3REnuLMYJq8kQVyqz8,6518
17
18
  scaffold_ca_python/core/name_utils.py,sha256=OAMl4YrgvaZgulemcqYpUp8iHNvR8ieTrccNVc_zQB4,2765
18
19
  scaffold_ca_python/core/project_detector.py,sha256=G-n9domRQp65uzUbb7CXJk1nFMCTb347RMvfiVoYAxI,3321
19
20
  scaffold_ca_python/core/pyproject_writer.py,sha256=zpxgubt-XSZR5domgL4LG_9AWD9b_cNrDM8fuHmZRzE,7792
@@ -36,7 +37,7 @@ scaffold_ca_python/factory/simple/model_factory.py,sha256=HFIPO_B8UZco972rnoLYMw
36
37
  scaffold_ca_python/factory/simple/use_case_factory.py,sha256=-m1oFPbwfEGwiFJTf1RHIvw1WG7tG0IBuVfdpUYCbgQ,1961
37
38
  scaffold_ca_python/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
38
39
  scaffold_ca_python/models/context.py,sha256=H-PuANo8Z8nSCcbnM9L6ATC6Ikuqt_SfYvI5Gsqo3F4,1916
39
- scaffold_ca_python/models/file_operation.py,sha256=Wy1rDdllw4j7RwFMgRE-xFlCwnJkmI6dQj3r-x4CSOk,1145
40
+ scaffold_ca_python/models/file_operation.py,sha256=R5Tu3KBvUnSmH5QZXBQTPoQjvr4YdH1YOKsKS9ICH_Q,1698
40
41
  scaffold_ca_python/models/layer.py,sha256=5e11596xVV22HZnRv4r4TvEv-U6UKD08wATAh_UXXQU,1082
41
42
  scaffold_ca_python/models/violation.py,sha256=1FCNkfezEhmQ_ANn4KdAVAzLxYj8v4gwNuXH0FQPHmw,546
42
43
  scaffold_ca_python/templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -66,7 +67,9 @@ scaffold_ca_python/templates/entry_point/mcp/server.py.jinja2,sha256=gVzJm_ugY9E
66
67
  scaffold_ca_python/templates/entry_point/mcp/test_app.py.jinja2,sha256=MjVDhAXTz35HcCOVTsaWA--qvD_Qyn2SNG99CF6wctk,967
67
68
  scaffold_ca_python/templates/entry_point/mcp/test_prompts.py.jinja2,sha256=Y9C7z7jpjoTKANlFB6hNJUUw2abt5qF2C4Nm7MkvDvA,1566
68
69
  scaffold_ca_python/templates/entry_point/mcp/test_resources.py.jinja2,sha256=2jHfPqyJ4TJZJupxe8T7gVz2ysaqZHcAog6186ie2CI,1763
70
+ scaffold_ca_python/templates/entry_point/mcp/test_tool_stub.py.jinja2,sha256=Tq4uDcZc77bCC9gCVidmM5HmtjPfACaHBoQW0qEhIWo,295
69
71
  scaffold_ca_python/templates/entry_point/mcp/test_tools.py.jinja2,sha256=bd_8nvLuqPApKIYQoBhEokMYZrPYFUn1pnDEIOVxwCo,1550
72
+ scaffold_ca_python/templates/entry_point/mcp/tool_stub.py.jinja2,sha256=VaGWAUF-BFQ7EeOHXUHQ2H-Mzr1UfbfziDsS7EVWUCc,289
70
73
  scaffold_ca_python/templates/entry_point/mcp/tools.py.jinja2,sha256=Eqxa-pc_Mf4L0QvWMefKeg43bnjxwYuaNXRg6I9H1pQ,753
71
74
  scaffold_ca_python/templates/entry_point/restapi/__init__.py.jinja2,sha256=BrpLExG7nc6cEOYWpJKymUXEvGVc1lU9sb_kBw_HPYg,36
72
75
  scaffold_ca_python/templates/entry_point/restapi/app.py.jinja2,sha256=HQQqi8ZiLTaNIt9g9_iJyjxAGt6KXj4lqGcJLI1Qx9s,2626
@@ -92,7 +95,7 @@ scaffold_ca_python/templates/project/gitignore.jinja2,sha256=ZRf2cRtqbe2d48RfWJW
92
95
  scaffold_ca_python/templates/project/layer_init.jinja2,sha256=oIlTSrce4ozqEKUU_1TB4473ufiE0SiGG_PJmEkT4FA,25
93
96
  scaffold_ca_python/templates/project/main.py.jinja2,sha256=di_ujhkFafXqJUymSdVjEF0tihQ_0xKbKSIyV91rPpk,161
94
97
  scaffold_ca_python/templates/project/mypy_ini.jinja2,sha256=SJ_UiLRZ8wBJ7hqSo3QDeMSsempBfaGvqiR_ASyIxR0,93
95
- scaffold_ca_python/templates/project/pyproject_toml.jinja2,sha256=O8G8S3f7rmRzQkjuRj2n_445bGD8fBDZu6S0QkwSzi0,1574
98
+ scaffold_ca_python/templates/project/pyproject_toml.jinja2,sha256=7c2fpOc7lHxuItmW1NXdoCkYHp_lnswgx6NtHstk1Vw,1574
96
99
  scaffold_ca_python/templates/project/python_version.jinja2,sha256=Auc1s9_hwygz61ULf_j_oX9fK8P6HnuuYaj1o4g845g,5
97
100
  scaffold_ca_python/templates/project/application/config/__init__.py.jinja2,sha256=L9xWIW2Gd3VhRdx1RfUf7efBrIuFf3ft54eLQT1tG9g,32
98
101
  scaffold_ca_python/templates/project/application/config/config.py.jinja2,sha256=218sZQ1115odJcoI7W6-1kiFjkWjO56d80fqQXk4dyM,323
@@ -102,8 +105,8 @@ scaffold_ca_python/templates/project/application/config/resource_container.py.ji
102
105
  scaffold_ca_python/templates/project/application/config/usecases_container.py.jinja2,sha256=3fEHe6mkXkDHlfO5nhLqzLB7ccTpqwtjHZfLyfysbro,527
103
106
  scaffold_ca_python/templates/use_case/test_use_case.py.jinja2,sha256=3K1bNKDBQc29rcv8UHUZnROCUmcFSCwJhbm0xP9Uako,364
104
107
  scaffold_ca_python/templates/use_case/use_case.py.jinja2,sha256=KPtaK1wvZ-7qHdDZyuVfqXTPYwJdNUMPXQZGuKZFA7Y,217
105
- scaffold_ca_python-0.1.4.dist-info/METADATA,sha256=Uk7rsmYTy1wNQRZZVXquueaB2t-KUqrLpFJx52hIvLM,6803
106
- scaffold_ca_python-0.1.4.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
107
- scaffold_ca_python-0.1.4.dist-info/entry_points.txt,sha256=4PyYg6mNCvAlt2yF_o1-QR2CBXiowYGpNyqcP88tu6I,104
108
- scaffold_ca_python-0.1.4.dist-info/licenses/LICENSE,sha256=3ZZEP7DK_h3lEjS8r-DvQCec2q7zAqqq7HmSq6cuL0E,1068
109
- scaffold_ca_python-0.1.4.dist-info/RECORD,,
108
+ scaffold_ca_python-0.2.0.dist-info/METADATA,sha256=PKj8CVbANvsRzv3Ly7SelKv4krAlFbBRkXnv2NmTOnM,6960
109
+ scaffold_ca_python-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
110
+ scaffold_ca_python-0.2.0.dist-info/entry_points.txt,sha256=4PyYg6mNCvAlt2yF_o1-QR2CBXiowYGpNyqcP88tu6I,104
111
+ scaffold_ca_python-0.2.0.dist-info/licenses/LICENSE,sha256=3ZZEP7DK_h3lEjS8r-DvQCec2q7zAqqq7HmSq6cuL0E,1068
112
+ scaffold_ca_python-0.2.0.dist-info/RECORD,,