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>
This commit is contained in:
2026-10-06 10:30:23 -04:00
co-authored by Claude Opus 5.5
parent 06e89308a8
commit 1d9ac2b46a
4 changed files with 78 additions and 6 deletions
+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
+38 -6
View File
@@ -22,7 +22,7 @@ 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
@@ -75,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.
@@ -180,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:
@@ -392,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
@@ -410,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)
@@ -431,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 "")),
@@ -502,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 "
@@ -631,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.")
+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)