#!/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 cleanup 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" # ENVIRONMENT check (never fails): the real .well-known path, same Host/proto. WELLKNOWN=$(curl -s --max-time 20 -H "Host: $HOST" -H 'X-Forwarded-Proto: https' \ 'http://localhost/.well-known/oauth-protected-resource') if echo "$WELLKNOWN" | grep -q '"resource"'; then pass "/.well-known/ reachable" else echo "WARN - /.well-known/ blocked by web server config (claude.ai discovery will fail; allow .well-known in nginx)" fi # 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)" echo "ALL CHECKS PASSED"