The contract check ran as an Ansible task in the middle of every provider deploy, and the two client checks ran the same way. A test that is part of the deploy cannot be run without deploying, cannot be re-run on its own, and reports its verdict as a deploy failure. Twenty-seven task files lose 491 lines to this.
It is now what it always was. Each provider ships files/test/test.sh, templates/test.env.j2 and meta/tests.yml, and test-e2e-cli stages one shared harness beside it: run.sh drives contract.sh in a throwaway container on the provider's own network, then isolation.sh for adapter-fronted providers. The read probe each provider offers moved from the calling task into meta/mcp.yml next to the allowlist it must satisfy, and the schema now rejects a read probe naming a tool outside that allowlist.
The bearer the test presents is the one a client resolves through mcp.credential, via the new mcp_client_bearer lookup over resolve_credential, which moved to utils.roles.applications.mcp so it is the same function mcp_servers renders every client connection with. The probe presented whatever the calling role passed, which for the fifteen adapter-fronted providers is a different secret than any client holds.
Sidecar isolation is read from the engine rather than measured inside the container, so it no longer needs a delegation to the node holding the task: attachment is compared by network name, which drops the gwbridge special case, and a swarm worker says it cannot measure instead of skipping silently.
Retrying is bounded by wall clock against MCP_DEADLINE_SECONDS, which scales through the same lookup('timeout') as the harness budget it has to stay under. A flag that is neither true nor false aborts rather than reading as off.
mcp-provider-probe becomes mcp-provider-cli-test: an enabled provider must ship the test rather than include the task. mcp-probe-bearer becomes mcp-adapter-bearer, tying ADAPTER_BEARER to the declared credential key now that no probe include carries it. A new integration test renders the shared env template for every provider through Ansible's own templar, so a later change to it breaks visibly instead of poisoning twenty deploys quietly.
Hermes and OpenClaw keep their client-side proof as CLI tests of their own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Listmonk#0 of run 33414875521 failed the adapter probe in PASS 2:
REJECTED read call listmonk_get_stats answered 502:
{"error":{"code":-32000,"message":"upstream_error","data":{"upstream_status":403}}}
403, not a transport error: the adapter reached Listmonk and was turned away.
changed_when matched the psql tag `INSERT 0 1`, which by construction appears only
on the very first deploy -- mcp_identity.sql:14 guards the INSERT with
WHERE NOT EXISTS. On a rotation the row is already there, the statement takes the
UPDATE branch, the new sha256 lands in users.password, and changed_when evaluates
false. The restart block that 8e6fc81eff built then skips, and Listmonk keeps
answering from the API-user cache it loaded when compose recreated it two seconds
earlier -- still holding the previous hash.
The two halves have to move together. The users UPDATE carried no equality guard,
unlike the roles UPDATE two statements above it, so `UPDATE 1` fired on every run
and meant nothing. Guarding it makes the tag informative; only then may
changed_when read it. Guard alone leaves the rotation invisible exactly as today;
widened changed_when alone would restart Listmonk on every converged run.
Measured, not reasoned. sha256 recomputed locally and matched against the log:
'ZOPJ...XUNP' -> 6a8b5b5d... = PASS 1 token_hash (listmonk0.log:149940)
'aTiG...91Iz' -> a90e2a4d... = PASS 2 token_hash (:343692)
and the window is closed on both ends: compose up ended 21:08:57.93, the new hash
landed 21:08:59.70, the probe ran 21:09:02.15, nothing restarted in between. The
gate flip is verbatim in the log -- PASS 1 `INSERT 0 1 ... changed: true` then the
restart task; PASS 2 `INSERT 0 0 / UPDATE 1 ... changed: false` then
`skipping: false_condition: listmonk_mcp_identity.changed`.
Retrying could not have helped: probe.yml:115 short-circuits its 30 attempts on a
REJECTED verdict, and rightly so -- the database row was correct, the running
process was not.
Matching stdout_lines as list membership rather than a substring of stdout is
deliberate: psql prints each command tag on its own line, and 'UPDATE 1' as a
substring is also satisfied by `UPDATE 10`.
Listmonk was the only role in the tree inferring change from a psql tag --
grep -rn "INSERT 0 1" roles/ returns this one hit, while checkmk, n8n, baserow,
pretix, flowise and jellyfin all emit an explicit marker.
Not covered: the restart path is exercised against a first creation, never against
a rotation. A two-pass deploy closes that.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
After minting the MCP identity the role restarted Listmonk with `container restart`
on the container id. In swarm that id belongs to a task the orchestrator owns, and
the daemon refuses:
Error response from daemon: Cannot restart container 21bda395f531: failed
Thirty attempts, the same refusal each time, and the job ended there.
The compose path keeps `container restart`; swarm gets `container service update
--force` on the service name, which is what the orchestrator offers for this and
what web-app-jellyfin already does to reload its plugins. The swarm branch needs no
delegate_to -- a service update is the manager's business, not that of whichever node
happens to hold the task -- and carries a timeout, because forcing an update waits
for convergence.
Both branches sit in one block under the shared `when: listmonk_mcp_identity.changed`
so the condition cannot drift apart, and run_once moves up with it. Only the mode
test stays per branch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every MCP role keeps its task tree under tasks/utils/ without numeric
prefixes, so a caller can reach any of them through include_role
tasks_from. utils/roles/mapping.py holds that path as the single source.
Ansible resolves a relative include against the including file's own
directory when that file was reached by path, but against tasks/ when it
was reached through tasks_from. The same file therefore loaded from its
own role and failed from a caller using tasks_from. Every nested include
now names {{ role_path }}/tasks/utils/..., which holds under both bases,
and a lint walks each tasks_from entrypoint transitively to keep it so.
The exec-gating lint learns the same form, otherwise it loses the include
chain and reports inherited IS_STACK_HOST gates as missing.
The expression resolving a provider's stored token appeared eighteen
times. plugins/lookup/mcp_credential.py replaces it, and mcp_probe_owner
disappears from eight roles: it was only ever a step towards the bearer
and no probe read it.
The Hermes and OpenClaw probes stop aborting the play while it is still
running. Roles deploy in dependency order, so a client probing during its
own role can run before a provider exists at all, and failing there
prevents that provider from ever deploying. The verdict moves to the
reconciliation pass, gated on MCP_RECONCILE_STRICT, which the reconciler
sets and group_vars declares so the readers need no default.
The sys-svc-mcp-reconcile role is added here; wiring it into a stage is a
separate change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>