Files
computer-playbook/roles/web-app-baserow/tasks/utils/mcp.yml
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

84 lines
2.9 KiB
YAML

---
- name: "🔍 Resolve {{ application_id }} hosting node + container id"
ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/swarm/resolve_host_cid.yml') }}"
vars:
resolve_service_key: "{{ BASEROW_SERVICE_NAME }}"
resolve_output_prefix: BASEROW
resolve_compose_cid: "{{ BASEROW_CONTAINER }}"
- name: "🔑 Create the MCP endpoint, its owner and its workspace"
ansible.builtin.command:
argv:
- container
- exec
- -i
- -e
- "DJANGO_SETTINGS_MODULE={{ BASEROW_DJANGO_SETTINGS_MODULE }}"
- -e
- "SECRET_KEY={{ BASEROW_SECRET_KEY }}"
- -e
- "BASEROW_SECRET_KEY={{ BASEROW_SECRET_KEY }}"
- -e
- "MCP_OWNER_EMAIL={{ BASEROW_MCP_OWNER_EMAIL }}"
- -e
- "MCP_WORKSPACE_NAME={{ BASEROW_MCP_WORKSPACE_NAME }}"
- -e
- "MCP_ENDPOINT_NAME={{ BASEROW_MCP_ENDPOINT_NAME }}"
- -e
- "MCP_ENDPOINT_KEY={{ BASEROW_MCP_ENDPOINT_KEY }}"
- "{{ BASEROW_LOCAL_CID }}" # nocheck: container-exec-resolver
- "{{ BASEROW_PYTHON }}"
- "{{ BASEROW_MANAGE_PY }}"
- shell
- -c
- "{{ lookup('file', 'python/provision_mcp.py') }}"
register: baserow_mcp_provision
retries: 30
delay: 10
until: baserow_mcp_provision.rc == 0
changed_when: "'CHANGED' in baserow_mcp_provision.stdout"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
delegate_to: "{{ BASEROW_HOST_NODE }}"
run_once: true
- name: "🩺 Probe the MCP endpoint on the internal origin"
ansible.builtin.command:
argv:
- container
- exec
- -i
- -e
- "MCP_BASE_URL={{ BASEROW_INTERNAL_URL }}"
- -e
- "MCP_ENDPOINT_PATH={{ BASEROW_MCP_ENDPOINT_PATH }}"
- -e
- "MCP_ENDPOINT_KEY={{ BASEROW_MCP_ENDPOINT_KEY }}"
- "{{ BASEROW_LOCAL_CID }}" # nocheck: container-exec-resolver
- "{{ BASEROW_PYTHON }}"
- "-"
stdin: "{{ lookup('file', 'python/probe_mcp_endpoint.py') }}"
register: baserow_mcp_probe
retries: 30
delay: 10
until: baserow_mcp_probe.rc == 0 or 'REJECTED' in baserow_mcp_probe.stderr
failed_when: false
changed_when: false
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
delegate_to: "{{ BASEROW_HOST_NODE }}"
run_once: true
- name: "🛑 Refuse an endpoint that never serves the MCP stream"
ansible.builtin.assert:
that:
- baserow_mcp_probe.rc == 0
fail_msg: >-
{{ BASEROW_INTERNAL_URL }}{{ BASEROW_MCP_ENDPOINT_PATH }} did not answer 401
for an unknown key and text/event-stream for the provisioned one, so every
MCP client would be pointed at a dead or unguarded endpoint.
- name: "🔁 Rebuild the adapter sidecar against the current contract"
ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/svc-ai-mcp-adapter/tasks/rebuild.yml') }}"
- name: "🔄 Start the sidecar before probing it"
ansible.builtin.meta: flush_handlers