diff --git a/logs.sh b/logs.sh new file mode 100755 index 0000000..950e748 --- /dev/null +++ b/logs.sh @@ -0,0 +1,370 @@ +#!/bin/bash +# Fetch logs and manage containers in the mcpctl Portainer stack +# Usage: ./logs.sh [container_name] [--tail N] [--since TIME] [--follow] +# ./logs.sh restart Restart a container +# ./logs.sh pull Pull an image on the NAS +# ./logs.sh exec Run a command in a container + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +PORTAINER_URL="${PORTAINER_URL:-http://10.0.0.194:9000}" +PORTAINER_USER="${PORTAINER_USER:-michal}" +ENDPOINT_ID="2" + +TAIL_LINES=100 +SINCE="" +FOLLOW=false +SHOW_ALL=false +CONTAINER_NAME="" +TIMESTAMPS=true +COMMAND="" +CMD_TARGET="" +EXEC_CMD=() + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +DIM='\033[2m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $1" >&2; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1" >&2; } +log_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; } + +get_password() { + if [[ -n "$PORTAINER_PASSWORD" ]]; then + echo "$PORTAINER_PASSWORD" + return + fi + if [[ -f "$SCRIPT_DIR/.portainer_password" ]]; then + cat "$SCRIPT_DIR/.portainer_password" + return + fi + if [[ -f "$HOME/.portainer_password" ]]; then + cat "$HOME/.portainer_password" + return + fi + read -s -p "Enter Portainer password for $PORTAINER_USER: " password + echo >&2 + echo "$password" +} + +get_jwt_token() { + local password="$1" + local escaped_password + escaped_password=$(printf '%s' "$password" | jq -Rs .) + + local response + response=$(curl -s -X POST "$PORTAINER_URL/api/auth" \ + -H "Content-Type: application/json" \ + -d "{\"Username\":\"$PORTAINER_USER\",\"Password\":$escaped_password}") + + local token + token=$(echo "$response" | jq -r '.jwt // empty') + + if [[ -z "$token" ]]; then + log_error "Authentication failed: $(echo "$response" | jq -r '.message // "Unknown error"')" + exit 1 + fi + echo "$token" +} + +parse_since() { + local since="$1" + local now + now=$(date +%s) + + if [[ "$since" =~ ^([0-9]+)s$ ]]; then + echo $(( now - ${BASH_REMATCH[1]} )) + elif [[ "$since" =~ ^([0-9]+)m$ ]]; then + echo $(( now - ${BASH_REMATCH[1]} * 60 )) + elif [[ "$since" =~ ^([0-9]+)h$ ]]; then + echo $(( now - ${BASH_REMATCH[1]} * 3600 )) + elif [[ "$since" =~ ^([0-9]+)d$ ]]; then + echo $(( now - ${BASH_REMATCH[1]} * 86400 )) + else + date -d "$since" +%s 2>/dev/null || echo "$since" + fi +} + +list_containers() { + local token="$1" + + local containers + containers=$(curl -s -X GET "$PORTAINER_URL/api/endpoints/$ENDPOINT_ID/docker/containers/json?all=true" \ + -H "Authorization: Bearer $token") + + echo "" + printf "${CYAN}%-30s %-15s %-20s %s${NC}\n" "CONTAINER" "STATE" "STATUS" "IMAGE" + printf "${DIM}%-30s %-15s %-20s %s${NC}\n" "─────────" "─────" "──────" "─────" + + echo "$containers" | jq -r '.[] | [ + (.Names[0] // "unnamed" | ltrimstr("/")), + .State, + .Status, + (.Image | split(":")[0] | split("/")[-1]) + ] | @tsv' | sort | while IFS=$'\t' read -r name state status image; do + local color="$NC" + case "$state" in + running) color="$GREEN" ;; + exited|dead) color="$RED" ;; + restarting|paused) color="$YELLOW" ;; + esac + printf "%-30s ${color}%-15s${NC} %-20s %s\n" "$name" "$state" "$status" "$image" + done + + echo "" +} + +resolve_container() { + local token="$1" + local container_name="$2" + + local containers + containers=$(curl -s -X GET "$PORTAINER_URL/api/endpoints/$ENDPOINT_ID/docker/containers/json?all=true" \ + -H "Authorization: Bearer $token") + + local container_id + container_id=$(echo "$containers" | jq -r --arg name "$container_name" \ + '.[] | select(.Names[] | ltrimstr("/") == $name) | .Id') + + if [[ -z "$container_id" ]]; then + container_id=$(echo "$containers" | jq -r --arg name "$container_name" \ + '.[] | select(.Names[] | ltrimstr("/") | contains($name)) | .Id' | head -1) + fi + + if [[ -z "$container_id" ]]; then + log_error "Container '$container_name' not found" + echo "$containers" | jq -r '.[].Names[] | ltrimstr("/")' | sort >&2 + exit 1 + fi + + echo "$container_id" +} + +fetch_logs() { + local token="$1" + local container_name="$2" + + local container_id + container_id=$(resolve_container "$token" "$container_name") + + local query="stderr=true&stdout=true&tail=${TAIL_LINES}" + if [[ "$TIMESTAMPS" == "true" ]]; then + query="${query}×tamps=true" + fi + if [[ -n "$SINCE" ]]; then + local since_ts + since_ts=$(parse_since "$SINCE") + query="${query}&since=${since_ts}" + fi + + if [[ "$FOLLOW" == "true" ]]; then + query="${query}&follow=true" + log_info "Streaming logs for $container_name (Ctrl+C to stop)..." + curl -s -N -X GET "$PORTAINER_URL/api/endpoints/$ENDPOINT_ID/docker/containers/$container_id/logs?${query}" \ + -H "Authorization: Bearer $token" | \ + perl -pe 's/^.{8}//s' + else + curl -s -X GET "$PORTAINER_URL/api/endpoints/$ENDPOINT_ID/docker/containers/$container_id/logs?${query}" \ + -H "Authorization: Bearer $token" | \ + perl -pe 's/^.{8}//s' + fi +} + +fetch_all_logs() { + local token="$1" + + local containers + containers=$(curl -s -X GET "$PORTAINER_URL/api/endpoints/$ENDPOINT_ID/docker/containers/json?all=true" \ + -H "Authorization: Bearer $token") + + local names + names=$(echo "$containers" | jq -r '.[].Names[] | ltrimstr("/")' | sort) + + if [[ "$TAIL_LINES" == "100" ]]; then + TAIL_LINES=50 + fi + + while IFS= read -r name; do + echo "" + echo -e "${CYAN}=== ${name} ===${NC}" + FOLLOW=false fetch_logs "$token" "$name" 2>/dev/null || echo -e "${DIM} (no logs available)${NC}" + done <<< "$names" +} + +restart_container() { + local token="$1" + local container_name="$2" + + local container_id + container_id=$(resolve_container "$token" "$container_name") + + log_info "Restarting $container_name..." + local http_code + http_code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ + "$PORTAINER_URL/api/endpoints/$ENDPOINT_ID/docker/containers/$container_id/restart" \ + -H "Authorization: Bearer $token") + + if [[ "$http_code" == "204" ]]; then + log_info "$container_name restarted successfully" + else + log_error "Restart failed (HTTP $http_code)" + exit 1 + fi +} + +pull_image() { + local token="$1" + local image="$2" + + local image_name="${image%%:*}" + local tag="${image#*:}" + [[ "$tag" == "$image" ]] && tag="latest" + + log_info "Pulling $image_name:$tag on NAS..." + local response + response=$(curl -s -X POST \ + "$PORTAINER_URL/api/endpoints/$ENDPOINT_ID/docker/images/create?fromImage=$image_name&tag=$tag" \ + -H "Authorization: Bearer $token") + + if echo "$response" | grep -q '"error"'; then + log_error "Pull failed:" + echo "$response" | grep '"error"' + exit 1 + fi + + local status + status=$(echo "$response" | tail -1 | jq -r '.status // empty' 2>/dev/null) + log_info "$status" +} + +exec_container() { + local token="$1" + local container_name="$2" + shift 2 + local cmd=("$@") + + local container_id + container_id=$(resolve_container "$token" "$container_name") + + local cmd_json + cmd_json=$(printf '%s\n' "${cmd[@]}" | jq -R . | jq -s '.') + + local exec_id + exec_id=$(curl -s -X POST \ + "$PORTAINER_URL/api/endpoints/$ENDPOINT_ID/docker/containers/$container_id/exec" \ + -H "Authorization: Bearer $token" \ + -H "Content-Type: application/json" \ + -d "{\"AttachStdout\":true,\"AttachStderr\":true,\"Cmd\":$cmd_json}" | jq -r '.Id') + + if [[ -z "$exec_id" || "$exec_id" == "null" ]]; then + log_error "Failed to create exec instance" + exit 1 + fi + + curl -s -X POST \ + "$PORTAINER_URL/api/endpoints/$ENDPOINT_ID/docker/exec/$exec_id/start" \ + -H "Authorization: Bearer $token" \ + -H "Content-Type: application/json" \ + -d '{"Detach":false,"Tty":true}' +} + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + --tail|-n) + TAIL_LINES="$2" + shift 2 + ;; + --since) + SINCE="$2" + shift 2 + ;; + --follow|-f) + FOLLOW=true + shift + ;; + --all|-a) + SHOW_ALL=true + shift + ;; + --no-timestamps) + TIMESTAMPS=false + shift + ;; + --help|-h) + echo "Usage: $0 [container_name] [options]" + echo " $0 restart " + echo " $0 pull " + echo " $0 exec " + echo "" + echo "Fetch logs and manage containers in the mcpctl Portainer stack." + echo "" + echo "LOGS:" + echo " (no args) List all containers with status" + echo " Last 100 lines from container" + echo " --all, -a Show logs from all containers" + echo " --tail N, -n N Number of lines (default: 100)" + echo " --since TIME Logs since TIME (e.g., 1h, 30m, 2d)" + echo " --follow, -f Stream logs in real-time" + echo " --no-timestamps Hide timestamps" + echo "" + echo "MANAGEMENT:" + echo " restart Restart a container" + echo " pull Pull an image on the NAS" + echo " exec Run a command in a container" + exit 0 + ;; + restart) + COMMAND="restart" + CMD_TARGET="${2:?Usage: $0 restart }" + shift 2 + ;; + pull) + COMMAND="pull" + CMD_TARGET="${2:?Usage: $0 pull }" + shift 2 + ;; + exec) + COMMAND="exec" + CMD_TARGET="${2:?Usage: $0 exec }" + shift 2 + EXEC_CMD=("$@") + if [[ ${#EXEC_CMD[@]} -eq 0 ]]; then + log_error "Usage: $0 exec " + exit 1 + fi + break + ;; + -*) + log_error "Unknown option: $1" + exit 1 + ;; + *) + CONTAINER_NAME="$1" + shift + ;; + esac +done + +# Main +password=$(get_password) +token=$(get_jwt_token "$password") + +if [[ "$COMMAND" == "restart" ]]; then + restart_container "$token" "$CMD_TARGET" +elif [[ "$COMMAND" == "pull" ]]; then + pull_image "$token" "$CMD_TARGET" +elif [[ "$COMMAND" == "exec" ]]; then + exec_container "$token" "$CMD_TARGET" "${EXEC_CMD[@]}" +elif [[ "$SHOW_ALL" == "true" ]]; then + fetch_all_logs "$token" +elif [[ -n "$CONTAINER_NAME" ]]; then + fetch_logs "$token" "$CONTAINER_NAME" +else + list_containers "$token" +fi diff --git a/src/mcpd/src/services/docker/container-manager.ts b/src/mcpd/src/services/docker/container-manager.ts index 0ea15f5..527d893 100644 --- a/src/mcpd/src/services/docker/container-manager.ts +++ b/src/mcpd/src/services/docker/container-manager.ts @@ -138,6 +138,19 @@ export class DockerContainerManager implements McpOrchestrator { if (port !== undefined) { result.port = port; } + + // Extract container IP from first non-default network + const networks = info.NetworkSettings?.Networks; + if (networks) { + for (const [, net] of Object.entries(networks)) { + const netInfo = net as { IPAddress?: string }; + if (netInfo.IPAddress) { + result.ip = netInfo.IPAddress; + break; + } + } + } + return result; } diff --git a/src/mcpd/src/services/health-probe.service.ts b/src/mcpd/src/services/health-probe.service.ts index d13a766..7ede791 100644 --- a/src/mcpd/src/services/health-probe.service.ts +++ b/src/mcpd/src/services/health-probe.service.ts @@ -112,7 +112,7 @@ export class HealthProbeRunner { try { if (server.transport === 'SSE' || server.transport === 'STREAMABLE_HTTP') { - result = await this.probeHttp(instance, healthCheck, timeoutMs); + result = await this.probeHttp(instance, server, healthCheck, timeoutMs); } else { result = await this.probeStdio(instance, server, healthCheck, timeoutMs); } @@ -172,11 +172,26 @@ export class HealthProbeRunner { /** Probe an HTTP/SSE MCP server by sending a JSON-RPC tool call. */ private async probeHttp( instance: McpInstance, + server: McpServer, healthCheck: HealthCheckSpec, timeoutMs: number, ): Promise { - if (!instance.port) { - return { healthy: false, latencyMs: 0, message: 'No port assigned' }; + if (!instance.containerId) { + return { healthy: false, latencyMs: 0, message: 'No container ID' }; + } + + // Get container IP for internal network communication + // (mcpd and MCP containers share the mcp-servers network) + const containerInfo = await this.orchestrator.inspectContainer(instance.containerId); + const containerPort = (server.containerPort as number | null) ?? 3000; + + let url: string; + if (containerInfo.ip) { + url = `http://${containerInfo.ip}:${containerPort}`; + } else if (instance.port) { + url = `http://localhost:${instance.port}`; + } else { + return { healthy: false, latencyMs: 0, message: 'No container IP or port' }; } const start = Date.now(); @@ -187,7 +202,7 @@ export class HealthProbeRunner { try { // Initialize - const initResp = await fetch(`http://localhost:${instance.port}`, { + const initResp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream' }, body: JSON.stringify({ @@ -206,14 +221,14 @@ export class HealthProbeRunner { if (sessionId) headers['Mcp-Session-Id'] = sessionId; // Send initialized notification - await fetch(`http://localhost:${instance.port}`, { + await fetch(url, { method: 'POST', headers, body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }), signal: controller.signal, }); // Call health check tool - const toolResp = await fetch(`http://localhost:${instance.port}`, { + const toolResp = await fetch(url, { method: 'POST', headers, body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', diff --git a/src/mcpd/src/services/orchestrator.ts b/src/mcpd/src/services/orchestrator.ts index 17553de..8a34d6b 100644 --- a/src/mcpd/src/services/orchestrator.ts +++ b/src/mcpd/src/services/orchestrator.ts @@ -30,6 +30,8 @@ export interface ContainerInfo { name: string; state: 'running' | 'stopped' | 'starting' | 'error' | 'unknown'; port?: number; + /** Container IP on the first non-default network (for internal communication) */ + ip?: string; createdAt: Date; } diff --git a/templates/home-assistant.yaml b/templates/home-assistant.yaml index 957fda8..e1d4cf6 100644 --- a/templates/home-assistant.yaml +++ b/templates/home-assistant.yaml @@ -1,16 +1,22 @@ name: home-assistant version: "1.0.0" description: Home Assistant MCP server for smart home control and entity management -packageName: "home-assistant-mcp-server" -transport: STDIO -repositoryUrl: https://github.com/tevonsb/homeassistant-mcp +dockerImage: "ghcr.io/homeassistant-ai/ha-mcp:latest" +transport: SSE +containerPort: 8086 +repositoryUrl: https://github.com/homeassistant-ai/ha-mcp +command: + - python + - -c + - "from ha_mcp.server import HomeAssistantSmartMCPServer; s = HomeAssistantSmartMCPServer(); s.mcp.run(transport='sse', host='0.0.0.0', port=8086)" healthCheck: - tool: get_entities - arguments: {} + tool: ha_search_entities + arguments: + query: "light" env: - - name: HASS_URL + - name: HOMEASSISTANT_URL description: Home Assistant instance URL (e.g. http://homeassistant.local:8123) required: true - - name: HASS_TOKEN + - name: HOMEASSISTANT_TOKEN description: Home Assistant long-lived access token required: true