From 1d9ac2b46a108b4e9e2b2ea408e56e70678eeb52 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 10:30:23 -0400 Subject: [PATCH] feat(mcp): built-in playbooks get steward_url from Steward itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 --- steward/mcp/_context.py | 12 +++++++++ steward/mcp/server.py | 3 +++ steward/mcp/tools/automation.py | 44 ++++++++++++++++++++++++++++----- tests/mcp/test_automation.py | 25 +++++++++++++++++++ 4 files changed, 78 insertions(+), 6 deletions(-) diff --git a/steward/mcp/_context.py b/steward/mcp/_context.py index 60a78d3..85df7a8 100644 --- a/steward/mcp/_context.py +++ b/steward/mcp/_context.py @@ -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("/") diff --git a/steward/mcp/server.py b/steward/mcp/server.py index f221e86..ca25c19 100644 --- a/steward/mcp/server.py +++ b/steward/mcp/server.py @@ -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 diff --git a/steward/mcp/tools/automation.py b/steward/mcp/tools/automation.py index e717881..93e6867 100644 --- a/steward/mcp/tools/automation.py +++ b/steward/mcp/tools/automation.py @@ -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:", or "inventory:" 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.") diff --git a/tests/mcp/test_automation.py b/tests/mcp/test_automation.py index a9a918d..777e97b 100644 --- a/tests/mcp/test_automation.py +++ b/tests/mcp/test_automation.py @@ -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)