feat(mcp): write, test and delete this instance's own playbooks (#5748)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m29s
CI / unit (push) Successful in 1m39s
CI / publish (push) Successful in 1m6s

#5748 "MCP can write, edit and test this instance's own playbooks
(steward-local)": two admin tools beside run_playbook, so an agent can
build automation for these hosts that isn't part of Steward itself.

- save_playbook(playbook, content, source="steward-local", replace=false)
  goes through the editor's own validate_playbook_yaml and
  write_playbook. A playbook that isn't a list of plays comes back with
  the YAML error and its line, and nothing is written. An existing file
  is overwritten only with replace=true. steward-builtin is refused
  (Steward's playbooks change in its repository), as are git sources
  (their repo owns them).
- After writing, `ansible-playbook --syntax-check` runs against a
  throwaway localhost inventory (no host contacted, 60 s), so a module
  name Ansible can't resolve shows before any run. The result rides the
  reply; the file is kept either way.
- delete_playbook(playbook, source="steward-local").
- Both audit as ansible.playbook.created/updated/deleted, "<user> via
  MCP", with the size and never the content.

The testing loop is the existing one: run_playbook defaults to check
mode, on one target, then get_ansible_run. The integration test does
that loop with the real ansible-playbook.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 12:01:39 -04:00
co-authored by Claude Opus 5.5
parent 39685585cd
commit e122860cdc
6 changed files with 299 additions and 6 deletions
+3 -3
View File
@@ -212,7 +212,7 @@ def read_playbook(source_path: str, relative_path: str) -> str | None:
return target.read_text(errors="replace")
def _resolve_writable(source_path: str, relative_path: str) -> tuple[Path | None, str | None]:
def resolve_writable(source_path: str, relative_path: str) -> tuple[Path | None, str | None]:
"""Resolve relative_path under source_path, guarding traversal + extension.
Returns (target_path, None) on success or (None, error). The root need not
@@ -233,7 +233,7 @@ def _resolve_writable(source_path: str, relative_path: str) -> tuple[Path | None
def write_playbook(source_path: str, relative_path: str, content: str) -> tuple[bool, str | None]:
"""Write a playbook into a source, creating parent dirs. Traversal-guarded."""
target, err = _resolve_writable(source_path, relative_path)
target, err = resolve_writable(source_path, relative_path)
if err:
return False, err
try:
@@ -246,7 +246,7 @@ def write_playbook(source_path: str, relative_path: str, content: str) -> tuple[
def delete_playbook(source_path: str, relative_path: str) -> tuple[bool, str | None]:
"""Delete a playbook from a source. Traversal-guarded; no-op if absent."""
target, err = _resolve_writable(source_path, relative_path)
target, err = resolve_writable(source_path, relative_path)
if err:
return False, err
try: