2 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 1d9ac2b46a feat(mcp): built-in playbooks get steward_url from Steward itself
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 48s
CI / unit (push) Successful in 1m11s
CI / publish (push) Successful in 1m23s
#5015 "Built-in host-agent playbooks make an MCP caller supply steward_url"

- run_playbook, create_schedule and set_alert_action fill a steward-builtin
  playbook's steward_url when the caller leaves it out or empty, from
  Settings → General → Public base URL, else the scheme and host the MCP
  request arrived on — the same source the web actions use.
- run_playbook's reply names what it filled (filled_by_steward), and
  get_playbook shows that value as the variable's default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:30:23 -04:00
bvandeusenandClaude Opus 5.5 06e89308a8 feat(ansible): a run's scope reads as the target or group it names
#5014 "Ansible run scope is shown as a raw id"

- inventory_gen.describe_scope / scope_labels turn "steward:target:<uuid>"
  into "target web-1", "steward:group:<uuid>" into "group Docker Swarm",
  and a deleted one into "target abcdef01 (deleted)".
- MCP run and schedule rows carry scope_label beside the stored scope; the
  ansible.run.started audit detail records it too.
- The web run page and schedules table show the name, with the stored
  scope as the tooltip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:29:06 -04:00
10 changed files with 152 additions and 18 deletions
+36
View File
@@ -106,3 +106,39 @@ async def fetch_scope_targets(db, scope: str) -> list:
result = await db.execute(stmt)
return list(result.scalars().all())
def describe_scope(scope: str, targets: dict[str, str], groups: dict[str, str]) -> str:
"""A run's inventory scope in words: "target web-1", "group Docker Swarm",
"all inventory targets", "<source>/<inventory file>". `targets` and
`groups` map id → name; a scope naming a deleted one says so."""
scope = scope or ""
if scope == "steward:all":
return "all inventory targets"
for prefix, kind, names in (("steward:target:", "target", targets),
("steward:group:", "group", groups)):
if scope.startswith(prefix):
ident = scope[len(prefix):]
name = names.get(ident)
return f"{kind} {name}" if name else f"{kind} {ident[:8]} (deleted)"
if scope.startswith("repo:"):
source, _, inv = scope[len("repo:"):].rpartition(":")
return f"{source}/{inv}" if source else inv
return scope
async def scope_labels(db, scopes) -> dict[str, str]:
"""{scope: describe_scope(scope)} for the given scopes, in two queries."""
from sqlalchemy import select
from steward.models.ansible_inventory import AnsibleGroup, AnsibleTarget
scopes = {s for s in scopes if s}
t_ids = {s[len("steward:target:"):] for s in scopes if s.startswith("steward:target:")}
g_ids = {s[len("steward:group:"):] for s in scopes if s.startswith("steward:group:")}
targets = dict((await db.execute(
select(AnsibleTarget.id, AnsibleTarget.name).where(AnsibleTarget.id.in_(t_ids))
)).all()) if t_ids else {}
groups = dict((await db.execute(
select(AnsibleGroup.id, AnsibleGroup.name).where(AnsibleGroup.id.in_(g_ids))
)).all()) if g_ids else {}
return {s: describe_scope(s, targets, groups) for s in scopes}
+6 -1
View File
@@ -9,6 +9,7 @@ from quart import (
from sqlalchemy import select
from steward.ansible import executor, sources as src_module
from steward.ansible.inventory_gen import scope_labels
from steward.auth.middleware import require_role
from steward.models.ansible import AnsibleRun, AnsibleRunStatus
from steward.models.ansible_schedule import AnsibleSchedule
@@ -306,8 +307,10 @@ async def run_detail(run_id: str):
res = await db.execute(
select(User.username).where(User.id == run.triggered_by))
triggered_label = res.scalar_one_or_none() or "(deleted user)"
scope_label = (await scope_labels(db, [run.inventory_scope])).get(run.inventory_scope)
return await render_template(
"ansible/run_detail.html", run=run, triggered_label=triggered_label)
"ansible/run_detail.html", run=run, triggered_label=triggered_label,
scope_label=scope_label)
@ansible_bp.post("/runs/<run_id>/cancel")
@@ -368,6 +371,7 @@ async def schedules():
r.id: r for r in (await db.execute(
select(AnsibleRun).where(AnsibleRun.id.in_(run_ids)))).scalars().all()
} if run_ids else {}
labels = await scope_labels(db, [s.inventory_scope for s in scheds])
source_data = [
{"name": s["name"], "playbooks": src_module.discover_playbooks(s["path"])}
@@ -380,6 +384,7 @@ async def schedules():
rows.append({
"s": s,
"last_run": runs.get(s.last_run_id) if s.last_run_id else None,
"scope_label": labels.get(s.inventory_scope, s.inventory_scope),
"next_run": (s.last_run_at + timedelta(seconds=s.interval_seconds)) if s.last_run_at else None,
})
if editing_id and s.id == editing_id:
+12
View File
@@ -18,6 +18,8 @@ if TYPE_CHECKING:
_caller_ctx: ContextVar["Caller | None"] = ContextVar("steward_mcp_caller", default=None)
_app_ctx: ContextVar["Quart | None"] = ContextVar("steward_mcp_app", default=None)
# The scheme and host the caller reached /mcp on, e.g. "https://steward.example".
_base_url_ctx: ContextVar[str] = ContextVar("steward_mcp_base_url", default="")
def current_caller() -> "Caller":
@@ -36,3 +38,13 @@ def mcp_app() -> "Quart":
if app is None:
raise RuntimeError("no Steward app in MCP context")
return app
def public_url() -> str:
"""Steward's externally reachable base URL, as the web side's
public_base_url() gives it: Settings → General → Public base URL when set,
else the scheme and host this MCP request arrived on; "" when neither is
known."""
app = _app_ctx.get()
configured = (app.config.get("PUBLIC_BASE_URL") or "").strip() if app is not None else ""
return (configured or _base_url_ctx.get()).rstrip("/")
+3
View File
@@ -279,11 +279,14 @@ def mount_mcp(app) -> StewardMCPServer:
caller_token = _context._caller_ctx.set(caller)
app_token = _context._app_ctx.set(app)
host = headers.get("host", "")
url_token = _context._base_url_ctx.set(f"{scope.get('scheme', 'http')}://{host}" if host else "")
try:
await mcp_asgi(scope, receive, send)
finally:
_context._caller_ctx.reset(caller_token)
_context._app_ctx.reset(app_token)
_context._base_url_ctx.reset(url_token)
original_asgi = app.asgi_app
+54 -14
View File
@@ -20,8 +20,9 @@ from sqlalchemy import select
from sqlalchemy.orm import selectinload
from steward.ansible import executor, sources as src_module
from steward.ansible.inventory_gen import scope_labels
from steward.ansible.runner import trigger_run
from steward.mcp._context import current_caller, mcp_app
from steward.mcp._context import current_caller, mcp_app, public_url
from steward.models.alerts import AlertRule
from steward.models.ansible import AnsibleRun, AnsibleRunStatus
from steward.models.ansible_inventory import AnsibleGroup, AnsibleTarget
@@ -74,6 +75,27 @@ def public_variables(variables: list[dict]) -> list[dict]:
for v in variables]
# Variables Steward's own bundled playbooks take that Steward can answer itself.
STEWARD_KNOWN_VARS = {"steward_url": public_url}
def steward_defaults(source: dict, variables: list[dict], extra_vars: dict | None) -> tuple[dict, dict]:
"""(extra_vars, filled): a built-in playbook's Steward-known variables
(steward_url) filled in when the caller left them out or empty, as the
web actions do. Other sources' playbooks are left as they are."""
extra_vars = dict(extra_vars or {})
if source.get("name") != src_module.BUILTIN_SOURCE_NAME:
return extra_vars, {}
filled = {}
declared = {v["name"] for v in variables}
for name, value in STEWARD_KNOWN_VARS.items():
if name in declared and not str(extra_vars.get(name) or "").strip():
known = value()
if known:
extra_vars[name] = filled[name] = known
return extra_vars, filled
def split_vars(variables: list[dict], extra_vars: dict | None) -> tuple[dict, dict]:
"""(plain, secret): the values the run stores, and those it must not.
@@ -179,11 +201,15 @@ async def get_playbook(source: str, playbook: str) -> dict:
"""
s = _source(source)
content = _playbook(s, playbook)
variables = src_module.discover_playbook_variables(content)
_, filled = steward_defaults(s, variables, {})
variables = [{**v, "default": filled[v["name"]], "filled_by_steward": True}
if v["name"] in filled else v for v in variables]
out = {
"source": s["name"],
"playbook": playbook.strip(),
**src_module.discover_playbook_meta(content),
"variables": public_variables(src_module.discover_playbook_variables(content)),
"variables": public_variables(variables),
"content": content[:CONTENT_CHARS],
}
if len(content) > CONTENT_CHARS:
@@ -240,13 +266,15 @@ def recap_totals(results: dict | None) -> dict | None:
**{k: sum(int((h or {}).get(k) or 0) for h in hosts.values()) for k in RECAP_KEYS}}
def run_row(r: AnsibleRun, users: dict) -> dict:
def run_row(r: AnsibleRun, users: dict, labels: dict) -> dict:
"""`labels` is scope_labels() for the runs' scopes."""
end = r.finished_at
return {
"run_id": r.id,
"source": r.source_name,
"playbook": r.playbook_path,
"scope": r.inventory_scope,
"scope_label": labels.get(r.inventory_scope, r.inventory_scope),
"status": r.status.value,
"check": bool((r.params or {}).get("check")),
"triggered_by": users.get(r.triggered_by, "(deleted user)") if r.triggered_by else "system",
@@ -293,7 +321,8 @@ async def list_ansible_runs(status: str = "", playbook: str = "", range: str = "
async with db_session() as db:
runs = list((await db.execute(stmt)).scalars())
users = await _users(db, [r.triggered_by for r in runs])
out = {"range": key, **bounded([run_row(r, users) for r in runs], limit, key="runs")}
labels = await scope_labels(db, [r.inventory_scope for r in runs])
out = {"range": key, **bounded([run_row(r, users, labels) for r in runs], limit, key="runs")}
if out["truncated"]:
out["total"] = None
out["more"] = (f"More than {limit} runs in range. Pass a larger `limit` (max 500), "
@@ -333,9 +362,10 @@ async def _run_detail(run_id: str, tail: int, grep: str) -> dict:
async with db_session() as db:
run = await _load_run(db, run_id)
users = await _users(db, [run.triggered_by])
labels = await scope_labels(db, [run.inventory_scope])
params = dict(run.params or {})
return {
**run_row(run, users),
**run_row(run, users, labels),
"limit": params.get("limit"),
"tags": params.get("tags"),
"extra_vars": params.get("extra_vars_map") or {},
@@ -387,7 +417,9 @@ async def run_playbook(source: str, playbook: str, scope: str = "all",
"group:<name or id>", or "inventory:<file>" for an inventory file in the
same source. list_inventory shows the choices.
extra_vars: {"name": value}. Variables the playbook marks secret are
passed to the run but never stored or returned.
passed to the run but never stored or returned. Steward's built-in
playbooks get `steward_url` (Steward's own address) filled in when it is
left out; the reply's `filled_by_steward` says so.
limit, tags: Ansible's --limit and --tags; empty for none.
check: true (default) runs with --check --diff, which reports what would
change and changes nothing. Pass false to apply — after a check run
@@ -405,7 +437,9 @@ async def run_playbook(source: str, playbook: str, scope: str = "all",
raise ValueError(f"{playbook} is flagged destructive by its author (steward:confirm). "
"Run it with check=true first; to apply, pass check=false and "
"confirmed=true. Nothing was run.")
plain, secret = split_vars(src_module.discover_playbook_variables(content), extra_vars)
variables = src_module.discover_playbook_variables(content)
extra_vars, filled = steward_defaults(s, variables, extra_vars)
plain, secret = split_vars(variables, extra_vars)
async with db_session() as db:
stored_scope, scope_desc = await _scope(db, s, scope)
params = build_params(plain, limit, tags, check)
@@ -418,7 +452,7 @@ async def run_playbook(source: str, playbook: str, scope: str = "all",
raise ValueError(f"the run could not start: {err}.")
await audit_mcp("ansible.run.started", "ansible_run", run.id, {
"source": s["name"], "playbook": playbook.strip(), "scope": stored_scope,
"check": check, "limit": params.get("limit"), "tags": params.get("tags"),
"scope_label": scope_desc, "check": check, "limit": params.get("limit"), "tags": params.get("tags"),
"extra_vars": sorted(plain), "secret_vars": sorted(secret)})
return {
"run_id": run.id,
@@ -426,6 +460,7 @@ async def run_playbook(source: str, playbook: str, scope: str = "all",
"scope": scope_desc,
"status": run.status.value,
"secret_vars_passed": sorted(secret),
**({"filled_by_steward": filled} if filled else {}),
"summary": (f"{'Check-mode (dry) run' if check else 'Run'} of {playbook.strip()} "
f"on {scope_desc} started."
+ (" It changes nothing; pass check=false to apply." if check else "")),
@@ -447,7 +482,7 @@ async def cancel_ansible_run(run_id: str) -> dict:
# ── schedules ────────────────────────────────────────────────────────────────
def schedule_row(sc: AnsibleSchedule, last: AnsibleRun | None) -> dict:
def schedule_row(sc: AnsibleSchedule, last: AnsibleRun | None, labels: dict) -> dict:
params = sc.params or {}
nxt = sc.last_run_at + timedelta(seconds=sc.interval_seconds) if sc.last_run_at else None
return {
@@ -456,6 +491,7 @@ def schedule_row(sc: AnsibleSchedule, last: AnsibleRun | None) -> dict:
"source": sc.source_name,
"playbook": sc.playbook_path,
"scope": sc.inventory_scope,
"scope_label": labels.get(sc.inventory_scope, sc.inventory_scope),
"interval_s": sc.interval_seconds,
"enabled": sc.enabled,
"check": bool(params.get("check")),
@@ -487,7 +523,8 @@ async def list_schedules() -> dict:
ids = [s.last_run_id for s in scheds if s.last_run_id]
runs = {r.id: r for r in (await db.execute(
select(AnsibleRun).where(AnsibleRun.id.in_(ids)))).scalars()} if ids else {}
rows = [schedule_row(s, runs.get(s.last_run_id)) for s in scheds]
labels = await scope_labels(db, [s.inventory_scope for s in scheds])
rows = [schedule_row(s, runs.get(s.last_run_id), labels) for s in scheds]
return {"schedules": rows, "total": len(rows)}
@@ -495,7 +532,8 @@ async def _schedule_fields(db, source: str, playbook: str, scope: str, extra_var
limit: str, tags: str, check: bool) -> dict:
s = _source(source)
content = _playbook(s, playbook)
plain, secret = split_vars(src_module.discover_playbook_variables(content), extra_vars)
variables = src_module.discover_playbook_variables(content)
plain, secret = split_vars(variables, steward_defaults(s, variables, extra_vars)[0])
if secret:
raise ValueError(f"{', '.join(sorted(secret))} {'is' if len(secret) == 1 else 'are'} secret "
"and a schedule would have to store it. Put it in the target's or group's "
@@ -531,10 +569,11 @@ async def create_schedule(name: str, source: str, playbook: str, interval_s: int
sc = AnsibleSchedule(name=name.strip()[:128], interval_seconds=_interval(interval_s),
enabled=enabled, **fields)
db.add(sc)
labels = await scope_labels(db, [sc.inventory_scope])
await audit_mcp("ansible.schedule.created", "ansible_schedule", sc.id,
{"name": sc.name, "playbook": sc.playbook_path, "interval_s": sc.interval_seconds,
"check": check})
return schedule_row(sc, None)
return schedule_row(sc, None, labels)
async def update_schedule(schedule: str, name: str | None = None, source: str | None = None,
@@ -569,7 +608,7 @@ async def update_schedule(schedule: str, name: str | None = None, source: str |
sc.interval_seconds = _interval(interval_s)
if enabled is not None:
sc.enabled = enabled
row = schedule_row(sc, None)
row = schedule_row(sc, None, await scope_labels(db, [sc.inventory_scope]))
changed = sorted(k for k, v in {"name": name, "source": source, "playbook": playbook,
"interval_s": interval_s, "scope": scope, "extra_vars": extra_vars,
"limit": limit, "tags": tags, "check": check,
@@ -623,7 +662,8 @@ async def set_alert_action(rule: str, source: str = "", playbook: str = "", inve
raise ValueError(f"source {s['name']!r} has no inventory file {inv!r}"
+ (f"; it has {', '.join(found)}." if found else "; it has none.")
+ " Nothing was saved.")
plain, secret = split_vars(src_module.discover_playbook_variables(content), extra_vars)
variables = src_module.discover_playbook_variables(content)
plain, secret = split_vars(variables, steward_defaults(s, variables, extra_vars)[0])
if secret:
raise ValueError(f"{', '.join(sorted(secret))} is secret and an alert action would "
"have to store it. Nothing was saved.")
+1 -1
View File
@@ -20,7 +20,7 @@
<div style="display:grid;grid-template-columns:auto 1fr;gap:0.4rem 1rem;font-size:0.9rem;margin-bottom:1rem;">
<span style="color:var(--text-muted);">ID</span><span style="color:var(--text-dim);font-family:monospace;">{{ run.id }}</span>
<span style="color:var(--text-muted);">Playbook</span><span>{{ run.playbook_path }}</span>
<span style="color:var(--text-muted);">Scope</span><span style="font-family:ui-monospace,monospace;font-size:0.85rem;">{{ run.inventory_scope }}</span>
<span style="color:var(--text-muted);">Scope</span><span title="{{ run.inventory_scope }}">{{ scope_label or run.inventory_scope }}</span>
<span style="color:var(--text-muted);">Source</span><span>{{ run.source_name }}</span>
<span style="color:var(--text-muted);">Triggered by</span><span>{{ triggered_label }}</span>
<span style="color:var(--text-muted);">Status</span><span style="color:{{ status_color }};font-weight:bold;">{{ run.status.value.upper() }}</span>
+1 -1
View File
@@ -35,7 +35,7 @@
<tr style="{% if not s.enabled %}opacity:0.55;{% endif %}">
<td style="font-weight:500;">{{ s.name }}</td>
<td style="font-size:0.85rem;"><span style="color:var(--text-dim);">{{ s.source_name }}</span> / {{ s.playbook_path }}</td>
<td style="font-size:0.82rem;color:var(--text-muted);font-family:ui-monospace,monospace;">{{ s.inventory_scope }}</td>
<td style="font-size:0.85rem;" title="{{ s.inventory_scope }}">{{ row.scope_label }}</td>
<td style="font-size:0.85rem;">{{ fmt_interval(s.interval_seconds) }}</td>
<td style="font-size:0.82rem;">
{% if s.last_error %}
+10
View File
@@ -128,3 +128,13 @@ def test_yaml_injected_hostvars_survive():
def test_yaml_empty_targets():
parsed = yaml.safe_load(inventory_to_yaml(generate_inventory([])))
assert parsed == {"all": {"hosts": {}}}
def test_describe_scope_names_what_a_run_targeted():
from steward.ansible.inventory_gen import describe_scope
targets, groups = {"t1": "web-1"}, {"g1": "Docker Swarm"}
assert describe_scope("steward:all", targets, groups) == "all inventory targets"
assert describe_scope("steward:target:t1", targets, groups) == "target web-1"
assert describe_scope("steward:group:g1", targets, groups) == "group Docker Swarm"
assert describe_scope("steward:target:abcdef0123", targets, groups) == "target abcdef01 (deleted)"
assert describe_scope("repo:homelab:hosts.yml", targets, groups) == "homelab/hosts.yml"
+4 -1
View File
@@ -121,6 +121,8 @@ def test_runs_schedules_and_alert_actions_through_mcp(app, tmp_path):
done = await a.wait_for_run(started["run_id"], timeout_s=120)
assert done["finished"] and done["status"] == "success", done["tail"]
assert done["check"] is True and done["triggered_by"] == username
# The scope reads as the target's name, not its id.
assert done["scope"].startswith("steward:target:") and done["scope_label"] == f"target {target_name}"
assert not out_file.exists()
log = "\n".join(done["tail"])
assert "GREETING-yo" in log and f"TOKEN-LEN-{len(SECRET)}" in log
@@ -134,6 +136,7 @@ def test_runs_schedules_and_alert_actions_through_mcp(app, tmp_path):
detail = json.loads(rows[0].detail_json)
assert detail["check"] is True and detail["token_id"] == 42
assert detail["secret_vars"] == ["api_token"] and SECRET not in rows[0].detail_json
assert detail["scope_label"].startswith(f"target {target_name}")
# ── applying a confirm playbook needs confirmed=true ──────────────
with pytest.raises(ValueError, match="flagged destructive"):
@@ -166,7 +169,7 @@ def test_runs_schedules_and_alert_actions_through_mcp(app, tmp_path):
sc = await a.create_schedule(f"sched-{tag}", src, "play.yml", 3600,
scope=f"target:{target_name}",
extra_vars={"out_file": str(out_file)})
assert sc["check"] is True and sc["enabled"] is True
assert sc["check"] is True and sc["enabled"] is True and sc["scope_label"] == f"target {target_name}"
up = await a.update_schedule(f"sched-{tag}", enabled=False, interval_s=86400)
assert (up["enabled"], up["interval_s"], up["check"]) == (False, 86400, True)
assert up["extra_vars"] == {"out_file": str(out_file)} # untouched fields stay
+25
View File
@@ -107,3 +107,28 @@ def test_a_viewer_token_cannot_start_a_run(app):
assert "needs the operator role" in app.mcp_server.refusal("run_playbook", viewer)
assert app.mcp_server.refusal("run_playbook", operator) is None
assert "needs the admin role" in app.mcp_server.refusal("set_alert_action", operator)
def test_builtin_playbooks_get_steward_url_from_steward():
from steward.mcp import _context
app = NS(config={"PUBLIC_BASE_URL": ""})
a, u = _context._app_ctx.set(app), _context._base_url_ctx.set("http://steward.lan")
try:
declared = [{"name": "steward_url", "default": "", "secret": False, "required": False,
"prompt": None}]
builtin = {"name": "steward-builtin"}
got, filled = automation.steward_defaults(builtin, declared, {"agent_interval": 30})
assert got == {"agent_interval": 30, "steward_url": "http://steward.lan"}
assert filled == {"steward_url": "http://steward.lan"}
# The configured public URL wins over the address the request came in on.
app.config["PUBLIC_BASE_URL"] = "https://steward.example/"
assert automation.steward_defaults(builtin, declared, {})[1] == {
"steward_url": "https://steward.example"}
# A value the caller passed stands; another source's playbook is untouched.
assert automation.steward_defaults(builtin, declared, {"steward_url": "http://x"})[1] == {}
assert automation.steward_defaults({"name": "homelab"}, declared, {}) == ({}, {})
# A playbook that doesn't declare it gets nothing extra.
assert automation.steward_defaults(builtin, [], {}) == ({}, {})
finally:
_context._app_ctx.reset(a)
_context._base_url_ctx.reset(u)