- 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
173 lines
9.4 KiB
Bash
173 lines
9.4 KiB
Bash
#!/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"
|