forked from HomeLab/unraid-mcp
- Added KFC (Kent Feature Creator) spec workflow agents for requirements, design, tasks, testing, implementation and evaluation - Added Claude Code settings configuration for agent workflows - Added GraphQL introspection query and schema files for Unraid API exploration - Updated development script with additional debugging and schema inspection capabilities - Enhanced logging configuration with structured formatting - Updated pyproject.toml dependencies and uv.lock 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
556 lines
17 KiB
Bash
Executable File
556 lines
17 KiB
Bash
Executable File
#!/bin/bash
|
||
|
||
# Unraid MCP Server Development Script
|
||
# Safely manages server processes during development with accurate process detection
|
||
|
||
set -euo pipefail
|
||
|
||
# Configuration
|
||
DEFAULT_PORT=6970
|
||
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
LOG_DIR="$PROJECT_DIR/logs"
|
||
LOG_FILE="$LOG_DIR/unraid-mcp.log"
|
||
PID_FILE="$LOG_DIR/dev.pid"
|
||
# Ensure logs directory exists
|
||
mkdir -p "$LOG_DIR"
|
||
|
||
# All colors are now handled by Rich logging system
|
||
|
||
# Helper function for unified Rich logging
|
||
log() {
|
||
local message="$1"
|
||
local level="${2:-info}"
|
||
local indent="${3:-0}"
|
||
local file_timestamp="$(date +'%Y-%m-%d %H:%M:%S')"
|
||
|
||
# Use unified Rich logger for beautiful console output - escape single quotes
|
||
local escaped_message="${message//\'/\'\"\'\"\'}"
|
||
uv run python -c "from unraid_mcp.config.logging import log_with_level_and_indent; log_with_level_and_indent('$escaped_message', '$level', $indent)"
|
||
|
||
# File output without color
|
||
printf "[%s] %s\n" "$file_timestamp" "$message" >> "$LOG_FILE"
|
||
}
|
||
|
||
# Convenience functions for different log levels
|
||
log_error() { log "$1" "error" "${2:-0}"; }
|
||
log_warning() { log "$1" "warning" "${2:-0}"; }
|
||
log_success() { log "$1" "success" "${2:-0}"; }
|
||
log_info() { log "$1" "info" "${2:-0}"; }
|
||
log_status() { log "$1" "status" "${2:-0}"; }
|
||
|
||
# Rich header function
|
||
log_header() {
|
||
uv run python -c "from unraid_mcp.config.logging import log_header; log_header('$1')"
|
||
}
|
||
|
||
# Rich separator function
|
||
log_separator() {
|
||
uv run python -c "from unraid_mcp.config.logging import log_separator; log_separator()"
|
||
}
|
||
|
||
# Get port from environment or use default
|
||
get_port() {
|
||
local port="${UNRAID_MCP_PORT:-$DEFAULT_PORT}"
|
||
echo "$port"
|
||
}
|
||
|
||
# Write PID to file
|
||
write_pid_file() {
|
||
local pid=$1
|
||
echo "$pid" > "$PID_FILE"
|
||
}
|
||
|
||
# Read PID from file
|
||
read_pid_file() {
|
||
if [[ -f "$PID_FILE" ]]; then
|
||
cat "$PID_FILE" 2>/dev/null
|
||
fi
|
||
}
|
||
|
||
# Check if PID file contains valid running process
|
||
is_pid_valid() {
|
||
local pid=$1
|
||
[[ -n "$pid" ]] && [[ "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" 2>/dev/null
|
||
}
|
||
|
||
# Clean up PID file
|
||
cleanup_pid_file() {
|
||
if [[ -f "$PID_FILE" ]]; then
|
||
rm -f "$PID_FILE"
|
||
log_info "🗑️ Cleaned up PID file"
|
||
fi
|
||
}
|
||
|
||
# Get PID from PID file if valid, otherwise return empty
|
||
get_valid_pid_from_file() {
|
||
local pid=$(read_pid_file)
|
||
if is_pid_valid "$pid"; then
|
||
echo "$pid"
|
||
else
|
||
# Clean up stale PID file
|
||
[[ -f "$PID_FILE" ]] && cleanup_pid_file
|
||
echo ""
|
||
fi
|
||
}
|
||
|
||
# Find processes using multiple detection methods
|
||
find_server_processes() {
|
||
local port=$(get_port)
|
||
local pids=()
|
||
|
||
# Method 0: Check PID file first (most reliable)
|
||
local pid_from_file=$(get_valid_pid_from_file)
|
||
if [[ -n "$pid_from_file" ]]; then
|
||
log_status "🔍 Found server PID from file: $pid_from_file"
|
||
pids+=("$pid_from_file")
|
||
fi
|
||
|
||
# Method 1: Command line pattern matching (fallback)
|
||
while IFS= read -r line; do
|
||
if [[ -n "$line" ]]; then
|
||
local pid=$(echo "$line" | awk '{print $2}')
|
||
# Add to pids if not already present
|
||
if [[ ! " ${pids[@]} " =~ " $pid " ]]; then
|
||
pids+=("$pid")
|
||
fi
|
||
fi
|
||
done < <(ps aux | grep -E 'python.*unraid.*mcp|python.*main\.py|uv run.*main\.py|uv run -m unraid_mcp' | grep -v grep | grep -v "$0")
|
||
|
||
# Method 2: Port binding verification (fallback)
|
||
if command -v lsof >/dev/null 2>&1; then
|
||
while IFS= read -r line; do
|
||
if [[ -n "$line" ]]; then
|
||
local pid=$(echo "$line" | awk '{print $2}')
|
||
# Add to pids if not already present
|
||
if [[ ! " ${pids[@]} " =~ " $pid " ]]; then
|
||
pids+=("$pid")
|
||
fi
|
||
fi
|
||
done < <(lsof -i ":$port" 2>/dev/null | grep LISTEN || true)
|
||
fi
|
||
|
||
# Method 3: Working directory verification for fallback methods
|
||
local verified_pids=()
|
||
for pid in "${pids[@]}"; do
|
||
# Skip if not a valid PID
|
||
if ! [[ "$pid" =~ ^[0-9]+$ ]]; then
|
||
continue
|
||
fi
|
||
|
||
# If this PID came from the PID file, it's already verified
|
||
if [[ "$pid" == "$pid_from_file" ]]; then
|
||
verified_pids+=("$pid")
|
||
continue
|
||
fi
|
||
|
||
# Verify other PIDs by working directory
|
||
if [[ -d "/proc/$pid" ]]; then
|
||
local pwd_info=""
|
||
if command -v pwdx >/dev/null 2>&1; then
|
||
pwd_info=$(pwdx "$pid" 2>/dev/null | cut -d' ' -f2- || echo "unknown")
|
||
else
|
||
pwd_info=$(readlink -f "/proc/$pid/cwd" 2>/dev/null || echo "unknown")
|
||
fi
|
||
|
||
# Verify it's running from our project directory or a parent directory
|
||
if [[ "$pwd_info" == "$PROJECT_DIR"* ]] || [[ "$pwd_info" == *"unraid-mcp"* ]]; then
|
||
verified_pids+=("$pid")
|
||
fi
|
||
fi
|
||
done
|
||
|
||
# Output final list
|
||
printf '%s\n' "${verified_pids[@]}" | grep -E '^[0-9]+$' || true
|
||
}
|
||
|
||
# Terminate a process gracefully, then forcefully if needed
|
||
terminate_process() {
|
||
local pid=$1
|
||
local name=${2:-"process"}
|
||
|
||
if ! kill -0 "$pid" 2>/dev/null; then
|
||
log_warning "⚠️ Process $pid ($name) already terminated"
|
||
return 0
|
||
fi
|
||
|
||
log_warning "🔄 Terminating $name (PID: $pid)..."
|
||
|
||
# Step 1: Graceful shutdown (SIGTERM)
|
||
log_info "→ Sending SIGTERM to PID $pid" 1
|
||
kill -TERM "$pid" 2>/dev/null || {
|
||
log_warning "⚠️ Failed to send SIGTERM (process may have died)" 2
|
||
return 0
|
||
}
|
||
|
||
# Step 2: Wait for graceful shutdown (5 seconds)
|
||
local count=0
|
||
while [[ $count -lt 5 ]]; do
|
||
if ! kill -0 "$pid" 2>/dev/null; then
|
||
log_success "✅ Process $pid terminated gracefully" 1
|
||
|
||
# Clean up PID file if this was our server process
|
||
local pid_from_file=$(read_pid_file)
|
||
if [[ "$pid" == "$pid_from_file" ]]; then
|
||
cleanup_pid_file
|
||
fi
|
||
|
||
return 0
|
||
fi
|
||
sleep 1
|
||
((count++))
|
||
log_info "⏳ Waiting for graceful shutdown... (${count}/5)" 2
|
||
done
|
||
|
||
# Step 3: Force kill (SIGKILL)
|
||
log_error "⚡ Graceful shutdown timeout, sending SIGKILL to PID $pid" 1
|
||
kill -KILL "$pid" 2>/dev/null || {
|
||
log_warning "⚠️ Failed to send SIGKILL (process may have died)" 2
|
||
return 0
|
||
}
|
||
|
||
# Step 4: Final verification
|
||
sleep 1
|
||
if kill -0 "$pid" 2>/dev/null; then
|
||
log_error "❌ Failed to terminate process $pid" 1
|
||
return 1
|
||
else
|
||
log_success "✅ Process $pid terminated forcefully" 1
|
||
|
||
# Clean up PID file if this was our server process
|
||
local pid_from_file=$(read_pid_file)
|
||
if [[ "$pid" == "$pid_from_file" ]]; then
|
||
cleanup_pid_file
|
||
fi
|
||
|
||
return 0
|
||
fi
|
||
}
|
||
|
||
# Stop all server processes
|
||
stop_servers() {
|
||
log_header "Server Shutdown"
|
||
log_error "🛑 Stopping existing server processes..."
|
||
|
||
local pids=($(find_server_processes))
|
||
|
||
if [[ ${#pids[@]} -eq 0 ]]; then
|
||
log_success "✅ No processes to stop"
|
||
return 0
|
||
fi
|
||
|
||
local failed=0
|
||
for pid in "${pids[@]}"; do
|
||
if ! terminate_process "$pid" "Unraid MCP Server"; then
|
||
((failed++))
|
||
fi
|
||
done
|
||
|
||
# Wait for ports to be released
|
||
local port=$(get_port)
|
||
log_info "⏳ Waiting for port $port to be released..."
|
||
local port_wait=0
|
||
while [[ $port_wait -lt 3 ]]; do
|
||
if ! lsof -i ":$port" >/dev/null 2>&1; then
|
||
log_success "✅ Port $port released" 1
|
||
break
|
||
fi
|
||
sleep 1
|
||
((port_wait++))
|
||
done
|
||
|
||
if [[ $failed -gt 0 ]]; then
|
||
log_error "⚠️ Failed to stop $failed process(es)"
|
||
return 1
|
||
else
|
||
log_success "✅ All processes stopped successfully"
|
||
return 0
|
||
fi
|
||
}
|
||
|
||
# Start the new modular server
|
||
start_modular_server() {
|
||
log_header "Modular Server Startup"
|
||
log_success "🚀 Starting modular server..."
|
||
|
||
cd "$PROJECT_DIR"
|
||
|
||
# Check if main.py exists in unraid_mcp/
|
||
if [[ ! -f "unraid_mcp/main.py" ]]; then
|
||
log_error "❌ unraid_mcp/main.py not found. Make sure modular server is implemented."
|
||
return 1
|
||
fi
|
||
|
||
# Clear the log file and add a startup marker to capture fresh logs
|
||
echo "=== Server Starting at $(date) ===" > "$LOG_FILE"
|
||
|
||
# Start server in background using module syntax
|
||
log_info "→ Executing: uv run -m unraid_mcp.main" 1
|
||
# Start server in new process group to isolate it from parent signals
|
||
setsid nohup uv run -m unraid_mcp.main >> "$LOG_FILE" 2>&1 &
|
||
local pid=$!
|
||
|
||
# Write PID to file
|
||
write_pid_file "$pid"
|
||
log_info "📝 Written PID $pid to file: $PID_FILE" 1
|
||
|
||
# Give it a moment to start and write some logs
|
||
sleep 3
|
||
|
||
# Check if it's still running
|
||
if kill -0 "$pid" 2>/dev/null; then
|
||
local port=$(get_port)
|
||
log_success "✅ Modular server started successfully (PID: $pid, Port: $port)"
|
||
log_info "📋 Process info: $(ps -p "$pid" -o pid,ppid,cmd --no-headers 2>/dev/null || echo 'Process info unavailable')" 1
|
||
|
||
# Auto-tail logs after successful start
|
||
echo ""
|
||
log_success "📄 Following server logs in real-time..."
|
||
log_info "ℹ️ Press Ctrl+C to stop following logs (server will continue running)" 1
|
||
log_separator
|
||
echo ""
|
||
|
||
# Set up signal handler for graceful exit from log following
|
||
trap 'handle_log_interrupt' SIGINT
|
||
|
||
# Start tailing from beginning of the fresh log file
|
||
tail -f "$LOG_FILE"
|
||
|
||
return 0
|
||
else
|
||
log_error "❌ Modular server failed to start"
|
||
cleanup_pid_file
|
||
log_warning "📄 Check $LOG_FILE for error details"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
# Start the original server
|
||
start_original_server() {
|
||
log_header "Original Server Startup"
|
||
log_success "🚀 Starting original server..."
|
||
|
||
cd "$PROJECT_DIR"
|
||
|
||
# Check if original server exists
|
||
if [[ ! -f "unraid_mcp_server.py" ]]; then
|
||
log_error "❌ unraid_mcp_server.py not found"
|
||
return 1
|
||
fi
|
||
|
||
# Clear the log file and add a startup marker to capture fresh logs
|
||
echo "=== Server Starting at $(date) ===" > "$LOG_FILE"
|
||
|
||
# Start server in background
|
||
log_info "→ Executing: uv run unraid_mcp_server.py" 1
|
||
# Start server in new process group to isolate it from parent signals
|
||
setsid nohup uv run unraid_mcp_server.py >> "$LOG_FILE" 2>&1 &
|
||
local pid=$!
|
||
|
||
# Write PID to file
|
||
write_pid_file "$pid"
|
||
log_info "📝 Written PID $pid to file: $PID_FILE" 1
|
||
|
||
# Give it a moment to start and write some logs
|
||
sleep 3
|
||
|
||
# Check if it's still running
|
||
if kill -0 "$pid" 2>/dev/null; then
|
||
local port=$(get_port)
|
||
log_success "✅ Original server started successfully (PID: $pid, Port: $port)"
|
||
log_info "📋 Process info: $(ps -p "$pid" -o pid,ppid,cmd --no-headers 2>/dev/null || echo 'Process info unavailable')" 1
|
||
|
||
# Auto-tail logs after successful start
|
||
echo ""
|
||
log_success "📄 Following server logs in real-time..."
|
||
log_info "ℹ️ Press Ctrl+C to stop following logs (server will continue running)" 1
|
||
log_separator
|
||
echo ""
|
||
|
||
# Set up signal handler for graceful exit from log following
|
||
trap 'handle_log_interrupt' SIGINT
|
||
|
||
# Start tailing from beginning of the fresh log file
|
||
tail -f "$LOG_FILE"
|
||
|
||
return 0
|
||
else
|
||
log_error "❌ Original server failed to start"
|
||
cleanup_pid_file
|
||
log_warning "📄 Check $LOG_FILE for error details"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
# Show usage information
|
||
show_usage() {
|
||
echo "Usage: $0 [OPTIONS]"
|
||
echo ""
|
||
echo "Development script for Unraid MCP Server"
|
||
echo ""
|
||
echo "OPTIONS:"
|
||
echo " (no args) Stop existing servers, start modular server, and tail logs"
|
||
echo " --old Stop existing servers, start original server, and tail logs"
|
||
echo " --kill Stop existing servers only (don't start new one)"
|
||
echo " --status Show status of running servers"
|
||
echo " --logs [N] Show last N lines of server logs (default: 50)"
|
||
echo " --tail Follow server logs in real-time (without restarting server)"
|
||
echo " --help, -h Show this help message"
|
||
echo ""
|
||
echo "ENVIRONMENT VARIABLES:"
|
||
echo " UNRAID_MCP_PORT Port for server (default: $DEFAULT_PORT)"
|
||
echo ""
|
||
echo "EXAMPLES:"
|
||
echo " ./dev.sh # Restart with modular server"
|
||
echo " ./dev.sh --old # Restart with original server"
|
||
echo " ./dev.sh --kill # Stop all servers"
|
||
echo " ./dev.sh --status # Check server status"
|
||
echo " ./dev.sh --logs # Show last 50 lines of logs"
|
||
echo " ./dev.sh --logs 100 # Show last 100 lines of logs"
|
||
echo " ./dev.sh --tail # Follow logs in real-time"
|
||
}
|
||
|
||
# Show server status
|
||
show_status() {
|
||
local port=$(get_port)
|
||
log_header "Server Status"
|
||
log_status "🔍 Server Status Check"
|
||
log_info "📁 Project Directory: $PROJECT_DIR" 1
|
||
log_info "📝 PID File: $PID_FILE" 1
|
||
log_info "🔌 Expected Port: $port" 1
|
||
echo ""
|
||
|
||
# Check PID file status
|
||
local pid_from_file=$(read_pid_file)
|
||
if [[ -n "$pid_from_file" ]]; then
|
||
if is_pid_valid "$pid_from_file"; then
|
||
log_success "✅ PID File: Contains valid PID $pid_from_file" 1
|
||
else
|
||
log_warning "⚠️ PID File: Contains stale PID $pid_from_file (process not running)" 1
|
||
fi
|
||
else
|
||
log_warning "🚫 PID File: Not found or empty" 1
|
||
fi
|
||
echo ""
|
||
|
||
local pids=($(find_server_processes))
|
||
|
||
if [[ ${#pids[@]} -eq 0 ]]; then
|
||
log_warning "🟡 Status: No servers running" 1
|
||
else
|
||
log_success "✅ Status: ${#pids[@]} server(s) running" 1
|
||
for pid in "${pids[@]}"; do
|
||
local cmd=$(ps -p "$pid" -o cmd --no-headers 2>/dev/null || echo "Command unavailable")
|
||
local source="process scan"
|
||
if [[ "$pid" == "$pid_from_file" ]]; then
|
||
source="PID file"
|
||
fi
|
||
log_success "PID $pid ($source): $cmd" 2
|
||
done
|
||
fi
|
||
|
||
# Check port binding
|
||
if command -v lsof >/dev/null 2>&1; then
|
||
local port_info=$(lsof -i ":$port" 2>/dev/null | grep LISTEN || echo "")
|
||
if [[ -n "$port_info" ]]; then
|
||
log_success "Port $port: BOUND" 1
|
||
echo "$port_info" | while IFS= read -r line; do
|
||
log_info "$line" 2
|
||
done
|
||
else
|
||
log_warning "Port $port: FREE" 1
|
||
fi
|
||
fi
|
||
}
|
||
|
||
# Tail the server logs
|
||
tail_logs() {
|
||
local lines="${1:-50}"
|
||
|
||
log_info "📄 Tailing last $lines lines from server logs..."
|
||
|
||
if [[ ! -f "$LOG_FILE" ]]; then
|
||
log_error "❌ Log file not found: $LOG_FILE"
|
||
return 1
|
||
fi
|
||
|
||
echo ""
|
||
echo "=== Server Logs (last $lines lines) ==="
|
||
tail -n "$lines" "$LOG_FILE"
|
||
echo "=== End of Logs ===="
|
||
echo ""
|
||
}
|
||
|
||
# Handle SIGINT during log following
|
||
handle_log_interrupt() {
|
||
echo ""
|
||
log_info "📄 Stopped following logs. Server continues running in background."
|
||
log_info "💡 Use './dev.sh --status' to check server status" 1
|
||
log_info "💡 Use './dev.sh --tail' to resume following logs" 1
|
||
exit 0
|
||
}
|
||
|
||
# Follow server logs in real-time
|
||
follow_logs() {
|
||
log_success "📄 Following server logs in real-time..."
|
||
log_info "ℹ️ Press Ctrl+C to stop following logs"
|
||
|
||
if [[ ! -f "$LOG_FILE" ]]; then
|
||
log_error "❌ Log file not found: $LOG_FILE"
|
||
return 1
|
||
fi
|
||
|
||
# Set up signal handler for graceful exit
|
||
trap 'handle_log_interrupt' SIGINT
|
||
|
||
log_separator
|
||
echo ""
|
||
tail -f "$LOG_FILE"
|
||
}
|
||
|
||
# Main script logic
|
||
main() {
|
||
# Initialize log file
|
||
echo "=== Dev Script Started at $(date) ===" >> "$LOG_FILE"
|
||
|
||
case "${1:-}" in
|
||
--help|-h)
|
||
show_usage
|
||
;;
|
||
--status)
|
||
show_status
|
||
;;
|
||
--kill)
|
||
stop_servers
|
||
;;
|
||
--logs)
|
||
tail_logs "${2:-50}"
|
||
;;
|
||
--tail)
|
||
follow_logs
|
||
;;
|
||
--old)
|
||
if stop_servers; then
|
||
start_original_server
|
||
else
|
||
log_error "❌ Failed to stop existing servers"
|
||
exit 1
|
||
fi
|
||
;;
|
||
"")
|
||
if stop_servers; then
|
||
start_modular_server
|
||
else
|
||
log_error "❌ Failed to stop existing servers"
|
||
exit 1
|
||
fi
|
||
;;
|
||
*)
|
||
log_error "❌ Unknown option: $1"
|
||
show_usage
|
||
exit 1
|
||
;;
|
||
esac
|
||
}
|
||
|
||
# Run main function with all arguments
|
||
main "$@" |