#!/usr/bin/env bash # Shared helpers for the MCP integration scripts (mcp-smoke.sh, wp-basis-smoke.sh, # and Etappe 1b's smoke scripts). Source it from a script that runs INSIDE the DDEV # web container with /var/www/html as its working directory (cd there before # sourcing – HOST below shells out to `wp` immediately). # Requires jq and curl (both present in the DDEV web image). # Mutual exclusion of ALL smokes (every one sources this file inside the same web container): two at once would # share one site state – WP Super Cache edits wp-config.php, every smoke empties the test application passwords of the # admin (cleanup_app_passwords_for, harmless only because of this lock), Pods/Divi release options. The lock lives as # long as the script (fd 9 closes with it); a smoke that runs a second one itself must not source this file twice. exec 9>/tmp/lit-mcp-smoke.lock flock -n 9 || { echo "FAIL: another smoke is running (lock /tmp/lit-mcp-smoke.lock)"; exit 1; } PLUGIN=wp-content/plugins/WP-LIT-MCP ENDPOINT=http://localhost/wp-json/mcp/mcp-oauth-server # The site's own Host header (without scheme). A request that bypasses normal WP # routing (e.g. the raw discovery rewrite target mcp-smoke.sh probes) needs this # Host header, or WordPress 301s it to the canonical host before it ever reaches # the code under test. HOST=$(wp option get home | sed -E 's#^[a-zA-Z]+://##') # Optional: the Host header MCP requests carry (default: curl's own, "localhost"). A cache plugin that keys its files # by the request's host (WP Super Cache) needs the site's real host here (super-cache-smoke.sh sets it). MCP_HOST=${MCP_HOST:-} TOKEN="" SESSION="" INIT_RESPONSE="" REQUEST_ID=100 fail() { echo "FAIL: $*"; exit 1; } # A smoke script must reach its last line: finish() marks that and prints the verdict. # guard_exit (called first in every EXIT trap) turns any other exit – including a silent # stop that still returns 0 – into a failure, so a run can never pass without all checks. SMOKE_FINISHED=0 finish() { SMOKE_FINISHED=1; echo "ALL CHECKS PASSED"; } guard_exit() { local rc=$1 if [ "$rc" -eq 0 ] && [ "$SMOKE_FINISHED" != 1 ]; then echo "FAIL: script stopped before its last check (exit 0 without finish)" return 1 fi return "$rc" } pass() { echo "ok - $*"; } # Removes every "lit-mcp-integration-test" application password of a user (of ANY run – safe only under the smoke # lock above: no other smoke can be using one). 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 } # Mints an MCP access token for a user, exactly like mcp-oauth's TokenEndpoint does # after a completed OAuth flow (mint-token.php). Also creates the throwaway # "lit-mcp-integration-test" application password cleanup_app_passwords_for removes. mint_token() { # $1 = user_login -> prints the token wp eval-file "$PLUGIN/tests/Integration/mint-token.php" "$1" } # Raw JSON-RPC POST to the MCP endpoint; prints headers+body (-i). $3 (session id) # is optional. Low-level building block: used directly where a script needs the # response headers (e.g. to read Mcp-Session-Id off the initialize response) and by # rpc() below for everything after that. mcp() { # $1 = token, $2 = json body, $3 = session id (optional) local session_header=() [ -n "${3:-}" ] && session_header=(-H "Mcp-Session-Id: $3") local host_header=() [ -n "$MCP_HOST" ] && host_header=(-H "Host: $MCP_HOST") curl -s --max-time 20 -i -X POST "$ENDPOINT" \ "${host_header[@]}" \ -H "Authorization: Bearer $1" \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ "${session_header[@]}" -d "$2" } # Mints a token for the user and opens an MCP session. Sets TOKEN, SESSION and # INIT_RESPONSE (raw initialize response incl. headers). connect() { # $1 = user_login TOKEN=$(mint_token "$1") local init 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) INIT_RESPONSE="$init" echo "$init" | grep -q '"protocolVersion"' || fail "initialize failed: $init" } # JSON-RPC call over the session connect() opened; prints the response body only. rpc() { # $1 = method, $2 = params JSON REQUEST_ID=$((REQUEST_ID + 1)) local body body=$(jq -cn --arg m "$1" --argjson p "$2" --argjson id "$REQUEST_ID" '{jsonrpc: "2.0", id: $id, method: $m, params: $p}') local session_header=() [ -n "$SESSION" ] && session_header=(-H "Mcp-Session-Id: $SESSION") local host_header=() [ -n "$MCP_HOST" ] && host_header=(-H "Host: $MCP_HOST") curl -s --max-time 60 -X POST "$ENDPOINT" \ "${host_header[@]}" \ -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ "${session_header[@]}" -d "$body" } # Calls a tool; prints structuredContent, or {"__error": ""} for isError results. tool() { # $1 = tool name, $2 = arguments JSON local response response=$(rpc tools/call "$(jq -cn --arg n "$1" --argjson a "$2" '{name: $n, arguments: $a}')") echo "$response" | jq -e '.result' >/dev/null 2>&1 || fail "$1: protocol error: $response" if [ "$(echo "$response" | jq -r '.result.isError')" = "true" ]; then echo "$response" | jq -c '{__error: .result.content[0].text}' else echo "$response" | jq -c '.result.structuredContent' fi } # Runs an indirect ability through mcp-adapter-execute-ability; prints its result or {"__error": …}. ability() { # $1 = ability name (lit/…), $2 = parameters JSON local out out=$(tool mcp-adapter-execute-ability "$(jq -cn --arg n "$1" --argjson p "$2" '{ability_name: $n, parameters: $p}')") if echo "$out" | jq -e 'has("__error")' >/dev/null; then echo "$out" elif [ "$(echo "$out" | jq -r '.success')" = "true" ]; then echo "$out" | jq -c '.data' else echo "$out" | jq -c '{__error: .error}' fi } # Prints the lit error code (e.g. rev_mismatch) of a tool()/ability() result, or "". error_code() { # $1 = result JSON echo "$1" | jq -r '(.__error // "") | (try fromjson catch {}) | (.code // "")' }