Files
kevinveenbirkenbachandClaude Opus 5 70feb319be feat(nextcloud): front the Context Agent with the MCP adapter
The hub serves 94 tools across 23 categories, write tools included, and enabling the ExApp switches every category on. That surface was published on the public vhost and every discovered client was handed an app password for it. svc-ai-mcp-adapter now sits in front in mcp_passthrough mode: clients present credentials.mcp_bearer to contextagentmcp and reach the nine read tools of calendar, contacts and files, which files/mcp/tools.json names and the sidecar enforces per call. Those three categories are the ones whose upstream is_available is unconditional, so the served set does not vary with which optional apps a deployment installs.

Enforcing it in the sidecar rather than in each client is what makes it hold: web-app-openwebui renders no per-tool filter at all, so a client-side include list would have bounded two consumers out of three.

06c13e7f3e withdrew the declared path from the edge for exactly this reason - "that surface is what the adapter exists to narrow". Nextcloud answers the same route under a second spelling, without the /index.php front controller, which try_files rewrites back onto the route after the edge has already declined to block it. The withdrawal becomes a regex covering both, and only where stripping the front controller changes the path, so the four adapter providers without one render unchanged.

The app password is minted after the stack renders, so it reaches the sidecar through upstream.env and rebuild.yml rather than the compose environment: compose up resolves env_file while creating the container, a restart does not. The sidecar is the last service in the template because a Jinja set has no block scope and its env_file would otherwise displace the one talk, whiteboard and talk_recording each carry.

Two suites cover what nothing measured: the vhost template rendered without a container, asserting both spellings are withdrawn, and every shipped tools.json loaded through the adapter own loader, so a bad contract fails in a second instead of as a probe timing out an hour into a deploy. The guest spec probed mcp.endpoint.path, which this change makes the proxy answer 404 for its own reasons; it now probes all three public paths and requires no MCP body.

Not yet deployed: the sidecar build, the upstream.env delivery and the hub answering exactly nine tools are unverified against a running stack.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 20:55:05 +02:00

101 lines
4.3 KiB
YAML

---
- name: "🔍 Resolve the Nextcloud 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: "{{ NEXTCLOUD_SERVICE }}"
resolve_output_prefix: NEXTCLOUD
resolve_compose_cid: "{{ NEXTCLOUD_CONTAINER }}"
- name: "🔍 Resolve the proxy 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: "{{ NEXTCLOUD_PROXY_SERVICE }}"
resolve_output_prefix: NEXTCLOUD_PROXY
resolve_compose_cid: "{{ NEXTCLOUD_PROXY_CONTAINER }}"
- name: "🔍 Resolve the Context Agent 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: "{{ NEXTCLOUD_CONTEXT_AGENT_SERVICE }}"
resolve_output_prefix: NEXTCLOUD_CONTEXT_AGENT
resolve_compose_cid: "{{ NEXTCLOUD_CONTEXT_AGENT_CONTAINER }}"
- name: "⏳ Wait until the Context Agent answers its heartbeat"
ansible.builtin.command:
argv:
- container
- exec
- "{{ NEXTCLOUD_CONTEXT_AGENT_LOCAL_CID }}" # nocheck: container-exec-resolver
- python3
- -c
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:{{ NEXTCLOUD_CONTEXT_AGENT_PORT }}/heartbeat', timeout=5)"
register: nextcloud_context_agent_heartbeat
retries: 60
delay: 5
until: nextcloud_context_agent_heartbeat.rc == 0
changed_when: false
delegate_to: "{{ NEXTCLOUD_CONTEXT_AGENT_HOST_NODE }}"
run_once: true
- name: "🚀 Register the Context Agent as an AppAPI ExApp"
ansible.builtin.include_tasks:
file: "{{ role_path }}/tasks/utils/mcp/register.yml"
apply:
delegate_to: "{{ NEXTCLOUD_HOST_NODE }}"
run_once: true
vars:
nextcloud_exapp_renew: false
- name: "🔍 Read the MCP app password this deployment already issued"
ansible.builtin.set_fact:
nextcloud_mcp_token: "{{ lookup('mcp_credential', application_id) }}"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: "🩺 Check the stored app password against the MCP endpoint"
ansible.builtin.include_tasks: "{{ role_path }}/tasks/utils/mcp/probe.yml"
when: nextcloud_mcp_token | length > 0
- name: "🔑 Mint and persist a fresh MCP app password"
ansible.builtin.include_tasks: "{{ role_path }}/tasks/utils/mcp/token.yml"
when: nextcloud_mcp_token | length == 0 or nextcloud_mcp_probe.stdout | default('') != '200'
- name: "🩺 Re-check the freshly minted app password"
ansible.builtin.include_tasks: "{{ role_path }}/tasks/utils/mcp/probe.yml"
when: nextcloud_mcp_probe is not defined or nextcloud_mcp_probe.stdout != '200'
- name: "♻️ Re-register the ExApp a rotated app secret left behind"
ansible.builtin.include_tasks:
file: "{{ role_path }}/tasks/utils/mcp/register.yml"
apply:
delegate_to: "{{ NEXTCLOUD_HOST_NODE }}"
run_once: true
vars:
nextcloud_exapp_renew: true
when: nextcloud_mcp_probe.stdout != '200'
- name: "🩺 Re-check the endpoint behind the re-registered ExApp"
ansible.builtin.include_tasks: "{{ role_path }}/tasks/utils/mcp/probe.yml"
when: nextcloud_mcp_probe.stdout != '200'
- name: "🛑 Refuse an MCP endpoint that rejects the stored app password"
ansible.builtin.assert:
that:
- nextcloud_mcp_probe.stdout == '200'
fail_msg: >-
The MCP endpoint {{ NEXTCLOUD_MCP_PATH }} answered
{{ nextcloud_mcp_probe.stdout | default('no status') }} for the app password stored
for {{ application_id }}, so every MCP client would be configured with a
bearer the endpoint rejects.
- name: "🔑 Hand the minted app password to the adapter"
ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/svc-ai-mcp-adapter/tasks/upstream_key.yml') }}"
vars:
mcp_upstream_key_path: "{{ NEXTCLOUD_MCP_ADAPTER_UPSTREAM_ENV }}"
mcp_upstream_key: "{{ lookup('mcp_credential', application_id) }}"
- name: "🧱 Rebuild the MCP adapter of {{ application_id }} now the credential exists"
ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/svc-ai-mcp-adapter/tasks/rebuild.yml') }}"
- name: "🚿 Flush compose handlers for {{ application_id }}"
ansible.builtin.meta: flush_handlers