Files
WP-LIT-MCP/tests/Integration/mcp-smoke.sh
T
janniclas.loosenandClaude Opus 5.5 0e3332ad3e fix: close etappe-0 whole-branch review findings
- Autoloader: add automattic/jetpack-autoloader and load
  vendor/autoload_packages.php; require wordpress/mcp-adapter directly (^0.5).
- ServerRegistrar: make the adapter create_server() call and the
  OAuthServerRegistrar class_exists() check injectable so createServer()'s
  unhook-before-create ordering and the "mcp-oauth absent" branch are unit
  tested; add a smoke-test check that booting REST triggers no
  _doing_it_wrong().
- mcp-smoke.sh: assert mcp-adapter's default server route 404s, and that the
  subscriber role-gate denial actually carries our/the adapter's denial text
  (not just any isError/error).
- Guard tests/Integration/mint-token.php and tec-probe.php with
  `defined('WP_CLI') && WP_CLI || exit;`; add .gitattributes export-ignore
  for tests/docs-superpowers/.superpowers/phpunit/phpcs config.
- Security: add Server/RequestContext, tracking via
  rest_request_before_callbacks whether the current REST dispatch is our
  OAuth-protected /mcp/mcp-oauth-server route; AbilityRegistrar's
  permission_callback now denies (after the role gate) when abilities are
  invoked outside that route. Wired in Plugin::boot().
- Minors: single Plugin::VERSION constant used by ServerRegistrar;
  IntegrationRegistry caches its computed abilities list; tec-probe.php
  checks wp_insert_post's result and cleans up its probe post in a
  try/finally.

composer test: 47/47 green. composer lint: clean. Full mcp-smoke.sh run ends
ALL CHECKS PASSED (plus the expected /.well-known WARN).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-25 19:23:09 +02:00

173 lines
9.4 KiB
Bash
Raw 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
PLUGIN=wp-content/plugins/WP-LIT-MCP
ENDPOINT=http://localhost/wp-json/mcp/mcp-oauth-server
cd /var/www/html
fail() { echo "FAIL: $*"; exit 1; }
pass() { echo "ok - $*"; }
# --- 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_app_passwords_for() { # $1 = user_login
local login="$1"
[ -n "$login" ] || return 0
local escaped=${login//\\/\\\\}
escaped=${escaped//\'/\\\'}
wp eval "
\$u = get_user_by('login', '${escaped}');
if (\$u) {
foreach (WP_Application_Passwords::get_user_application_passwords(\$u->ID) as \$pw) {
if ((\$pw['name'] ?? '') === 'lit-mcp-integration-test') {
WP_Application_Passwords::delete_application_password(\$u->ID, \$pw['uuid']);
}
}
}
" >/dev/null 2>&1 || true
}
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
mcp() { # $1 token, $2 json body, $3 session id (optional) -> prints headers+body
local session_header=()
[ -n "${3:-}" ] && session_header=(-H "Mcp-Session-Id: $3")
curl -s --max-time 20 -i -X POST "$ENDPOINT" \
-H "Authorization: Bearer $1" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
"${session_header[@]}" -d "$2"
}
# 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),
# 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).
HOST=$(wp option get home | sed -E 's#^[a-zA-Z]+://##')
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=$(wp eval-file "$PLUGIN/tests/Integration/mint-token.php" "$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 the mint-
# token.php 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=$(wp eval-file "$PLUGIN/tests/Integration/mint-token.php" 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)
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"