Commit Graph
4 Commits
Author SHA1 Message Date
kevinveenbirkenbachandClaude Opus 5 791eee7c9f refactor(mcp)!: prove the endpoint contract with a CLI test, not a deploy probe
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>
2026-09-01 20:46:26 +02:00
kevinveenbirkenbachandClaude Opus 5 2071c347ae fix(listmonk): notice a rotated MCP token, not only a created one
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>
2026-08-31 23:55:57 +02:00
kevinveenbirkenbachandClaude Opus 5 8e6fc81eff fix(listmonk): reload the API user cache the way each deployment mode allows
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>
2026-08-31 15:17:33 +02:00
kevinveenbirkenbachandClaude Opus 5 a423bde6cc refactor(mcp): unify client entrypoints and read provider tokens through one lookup
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>
2026-08-09 21:06:31 +02:00