Files
computer-playbook/roles/web-app-moodle/tasks/utils/mcp/verify.yml
kevinveenbirkenbachandClaude Opus 5 5e810c4a5a fix(moodle): accept MCP requests under the internal service name
Moodle 303-redirects every request whose Host is not $CFG->wwwroot, and an MCP
client dialling the internal service name cannot follow that redirect from the
container network. The previous accommodation was on the wrong side: the probe
sent -H 'Host: <public domain>' and -H 'X-Forwarded-Proto: https' and pinned
mcp_probe_host to MOODLE_DOMAIN, so it asserted the endpoint works when spoofed
and said nothing about how a real client reaches it.

Both headers and the probe host are gone. Acceptance moves into config.php,
which reads MOODLE_INTERNAL_HOST -- rendered from MOODLE_MCP_SERVICE_KEY -- and,
when the incoming HTTP_HOST matches it, rewrites HTTP_HOST and SERVER_NAME to
the wwwroot host before Moodle's redirect logic runs. The comparison splits on
':' so a port cannot defeat the match, and a request from any other host is
untouched, leaving the canonical-host protection intact for real traffic.

None of that could have taken effect on a deployed instance. The bootstrap seeds
the code directory with `cp -an`, which never clobbers, so an existing volume
keeps its first-run config.php forever and every later config change is silently
discarded. moodle_refresh_config now copies the deployment-managed config.php on
every start, which is what makes the rewrite above reachable at all.

Not verified: no MCP call has been observed succeeding against the internal name
since the change. This is also the same failure class that ended the last
deploy on another role, where the client already shared the provider's network
and had the right alias registered, yet the DNS search suffix still resolved the
single-label name to the public ingress. If that applies here too, the Host fix
is correct and the request still never arrives -- and removing the spoofing
headers will surface it as a failure where the probe previously passed.

Two further consequences worth stating: config.php is now overwritten on every
start, so an operator edit inside the container is discarded; and the HTTP_HOST
rewrite runs in the hot path of every page load, narrow though its condition is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 14:15:01 +02:00

28 lines
1.1 KiB
YAML

---
- name: "🩺 List the MCP tools with the minted bearer"
ansible.builtin.shell: | # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node
container exec -i {{ MOODLE_MCP_LOCAL_CID | quote }} \
{{ lookup('timeout', 30) | curl(5, 0) }} -f \
-X POST \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H {{ ('Authorization: Bearer ' ~ moodle_mcp_token) | quote }} \
--data-binary {{ MOODLE_MCP_TOOLS_LIST_PAYLOAD | to_json | quote }} \
{{ MOODLE_MCP_URL | quote }}
register: moodle_mcp_tools_probe
changed_when: false
retries: 30
delay: 2
until: moodle_mcp_tools_probe.rc == 0 and '"tools"' in moodle_mcp_tools_probe.stdout
delegate_to: "{{ MOODLE_MCP_HOST_NODE }}"
run_once: true
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: "🛑 Refuse an MCP surface that exposes no tool"
ansible.builtin.assert:
that:
- (moodle_mcp_tools_probe.stdout | from_json).result.tools | length > 0
fail_msg: >-
{{ MOODLE_MCP_URL }} answered the bearer but listed no tool, so every MCP
client would connect to an empty surface.