From ac5639301c588e17cf4434db02eb1cb3d2041816 Mon Sep 17 00:00:00 2001 From: Jacob Magar Date: Fri, 13 Mar 2026 02:44:26 -0400 Subject: [PATCH] fix: split subscription_lock, fix safe_get None semantics, validate notification enums P-01: Replace single subscription_lock with two fine-grained locks: - _task_lock guards active_subscriptions (task lifecycle operations) - _data_lock guards resource_data (WebSocket message writes and reads) Eliminates serialization between WebSocket updates and tool reads. CQ-05: safe_get now preserves explicit None at terminal key. Uses sentinel _MISSING to distinguish "key absent" (returns default) from "key=null" (returns None). Fixes conflation that masked intentional null values from the Unraid API. SEC-M04: Validate list_type, importance, and notification_type against known enums before dispatching to GraphQL. Prevents wasting rate-limited requests on invalid values and avoids leaking schema details in errors. --- scripts/test-tools.sh | 742 ++++++++++++++++++++++++++++ unraid_mcp/core/utils.py | 22 +- unraid_mcp/server.py | 2 + unraid_mcp/subscriptions/manager.py | 20 +- unraid_mcp/tools/notifications.py | 22 + 5 files changed, 794 insertions(+), 14 deletions(-) create mode 100755 scripts/test-tools.sh diff --git a/scripts/test-tools.sh b/scripts/test-tools.sh new file mode 100755 index 0000000..4cd2f59 --- /dev/null +++ b/scripts/test-tools.sh @@ -0,0 +1,742 @@ +#!/usr/bin/env bash +# ============================================================================= +# test-tools.sh — Integration smoke-test for unraid-mcp MCP server tools +# +# Exercises every non-destructive action across all 10 tools using mcporter. +# The server is launched ad-hoc via mcporter's --stdio flag so no persistent +# process or registered server entry is required. +# +# Usage: +# ./scripts/test-tools.sh [--timeout-ms N] [--parallel] [--verbose] +# +# Options: +# --timeout-ms N Per-call timeout in milliseconds (default: 25000) +# --parallel Run independent test groups in parallel (default: off) +# --verbose Print raw mcporter output for each call +# +# Exit codes: +# 0 — all tests passed or skipped +# 1 — one or more tests failed +# 2 — prerequisite check failed (mcporter, uv, server startup) +# ============================================================================= + +set -Eeuo pipefail +shopt -s inherit_errexit + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- +readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +readonly PROJECT_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd -P)" +readonly SCRIPT_NAME="$(basename -- "${BASH_SOURCE[0]}")" +readonly TS_START="$(date +%s%N)" # nanosecond epoch +readonly LOG_FILE="${TMPDIR:-/tmp}/${SCRIPT_NAME%.sh}.$(date +%Y%m%d-%H%M%S).log" + +# Colours (disabled automatically when stdout is not a terminal) +if [[ -t 1 ]]; then + C_RESET='\033[0m' + C_BOLD='\033[1m' + C_GREEN='\033[0;32m' + C_RED='\033[0;31m' + C_YELLOW='\033[0;33m' + C_CYAN='\033[0;36m' + C_DIM='\033[2m' +else + C_RESET='' C_BOLD='' C_GREEN='' C_RED='' C_YELLOW='' C_CYAN='' C_DIM='' +fi + +# --------------------------------------------------------------------------- +# Defaults (overridable via flags) +# --------------------------------------------------------------------------- +CALL_TIMEOUT_MS=25000 +USE_PARALLEL=false +VERBOSE=false + +# --------------------------------------------------------------------------- +# Counters (updated by run_test / skip_test) +# --------------------------------------------------------------------------- +PASS_COUNT=0 +FAIL_COUNT=0 +SKIP_COUNT=0 +declare -a FAIL_NAMES=() + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --timeout-ms) + CALL_TIMEOUT_MS="${2:?--timeout-ms requires a value}" + shift 2 + ;; + --parallel) + USE_PARALLEL=true + shift + ;; + --verbose) + VERBOSE=true + shift + ;; + -h|--help) + printf 'Usage: %s [--timeout-ms N] [--parallel] [--verbose]\n' "${SCRIPT_NAME}" + exit 0 + ;; + *) + printf '[ERROR] Unknown argument: %s\n' "$1" >&2 + exit 2 + ;; + esac + done +} + +# --------------------------------------------------------------------------- +# Logging helpers +# --------------------------------------------------------------------------- +log_info() { printf "${C_CYAN}[INFO]${C_RESET} %s\n" "$*" | tee -a "${LOG_FILE}"; } +log_warn() { printf "${C_YELLOW}[WARN]${C_RESET} %s\n" "$*" | tee -a "${LOG_FILE}"; } +log_error() { printf "${C_RED}[ERROR]${C_RESET} %s\n" "$*" | tee -a "${LOG_FILE}" >&2; } + +elapsed_ms() { + local now + now="$(date +%s%N)" + printf '%d' "$(( (now - TS_START) / 1000000 ))" +} + +# --------------------------------------------------------------------------- +# Cleanup trap +# --------------------------------------------------------------------------- +cleanup() { + local rc=$? + if [[ $rc -ne 0 ]]; then + log_warn "Script exited with rc=${rc}. Log: ${LOG_FILE}" + fi +} +trap cleanup EXIT + +# --------------------------------------------------------------------------- +# Prerequisite checks +# --------------------------------------------------------------------------- +check_prerequisites() { + local missing=false + + if ! command -v mcporter &>/dev/null; then + log_error "mcporter not found in PATH. Install it and re-run." + missing=true + fi + + if ! command -v uv &>/dev/null; then + log_error "uv not found in PATH. Install it and re-run." + missing=true + fi + + if ! command -v python3 &>/dev/null; then + log_error "python3 not found in PATH." + missing=true + fi + + if [[ ! -f "${PROJECT_DIR}/pyproject.toml" ]]; then + log_error "pyproject.toml not found at ${PROJECT_DIR}. Wrong directory?" + missing=true + fi + + if [[ "${missing}" == true ]]; then + return 2 + fi +} + +# --------------------------------------------------------------------------- +# Server startup smoke-test +# Launches the stdio server and calls unraid_health action=check. +# Returns 0 if the server responds (even with an API error — that still +# means the Python process started cleanly), non-zero on import failure. +# --------------------------------------------------------------------------- +smoke_test_server() { + log_info "Smoke-testing server startup..." + + local output + output="$( + mcporter call \ + --stdio "uv run unraid-mcp-server" \ + --cwd "${PROJECT_DIR}" \ + --name "unraid-smoke" \ + --tool unraid_health \ + --args '{"action":"check"}' \ + --timeout 30000 \ + --output json \ + 2>&1 + )" || true + + # If mcporter returns the offline error the server failed to import/start + if printf '%s' "${output}" | grep -q '"kind": "offline"'; then + log_error "Server failed to start. Output:" + printf '%s\n' "${output}" >&2 + log_error "Common causes:" + log_error " • Missing module: check 'uv run unraid-mcp-server' locally" + log_error " • server.py has an import for a file that doesn't exist yet" + log_error " • Environment variable UNRAID_API_URL or UNRAID_API_KEY missing" + return 2 + fi + + log_info "Server started successfully (health response received)." + return 0 +} + +# --------------------------------------------------------------------------- +# mcporter call wrapper +# Usage: mcporter_call +# Writes the mcporter JSON output to stdout. +# Returns the mcporter exit code. +# --------------------------------------------------------------------------- +mcporter_call() { + local tool_name="${1:?tool_name required}" + local args_json="${2:?args_json required}" + + mcporter call \ + --stdio "uv run unraid-mcp-server" \ + --cwd "${PROJECT_DIR}" \ + --name "unraid" \ + --tool "${tool_name}" \ + --args "${args_json}" \ + --timeout "${CALL_TIMEOUT_MS}" \ + --output json \ + 2>&1 +} + +# --------------------------------------------------------------------------- +# Test runner +# Usage: run_test