feat(mcp): built-in playbooks get steward_url from Steward itself
#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:
@@ -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("/")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user