Files
mcp-nextcloud/docs/ADR-029-pact-contract-testing.md
Chris CoutinhoandClaude Opus 4.8 d33832aba9 test: add Pact consumer contract for astrolabe credentials status (ADR-029)
Introduce consumer-driven contract testing between nextcloud-mcp-server and the
astrolabe Nextcloud app, published to the homelab Pact Broker and verified in CI.

- pact-python dev dep + `contract` pytest marker
- tests/contract/test_astrolabe_credentials_consumer.py: consumer pact for the
  background-sync *status* call (provisioned -> has_background_access:true,
  sync_type:"app_password", integer provisioned_at; unprovisioned -> false/null)
- tests/contract/test_mcp_provider_verification.py: env-gated Verifier harness
  for this server's /api/v1/* provider role (provider-state handlers stubbed
  pending astrolabe's published pacts)
- .github/workflows/pact.yml: join tailnet -> publish pacts -> provider verify
  -> can-i-deploy; broker steps skip when PACT_BROKER is unset (forks)
- docs/ADR-029-pact-contract-testing.md

Fix astrolabe_client.get_background_sync_status: it previously read a
non-existent `app_password` field (always reporting no-access). Rewrite it to
read the real status contract (has_background_access / sync_type /
provisioned_at) and drop the unsatisfiable get_user_app_password.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 20:22:07 +02:00

5.5 KiB

ADR-029: Pact contract testing with astrolabe

Status

Accepted — 2026-06-10

Context

nextcloud-mcp-server (Python/FastMCP) and the astrolabe Nextcloud app (PHP) integrate over HTTP in both directions:

  • MCP server → astrolabe (one call): the background vector-sync reads a user's provisioning status via GET /apps/astrolabe/api/v1/background-sync/credentials/{user_id} (nextcloud_mcp_server/auth/astrolabe_client.py). This returns status only — {success, user_id, has_background_access, sync_type, provisioned_at}, always HTTP 200, and never the password. The app password itself flows the other way: astrolabe pushes it to the MCP server's /api/v1/users/{uid}/app-password.
  • astrolabe → MCP server (~10 calls): astrolabe's McpServerClient consumes this server's /api/v1/* HTTP API — status, vector-sync/status, search, vector-viz/search, webhooks (GET/POST/DELETE), apps, chunk-context, pdf-preview (nextcloud_mcp_server/app.py route table, api/webhooks.py).

Today nothing guarantees the two stay wire-compatible. A renamed JSON field or a changed status code on either side is only caught — if at all — by the heavy integration matrix (docker-compose, real Nextcloud, minutes per run). We want a fast, focused signal that fails the moment the contract drifts, runnable in unit-style CI on either repo independently.

Decision

Adopt consumer-driven contract testing with Pact, with a self-hosted Pact Broker in the homelab as the system of record. GitHub Actions reach the broker over Tailscale (the broker is not publicly exposed).

Because the integration is bidirectional, each repo plays both Pact roles:

Contract Consumer Provider Consumer tooling Provider tooling
credentials API nextcloud-mcp-server astrolabe pact-python pact-php verifier
/api/v1/* API astrolabe nextcloud-mcp-server pact-php pact-python Verifier

This repo (Python side)

  • pact-python (v3 API, from pact import Pact, Verifier, match) is a dev dependency.
  • Contract tests live under tests/contract/ behind a contract pytest marker, kept out of the default unit run.
    • Consumer (test_astrolabe_credentials_consumer.py): drives the real AstrolabeClient against a Pact mock server, pinning the request shape and the two status responses it branches on — provisioned (has_background_access: true, sync_type: "app_password", integer provisioned_at) and unprovisioned (has_background_access: false). The OAuth token fetch is stubbed so only the credentials call is exercised. Interactions merge into tests/contract/pacts/ (git-ignored — pacts are published to the broker, not committed).
    • Provider (test_mcp_provider_verification.py): a Verifier harness that replays astrolabe's published pacts against a running MCP server. It is environment-gated (PACT_PROVIDER_URL + a pact source) so it skips in the consumer-only job and in local runs. Provider-state handlers are registered by given(...) string in _PROVIDER_STATES and filled in as astrolabe publishes its consumer pacts; unknown states no-op so state-less interactions (/api/v1/status, /api/v1/vector-sync/status) verify immediately.

CI (.github/workflows/pact.yml)

Three jobs, each joining the tailnet with the shared tag:github-runner OAuth client before touching the broker:

  1. consumer — generate pacts, publish to the broker tagged with the branch and commit SHA.
  2. provider — stand up the MCP server (single-user compose profile), verify astrolabe's pacts against it, publish verification results (master only).
  3. can-i-deploy — gate master on pact-broker can-i-deploy … --pacticipant nextcloud-mcp-server --to-environment production.

Broker-dependent steps are skipped when PACT_BROKER is unset (forks).

Infrastructure (other repos / sessions)

  • Broker: pactfoundation/pact-broker deployed via ArgoCD (homelab-argocd), backed by the shared Zalando Postgres, reachable at pact-broker.internal.coutinho.io. Single basic-auth credential from AWS Secrets Manager via external-secrets.
  • CI access: jobs join the tailnet (tag:github-runner) and reach the broker via the PACT_BROKER secret (its Tailscale host). No new Terraform is required.

Required secrets (this repo)

TS_OAUTH_CLIENT_ID, TS_OAUTH_SECRET (Tailscale github-runner OAuth client), PACT_BROKER (base URL), PACT_USERNAME, PACT_PASSWORD (broker basic auth).

Consequences

  • Fast, independent signal: either side detects an incompatible change in a ~seconds-long job instead of waiting on the integration matrix.
  • Participant names are load-bearing: the consumer/provider names (nextcloud-mcp-server, astrolabe) must match exactly across both repos and the broker. They live in tests/contract/conftest.py here and must mirror the astrolabe pact tests.
  • Provider states are deferred work: the /api/v1/* provider verification is only as complete as the state handlers that seed its backends (webhooks DB, Qdrant). These land incrementally as astrolabe publishes its consumer pacts.
  • Broker is a homelab dependency: contract publication/verification needs the tailnet and the broker up. Steps degrade to skipped (not failed) when the broker is unreachable from a fork; on master an outage will fail can-i-deploy.