Files
2026-09-26 18:44:10 +02:00

160 lines
9.6 KiB
Bash
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# End-to-end checks of the OAuth-protected MCP endpoint.
# Runs INSIDE the DDEV web container (wp-cli and curl available there):
# cd /home/janniclas/Projekte/WordPress && ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/mcp-smoke.sh
set -euo pipefail
cd /var/www/html
# shellcheck source=lib.sh
source wp-content/plugins/WP-LIT-MCP/tests/Integration/lib.sh
# --- Cleanup: remove every "lit-mcp-integration-test" application password
# this script mints (for every user it mints one for) and the throwaway
# subscriber it creates. Registered up front so it runs on any exit path
# (success, fail(), or an unexpected error under `set -e`).
ADMIN_LOGIN=""
cleanup() {
cleanup_app_passwords_for "$ADMIN_LOGIN"
cleanup_app_passwords_for lit-smoke-sub
wp user delete lit-smoke-sub --yes >/dev/null 2>&1 || true
}
trap 'rc=$?; cleanup; guard_exit "$rc" || exit $?' EXIT
# 0. Booting REST triggers no _doing_it_wrong(). A regression in the
# unhook-before-create ordering (ServerRegistrar::createServer()) would
# otherwise surface as a "duplicate server id" _doing_it_wrong() notice when
# mcp-adapter's own mcp_adapter_init action fires during rest_api_init.
DOING_IT_WRONG=$(wp eval "
add_action('doing_it_wrong_run', static function () { echo 'DOING_IT_WRONG'; });
do_action('rest_api_init');
" 2>&1)
echo "$DOING_IT_WRONG" | grep -q 'DOING_IT_WRONG' \
&& fail "_doing_it_wrong() triggered while booting REST: $DOING_IT_WRONG"
pass "booting REST triggers no _doing_it_wrong()"
# 1. Discovery documents
#
# The local DDEV nginx config denies all dotfile paths (`location ~ /\. { deny
# all; }`, in the parent project's .ddev/nginx_full/nginx-site.conf, outside
# this plugin), which blocks the real `/.well-known/...` paths before the
# request ever reaches WordPress. That's an environment/deployment concern,
# not a WP-LIT-MCP or mcp-oauth defect, so it must not fail this test.
#
# The HARD checks below instead hit mcp-oauth's own rewrite target directly
# (index.php?mcp_oauth_discovery=...) via a request whose Host matches the
# site's home URL (WordPress issues a 301 to the canonical host otherwise;
# HOST comes from lib.sh), proving the discovery documents themselves are
# correct. The ENVIRONMENT check further down separately probes the real
# `/.well-known/` path and only warns, never fails, so a misconfigured web
# server doesn't block this suite while still surfacing the risk (real MCP
# clients like claude.ai use the real path, not the rewrite target).
curl -s --max-time 20 -H "Host: $HOST" -H 'X-Forwarded-Proto: https' \
'http://localhost/?mcp_oauth_discovery=protected-resource' \
| grep -q '"resource"' \
|| fail "protected-resource metadata missing"
pass "RFC 9728 protected-resource metadata served"
curl -s --max-time 20 -H "Host: $HOST" -H 'X-Forwarded-Proto: https' \
'http://localhost/?mcp_oauth_discovery=authorization-server' \
| grep -Eq '"client_id_metadata_document_supported":[[:space:]]*true' \
|| fail "authorization-server metadata missing CIMD flag"
pass "RFC 8414 authorization-server metadata advertises CIMD"
# The real .well-known path, same Host/proto, WITHOUT following redirects: clients use
# the exact resource_metadata URL from the 401 header. Needs the web server to pass
# /.well-known/ to WordPress (DDEV: .ddev/nginx/well-known.conf) and the plugin's
# DiscoveryCanonical (no trailing-slash 301).
for DOC in oauth-protected-resource oauth-authorization-server; do
CODE=$(curl -s --max-time 20 -o /dev/null -w '%{http_code}' -H "Host: $HOST" -H 'X-Forwarded-Proto: https' \
"http://localhost/.well-known/$DOC")
[ "$CODE" = "200" ] || fail "/.well-known/$DOC answered $CODE (web server blocks it or WordPress redirects)"
done
pass "/.well-known/ discovery documents answer directly (200, no redirect)"
# 2. Unauthenticated request -> 401 with WWW-Authenticate
UNAUTH=$(curl -s --max-time 20 -i -X POST "$ENDPOINT" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}')
echo "$UNAUTH" | head -1 | grep -q ' 401' || fail "expected 401 without token"
echo "$UNAUTH" | grep -qi '^www-authenticate: Bearer' || fail "missing WWW-Authenticate header"
pass "401 + WWW-Authenticate without token"
# 3. Administrator: initialize, tools/list, tools/call
ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1)
TOKEN=$(mint_token "$ADMIN_LOGIN")
INIT=$(mcp "$TOKEN" '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}')
SESSION=$(echo "$INIT" | grep -i '^mcp-session-id:' | awk '{print $2}' | tr -d '\r' || true)
echo "$INIT" | grep -q 'LIT MCP pflegt' || fail "instructions missing in initialize result"
pass "initialize returns LIT instructions (session: ${SESSION:-none})"
LIST=$(mcp "$TOKEN" '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' "$SESSION")
for tool in lit-guide lit-site-status mcp-adapter-execute-ability; do
echo "$LIST" | grep -q "\"$tool\"" || fail "tools/list lacks $tool"
done
pass "tools/list contains direct tools and meta-tools"
CALL=$(mcp "$TOKEN" '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"lit-site-status","arguments":{}}}' "$SESSION")
echo "$CALL" | grep -q '"isError":false' || fail "lit-site-status failed: $CALL"
echo "$CALL" | grep -q 'the-events-calendar' || fail "site-status lacks components"
pass "tools/call lit-site-status works"
# 3b. mcp-adapter's default (unprotected) server must not exist: the route
# it would register, mcp-adapter-default-server, must 404. Uses a temporary
# application password (Basic auth) instead of the MCP bearer token, since
# this checks a plain REST route, not the MCP endpoint. Named like mint_token's
# passwords so the cleanup trap above also removes it.
APP_PASSWORD=$(wp user application-password create "$ADMIN_LOGIN" lit-mcp-integration-test --porcelain)
DEFAULT_SERVER_STATUS=$(curl -s --max-time 20 -o /dev/null -w '%{http_code}' \
-H "Host: $HOST" -H 'X-Forwarded-Proto: https' \
-X POST 'http://localhost/wp-json/mcp/mcp-adapter-default-server' \
-u "${ADMIN_LOGIN}:${APP_PASSWORD}" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}')
[ "$DEFAULT_SERVER_STATUS" = "404" ] \
|| fail "mcp-adapter's default server is still reachable (HTTP $DEFAULT_SERVER_STATUS)"
pass "mcp-adapter's default server (mcp-adapter-default-server) is gone (404)"
# 4. Token must not authenticate other REST routes (mcp-oauth issue #62)
ME=$(curl -s --max-time 20 -o /dev/null -w '%{http_code}' http://localhost/wp-json/wp/v2/users/me -H "Authorization: Bearer $TOKEN")
[ "$ME" = "401" ] || fail "MCP token authenticates /wp/v2/users/me (HTTP $ME) – scope filter needed"
pass "MCP token is rejected on /wp/v2/users/me"
# 5. Role gate: subscriber has a valid token but must not use tools
wp user create lit-smoke-sub lit-smoke-sub@example.invalid --role=subscriber >/dev/null
SUB_TOKEN=$(mint_token lit-smoke-sub)
SUB_INIT=$(mcp "$SUB_TOKEN" '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}')
SUB_SESSION=$(echo "$SUB_INIT" | grep -i '^mcp-session-id:' | awk '{print $2}' | tr -d '\r' || true)
# 5a. Visibility (Task 7): VisibilityFilter hides our server's tool list and
# instructions from a subscriber entirely, before AbilityRegistrar's own
# per-call deny below is even reached. tools/list comes back empty (no direct
# tools, no meta-tools either – the adapter still accepts an empty array, see
# HandlerHelperTrait::validate_filtered_list); instructions carry only the
# no-access notice, never integration names or post types.
echo "$SUB_INIT" | grep -q 'nicht freigegeben' \
|| fail "subscriber initialize instructions lack the no-access notice: $SUB_INIT"
echo "$SUB_INIT" | grep -Eq 'Inhaltstypen|Divi|Pods|Aktive Integrationen|Direkt aufrufbare Tools' \
&& fail "subscriber initialize instructions leak integration/post-type details: $SUB_INIT"
pass "subscriber initialize instructions carry only the no-access notice"
SUB_LIST=$(mcp "$SUB_TOKEN" '{"jsonrpc":"2.0","id":10,"method":"tools/list"}' "$SUB_SESSION")
echo "$SUB_LIST" | grep -q '"tools":\[\]' \
|| fail "subscriber tools/list is not empty: $SUB_LIST"
pass "subscriber tools/list is empty (no direct tools, no meta-tools)"
SUB_CALL=$(mcp "$SUB_TOKEN" '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"lit-site-status","arguments":{}}}' "$SUB_SESSION")
echo "$SUB_CALL" | grep -Eq '"isError":[[:space:]]*true|"error":[[:space:]]*\{' || fail "subscriber could call lit-site-status: $SUB_CALL"
# The denial text must actually be our role-gate message, not just any error
# (a crash or validation failure also sets isError/error and must not pass).
echo "$SUB_CALL" | grep -q 'nicht freigegeben' \
|| fail "lit-site-status denial lacks role-gate message: $SUB_CALL"
SUB_META=$(mcp "$SUB_TOKEN" '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"mcp-adapter-execute-ability","arguments":{"ability_name":"lit/site-status","parameters":{}}}}' "$SUB_SESSION")
echo "$SUB_META" | grep -Eq '"isError":[[:space:]]*true|"error":[[:space:]]*\{' || fail "subscriber could use execute-ability: $SUB_META"
# execute-ability denies at the adapter's own capability gate (mcp_adapter_execute_ability_capability,
# set to lit_mcp_use by Access) before our ability's permission_callback ever runs, so the adapter's
# own "insufficient_capability" text ("...lacks required capability...") is what appears here, not
# our "nicht freigegeben" message.
echo "$SUB_META" | grep -Eq 'lacks required capability|nicht freigegeben' \
|| fail "execute-ability denial lacks role-gate message: $SUB_META"
pass "role gate blocks subscriber (direct tool and meta-tool)"
finish