diff --git a/mcp-servers/go/system-monitor-server/.gitignore b/mcp-servers/go/system-monitor-server/.gitignore new file mode 100644 index 000000000..c41c4870a --- /dev/null +++ b/mcp-servers/go/system-monitor-server/.gitignore @@ -0,0 +1,33 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +*.out + +# Go build artifacts +system-monitor-server +*.bin +dist/ +coverage/ +coverage.html + +# Dependency directories +vendor/ + +# IDE files +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db diff --git a/mcp-servers/go/system-monitor-server/Dockerfile b/mcp-servers/go/system-monitor-server/Dockerfile new file mode 100644 index 000000000..c18c7781d --- /dev/null +++ b/mcp-servers/go/system-monitor-server/Dockerfile @@ -0,0 +1,55 @@ +# Multi-stage build for system-monitor-server +FROM golang:1.21-alpine AS builder + +# Install build dependencies +RUN apk add --no-cache git ca-certificates tzdata + +# Set working directory +WORKDIR /app + +# Copy go mod files +COPY go.mod go.sum ./ + +# Download dependencies +RUN go mod download + +# Copy source code +COPY . . + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o system-monitor-server ./cmd/server + +# Final stage +FROM alpine:latest + +# Install runtime dependencies +RUN apk --no-cache add ca-certificates tzdata + +# Create non-root user +RUN addgroup -g 1001 -S appgroup && \ + adduser -u 1001 -S appuser -G appgroup + +# Set working directory +WORKDIR /app + +# Copy binary from builder stage +COPY --from=builder /app/system-monitor-server . + +# Copy configuration file +COPY --from=builder /app/config.yaml . + +# Create logs directory +RUN mkdir -p /app/logs && chown -R appuser:appgroup /app + +# Switch to non-root user +USER appuser + +# Expose port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 + +# Default command +CMD ["./system-monitor-server", "-transport=http", "-port=8080", "-log-level=info"] diff --git a/mcp-servers/go/system-monitor-server/Makefile b/mcp-servers/go/system-monitor-server/Makefile new file mode 100644 index 000000000..c1ba230ab --- /dev/null +++ b/mcp-servers/go/system-monitor-server/Makefile @@ -0,0 +1,253 @@ +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# 🖥️ SYSTEM-MONITOR-SERVER - Makefile +# (multi-package Go project with internal/, cmd/, pkg/ structure) +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# +# Author : Mihai Criveti +# Usage : make or just `make help` +# +# help: 🖥️ SYSTEM-MONITOR-SERVER (Go build & automation helpers) +# ───────────────────────────────────────────────────────────────────────── + +# ============================================================================= +# 📖 DYNAMIC HELP +# ============================================================================= +.PHONY: help +help: + @grep '^# help\:' $(firstword $(MAKEFILE_LIST)) | sed 's/^# help\: //' + +# ============================================================================= +# 📦 PROJECT METADATA (variables, colours) +# ============================================================================= +MODULE := github.com/IBM/mcp-context-forge/mcp-servers/go/system-monitor-server +BIN_NAME := system-monitor-server +VERSION ?= $(shell git describe --tags --dirty --always 2>/dev/null || echo "v0.0.0-dev") + +DIST_DIR := dist +COVERPROFILE := $(DIST_DIR)/coverage.out +COVERHTML := $(DIST_DIR)/coverage.html + +GO ?= go +GOOS ?= $(shell $(GO) env GOOS) +GOARCH ?= $(shell $(GO) env GOARCH) + +LDFLAGS := -s -w -X 'main.appVersion=$(VERSION)' + +ifeq ($(shell test -t 1 && echo tty),tty) +C_BLUE := \033[38;5;75m +C_RESET := \033[0m +else +C_BLUE := +C_RESET := +endif + +# ============================================================================= +# 🔧 TOOLING +# ============================================================================= +# help: 🔧 TOOLING +# help: tools - Install / update golangci-lint & staticcheck + +GOBIN := $(shell $(GO) env GOPATH)/bin + +tools: $(GOBIN)/golangci-lint $(GOBIN)/staticcheck +$(GOBIN)/golangci-lint: ; @$(GO) install github.com/golangci/golangci-lint/cmd/golangci-lint@latest +$(GOBIN)/staticcheck: ; @$(GO) install honnef.co/go/tools/cmd/staticcheck@latest + +# ============================================================================= +# 📂 MODULE & FORMAT +# ============================================================================= +# help: 📂 MODULE & FORMAT +# help: tidy - go mod tidy + verify +# help: fmt - Run gofmt & goimports + +tidy: + @$(GO) mod tidy + @$(GO) mod verify + +fmt: + @$(GO) fmt ./... + @go run golang.org/x/tools/cmd/goimports@latest -w . + +# ============================================================================= +# 🔍 LINTING & STATIC ANALYSIS +# ============================================================================= +# help: 🔍 LINTING & STATIC ANALYSIS +# help: vet - go vet +# help: staticcheck - Run staticcheck +# help: lint - Run golangci-lint +# help: pre-commit - Run all configured pre-commit hooks +.PHONY: vet staticcheck lint pre-commit + +vet: + @$(GO) vet ./... + +staticcheck: tools + @staticcheck ./... + +lint: tools + @golangci-lint run + +pre-commit: ## Run pre-commit hooks on all files + @command -v pre-commit >/dev/null 2>&1 || { \ + echo '✖ pre-commit not installed → pip install --user pre-commit'; exit 1; } + @pre-commit run --all-files --show-diff-on-failure + +# ============================================================================= +# 🧪 TESTS & COVERAGE +# ============================================================================= +# help: 🧪 TESTS & COVERAGE +# help: test - Run unit tests (race) +# help: coverage - Generate HTML coverage report + +test: + @$(GO) test -race -timeout=90s ./... + +coverage: + @mkdir -p $(DIST_DIR) + @$(GO) test ./... -covermode=count -coverprofile=$(COVERPROFILE) + @$(GO) tool cover -html=$(COVERPROFILE) -o $(COVERHTML) + @echo "$(C_BLUE)HTML coverage → $(COVERHTML)$(C_RESET)" + +# ============================================================================= +# 🛠 BUILD & RUN +# ============================================================================= +# help: 🛠 BUILD & RUN +# help: build - Build binary into ./dist +# help: install - go install into GOPATH/bin +# help: release - Cross-compile (honours GOOS/GOARCH) +# help: run - Build then run (stdio transport) +# help: run-stdio - Alias for "run" +# help: run-http - Run HTTP transport on :8080 (POST JSON-RPC) +# help: run-sse - Run SSE transport on :8080 (/sse, /messages) +# help: run-dual - Run BOTH SSE & HTTP on :8080 (/sse, /messages, /http) +# help: run-rest - Run REST API on :8080 (/api/v1/*) + +build: tidy + @mkdir -p $(DIST_DIR) + @$(GO) build -trimpath -ldflags '$(LDFLAGS)' -o $(DIST_DIR)/$(BIN_NAME) ./cmd/server + +install: + @$(GO) install -trimpath -ldflags '$(LDFLAGS)' ./cmd/server + +release: + @mkdir -p $(DIST_DIR)/$(GOOS)-$(GOARCH) + @GOOS=$(GOOS) GOARCH=$(GOARCH) CGO_ENABLED=0 \ + $(GO) build -trimpath -ldflags '$(LDFLAGS)' \ + -o $(DIST_DIR)/$(GOOS)-$(GOARCH)/$(BIN_NAME) ./cmd/server + +# ────── run helpers ──────────────────────────────────────────────────────── +run: build + @$(DIST_DIR)/$(BIN_NAME) -transport=stdio -log-level=info + +run-stdio: run # simple alias + +run-http: build + @$(DIST_DIR)/$(BIN_NAME) -transport=http -port=8080 + +run-sse: build + @$(DIST_DIR)/$(BIN_NAME) -transport=sse -port=8080 + +run-dual: build + @$(DIST_DIR)/$(BIN_NAME) -transport=dual -port=8080 + +run-rest: build + @$(DIST_DIR)/$(BIN_NAME) -transport=rest -port=8080 + +# ============================================================================= +# 🐳 DOCKER +# ============================================================================= +# help: 🐳 DOCKER +# help: docker-build - Build container image +# help: docker-run - Run container on :8080 (HTTP transport) +# help: docker-run-sse - Run container on :8080 (SSE transport) + +IMAGE ?= $(BIN_NAME):$(VERSION) + +docker-build: + @docker build --build-arg VERSION=$(VERSION) -t $(IMAGE) . + @docker images $(IMAGE) + +docker-run: docker-build + @docker run --rm -p 8080:8080 $(IMAGE) -transport=http -port=8080 + +docker-run-sse: docker-build + @docker run --rm -p 8080:8080 $(IMAGE) -transport=sse -port=8080 + +# ============================================================================= +# 📚 MCP TOOLS TESTING +# ============================================================================= +# help: 📚 MCP TOOLS +# help: test-metrics - Test get_system_metrics tool +# help: test-processes - Test list_processes tool +# help: test-health - Test check_service_health tool +# help: test-mcp - Test all MCP tools + +.PHONY: test-metrics test-processes test-health test-mcp + +test-metrics: + @echo "$(C_BLUE)➜ Testing get_system_metrics...$(C_RESET)" + @echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"get_system_metrics","arguments":{}},"id":1}' | $(DIST_DIR)/$(BIN_NAME) + +test-processes: + @echo "$(C_BLUE)➜ Testing list_processes...$(C_RESET)" + @echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"list_processes","arguments":{"sort_by":"cpu","limit":5}},"id":2}' | $(DIST_DIR)/$(BIN_NAME) + +test-health: + @echo "$(C_BLUE)➜ Testing check_service_health...$(C_RESET)" + @echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"check_service_health","arguments":{"services":[{"name":"local","type":"port","target":"localhost:22"}]}},"id":3}' | $(DIST_DIR)/$(BIN_NAME) + +test-mcp: build test-metrics test-processes test-health + @echo "\n$(C_BLUE)✔ MCP Tools Test Complete$(C_RESET)" + +# ============================================================================= +# 🚀 BENCHMARKING +# ============================================================================= +# help: 🚀 BENCHMARKING +# help: bench - Run Go benchmarks +# help: bench-http - Run HTTP load test using 'hey' (run make run-http first) + +.PHONY: bench bench-http + +bench: + @$(GO) test -bench=. -benchmem ./... + +bench-http: + @command -v hey >/dev/null || { echo '"hey" not installed'; exit 1; } + @echo "➜ load-test list_processes via /http" + @hey -m POST -T 'application/json' \ + -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"list_processes","arguments":{"limit":10}},"id":1}' \ + -n 10000 -c 50 http://localhost:8080/ + +# ============================================================================= +# 🔒 SECURITY & QUALITY +# ============================================================================= +# help: 🔒 SECURITY & QUALITY +# help: security - Run gosec security scanner +# help: check - Run all checks (fmt, vet, lint, test, security) + +.PHONY: security check + +security: + @command -v gosec >/dev/null || $(GO) install github.com/securecodewarrior/gosec/v2/cmd/gosec@latest + @gosec -quiet ./... + +check: fmt vet lint test security + @echo "$(C_BLUE)✔ All checks passed!$(C_RESET)" + +# ============================================================================= +# 🧹 CLEANUP +# ============================================================================= +# help: 🧹 CLEANUP +# help: clean - Remove build & coverage artefacts + +clean: + @rm -rf $(DIST_DIR) $(COVERPROFILE) $(COVERHTML) + @rm -f $(BIN_NAME) coverage.out coverage.html + @rm -rf coverage/ + @$(GO) clean + @echo "Workspace clean ✔" + +# --------------------------------------------------------------------------- +# Default goal +# --------------------------------------------------------------------------- +.DEFAULT_GOAL := help diff --git a/mcp-servers/go/system-monitor-server/README.md b/mcp-servers/go/system-monitor-server/README.md new file mode 100644 index 000000000..1cda58f00 --- /dev/null +++ b/mcp-servers/go/system-monitor-server/README.md @@ -0,0 +1,275 @@ +# 🖥️ System Monitor Server + +> Author: Mihai Criveti +> A comprehensive system monitoring MCP server written in Go that provides real-time system metrics, process monitoring, health checking, and log analysis capabilities for LLM applications. + +[![Go Version](https://img.shields.io/badge/go-1.23-blue)]() +[![License: Apache-2.0](https://img.shields.io/badge/license-Apache%202.0-blue)]() + +--- + +## Features + +- **MCP Tools**: System metrics, process monitoring, health checks, log tailing, disk usage +- **Real-time Monitoring**: Live metrics via WebSocket/SSE +- **Alert System**: Configurable threshold-based alerts +- **Security Controls**: Path validation, file size limits, rate limiting, ReDoS protection +- Five transports: `stdio`, `http` (JSON-RPC 2.0), `sse`, `dual` (MCP + REST), and `rest` (REST API only) +- Cross-platform support: Linux, macOS, Windows +- Build-time version injection via `main.appVersion` +- Comprehensive test coverage with HTML reports +- Docker support with multi-stage builds + +## Quick Start + +```bash +git clone git@github.com:IBM/mcp-context-forge.git +cd mcp-context-forge/mcp-servers/go/system-monitor-server + +# Build & run over stdio +make run + +# HTTP JSON-RPC on port 8080 +make run-http + +# SSE endpoint on port 8080 +make run-sse + +# REST API on port 8080 +make run-rest + +# Dual mode (MCP + REST) on port 8080 +make run-dual +``` + +## Installation + +**Requires Go 1.23+.** + +```bash +git clone git@github.com:IBM/mcp-context-forge.git +cd mcp-context-forge/mcp-servers/go/system-monitor-server +make install +``` + +### Claude Desktop Integration + +Add to your `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "system-monitor": { + "command": "/path/to/system-monitor-server", + "args": ["-log-level=error"] + } + } +} +``` + +## CLI Flags + +| Flag | Default | Description | +| ----------------- | --------- | ------------------------------------------------- | +| `-transport` | `stdio` | Options: `stdio`, `http`, `sse`, `dual`, `rest` | +| `-port` | `8080` | Port for HTTP/SSE/dual | +| `-log-level` | `info` | Logging level: `debug`, `info`, `warn`, `error` | +| `-config` | `config.yaml` | Path to configuration file | + +## MCP Features + +### Tools + +The server provides six main MCP tools: + +1. **get_system_metrics** - Returns current CPU, memory, disk, and network metrics + - No parameters required + +2. **list_processes** - Lists running processes with filtering and sorting + - Parameters: `filter_by`, `filter_value`, `sort_by`, `limit`, `include_threads` + +3. **monitor_process** - Monitors a specific process with alert thresholds + - Parameters: `pid`, `process_name`, `duration`, `interval`, `cpu_threshold`, `memory_threshold` + +4. **check_service_health** - Checks health of HTTP, TCP, or file-based services + - Parameters: `services` (array), `timeout` + - **SECURITY**: Command execution disabled (command injection risk) + +5. **tail_logs** - Streams log file contents with filtering + - Parameters: `file_path`, `lines`, `follow`, `filter`, `max_size` + - **SECURITY**: Path validation with symlink resolution, ReDoS protection + +6. **get_disk_usage** - Analyzes disk usage with detailed breakdowns + - Parameters: `path`, `max_depth`, `min_size`, `sort_by`, `file_types` + +### Configuration + +The server can be configured via `config.yaml`: + +```yaml +monitoring: + update_interval: "5s" + history_retention: "24h" + max_processes: 1000 + +alerts: + cpu_threshold: 80.0 + memory_threshold: 85.0 + disk_threshold: 90.0 + enabled: true + +health_checks: + - name: "web_server" + type: "http" + target: "http://localhost:8080/health" + interval: "30s" + +log_monitoring: + max_file_size: "100MB" + max_tail_lines: 1000 + # SECURITY: Only absolute paths, no /tmp by default + allowed_paths: ["/var/log"] + +security: + # SECURITY: Root path restricts ALL file access (chroot-like) + # Set to "/opt/monitoring-root" for production + root_path: "" # Empty = no root restriction + + # SECURITY: Only absolute paths, no /tmp by default + allowed_paths: ["/var/log"] + max_file_size: 104857600 # 100MB + rate_limit_rps: 10 + enable_audit_log: true +``` + +## API Endpoints + +### MCP Endpoints +- **STDIO**: Standard input/output (default for Claude Desktop) +- **HTTP**: `/` (JSON-RPC 2.0 endpoint) +- **SSE**: `/sse` (events), `/messages` (messages) +- **DUAL**: `/sse` & `/messages` (SSE), `/http` (HTTP) +- **REST**: `/api/v1/*` (REST API only) + +### Health & Version +``` +GET /health +GET /version +``` + +## Security Features + +### Root Directory Restriction (Chroot-like) +- **Configurable Root Path**: Set `security.root_path` to restrict all file access within a single directory +- **Defense in Depth**: When root_path is set, ALL file operations are confined to that directory tree +- **Production Recommended**: Configure a dedicated root like `/opt/monitoring-root` for production deployments +- **Layered Security**: Root restriction is enforced BEFORE allowed_paths checks + +### Path Traversal Protection +- **Symlink Resolution**: Uses `filepath.EvalSymlinks()` to prevent path traversal via symlinks +- **Directory Boundary Checks**: Validates paths are within allowed directories +- **No /tmp Access**: Removed from default allowed paths (security hardening) + +### Command Injection Prevention +- **Command Execution Disabled**: Health checker no longer allows arbitrary command execution +- **Alternative**: Use `list_processes` tool to check process status + +### ReDoS Protection +- **Pattern Length Limits**: Maximum 1000 characters +- **Dangerous Quantifier Detection**: Blocks nested quantifiers like `(a+)+` +- **Regex Timeout Protection**: Prevents CPU exhaustion + +### Memory Exhaustion Prevention +- **File Size Limits**: Enforced before reading files +- **Scanner Buffer Limits**: 10MB maximum per line +- **Rate Limiting**: Configurable requests per second + +## Examples + +### Get System Metrics +```bash +echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"get_system_metrics","arguments":{}},"id":1}' | ./system-monitor-server +``` + +### List Top CPU Processes +```bash +echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"list_processes","arguments":{"sort_by":"cpu","limit":5}},"id":2}' | ./system-monitor-server +``` + +### Check Service Health +```bash +echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"check_service_health","arguments":{"services":[{"name":"ssh","type":"port","target":"localhost:22"}]}},"id":3}' | ./system-monitor-server +``` + +## Docker + +```bash +make docker-build +make docker-run # HTTP mode +make docker-run-sse # SSE mode +``` + +## Development + +| Task | Command | +| -------------------- | --------------------------- | +| Format & tidy | `make fmt tidy` | +| Lint & vet | `make lint staticcheck vet` | +| Run pre-commit hooks | `make pre-commit` | +| Run all checks | `make check` | + +## Testing & Benchmarking + +```bash +make test # Unit tests (race detection) +make coverage # HTML coverage report → dist/coverage.html +make bench # Go benchmarks + +# Test MCP tools +make test-mcp # Test all MCP tools via stdio +``` + +## Cross-Compilation + +```bash +# Build for specific OS/ARCH +GOOS=linux GOARCH=amd64 make release +GOOS=darwin GOARCH=arm64 make release +GOOS=windows GOARCH=amd64 make release +``` + +Binaries appear under `dist/-/`. + +## Troubleshooting + +### Common Issues + +1. **Permission Denied**: Ensure the server has appropriate permissions to access system resources +2. **File Access Denied**: Check that file paths are in the `allowed_paths` configuration +3. **Symlink Path Traversal**: Server resolves symlinks and validates against allowed paths +4. **ReDoS Attack**: Server blocks dangerous regex patterns with nested quantifiers + +### Debug Mode + +Run with debug logging: +```bash +./system-monitor-server -log-level=debug +``` + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests +5. Run `make check` +6. Submit a pull request + +## License + +Apache-2.0 License - see LICENSE file for details. + +## Support + +- **Issues**: [GitHub Issues](https://github.com/IBM/mcp-context-forge/issues) +- **Discussions**: [GitHub Discussions](https://github.com/IBM/mcp-context-forge/discussions) diff --git a/mcp-servers/go/system-monitor-server/cmd/server/main.go b/mcp-servers/go/system-monitor-server/cmd/server/main.go new file mode 100644 index 000000000..56902b0a9 --- /dev/null +++ b/mcp-servers/go/system-monitor-server/cmd/server/main.go @@ -0,0 +1,1012 @@ +// -*- coding: utf-8 -*- +// system-monitor-server - comprehensive system monitoring MCP server +// +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Mihai Criveti, Manav Gupta +// +// This file implements an MCP (Model Context Protocol) server written in Go +// that provides comprehensive system monitoring capabilities including process +// management, resource usage, and system health metrics. +// +// Build: +// go build -o system-monitor-server ./cmd/server +// +// Available Tools: +// - get_system_metrics: Retrieve current system resource usage +// - list_processes: List running processes with filtering options +// - monitor_process: Monitor specific process health and resource usage +// - check_service_health: Check health of system services and applications +// - tail_logs: Stream log file contents with filtering +// - get_disk_usage: Analyze disk usage with detailed breakdowns +// +// Transport Modes: +// - stdio: For desktop clients like Claude Desktop (default) +// - sse: Server-Sent Events for web-based MCP clients +// - http: HTTP streaming for REST-like interactions +// - dual: Both SSE and HTTP on the same port (SSE at /sse, HTTP at /http) +// - rest: REST API endpoints for direct HTTP access (no MCP protocol) +// +// Authentication: +// Optional Bearer token authentication for SSE and HTTP transports. +// Use -auth-token flag or AUTH_TOKEN environment variable. +// +// Usage Examples: +// +// # 1) STDIO transport (for Claude Desktop integration) +// ./system-monitor-server +// ./system-monitor-server -log-level=debug # with debug logging +// ./system-monitor-server -log-level=none # silent mode +// +// # 2) SSE transport (for web clients) +// # Basic SSE server on localhost:8080 +// ./system-monitor-server -transport=sse +// +// # SSE on all interfaces with custom port +// ./system-monitor-server -transport=sse -listen=0.0.0.0 -port=3000 +// +// # SSE with public URL for remote access +// ./system-monitor-server -transport=sse -port=8080 \ +// -public-url=https://monitor.example.com +// +// # SSE with Bearer token authentication +// ./system-monitor-server -transport=sse -auth-token=secret123 +// # Or using environment variable: +// AUTH_TOKEN=secret123 ./system-monitor-server -transport=sse +// +// # 3) HTTP transport (for REST-style access) +// # Basic HTTP server +// ./system-monitor-server -transport=http +// +// # HTTP with custom address and base path +// ./system-monitor-server -transport=http -addr=127.0.0.1:9090 \ +// -log-level=debug +// +// # 4) DUAL mode (both SSE and HTTP) +// ./system-monitor-server -transport=dual -port=8080 +// # SSE will be at /sse, HTTP at /http, REST at /api/v1 +// +// # 5) REST API mode (direct HTTP REST endpoints) +// ./system-monitor-server -transport=rest -port=8080 +// # REST API at /api/v1/* with OpenAPI docs at /api/v1/docs +// +// Endpoint URLs: +// +// SSE Transport: +// Events: http://localhost:8080/sse +// Messages: http://localhost:8080/messages +// Health: http://localhost:8080/health +// Version: http://localhost:8080/version +// +// HTTP Transport: +// MCP: http://localhost:8080/ +// Health: http://localhost:8080/health +// Version: http://localhost:8080/version +// +// DUAL Transport: +// SSE Events: http://localhost:8080/sse +// SSE Messages: http://localhost:8080/messages and http://localhost:8080/message +// HTTP MCP: http://localhost:8080/http +// REST API: http://localhost:8080/api/v1/* +// API Docs: http://localhost:8080/api/v1/docs +// Health: http://localhost:8080/health +// Version: http://localhost:8080/version +// +// REST Transport: +// REST API: http://localhost:8080/api/v1/* +// API Docs: http://localhost:8080/api/v1/docs +// OpenAPI: http://localhost:8080/api/v1/openapi.json +// Health: http://localhost:8080/health +// Version: http://localhost:8080/version +// +// Authentication Headers: +// When auth-token is configured, include in requests: +// Authorization: Bearer +// +// Example with curl: +// curl -H "Authorization: Bearer " http://localhost:8080/sse +// +// Claude Desktop Configuration (stdio): +// Add to claude_desktop_config.json: +// { +// "mcpServers": { +// "system-monitor": { +// "command": "/path/to/system-monitor-server", +// "args": ["-log-level=error"] +// } +// } +// } +// +// Web Client Configuration (SSE with auth): +// const client = new MCPClient({ +// transport: 'sse', +// endpoint: 'http://localhost:8080', +// headers: { +// 'Authorization': 'Bearer secret123' +// } +// }); +// +// Testing Examples: +// +// # HTTP Transport - Use POST with JSON-RPC: +// # Initialize connection +// curl -X POST http://localhost:8080/ \ +// -H "Content-Type: application/json" \ +// -d '{"jsonrpc":"2.0","method":"initialize","params":{"clientInfo":{"name":"test","version":"1.0"}},"id":1}' +// +// # List available tools +// curl -X POST http://localhost:8080/ \ +// -H "Content-Type: application/json" \ +// -d '{"jsonrpc":"2.0","method":"tools/list","id":2}' +// +// # Call get_system_metrics tool +// curl -X POST http://localhost:8080/ \ +// -H "Content-Type: application/json" \ +// -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"get_system_metrics","arguments":{}},"id":3}' +// +// # SSE Transport - For event streaming: +// # Connect to SSE endpoint (this will stream events) +// curl -N http://localhost:8080/sse +// +// # Send messages via the messages endpoint (in another terminal) +// curl -X POST http://localhost:8080/messages \ +// -H "Content-Type: application/json" \ +// -d '{"jsonrpc":"2.0","method":"initialize","params":{"clientInfo":{"name":"test","version":"1.0"}},"id":1}' +// +// Environment Variables: +// AUTH_TOKEN - Bearer token for authentication (overrides -auth-token flag) +// +// ------------------------------------------------------------------- + +package main + +import ( + "bufio" + "context" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "strings" + "time" + + "github.com/IBM/mcp-context-forge/mcp-servers/go/system-monitor-server/internal/config" + "github.com/IBM/mcp-context-forge/mcp-servers/go/system-monitor-server/internal/metrics" + "github.com/IBM/mcp-context-forge/mcp-servers/go/system-monitor-server/internal/monitor" + "github.com/IBM/mcp-context-forge/mcp-servers/go/system-monitor-server/pkg/types" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +/* ------------------------------------------------------------------ */ +/* constants */ +/* ------------------------------------------------------------------ */ + +const ( + appName = "system-monitor-server" + appVersion = "1.0.0" + + // Default values + defaultPort = 8080 + defaultListen = "0.0.0.0" + defaultLogLevel = "info" + + // Environment variables + envAuthToken = "AUTH_TOKEN" +) + +/* ------------------------------------------------------------------ */ +/* logging */ +/* ------------------------------------------------------------------ */ + +// logLvl represents logging verbosity levels +type logLvl int + +const ( + logNone logLvl = iota + logError + logWarn + logInfo + logDebug +) + +var ( + curLvl = logInfo + logger = log.New(os.Stderr, "", log.LstdFlags) +) + +// parseLvl converts a string log level to logLvl type +func parseLvl(s string) logLvl { + switch strings.ToLower(s) { + case "debug": + return logDebug + case "info": + return logInfo + case "warn", "warning": + return logWarn + case "error": + return logError + case "none", "off", "silent": + return logNone + default: + return logInfo + } +} + +// logAt logs a message if the current log level permits +func logAt(l logLvl, f string, v ...any) { + if curLvl >= l { + logger.Printf(f, v...) + } +} + +/* ------------------------------------------------------------------ */ +/* version / health helpers */ +/* ------------------------------------------------------------------ */ + +// versionJSON returns server version information as JSON +func versionJSON() string { + return fmt.Sprintf(`{"name":%q,"version":%q,"mcp_version":"1.0"}`, appName, appVersion) +} + +// healthJSON returns server health status as JSON +func healthJSON() string { + return fmt.Sprintf(`{"status":"healthy","uptime_seconds":%d}`, int(time.Since(startTime).Seconds())) +} + +var startTime = time.Now() + +/* ------------------------------------------------------------------ */ +/* tool handlers */ +/* ------------------------------------------------------------------ */ + +// handleGetSystemMetrics returns current system resource usage +func handleGetSystemMetrics(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + collector := metrics.NewSystemCollector() + metrics, err := collector.GetSystemMetrics(ctx) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to get system metrics: %v", err)), nil + } + + jsonData, err := json.Marshal(metrics) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal metrics: %v", err)), nil + } + + logAt(logInfo, "get_system_metrics: collected system metrics") + return mcp.NewToolResultText(string(jsonData)), nil +} + +// handleListProcesses lists running processes with filtering options +func handleListProcesses(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Parse request parameters + processReq := &types.ProcessListRequest{ + FilterBy: req.GetString("filter_by", ""), + FilterValue: req.GetString("filter_value", ""), + SortBy: req.GetString("sort_by", "cpu"), + Limit: req.GetInt("limit", 0), + IncludeThreads: req.GetBool("include_threads", false), + } + + collector := metrics.NewProcessCollector() + processes, err := collector.ListProcesses(ctx, processReq) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to list processes: %v", err)), nil + } + + jsonData, err := json.Marshal(processes) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal processes: %v", err)), nil + } + + logAt(logInfo, "list_processes: found %d processes", len(processes)) + return mcp.NewToolResultText(string(jsonData)), nil +} + +// handleMonitorProcess monitors a specific process for a given duration +func handleMonitorProcess(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Parse request parameters + processReq := &types.ProcessMonitorRequest{ + PID: int32(req.GetInt("pid", 0)), + ProcessName: req.GetString("process_name", ""), + Duration: req.GetInt("duration", 60), + Interval: req.GetInt("interval", 5), + } + + // Parse alert thresholds if provided + if cpuThreshold := req.GetFloat("cpu_threshold", 0); cpuThreshold > 0 { + processReq.AlertThresholds.CPUPercent = cpuThreshold + } + if memThreshold := req.GetFloat("memory_threshold", 0); memThreshold > 0 { + processReq.AlertThresholds.MemoryPercent = memThreshold + } + if memRSSThreshold := req.GetInt("memory_rss_threshold", 0); memRSSThreshold > 0 { + processReq.AlertThresholds.MemoryRSS = uint64(memRSSThreshold) + } + + collector := metrics.NewProcessCollector() + results, err := collector.MonitorProcess(ctx, processReq) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to monitor process: %v", err)), nil + } + + jsonData, err := json.Marshal(results) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal monitoring results: %v", err)), nil + } + + logAt(logInfo, "monitor_process: monitored process for %d seconds", processReq.Duration) + return mcp.NewToolResultText(string(jsonData)), nil +} + +// handleCheckServiceHealth checks health of system services +func handleCheckServiceHealth(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Parse services from request + servicesJSON := req.GetString("services", "[]") + var services []types.ServiceCheck + if err := json.Unmarshal([]byte(servicesJSON), &services); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to parse services: %v", err)), nil + } + + healthReq := &types.HealthCheckRequest{ + Services: services, + Timeout: req.GetInt("timeout", 10), + } + + checker := monitor.NewHealthChecker() + results, err := checker.CheckServiceHealth(ctx, healthReq) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to check service health: %v", err)), nil + } + + jsonData, err := json.Marshal(results) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal health check results: %v", err)), nil + } + + logAt(logInfo, "check_service_health: checked %d services", len(services)) + return mcp.NewToolResultText(string(jsonData)), nil +} + +// handleTailLogs streams log file contents with filtering +func handleTailLogs(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Parse request parameters + logReq := &types.LogTailRequest{ + FilePath: req.GetString("file_path", ""), + Lines: req.GetInt("lines", 100), + Follow: req.GetBool("follow", false), + Filter: req.GetString("filter", ""), + MaxSize: int64(req.GetInt("max_size", 0)), + } + + if logReq.FilePath == "" { + return mcp.NewToolResultError("file_path parameter is required"), nil + } + + // Create log monitor with default security settings + cfg := config.DefaultConfig() + monitor := monitor.NewLogMonitor(cfg.Security.RootPath, cfg.Security.AllowedPaths, cfg.Security.MaxFileSize) + + result, err := monitor.TailLogs(ctx, logReq) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to tail logs: %v", err)), nil + } + + jsonData, err := json.Marshal(result) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal log tail result: %v", err)), nil + } + + logAt(logInfo, "tail_logs: tailed %d lines from %s", result.TotalLines, logReq.FilePath) + return mcp.NewToolResultText(string(jsonData)), nil +} + +// handleGetDiskUsage analyzes disk usage with detailed breakdowns +func handleGetDiskUsage(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Parse request parameters + diskReq := &types.DiskUsageRequest{ + Path: req.GetString("path", "."), + MaxDepth: req.GetInt("max_depth", 0), + MinSize: int64(req.GetInt("min_size", 0)), + SortBy: req.GetString("sort_by", "size"), + FileTypes: req.GetStringSlice("file_types", []string{}), + } + + // Create log monitor with default security settings + cfg := config.DefaultConfig() + monitor := monitor.NewLogMonitor(cfg.Security.RootPath, cfg.Security.AllowedPaths, cfg.Security.MaxFileSize) + + result, err := monitor.GetDiskUsage(ctx, diskReq) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to get disk usage: %v", err)), nil + } + + jsonData, err := json.Marshal(result) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal disk usage result: %v", err)), nil + } + + logAt(logInfo, "get_disk_usage: analyzed %d items in %s", result.ItemCount, diskReq.Path) + return mcp.NewToolResultText(string(jsonData)), nil +} + +/* ------------------------------------------------------------------ */ +/* authentication middleware */ +/* ------------------------------------------------------------------ */ + +// authMiddleware creates a middleware that checks for Bearer token authentication +func authMiddleware(token string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Skip auth for health and version endpoints + if r.URL.Path == "/health" || r.URL.Path == "/version" { + next.ServeHTTP(w, r) + return + } + + // Get Authorization header + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + logAt(logWarn, "missing authorization header from %s for %s", r.RemoteAddr, r.URL.Path) + w.Header().Set("WWW-Authenticate", `Bearer realm="MCP Server"`) + http.Error(w, "Authorization required", http.StatusUnauthorized) + return + } + + // Check Bearer token format + const bearerPrefix = "Bearer " + if !strings.HasPrefix(authHeader, bearerPrefix) { + logAt(logWarn, "invalid authorization format from %s", r.RemoteAddr) + http.Error(w, "Invalid authorization format", http.StatusUnauthorized) + return + } + + // Verify token + providedToken := strings.TrimPrefix(authHeader, bearerPrefix) + if providedToken != token { + logAt(logWarn, "invalid token from %s", r.RemoteAddr) + http.Error(w, "Invalid token", http.StatusUnauthorized) + return + } + + // Token valid, proceed with request + logAt(logDebug, "authenticated request from %s to %s", r.RemoteAddr, r.URL.Path) + next.ServeHTTP(w, r) + }) +} + +/* ------------------------------------------------------------------ */ +/* main */ +/* ------------------------------------------------------------------ */ + +func main() { + /* ---------------------------- flags --------------------------- */ + var ( + transport = flag.String("transport", "stdio", "Transport: stdio | sse | http | dual | rest") + addrFlag = flag.String("addr", "", "Full listen address (host:port) - overrides -listen/-port") + listenHost = flag.String("listen", defaultListen, "Listen interface for sse/http") + port = flag.Int("port", defaultPort, "TCP port for sse/http") + publicURL = flag.String("public-url", "", "External base URL advertised to SSE clients") + authToken = flag.String("auth-token", "", "Bearer token for authentication (SSE/HTTP only)") + logLevel = flag.String("log-level", defaultLogLevel, "Logging level: debug|info|warn|error|none") + showHelp = flag.Bool("help", false, "Show help message") + ) + + // Custom usage function + flag.Usage = func() { + const ind = " " + fmt.Fprintf(flag.CommandLine.Output(), + "%s %s - comprehensive system monitoring for LLM agents via MCP\n\n", + appName, appVersion) + fmt.Fprintln(flag.CommandLine.Output(), "Options:") + flag.VisitAll(func(fl *flag.Flag) { + fmt.Fprintf(flag.CommandLine.Output(), ind+"-%s\n", fl.Name) + fmt.Fprintf(flag.CommandLine.Output(), ind+ind+"%s (default %q)\n\n", + fl.Usage, fl.DefValue) + }) + fmt.Fprintf(flag.CommandLine.Output(), + "Examples:\n"+ + ind+"%s -transport=stdio -log-level=none\n"+ + ind+"%s -transport=sse -listen=0.0.0.0 -port=8080\n"+ + ind+"%s -transport=http -addr=127.0.0.1:9090\n"+ + ind+"%s -transport=dual -port=8080 -auth-token=secret123\n"+ + ind+"%s -transport=rest -port=8080\n\n"+ + "MCP Protocol Endpoints:\n"+ + ind+"SSE: /sse (events), /messages (messages)\n"+ + ind+"HTTP: / (single endpoint)\n"+ + ind+"DUAL: /sse & /messages (SSE), /http (HTTP), /api/v1/* (REST)\n"+ + ind+"REST: /api/v1/* (REST API only, no MCP)\n\n"+ + "Environment Variables:\n"+ + ind+"AUTH_TOKEN - Bearer token for authentication (overrides -auth-token flag)\n", + os.Args[0], os.Args[0], os.Args[0], os.Args[0], os.Args[0]) + } + + flag.Parse() + + if *showHelp { + flag.Usage() + os.Exit(0) + } + + /* ----------------------- configuration setup ------------------ */ + // Check for auth token in environment variable (overrides flag) + if envToken := os.Getenv(envAuthToken); envToken != "" { + *authToken = envToken + logAt(logDebug, "using auth token from environment variable") + } + + /* ------------------------- logging setup ---------------------- */ + curLvl = parseLvl(*logLevel) + if curLvl == logNone { + logger.SetOutput(io.Discard) + } + + logAt(logDebug, "starting %s %s", appName, appVersion) + if *authToken != "" && *transport != "stdio" { + logAt(logInfo, "authentication enabled with Bearer token") + } + + /* ----------------------- build MCP server --------------------- */ + // Create server with appropriate options + s := server.NewMCPServer( + appName, + appVersion, + server.WithToolCapabilities(false), // No progress reporting needed + server.WithResourceCapabilities(false, false), // No resource capabilities + server.WithPromptCapabilities(false), // No prompt capabilities + server.WithLogging(), // Enable MCP protocol logging + server.WithRecovery(), // Recover from panics in handlers + ) + + /* ----------------------- register tools ----------------------- */ + // Register get_system_metrics tool + getSystemMetricsTool := mcp.NewTool("get_system_metrics", + mcp.WithDescription("Get current system resource usage including CPU, memory, disk, and network metrics"), + mcp.WithTitleAnnotation("Get System Metrics"), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(false), + mcp.WithOpenWorldHintAnnotation(false), + ) + s.AddTool(getSystemMetricsTool, handleGetSystemMetrics) + + // Register list_processes tool + listProcessesTool := mcp.NewTool("list_processes", + mcp.WithDescription("List running processes with filtering and sorting options"), + mcp.WithTitleAnnotation("List Processes"), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(false), + mcp.WithOpenWorldHintAnnotation(false), + mcp.WithString("filter_by", + mcp.Description("Filter processes by: name, user, pid"), + ), + mcp.WithString("filter_value", + mcp.Description("Value to filter by"), + ), + mcp.WithString("sort_by", + mcp.Description("Sort processes by: cpu, memory, name, pid (default: cpu)"), + ), + mcp.WithNumber("limit", + mcp.Description("Maximum number of processes to return (0 = no limit)"), + ), + mcp.WithBoolean("include_threads", + mcp.Description("Include thread count information"), + ), + ) + s.AddTool(listProcessesTool, handleListProcesses) + + // Register monitor_process tool + monitorProcessTool := mcp.NewTool("monitor_process", + mcp.WithDescription("Monitor specific process health and resource usage over time"), + mcp.WithTitleAnnotation("Monitor Process"), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(false), + mcp.WithOpenWorldHintAnnotation(false), + mcp.WithNumber("pid", + mcp.Description("Process ID to monitor"), + ), + mcp.WithString("process_name", + mcp.Description("Process name to monitor (alternative to PID)"), + ), + mcp.WithNumber("duration", + mcp.Description("Monitoring duration in seconds (default: 60)"), + ), + mcp.WithNumber("interval", + mcp.Description("Monitoring interval in seconds (default: 5)"), + ), + mcp.WithNumber("cpu_threshold", + mcp.Description("CPU usage threshold for alerts (percentage)"), + ), + mcp.WithNumber("memory_threshold", + mcp.Description("Memory usage threshold for alerts (percentage)"), + ), + mcp.WithNumber("memory_rss_threshold", + mcp.Description("Memory RSS threshold for alerts (bytes)"), + ), + ) + s.AddTool(monitorProcessTool, handleMonitorProcess) + + // Register check_service_health tool + checkServiceHealthTool := mcp.NewTool("check_service_health", + mcp.WithDescription("Check health of system services and applications"), + mcp.WithTitleAnnotation("Check Service Health"), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(false), + mcp.WithOpenWorldHintAnnotation(false), + mcp.WithString("services", + mcp.Required(), + mcp.Description("JSON array of services to check with name, type, target, and expected values"), + ), + mcp.WithNumber("timeout", + mcp.Description("Timeout in seconds for health checks (default: 10)"), + ), + ) + s.AddTool(checkServiceHealthTool, handleCheckServiceHealth) + + // Register tail_logs tool + tailLogsTool := mcp.NewTool("tail_logs", + mcp.WithDescription("Stream log file contents with filtering and security controls"), + mcp.WithTitleAnnotation("Tail Logs"), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(false), + mcp.WithOpenWorldHintAnnotation(false), + mcp.WithString("file_path", + mcp.Required(), + mcp.Description("Path to the log file to tail"), + ), + mcp.WithNumber("lines", + mcp.Description("Number of lines to tail (default: 100)"), + ), + mcp.WithBoolean("follow", + mcp.Description("Follow the file for new lines (default: false)"), + ), + mcp.WithString("filter", + mcp.Description("Regex filter for log lines"), + ), + mcp.WithNumber("max_size", + mcp.Description("Maximum file size to process (bytes)"), + ), + ) + s.AddTool(tailLogsTool, handleTailLogs) + + // Register get_disk_usage tool + getDiskUsageTool := mcp.NewTool("get_disk_usage", + mcp.WithDescription("Analyze disk usage with detailed breakdowns and filtering"), + mcp.WithTitleAnnotation("Get Disk Usage"), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(false), + mcp.WithOpenWorldHintAnnotation(false), + mcp.WithString("path", + mcp.Description("Path to analyze (default: current directory)"), + ), + mcp.WithNumber("max_depth", + mcp.Description("Maximum directory depth to analyze (0 = unlimited)"), + ), + mcp.WithNumber("min_size", + mcp.Description("Minimum file size to include (bytes)"), + ), + mcp.WithString("sort_by", + mcp.Description("Sort results by: size, name, modified (default: size)"), + ), + mcp.WithArray("file_types", + mcp.Description("Filter by file extensions (e.g., [\"txt\", \"log\"])"), + ), + ) + s.AddTool(getDiskUsageTool, handleGetDiskUsage) + + /* -------------------- choose transport & serve ---------------- */ + switch strings.ToLower(*transport) { + + /* ---------------------------- stdio -------------------------- */ + case "stdio": + if *authToken != "" { + logAt(logWarn, "auth-token is ignored for stdio transport") + } + logAt(logInfo, "serving via stdio transport") + if err := server.ServeStdio(s); err != nil { + logger.Fatalf("stdio server error: %v", err) + } + + /* ----------------------------- sse --------------------------- */ + case "sse": + addr := effectiveAddr(*addrFlag, *listenHost, *port) + mux := http.NewServeMux() + + // Configure SSE options - no base path for root serving + opts := []server.SSEOption{} + if *publicURL != "" { + // Ensure public URL doesn't have trailing slash + opts = append(opts, server.WithBaseURL(strings.TrimRight(*publicURL, "/"))) + } + + // Register SSE handler at root + sseHandler := server.NewSSEServer(s, opts...) + mux.Handle("/", sseHandler) + + // Register health and version endpoints + registerHealthAndVersion(mux) + + logAt(logInfo, "SSE server ready on http://%s", addr) + logAt(logInfo, " MCP SSE events: /sse") + logAt(logInfo, " MCP SSE messages: /messages") + logAt(logInfo, " Health check: /health") + logAt(logInfo, " Version info: /version") + + if *publicURL != "" { + logAt(logInfo, " Public URL: %s", *publicURL) + } + + if *authToken != "" { + logAt(logInfo, " Authentication: Bearer token required") + } + + // Create handler chain + var handler http.Handler = mux + handler = loggingHTTPMiddleware(handler) + if *authToken != "" { + handler = authMiddleware(*authToken, handler) + } + + // Start server + if err := http.ListenAndServe(addr, handler); err != nil && err != http.ErrServerClosed { + logger.Fatalf("SSE server error: %v", err) + } + + /* ----------------------- streamable http --------------------- */ + case "http": + addr := effectiveAddr(*addrFlag, *listenHost, *port) + mux := http.NewServeMux() + + // Register HTTP handler at root + httpHandler := server.NewStreamableHTTPServer(s) + mux.Handle("/", httpHandler) + + // Register health and version endpoints + registerHealthAndVersion(mux) + + // Add a helpful GET handler for root + mux.HandleFunc("/info", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"message":"MCP HTTP server ready","instructions":"Use POST requests with JSON-RPC 2.0 payloads","example":{"jsonrpc":"2.0","method":"tools/list","id":1}}`) + }) + + logAt(logInfo, "HTTP server ready on http://%s", addr) + logAt(logInfo, " MCP endpoint: / (POST with JSON-RPC)") + logAt(logInfo, " Info: /info") + logAt(logInfo, " Health check: /health") + logAt(logInfo, " Version info: /version") + + if *authToken != "" { + logAt(logInfo, " Authentication: Bearer token required") + } + + // Example command + logAt(logInfo, "Test with: curl -X POST http://%s/ -H 'Content-Type: application/json' -d '{\"jsonrpc\":\"2.0\",\"method\":\"tools/list\",\"id\":1}'", addr) + + // Create handler chain + var handler http.Handler = mux + handler = loggingHTTPMiddleware(handler) + if *authToken != "" { + handler = authMiddleware(*authToken, handler) + } + + // Start server + if err := http.ListenAndServe(addr, handler); err != nil && err != http.ErrServerClosed { + logger.Fatalf("HTTP server error: %v", err) + } + + /* ---------------------------- dual --------------------------- */ + case "dual": + addr := effectiveAddr(*addrFlag, *listenHost, *port) + mux := http.NewServeMux() + + // Configure SSE handler for /sse and /messages + sseOpts := []server.SSEOption{} + if *publicURL != "" { + sseOpts = append(sseOpts, server.WithBaseURL(strings.TrimRight(*publicURL, "/"))) + } + sseHandler := server.NewSSEServer(s, sseOpts...) + + // Configure HTTP handler for /http + httpHandler := server.NewStreamableHTTPServer(s, server.WithEndpointPath("/http")) + + // Register handlers + mux.Handle("/sse", sseHandler) + mux.Handle("/messages", sseHandler) // Support plural (backward compatibility) + mux.Handle("/message", sseHandler) // Support singular (MCP Gateway compatibility) + mux.Handle("/http", httpHandler) + + // Register health and version endpoints + registerHealthAndVersion(mux) + + logAt(logInfo, "DUAL server ready on http://%s", addr) + logAt(logInfo, " SSE events: /sse") + logAt(logInfo, " SSE messages: /messages (plural) and /message (singular)") + logAt(logInfo, " HTTP endpoint: /http") + logAt(logInfo, " Health check: /health") + logAt(logInfo, " Version info: /version") + + if *publicURL != "" { + logAt(logInfo, " Public URL: %s", *publicURL) + } + + if *authToken != "" { + logAt(logInfo, " Authentication: Bearer token required") + } + + // Create handler chain + var handler http.Handler = mux + handler = loggingHTTPMiddleware(handler) + if *authToken != "" { + handler = authMiddleware(*authToken, handler) + } + + // Start server + if err := http.ListenAndServe(addr, handler); err != nil && err != http.ErrServerClosed { + logger.Fatalf("DUAL server error: %v", err) + } + + /* ---------------------------- rest --------------------------- */ + case "rest": + addr := effectiveAddr(*addrFlag, *listenHost, *port) + mux := http.NewServeMux() + + // Register health and version endpoints + registerHealthAndVersion(mux) + + logAt(logInfo, "REST API server ready on http://%s", addr) + logAt(logInfo, " Health check: /health") + logAt(logInfo, " Version info: /version") + + if *authToken != "" { + logAt(logInfo, " Authentication: Bearer token required") + } + + // Example commands + logAt(logInfo, "Test commands:") + logAt(logInfo, " Get metrics: curl http://%s/api/v1/metrics", addr) + logAt(logInfo, " List processes: curl http://%s/api/v1/processes", addr) + + // Create handler chain + var handler http.Handler = mux + handler = loggingHTTPMiddleware(handler) + if *authToken != "" { + handler = authMiddleware(*authToken, handler) + } + + // Start server + if err := http.ListenAndServe(addr, handler); err != nil && err != http.ErrServerClosed { + logger.Fatalf("REST server error: %v", err) + } + + default: + fmt.Fprintf(os.Stderr, "Error: unknown transport %q\n\n", *transport) + flag.Usage() + os.Exit(2) + } +} + +/* ------------------------------------------------------------------ */ +/* helper functions */ +/* ------------------------------------------------------------------ */ + +// effectiveAddr determines the actual address to listen on +func effectiveAddr(addrFlag, listen string, port int) string { + if addrFlag != "" { + return addrFlag + } + return fmt.Sprintf("%s:%d", listen, port) +} + +// registerHealthAndVersion adds health and version endpoints to the mux +func registerHealthAndVersion(mux *http.ServeMux) { + // Health endpoint - JSON response + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(healthJSON())) + }) + + // Version endpoint - JSON response + mux.HandleFunc("/version", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(versionJSON())) + }) +} + +/* -------------------- HTTP middleware ----------------------------- */ + +// loggingHTTPMiddleware provides request logging when log level permits +func loggingHTTPMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if curLvl < logInfo { + next.ServeHTTP(w, r) + return + } + + start := time.Now() + + // Wrap response writer to capture status code + rw := &statusWriter{ResponseWriter: w, status: http.StatusOK, written: false} + + // Call the next handler + next.ServeHTTP(rw, r) + + // Log the request with body size for POST requests + duration := time.Since(start) + if r.Method == "POST" && curLvl >= logDebug { + logAt(logDebug, "%s %s %s %d (Content-Length: %s) %v", + r.RemoteAddr, r.Method, r.URL.Path, rw.status, r.Header.Get("Content-Length"), duration) + } else { + logAt(logInfo, "%s %s %s %d %v", + r.RemoteAddr, r.Method, r.URL.Path, rw.status, duration) + } + }) +} + +// statusWriter wraps http.ResponseWriter so we can capture the status code +// *and* still pass through streaming-related interfaces (Flusher, Hijacker, +// CloseNotifier) that SSE / HTTP streaming require. +type statusWriter struct { + http.ResponseWriter + status int + written bool +} + +/* -------- core ResponseWriter behaviour -------- */ + +func (sw *statusWriter) WriteHeader(code int) { + if !sw.written { + sw.status = code + sw.written = true + sw.ResponseWriter.WriteHeader(code) + } +} + +func (sw *statusWriter) Write(b []byte) (int, error) { + if !sw.written { + sw.WriteHeader(http.StatusOK) + } + return sw.ResponseWriter.Write(b) +} + +/* -------- pass-through for streaming interfaces -------- */ + +// Flush lets the underlying handler stream (needed for SSE) +func (sw *statusWriter) Flush() { + if f, ok := sw.ResponseWriter.(http.Flusher); ok { + if !sw.written { + sw.WriteHeader(http.StatusOK) + } + f.Flush() + } +} + +// Hijack lets handlers switch to raw TCP (not used by SSE but good hygiene) +func (sw *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if h, ok := sw.ResponseWriter.(http.Hijacker); ok { + return h.Hijack() + } + return nil, nil, fmt.Errorf("hijacking not supported") +} + +// CloseNotify keeps SSE clients informed if the peer goes away +// Deprecated: Use Request.Context() instead. Kept for compatibility with older SSE implementations. +func (sw *statusWriter) CloseNotify() <-chan bool { + // nolint:staticcheck // SA1019: http.CloseNotifier is deprecated but required for SSE compatibility + if cn, ok := sw.ResponseWriter.(http.CloseNotifier); ok { + return cn.CloseNotify() + } + // If the underlying writer doesn't support it, fabricate a never-closing chan + done := make(chan bool, 1) + return done +} diff --git a/mcp-servers/go/system-monitor-server/cmd/server/main_test.go b/mcp-servers/go/system-monitor-server/cmd/server/main_test.go new file mode 100644 index 000000000..56978b437 --- /dev/null +++ b/mcp-servers/go/system-monitor-server/cmd/server/main_test.go @@ -0,0 +1,578 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/mark3labs/mcp-go/mcp" +) + +func TestVersionJSON(t *testing.T) { + version := versionJSON() + if version == "" { + t.Error("Version JSON should not be empty") + } + + // Test that it's valid JSON + var v map[string]interface{} + if err := json.Unmarshal([]byte(version), &v); err != nil { + t.Errorf("Version JSON should be valid JSON: %v", err) + } + + // Test that it contains expected fields + if v["name"] != appName { + t.Errorf("Expected name %s, got %s", appName, v["name"]) + } + if v["version"] != appVersion { + t.Errorf("Expected version %s, got %s", appVersion, v["version"]) + } +} + +func TestHealthJSON(t *testing.T) { + health := healthJSON() + if health == "" { + t.Error("Health JSON should not be empty") + } + + // Test that it's valid JSON + var h map[string]interface{} + if err := json.Unmarshal([]byte(health), &h); err != nil { + t.Errorf("Health JSON should be valid JSON: %v", err) + } + + // Test that it contains expected fields + if h["status"] != "healthy" { + t.Errorf("Expected status 'healthy', got %s", h["status"]) + } + if _, ok := h["uptime_seconds"]; !ok { + t.Error("Expected uptime_seconds field") + } +} + +func TestParseLvl(t *testing.T) { + tests := []struct { + input string + expected logLvl + }{ + {"debug", logDebug}, + {"DEBUG", logDebug}, + {"info", logInfo}, + {"INFO", logInfo}, + {"warn", logWarn}, + {"warning", logWarn}, + {"error", logError}, + {"none", logNone}, + {"off", logNone}, + {"silent", logNone}, + {"invalid", logInfo}, // default + {"", logInfo}, // default + } + + for _, test := range tests { + result := parseLvl(test.input) + if result != test.expected { + t.Errorf("parseLvl(%s) = %v, expected %v", test.input, result, test.expected) + } + } +} + +func TestLogAt(t *testing.T) { + // Test that logAt respects log levels + // This is a bit tricky to test without capturing output, but we can test the logic + originalLevel := curLvl + defer func() { curLvl = originalLevel }() + + // Test with different log levels + curLvl = logDebug + logAt(logDebug, "debug message") + logAt(logInfo, "info message") + logAt(logWarn, "warn message") + logAt(logError, "error message") + + curLvl = logWarn + logAt(logDebug, "debug message") // Should not log + logAt(logInfo, "info message") // Should not log + logAt(logWarn, "warn message") // Should log + logAt(logError, "error message") // Should log +} + +func TestHandleGetSystemMetrics(t *testing.T) { + ctx := context.Background() + req := mcp.CallToolRequest{} + + result, err := handleGetSystemMetrics(ctx, req) + if err != nil { + t.Fatalf("handleGetSystemMetrics failed: %v", err) + } + + if result.IsError { + // Get text content from the result + if len(result.Content) > 0 { + if textContent, ok := mcp.AsTextContent(result.Content[0]); ok { + t.Errorf("Expected success, got error: %s", textContent.Text) + } else { + t.Error("Expected success, got error") + } + } else { + t.Error("Expected success, got error") + } + } + + // Test that result contains valid JSON + if len(result.Content) > 0 { + if textContent, ok := mcp.AsTextContent(result.Content[0]); ok { + var metrics map[string]interface{} + if err := json.Unmarshal([]byte(textContent.Text), &metrics); err != nil { + t.Errorf("Result should be valid JSON: %v", err) + } + + // Test that it contains expected fields + expectedFields := []string{"timestamp", "cpu", "memory", "disk", "network"} + for _, field := range expectedFields { + if _, ok := metrics[field]; !ok { + t.Errorf("Expected field %s in metrics", field) + } + } + } else { + t.Error("Expected text content in result") + } + } else { + t.Error("Expected content in result") + } +} + +func TestHandleListProcesses(t *testing.T) { + ctx := context.Background() + + // Test basic request + req := mcp.CallToolRequest{} + result, err := handleListProcesses(ctx, req) + if err != nil { + t.Fatalf("handleListProcesses failed: %v", err) + } + + if result.IsError { + if len(result.Content) > 0 { + if textContent, ok := mcp.AsTextContent(result.Content[0]); ok { + t.Errorf("Expected success, got error: %s", textContent.Text) + } else { + t.Error("Expected success, got error") + } + } else { + t.Error("Expected success, got error") + } + } + + // Test with parameters - create a proper CallToolRequest + req = mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Arguments: map[string]interface{}{ + "filter_by": "name", + "filter_value": "go", + "sort_by": "cpu", + "limit": 10, + }, + }, + } + + result, err = handleListProcesses(ctx, req) + if err != nil { + t.Fatalf("handleListProcesses with params failed: %v", err) + } + + if result.IsError { + if len(result.Content) > 0 { + if textContent, ok := mcp.AsTextContent(result.Content[0]); ok { + t.Errorf("Expected success with params, got error: %s", textContent.Text) + } else { + t.Error("Expected success with params, got error") + } + } else { + t.Error("Expected success with params, got error") + } + } +} + +func TestHandleMonitorProcess(t *testing.T) { + ctx := context.Background() + + // Test with invalid request (no PID or name) + req := mcp.CallToolRequest{} + result, err := handleMonitorProcess(ctx, req) + if err != nil { + t.Fatalf("handleMonitorProcess failed: %v", err) + } + + if !result.IsError { + t.Error("Expected error for invalid request") + } + + // Test with valid PID (use current process) + req = mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Arguments: map[string]interface{}{ + "pid": int(os.Getpid()), + "duration": 1, + "interval": 1, + }, + }, + } + + result, err = handleMonitorProcess(ctx, req) + if err != nil { + t.Fatalf("handleMonitorProcess with PID failed: %v", err) + } + + // This might succeed or fail depending on process access, but shouldn't panic +} + +func TestHandleCheckServiceHealth(t *testing.T) { + ctx := context.Background() + + // Test with empty services + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Arguments: map[string]interface{}{ + "services": "[]", + }, + }, + } + + result, err := handleCheckServiceHealth(ctx, req) + if err != nil { + t.Fatalf("handleCheckServiceHealth failed: %v", err) + } + + if result.IsError { + if len(result.Content) > 0 { + if textContent, ok := mcp.AsTextContent(result.Content[0]); ok { + t.Errorf("Expected success, got error: %s", textContent.Text) + } else { + t.Error("Expected success, got error") + } + } else { + t.Error("Expected success, got error") + } + } + + // Test with invalid JSON + req = mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Arguments: map[string]interface{}{ + "services": "invalid json", + }, + }, + } + + result, err = handleCheckServiceHealth(ctx, req) + if err != nil { + t.Fatalf("handleCheckServiceHealth failed: %v", err) + } + + if !result.IsError { + t.Error("Expected error for invalid JSON") + } +} + +func TestHandleTailLogs(t *testing.T) { + ctx := context.Background() + + // Test with missing file_path + req := mcp.CallToolRequest{} + result, err := handleTailLogs(ctx, req) + if err != nil { + t.Fatalf("handleTailLogs failed: %v", err) + } + + if !result.IsError { + t.Error("Expected error for missing file_path") + } + + // SECURITY TEST: Verify that /tmp is not allowed (security hardening) + // Create a temp file in /tmp to test that access is properly denied + tmpFile, err := os.CreateTemp("/tmp", "test-log-*.txt") + if err != nil { + t.Skip("Cannot create temp file for security test") + } + defer os.Remove(tmpFile.Name()) + defer tmpFile.Close() + + tmpFile.WriteString("test log line\n") + tmpFile.Close() + + req = mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Arguments: map[string]interface{}{ + "file_path": tmpFile.Name(), + "lines": 10, + }, + }, + } + + result, err = handleTailLogs(ctx, req) + if err != nil { + t.Fatalf("handleTailLogs failed: %v", err) + } + + // SECURITY: Expect error because /tmp is not in allowed paths + if !result.IsError { + t.Error("Expected error for /tmp access (security hardening), but got success") + } + + // Verify the error message mentions path validation + if len(result.Content) > 0 { + if textContent, ok := mcp.AsTextContent(result.Content[0]); ok { + if !strings.Contains(textContent.Text, "file path validation failed") && + !strings.Contains(textContent.Text, "not in allowed directories") { + t.Errorf("Expected path validation error, got: %s", textContent.Text) + } + } + } +} + +func TestHandleGetDiskUsage(t *testing.T) { + ctx := context.Background() + + // SECURITY TEST: Verify that /tmp is not allowed (security hardening) + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Arguments: map[string]interface{}{ + "path": "/tmp", + "max_depth": 1, + }, + }, + } + + result, err := handleGetDiskUsage(ctx, req) + if err != nil { + t.Fatalf("handleGetDiskUsage failed: %v", err) + } + + // SECURITY: Expect error because /tmp is not in allowed paths + if !result.IsError { + t.Error("Expected error for /tmp access (security hardening), but got success") + } + + // Verify the error message mentions path validation + if len(result.Content) > 0 { + if textContent, ok := mcp.AsTextContent(result.Content[0]); ok { + if !strings.Contains(textContent.Text, "failed to get disk usage") && + !strings.Contains(textContent.Text, "not in allowed directories") { + t.Errorf("Expected path validation error, got: %s", textContent.Text) + } + } + } +} + +func TestAuthMiddleware(t *testing.T) { + // Test without token + handler := authMiddleware("", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected 401, got %d", w.Code) + } + + // Test with invalid token + req.Header.Set("Authorization", "Bearer invalid") + w = httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected 401, got %d", w.Code) + } + + // Test with valid token + req.Header.Set("Authorization", "Bearer valid-token") + w = httptest.NewRecorder() + + handler = authMiddleware("valid-token", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", w.Code) + } + + // Test health endpoint (should skip auth) + req = httptest.NewRequest("GET", "/health", nil) + w = httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected 200 for health endpoint, got %d", w.Code) + } +} + +func TestEffectiveAddr(t *testing.T) { + tests := []struct { + addrFlag string + listen string + port int + expected string + }{ + {"", "0.0.0.0", 8080, "0.0.0.0:8080"}, + {"", "localhost", 3000, "localhost:3000"}, + {"127.0.0.1:9090", "0.0.0.0", 8080, "127.0.0.1:9090"}, + {"", "", 8080, ":8080"}, + } + + for _, test := range tests { + result := effectiveAddr(test.addrFlag, test.listen, test.port) + if result != test.expected { + t.Errorf("effectiveAddr(%s, %s, %d) = %s, expected %s", + test.addrFlag, test.listen, test.port, result, test.expected) + } + } +} + +func TestRegisterHealthAndVersion(t *testing.T) { + mux := http.NewServeMux() + registerHealthAndVersion(mux) + + // Test health endpoint + req := httptest.NewRequest("GET", "/health", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected 200 for health, got %d", w.Code) + } + + if w.Header().Get("Content-Type") != "application/json" { + t.Errorf("Expected JSON content type, got %s", w.Header().Get("Content-Type")) + } + + // Test version endpoint + req = httptest.NewRequest("GET", "/version", nil) + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected 200 for version, got %d", w.Code) + } + + if w.Header().Get("Content-Type") != "application/json" { + t.Errorf("Expected JSON content type, got %s", w.Header().Get("Content-Type")) + } +} + +func TestLoggingHTTPMiddleware(t *testing.T) { + // Test with different log levels + originalLevel := curLvl + defer func() { curLvl = originalLevel }() + + handler := loggingHTTPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + + // Test with info level + curLvl = logInfo + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", w.Code) + } + + // Test with debug level and POST request + curLvl = logDebug + req = httptest.NewRequest("POST", "/test", strings.NewReader("test body")) + req.Header.Set("Content-Length", "9") + w = httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", w.Code) + } +} + +func TestStatusWriter(t *testing.T) { + w := httptest.NewRecorder() + sw := &statusWriter{ResponseWriter: w, status: http.StatusOK, written: false} + + // Test WriteHeader + sw.WriteHeader(http.StatusCreated) + if sw.status != http.StatusCreated { + t.Errorf("Expected status %d, got %d", http.StatusCreated, sw.status) + } + if !sw.written { + t.Error("Expected written to be true") + } + + // Test Write (should call WriteHeader automatically) + w2 := httptest.NewRecorder() + sw2 := &statusWriter{ResponseWriter: w2, status: http.StatusOK, written: false} + + sw2.Write([]byte("test")) + if sw2.status != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, sw2.status) + } + if !sw2.written { + t.Error("Expected written to be true") + } + + // Test Flush + w3 := httptest.NewRecorder() + sw3 := &statusWriter{ResponseWriter: w3, status: http.StatusOK, written: false} + + sw3.Flush() + if sw3.status != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, sw3.status) + } + if !sw3.written { + t.Error("Expected written to be true") + } +} + +func TestStatusWriterHijack(t *testing.T) { + w := httptest.NewRecorder() + sw := &statusWriter{ResponseWriter: w, status: http.StatusOK, written: false} + + // Test Hijack (should return error since httptest.ResponseRecorder doesn't support it) + conn, rw, err := sw.Hijack() + if err == nil { + t.Error("Expected error for Hijack") + } + if conn != nil || rw != nil { + t.Error("Expected nil conn and rw") + } +} + +func TestStatusWriterCloseNotify(t *testing.T) { + w := httptest.NewRecorder() + sw := &statusWriter{ResponseWriter: w, status: http.StatusOK, written: false} + + // Test CloseNotify (should return a channel) + ch := sw.CloseNotify() + if ch == nil { + t.Error("Expected non-nil channel") + } + + // Test that the channel doesn't close immediately + select { + case <-ch: + t.Error("Channel should not be closed immediately") + case <-time.After(10 * time.Millisecond): + // Expected - channel should not close + } +} diff --git a/mcp-servers/go/system-monitor-server/config.yaml b/mcp-servers/go/system-monitor-server/config.yaml new file mode 100644 index 000000000..4d1f7cd54 --- /dev/null +++ b/mcp-servers/go/system-monitor-server/config.yaml @@ -0,0 +1,60 @@ +# System Monitor Server Configuration +# This file contains configuration for the system monitoring MCP server + +monitoring: + update_interval: "5s" + history_retention: "24h" + max_processes: 1000 + process_update_freq: "1s" + +alerts: + cpu_threshold: 80.0 # percent + memory_threshold: 85.0 # percent + disk_threshold: 90.0 # percent + enabled: true + +health_checks: + - name: "web_server" + type: "http" + target: "http://localhost:8080/health" + interval: "30s" + timeout: "5s" + + - name: "database" + type: "port" + target: "localhost:5432" + interval: "60s" + timeout: "5s" + +log_monitoring: + max_file_size: "100MB" + max_tail_lines: 1000 + # SECURITY: Only allow reading from /var/log by default + # Add additional paths as needed for your environment + # NEVER use /tmp (too permissive) or relative paths + allowed_paths: + - "/var/log" + # Uncomment and customize paths as needed: + # - "/opt/myapp/logs" + # - "/home/user/application/logs" + follow_timeout: "30s" + +security: + # SECURITY: Root path restricts ALL file access within this directory (chroot-like) + # When set, all file operations are confined to this root directory + # Leave empty ("") to disable root restriction (not recommended for production) + # Example: "/opt/monitoring-root" - all paths must be within this directory + root_path: "" # Empty = no root restriction + + # SECURITY: Only allow reading from /var/log by default + # Customize this list for your specific monitoring needs + # Always use absolute paths, never use /tmp or relative paths + # If root_path is set, these paths must be relative to the root + allowed_paths: + - "/var/log" + # Uncomment and customize paths as needed: + # - "/opt/myapp/logs" + # - "/home/user/application/logs" + max_file_size: 104857600 # 100MB in bytes + rate_limit_rps: 10 + enable_audit_log: true diff --git a/mcp-servers/go/system-monitor-server/go.mod b/mcp-servers/go/system-monitor-server/go.mod new file mode 100644 index 000000000..c9ea11007 --- /dev/null +++ b/mcp-servers/go/system-monitor-server/go.mod @@ -0,0 +1,28 @@ +module github.com/IBM/mcp-context-forge/mcp-servers/go/system-monitor-server + +go 1.23 + +toolchain go1.24.7 + +require ( + github.com/hpcloud/tail v1.0.0 + github.com/mark3labs/mcp-go v0.32.0 + github.com/shirou/gopsutil/v3 v3.23.12 +) + +require ( + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + github.com/yusufpapurcu/wmi v1.2.3 // indirect + golang.org/x/sys v0.15.0 // indirect + gopkg.in/fsnotify.v1 v1.4.7 // indirect + gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect +) diff --git a/mcp-servers/go/system-monitor-server/go.sum b/mcp-servers/go/system-monitor-server/go.sum new file mode 100644 index 000000000..902c0b123 --- /dev/null +++ b/mcp-servers/go/system-monitor-server/go.sum @@ -0,0 +1,70 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/mark3labs/mcp-go v0.32.0 h1:fgwmbfL2gbd67obg57OfV2Dnrhs1HtSdlY/i5fn7MU8= +github.com/mark3labs/mcp-go v0.32.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4= +github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= +github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/mcp-servers/go/system-monitor-server/internal/config/config.go b/mcp-servers/go/system-monitor-server/internal/config/config.go new file mode 100644 index 000000000..814bd2085 --- /dev/null +++ b/mcp-servers/go/system-monitor-server/internal/config/config.go @@ -0,0 +1,109 @@ +package config + +import ( + "time" +) + +// Config represents the application configuration +type Config struct { + Monitoring MonitoringConfig `yaml:"monitoring"` + Alerts AlertsConfig `yaml:"alerts"` + HealthChecks []HealthCheckConfig `yaml:"health_checks"` + LogMonitoring LogMonitoringConfig `yaml:"log_monitoring"` + Security SecurityConfig `yaml:"security"` +} + +// MonitoringConfig represents monitoring configuration +type MonitoringConfig struct { + UpdateInterval time.Duration `yaml:"update_interval"` + HistoryRetention time.Duration `yaml:"history_retention"` + MaxProcesses int `yaml:"max_processes"` + ProcessUpdateFreq time.Duration `yaml:"process_update_freq"` +} + +// AlertsConfig represents alert configuration +type AlertsConfig struct { + CPUThreshold float64 `yaml:"cpu_threshold"` + MemoryThreshold float64 `yaml:"memory_threshold"` + DiskThreshold float64 `yaml:"disk_threshold"` + Enabled bool `yaml:"enabled"` +} + +// HealthCheckConfig represents a single health check configuration +type HealthCheckConfig struct { + Name string `yaml:"name"` + Type string `yaml:"type"` // port, http, file (command disabled for security) + Target string `yaml:"target"` + Interval time.Duration `yaml:"interval"` + Timeout time.Duration `yaml:"timeout"` + Expected map[string]string `yaml:"expected,omitempty"` +} + +// LogMonitoringConfig represents log monitoring configuration +type LogMonitoringConfig struct { + MaxFileSize string `yaml:"max_file_size"` + MaxTailLines int `yaml:"max_tail_lines"` + AllowedPaths []string `yaml:"allowed_paths"` + FollowTimeout time.Duration `yaml:"follow_timeout"` +} + +// SecurityConfig represents security configuration +type SecurityConfig struct { + RootPath string `yaml:"root_path"` // Root directory - all file access restricted within this path (empty = no restriction) + AllowedPaths []string `yaml:"allowed_paths"` + MaxFileSize int64 `yaml:"max_file_size"` + RateLimitRPS int `yaml:"rate_limit_rps"` + EnableAuditLog bool `yaml:"enable_audit_log"` +} + +// DefaultConfig returns a default configuration +func DefaultConfig() *Config { + return &Config{ + Monitoring: MonitoringConfig{ + UpdateInterval: 5 * time.Second, + HistoryRetention: 24 * time.Hour, + MaxProcesses: 1000, + ProcessUpdateFreq: 1 * time.Second, + }, + Alerts: AlertsConfig{ + CPUThreshold: 80.0, + MemoryThreshold: 85.0, + DiskThreshold: 90.0, + Enabled: true, + }, + HealthChecks: []HealthCheckConfig{ + { + Name: "web_server", + Type: "http", + Target: "http://localhost:8080/health", + Interval: 30 * time.Second, + Timeout: 5 * time.Second, + }, + { + Name: "database", + Type: "port", + Target: "localhost:5432", + Interval: 60 * time.Second, + Timeout: 5 * time.Second, + }, + }, + LogMonitoring: LogMonitoringConfig{ + MaxFileSize: "100MB", + MaxTailLines: 1000, + // SECURITY: Removed /tmp (too permissive), using absolute paths only + AllowedPaths: []string{"/var/log"}, + FollowTimeout: 30 * time.Second, + }, + Security: SecurityConfig{ + // SECURITY: RootPath restricts all file access within this directory (chroot-like) + // Empty string means no root restriction (not recommended for production) + RootPath: "", // Set to a path like "/opt/monitoring-root" to enable root restriction + // SECURITY: Removed /tmp (too permissive), using absolute paths only + // Users should configure specific directories in config.yaml as needed + AllowedPaths: []string{"/var/log"}, + MaxFileSize: 100 * 1024 * 1024, // 100MB + RateLimitRPS: 10, + EnableAuditLog: true, + }, + } +} diff --git a/mcp-servers/go/system-monitor-server/internal/config/config_test.go b/mcp-servers/go/system-monitor-server/internal/config/config_test.go new file mode 100644 index 000000000..abc666ce3 --- /dev/null +++ b/mcp-servers/go/system-monitor-server/internal/config/config_test.go @@ -0,0 +1,242 @@ +package config + +import ( + "testing" + "time" +) + +func TestDefaultConfig(t *testing.T) { + config := DefaultConfig() + + // Test Monitoring config + if config.Monitoring.UpdateInterval != 5*time.Second { + t.Errorf("Expected UpdateInterval 5s, got %v", config.Monitoring.UpdateInterval) + } + if config.Monitoring.HistoryRetention != 24*time.Hour { + t.Errorf("Expected HistoryRetention 24h, got %v", config.Monitoring.HistoryRetention) + } + if config.Monitoring.MaxProcesses != 1000 { + t.Errorf("Expected MaxProcesses 1000, got %d", config.Monitoring.MaxProcesses) + } + if config.Monitoring.ProcessUpdateFreq != 1*time.Second { + t.Errorf("Expected ProcessUpdateFreq 1s, got %v", config.Monitoring.ProcessUpdateFreq) + } + + // Test Alerts config + if config.Alerts.CPUThreshold != 80.0 { + t.Errorf("Expected CPUThreshold 80.0, got %f", config.Alerts.CPUThreshold) + } + if config.Alerts.MemoryThreshold != 85.0 { + t.Errorf("Expected MemoryThreshold 85.0, got %f", config.Alerts.MemoryThreshold) + } + if config.Alerts.DiskThreshold != 90.0 { + t.Errorf("Expected DiskThreshold 90.0, got %f", config.Alerts.DiskThreshold) + } + if !config.Alerts.Enabled { + t.Error("Expected Alerts.Enabled to be true") + } + + // Test HealthChecks config + if len(config.HealthChecks) != 2 { + t.Errorf("Expected 2 health checks, got %d", len(config.HealthChecks)) + } + + webServerCheck := config.HealthChecks[0] + if webServerCheck.Name != "web_server" { + t.Errorf("Expected web_server name, got %s", webServerCheck.Name) + } + if webServerCheck.Type != "http" { + t.Errorf("Expected http type, got %s", webServerCheck.Type) + } + if webServerCheck.Target != "http://localhost:8080/health" { + t.Errorf("Expected web server target, got %s", webServerCheck.Target) + } + + databaseCheck := config.HealthChecks[1] + if databaseCheck.Name != "database" { + t.Errorf("Expected database name, got %s", databaseCheck.Name) + } + if databaseCheck.Type != "port" { + t.Errorf("Expected port type, got %s", databaseCheck.Type) + } + if databaseCheck.Target != "localhost:5432" { + t.Errorf("Expected database target, got %s", databaseCheck.Target) + } + + // Test LogMonitoring config + if config.LogMonitoring.MaxFileSize != "100MB" { + t.Errorf("Expected MaxFileSize 100MB, got %s", config.LogMonitoring.MaxFileSize) + } + if config.LogMonitoring.MaxTailLines != 1000 { + t.Errorf("Expected MaxTailLines 1000, got %d", config.LogMonitoring.MaxTailLines) + } + // SECURITY: Only /var/log should be allowed by default (removed /tmp and ./logs) + if len(config.LogMonitoring.AllowedPaths) != 1 { + t.Errorf("Expected 1 allowed path, got %d", len(config.LogMonitoring.AllowedPaths)) + } + if config.LogMonitoring.AllowedPaths[0] != "/var/log" { + t.Errorf("Expected allowed path /var/log, got %s", config.LogMonitoring.AllowedPaths[0]) + } + if config.LogMonitoring.FollowTimeout != 30*time.Second { + t.Errorf("Expected FollowTimeout 30s, got %v", config.LogMonitoring.FollowTimeout) + } + + // Test Security config + // SECURITY: Only /var/log should be allowed by default (removed /tmp and ./logs) + if len(config.Security.AllowedPaths) != 1 { + t.Errorf("Expected 1 security allowed path, got %d", len(config.Security.AllowedPaths)) + } + if config.Security.AllowedPaths[0] != "/var/log" { + t.Errorf("Expected security allowed path /var/log, got %s", config.Security.AllowedPaths[0]) + } + if config.Security.MaxFileSize != 100*1024*1024 { + t.Errorf("Expected MaxFileSize 100MB, got %d", config.Security.MaxFileSize) + } + if config.Security.RateLimitRPS != 10 { + t.Errorf("Expected RateLimitRPS 10, got %d", config.Security.RateLimitRPS) + } + if !config.Security.EnableAuditLog { + t.Error("Expected EnableAuditLog to be true") + } +} + +func TestConfigStructs(t *testing.T) { + // Test that all struct fields are properly defined + config := DefaultConfig() + + // Test MonitoringConfig + if config.Monitoring.UpdateInterval == 0 { + t.Error("UpdateInterval should not be zero") + } + if config.Monitoring.HistoryRetention == 0 { + t.Error("HistoryRetention should not be zero") + } + if config.Monitoring.MaxProcesses == 0 { + t.Error("MaxProcesses should not be zero") + } + if config.Monitoring.ProcessUpdateFreq == 0 { + t.Error("ProcessUpdateFreq should not be zero") + } + + // Test AlertsConfig + if config.Alerts.CPUThreshold <= 0 { + t.Error("CPUThreshold should be positive") + } + if config.Alerts.MemoryThreshold <= 0 { + t.Error("MemoryThreshold should be positive") + } + if config.Alerts.DiskThreshold <= 0 { + t.Error("DiskThreshold should be positive") + } + + // Test HealthCheckConfig + for i, check := range config.HealthChecks { + if check.Name == "" { + t.Errorf("HealthCheck %d should have a name", i) + } + if check.Type == "" { + t.Errorf("HealthCheck %d should have a type", i) + } + if check.Target == "" { + t.Errorf("HealthCheck %d should have a target", i) + } + if check.Interval == 0 { + t.Errorf("HealthCheck %d should have an interval", i) + } + if check.Timeout == 0 { + t.Errorf("HealthCheck %d should have a timeout", i) + } + } + + // Test LogMonitoringConfig + if config.LogMonitoring.MaxFileSize == "" { + t.Error("MaxFileSize should not be empty") + } + if config.LogMonitoring.MaxTailLines <= 0 { + t.Error("MaxTailLines should be positive") + } + if len(config.LogMonitoring.AllowedPaths) == 0 { + t.Error("AllowedPaths should not be empty") + } + if config.LogMonitoring.FollowTimeout == 0 { + t.Error("FollowTimeout should not be zero") + } + + // Test SecurityConfig + if len(config.Security.AllowedPaths) == 0 { + t.Error("Security AllowedPaths should not be empty") + } + if config.Security.MaxFileSize <= 0 { + t.Error("MaxFileSize should be positive") + } + if config.Security.RateLimitRPS <= 0 { + t.Error("RateLimitRPS should be positive") + } +} + +func TestConfigImmutable(t *testing.T) { + // Test that DefaultConfig returns a new instance each time + config1 := DefaultConfig() + config2 := DefaultConfig() + + if config1 == config2 { + t.Error("DefaultConfig should return different instances") + } + + // Modify one config and ensure the other is not affected + config1.Monitoring.MaxProcesses = 9999 + if config2.Monitoring.MaxProcesses == 9999 { + t.Error("Modifying one config should not affect another") + } +} + +func TestConfigFieldTypes(t *testing.T) { + config := DefaultConfig() + + // Test that numeric fields have correct types + if config.Monitoring.MaxProcesses < 0 { + t.Error("MaxProcesses should be non-negative") + } + if config.Alerts.CPUThreshold < 0 || config.Alerts.CPUThreshold > 100 { + t.Error("CPUThreshold should be between 0 and 100") + } + if config.Alerts.MemoryThreshold < 0 || config.Alerts.MemoryThreshold > 100 { + t.Error("MemoryThreshold should be between 0 and 100") + } + if config.Alerts.DiskThreshold < 0 || config.Alerts.DiskThreshold > 100 { + t.Error("DiskThreshold should be between 0 and 100") + } + if config.LogMonitoring.MaxTailLines < 0 { + t.Error("MaxTailLines should be non-negative") + } + if config.Security.MaxFileSize < 0 { + t.Error("MaxFileSize should be non-negative") + } + if config.Security.RateLimitRPS < 0 { + t.Error("RateLimitRPS should be non-negative") + } + + // Test that duration fields are positive + if config.Monitoring.UpdateInterval <= 0 { + t.Error("UpdateInterval should be positive") + } + if config.Monitoring.HistoryRetention <= 0 { + t.Error("HistoryRetention should be positive") + } + if config.Monitoring.ProcessUpdateFreq <= 0 { + t.Error("ProcessUpdateFreq should be positive") + } + if config.LogMonitoring.FollowTimeout <= 0 { + t.Error("FollowTimeout should be positive") + } + + // Test health check durations + for i, check := range config.HealthChecks { + if check.Interval <= 0 { + t.Errorf("HealthCheck %d interval should be positive", i) + } + if check.Timeout <= 0 { + t.Errorf("HealthCheck %d timeout should be positive", i) + } + } +} diff --git a/mcp-servers/go/system-monitor-server/internal/metrics/process.go b/mcp-servers/go/system-monitor-server/internal/metrics/process.go new file mode 100644 index 000000000..9d7ebdc24 --- /dev/null +++ b/mcp-servers/go/system-monitor-server/internal/metrics/process.go @@ -0,0 +1,328 @@ +package metrics + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "github.com/IBM/mcp-context-forge/mcp-servers/go/system-monitor-server/pkg/types" + "github.com/shirou/gopsutil/v3/cpu" + "github.com/shirou/gopsutil/v3/process" +) + +// ProcessCollector handles process monitoring and management +type ProcessCollector struct { + lastProcessTimes map[int32]cpu.TimesStat +} + +// NewProcessCollector creates a new process collector +func NewProcessCollector() *ProcessCollector { + return &ProcessCollector{ + lastProcessTimes: make(map[int32]cpu.TimesStat), + } +} + +// ListProcesses lists running processes with filtering and sorting options +func (pc *ProcessCollector) ListProcesses(ctx context.Context, req *types.ProcessListRequest) ([]types.ProcessInfo, error) { + processes, err := process.ProcessesWithContext(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get processes: %w", err) + } + + var processInfos []types.ProcessInfo + + for _, p := range processes { + info, err := pc.getProcessInfo(ctx, p, req.IncludeThreads) + if err != nil { + // Skip processes we can't access + continue + } + + // Apply filters + if !pc.matchesFilter(info, req.FilterBy, req.FilterValue) { + continue + } + + processInfos = append(processInfos, *info) + } + + // Apply sorting + pc.sortProcesses(processInfos, req.SortBy) + + // Apply limit + if req.Limit > 0 && len(processInfos) > req.Limit { + processInfos = processInfos[:req.Limit] + } + + return processInfos, nil +} + +// MonitorProcess monitors a specific process for a given duration +func (pc *ProcessCollector) MonitorProcess(ctx context.Context, req *types.ProcessMonitorRequest) ([]types.ProcessMonitorResult, error) { + var targetProcess *process.Process + var err error + + // Find the target process + if req.PID > 0 { + targetProcess, err = process.NewProcessWithContext(ctx, req.PID) + if err != nil { + return nil, fmt.Errorf("failed to find process with PID %d: %w", req.PID, err) + } + } else if req.ProcessName != "" { + targetProcess, err = pc.findProcessByName(ctx, req.ProcessName) + if err != nil { + return nil, fmt.Errorf("failed to find process with name %s: %w", req.ProcessName, err) + } + } else { + return nil, fmt.Errorf("either PID or process name must be specified") + } + + // Verify the process exists + exists, err := targetProcess.IsRunningWithContext(ctx) + if err != nil || !exists { + return nil, fmt.Errorf("process not found or not running") + } + + var results []types.ProcessMonitorResult + duration := time.Duration(req.Duration) * time.Second + interval := time.Duration(req.Interval) * time.Second + startTime := time.Now() + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return results, ctx.Err() + case <-ticker.C: + if time.Since(startTime) > duration { + return results, nil + } + + info, err := pc.getProcessInfo(ctx, targetProcess, false) + if err != nil { + // Process might have exited + break + } + + // Check for alerts + alerts := pc.checkAlerts(*info, req.AlertThresholds) + + result := types.ProcessMonitorResult{ + ProcessInfo: *info, + Alerts: alerts, + Timestamp: time.Now(), + } + + results = append(results, result) + } + } +} + +// getProcessInfo extracts detailed information about a process +func (pc *ProcessCollector) getProcessInfo(ctx context.Context, p *process.Process, includeThreads bool) (*types.ProcessInfo, error) { + // Get basic process info + name, err := p.NameWithContext(ctx) + if err != nil { + return nil, err + } + + pid := p.Pid + + // Get CPU and memory usage + cpuPercent, err := p.CPUPercentWithContext(ctx) + if err != nil { + cpuPercent = 0.0 + } + + memInfo, err := p.MemoryInfoWithContext(ctx) + if err != nil { + return nil, err + } + + memPercent, err := p.MemoryPercentWithContext(ctx) + if err != nil { + memPercent = 0.0 + } + + // Get process status + status, err := p.StatusWithContext(ctx) + if err != nil { + status = []string{"unknown"} + } + + // Get create time + createTime, err := p.CreateTimeWithContext(ctx) + if err != nil { + createTime = 0 + } + + // Get username + username, err := p.UsernameWithContext(ctx) + if err != nil { + username = "unknown" + } + + // Get command line + cmdline, err := p.CmdlineWithContext(ctx) + if err != nil { + cmdline = "" + } + + // Get thread count if requested + var threadCount int32 + if includeThreads { + threads, err := p.ThreadsWithContext(ctx) + if err == nil { + threadCount = int32(len(threads)) + } + } + + // Calculate CPU usage from times if available + cpuTimes, err := p.TimesWithContext(ctx) + if err == nil { + if lastTimes, exists := pc.lastProcessTimes[pid]; exists { + cpuPercent = pc.calculateProcessCPUUsage(lastTimes, *cpuTimes) + } + pc.lastProcessTimes[pid] = *cpuTimes + } + + statusStr := "unknown" + if len(status) > 0 { + statusStr = status[0] + } + + return &types.ProcessInfo{ + PID: pid, + Name: name, + CPUPercent: cpuPercent, + MemoryPercent: memPercent, + MemoryRSS: memInfo.RSS, + MemoryVMS: memInfo.VMS, + Status: statusStr, + CreateTime: createTime, + Username: username, + Command: cmdline, + Threads: threadCount, + }, nil +} + +// findProcessByName finds a process by name +func (pc *ProcessCollector) findProcessByName(ctx context.Context, name string) (*process.Process, error) { + processes, err := process.ProcessesWithContext(ctx) + if err != nil { + return nil, err + } + + for _, p := range processes { + processName, err := p.NameWithContext(ctx) + if err != nil { + continue + } + + if strings.EqualFold(processName, name) { + return p, nil + } + } + + return nil, fmt.Errorf("process with name %s not found", name) +} + +// matchesFilter checks if a process matches the given filter criteria +func (pc *ProcessCollector) matchesFilter(info *types.ProcessInfo, filterBy, filterValue string) bool { + if filterBy == "" || filterValue == "" { + return true + } + + switch filterBy { + case "name": + return strings.Contains(strings.ToLower(info.Name), strings.ToLower(filterValue)) + case "user": + return strings.Contains(strings.ToLower(info.Username), strings.ToLower(filterValue)) + case "pid": + return fmt.Sprintf("%d", info.PID) == filterValue + default: + return true + } +} + +// sortProcesses sorts processes by the specified criteria +func (pc *ProcessCollector) sortProcesses(processes []types.ProcessInfo, sortBy string) { + switch sortBy { + case "cpu": + sort.Slice(processes, func(i, j int) bool { + return processes[i].CPUPercent > processes[j].CPUPercent + }) + case "memory": + sort.Slice(processes, func(i, j int) bool { + return processes[i].MemoryPercent > processes[j].MemoryPercent + }) + case "name": + sort.Slice(processes, func(i, j int) bool { + return processes[i].Name < processes[j].Name + }) + case "pid": + sort.Slice(processes, func(i, j int) bool { + return processes[i].PID < processes[j].PID + }) + } +} + +// checkAlerts checks if process metrics exceed alert thresholds +func (pc *ProcessCollector) checkAlerts(info types.ProcessInfo, thresholds types.Thresholds) []types.Alert { + var alerts []types.Alert + + if thresholds.CPUPercent > 0 && info.CPUPercent > thresholds.CPUPercent { + alerts = append(alerts, types.Alert{ + Type: "cpu", + Message: fmt.Sprintf("CPU usage %.2f%% exceeds threshold %.2f%%", info.CPUPercent, thresholds.CPUPercent), + Threshold: thresholds.CPUPercent, + Value: info.CPUPercent, + Timestamp: time.Now(), + }) + } + + if thresholds.MemoryPercent > 0 && float64(info.MemoryPercent) > thresholds.MemoryPercent { + alerts = append(alerts, types.Alert{ + Type: "memory", + Message: fmt.Sprintf("Memory usage %.2f%% exceeds threshold %.2f%%", info.MemoryPercent, thresholds.MemoryPercent), + Threshold: thresholds.MemoryPercent, + Value: float64(info.MemoryPercent), + Timestamp: time.Now(), + }) + } + + if thresholds.MemoryRSS > 0 && info.MemoryRSS > thresholds.MemoryRSS { + alerts = append(alerts, types.Alert{ + Type: "memory_rss", + Message: fmt.Sprintf("Memory RSS %d bytes exceeds threshold %d bytes", info.MemoryRSS, thresholds.MemoryRSS), + Threshold: float64(thresholds.MemoryRSS), + Value: float64(info.MemoryRSS), + Timestamp: time.Now(), + }) + } + + return alerts +} + +// calculateProcessCPUUsage calculates CPU usage percentage from process times +func (pc *ProcessCollector) calculateProcessCPUUsage(t1, t2 cpu.TimesStat) float64 { + total1 := t1.User + t1.System + t1.Nice + t1.Iowait + t1.Irq + t1.Softirq + t1.Steal + t1.Guest + t1.GuestNice + t1.Idle + total2 := t2.User + t2.System + t2.Nice + t2.Iowait + t2.Irq + t2.Softirq + t2.Steal + t2.Guest + t2.GuestNice + t2.Idle + + if total2 <= total1 { + return 0.0 + } + + idle := t2.Idle - t1.Idle + total := total2 - total1 + + if total == 0 { + return 0.0 + } + + return 100.0 * (total - idle) / total +} diff --git a/mcp-servers/go/system-monitor-server/internal/metrics/process_test.go b/mcp-servers/go/system-monitor-server/internal/metrics/process_test.go new file mode 100644 index 000000000..8418f54e5 --- /dev/null +++ b/mcp-servers/go/system-monitor-server/internal/metrics/process_test.go @@ -0,0 +1,533 @@ +package metrics + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/IBM/mcp-context-forge/mcp-servers/go/system-monitor-server/pkg/types" + "github.com/shirou/gopsutil/v3/cpu" + "github.com/shirou/gopsutil/v3/process" +) + +func TestProcessCollector_ListProcesses(t *testing.T) { + collector := NewProcessCollector() + ctx := context.Background() + + // Test basic process listing + req := &types.ProcessListRequest{ + SortBy: "cpu", + Limit: 10, + } + + processes, err := collector.ListProcesses(ctx, req) + if err != nil { + t.Fatalf("Failed to list processes: %v", err) + } + + // Should have at least one process (the test process itself) + if len(processes) == 0 { + t.Error("Should have at least one process") + } + + // Test filtering by name + req = &types.ProcessListRequest{ + FilterBy: "name", + FilterValue: "go", + Limit: 5, + } + + processes, err = collector.ListProcesses(ctx, req) + if err != nil { + t.Fatalf("Failed to list filtered processes: %v", err) + } + + // All returned processes should contain "go" in their name (case insensitive) + for _, proc := range processes { + name := strings.ToLower(proc.Name) + if !strings.Contains(name, "go") { + t.Errorf("Process %s should contain 'go' in name", proc.Name) + } + } +} + +func TestProcessCollector_MatchesFilter(t *testing.T) { + collector := NewProcessCollector() + + info := &types.ProcessInfo{ + PID: 1234, + Name: "test-process", + Username: "testuser", + } + + // Test name filter + if !collector.matchesFilter(info, "name", "test") { + t.Error("Should match name filter") + } + + if collector.matchesFilter(info, "name", "other") { + t.Error("Should not match different name") + } + + // Test user filter + if !collector.matchesFilter(info, "user", "test") { + t.Error("Should match user filter") + } + + if collector.matchesFilter(info, "user", "other") { + t.Error("Should not match different user") + } + + // Test PID filter + if !collector.matchesFilter(info, "pid", "1234") { + t.Error("Should match PID filter") + } + + if collector.matchesFilter(info, "pid", "5678") { + t.Error("Should not match different PID") + } + + // Test empty filter (should match) + if !collector.matchesFilter(info, "", "") { + t.Error("Empty filter should match") + } +} + +func TestProcessCollector_SortProcesses(t *testing.T) { + collector := NewProcessCollector() + + processes := []types.ProcessInfo{ + {Name: "z-process", CPUPercent: 10.0, MemoryPercent: 5.0, PID: 3}, + {Name: "a-process", CPUPercent: 30.0, MemoryPercent: 15.0, PID: 1}, + {Name: "m-process", CPUPercent: 20.0, MemoryPercent: 10.0, PID: 2}, + } + + // Test CPU sorting + collector.sortProcesses(processes, "cpu") + if processes[0].CPUPercent != 30.0 { + t.Error("Processes should be sorted by CPU usage (descending)") + } + + // Test memory sorting + collector.sortProcesses(processes, "memory") + if processes[0].MemoryPercent != 15.0 { + t.Error("Processes should be sorted by memory usage (descending)") + } + + // Test name sorting + collector.sortProcesses(processes, "name") + if processes[0].Name != "a-process" { + t.Error("Processes should be sorted by name (ascending)") + } + + // Test PID sorting + collector.sortProcesses(processes, "pid") + if processes[0].PID != 1 { + t.Error("Processes should be sorted by PID (ascending)") + } +} + +func TestProcessCollector_CheckAlerts(t *testing.T) { + collector := NewProcessCollector() + + info := types.ProcessInfo{ + CPUPercent: 85.0, + MemoryPercent: 90.0, + MemoryRSS: 1000000, + } + + thresholds := types.Thresholds{ + CPUPercent: 80.0, + MemoryPercent: 85.0, + MemoryRSS: 500000, + } + + alerts := collector.checkAlerts(info, thresholds) + + // Should have 3 alerts (CPU, memory, memory RSS) + if len(alerts) != 3 { + t.Errorf("Expected 3 alerts, got %d", len(alerts)) + } + + // Check alert types + alertTypes := make(map[string]bool) + for _, alert := range alerts { + alertTypes[alert.Type] = true + } + + if !alertTypes["cpu"] { + t.Error("Should have CPU alert") + } + if !alertTypes["memory"] { + t.Error("Should have memory alert") + } + if !alertTypes["memory_rss"] { + t.Error("Should have memory RSS alert") + } +} + +func TestCalculateProcessCPUUsage(t *testing.T) { + collector := NewProcessCollector() + + // Test with identical times (should return 0) + t1 := cpu.TimesStat{ + User: 100, System: 50, Nice: 10, Iowait: 20, + Irq: 5, Softirq: 10, Steal: 0, Guest: 0, GuestNice: 0, Idle: 1000, + } + t2 := t1 + + usage := collector.calculateProcessCPUUsage(t1, t2) + if usage != 0.0 { + t.Errorf("Expected 0.0 for identical times, got %f", usage) + } + + // Test with different times + t2.Idle = 900 // 100 less idle time + t2.User = 150 // More user time + usage = collector.calculateProcessCPUUsage(t1, t2) + if usage < 0 || usage > 100 { + t.Errorf("Expected usage between 0 and 100, got %f", usage) + } +} + +func TestProcessCollector_GetProcessInfo(t *testing.T) { + collector := NewProcessCollector() + ctx := context.Background() + + // Get current process + currentPID := int32(os.Getpid()) + p, err := process.NewProcessWithContext(ctx, currentPID) + if err != nil { + t.Fatalf("Failed to get current process: %v", err) + } + + // Test without threads + info, err := collector.getProcessInfo(ctx, p, false) + if err != nil { + t.Fatalf("Failed to get process info: %v", err) + } + + if info == nil { + t.Fatal("Expected non-nil process info") + } + + if info.PID != currentPID { + t.Errorf("Expected PID %d, got %d", currentPID, info.PID) + } + + if info.Name == "" { + t.Error("Process name should not be empty") + } + + if info.CPUPercent < 0 || info.CPUPercent > 100 { + t.Errorf("CPU percent should be between 0 and 100, got %f", info.CPUPercent) + } + + if info.MemoryPercent < 0 || info.MemoryPercent > 100 { + t.Errorf("Memory percent should be between 0 and 100, got %f", info.MemoryPercent) + } + + if info.MemoryRSS <= 0 { + t.Error("Memory RSS should be positive") + } + + if info.MemoryVMS <= 0 { + t.Error("Memory VMS should be positive") + } + + if info.Status == "" { + t.Error("Process status should not be empty") + } + + if info.CreateTime <= 0 { + t.Error("Create time should be positive") + } + + if info.Username == "" { + t.Error("Username should not be empty") + } + + if info.Threads != 0 { + t.Error("Threads should be 0 when not requested") + } + + // Test with threads + info, err = collector.getProcessInfo(ctx, p, true) + if err != nil { + t.Fatalf("Failed to get process info with threads: %v", err) + } + + if info.Threads < 0 { + t.Error("Thread count should be non-negative") + } +} + +func TestProcessCollector_FindProcessByName(t *testing.T) { + collector := NewProcessCollector() + ctx := context.Background() + + // Test finding current process by name + currentProcess, err := process.NewProcessWithContext(ctx, int32(os.Getpid())) + if err != nil { + t.Fatalf("Failed to get current process: %v", err) + } + + name, err := currentProcess.NameWithContext(ctx) + if err != nil { + t.Fatalf("Failed to get process name: %v", err) + } + + foundProcess, err := collector.findProcessByName(ctx, name) + if err != nil { + t.Fatalf("Failed to find process by name: %v", err) + } + + if foundProcess == nil { + t.Fatal("Expected non-nil process") + } + + foundName, err := foundProcess.NameWithContext(ctx) + if err != nil { + t.Fatalf("Failed to get found process name: %v", err) + } + + if !strings.EqualFold(foundName, name) { + t.Errorf("Expected process name %s, got %s", name, foundName) + } + + // Test finding non-existent process + _, err = collector.findProcessByName(ctx, "nonexistent-process-12345") + if err == nil { + t.Error("Expected error for non-existent process") + } +} + +func TestProcessCollector_MatchesFilterEdgeCases(t *testing.T) { + collector := NewProcessCollector() + + info := &types.ProcessInfo{ + PID: 1234, + Name: "test-process", + Username: "testuser", + } + + // Test empty filter (should match) + if !collector.matchesFilter(info, "", "") { + t.Error("Empty filter should match") + } + + // Test empty filter value (should match) + if !collector.matchesFilter(info, "name", "") { + t.Error("Empty filter value should match") + } + + // Test unknown filter type (should match) + if !collector.matchesFilter(info, "unknown", "value") { + t.Error("Unknown filter type should match") + } + + // Test case sensitivity + if !collector.matchesFilter(info, "name", "TEST") { + t.Error("Name filter should be case insensitive") + } + + if !collector.matchesFilter(info, "user", "TEST") { + t.Error("User filter should be case insensitive") + } + + // Test partial matches + if !collector.matchesFilter(info, "name", "test") { + t.Error("Name filter should match partial strings") + } + + if !collector.matchesFilter(info, "user", "test") { + t.Error("User filter should match partial strings") + } +} + +func TestProcessCollector_SortProcessesEdgeCases(t *testing.T) { + collector := NewProcessCollector() + + processes := []types.ProcessInfo{ + {Name: "z-process", CPUPercent: 10.0, MemoryPercent: 5.0, PID: 3}, + {Name: "a-process", CPUPercent: 30.0, MemoryPercent: 15.0, PID: 1}, + {Name: "m-process", CPUPercent: 20.0, MemoryPercent: 10.0, PID: 2}, + } + + // Test unknown sort criteria (should not change order) + originalOrder := make([]types.ProcessInfo, len(processes)) + copy(originalOrder, processes) + + collector.sortProcesses(processes, "unknown") + for i, proc := range processes { + if proc.Name != originalOrder[i].Name { + t.Error("Unknown sort criteria should not change order") + } + } + + // Test empty sort criteria (should not change order) + copy(processes, originalOrder) + collector.sortProcesses(processes, "") + for i, proc := range processes { + if proc.Name != originalOrder[i].Name { + t.Error("Empty sort criteria should not change order") + } + } +} + +func TestProcessCollector_CheckAlertsEdgeCases(t *testing.T) { + collector := NewProcessCollector() + + info := types.ProcessInfo{ + CPUPercent: 50.0, + MemoryPercent: 60.0, + MemoryRSS: 1000000, + } + + // Test with zero thresholds (should not alert) + thresholds := types.Thresholds{ + CPUPercent: 0.0, + MemoryPercent: 0.0, + MemoryRSS: 0, + } + + alerts := collector.checkAlerts(info, thresholds) + if len(alerts) != 0 { + t.Errorf("Expected 0 alerts with zero thresholds, got %d", len(alerts)) + } + + // Test with very high thresholds (should not alert) + thresholds = types.Thresholds{ + CPUPercent: 100.0, + MemoryPercent: 100.0, + MemoryRSS: 10000000, + } + + alerts = collector.checkAlerts(info, thresholds) + if len(alerts) != 0 { + t.Errorf("Expected 0 alerts with high thresholds, got %d", len(alerts)) + } + + // Test with exact threshold values (should not alert) + thresholds = types.Thresholds{ + CPUPercent: 50.0, + MemoryPercent: 60.0, + MemoryRSS: 1000000, + } + + alerts = collector.checkAlerts(info, thresholds) + if len(alerts) != 0 { + t.Errorf("Expected 0 alerts with exact thresholds, got %d", len(alerts)) + } +} + +func TestProcessCollector_CalculateProcessCPUUsageEdgeCases(t *testing.T) { + collector := NewProcessCollector() + + // Test with zero times + t1 := cpu.TimesStat{} + t2 := cpu.TimesStat{} + + usage := collector.calculateProcessCPUUsage(t1, t2) + if usage != 0.0 { + t.Errorf("Expected 0.0 for zero times, got %f", usage) + } + + // Test with decreasing times (should return 0) + t1 = cpu.TimesStat{ + User: 100, System: 50, Nice: 10, Iowait: 20, + Irq: 5, Softirq: 10, Steal: 0, Guest: 0, GuestNice: 0, Idle: 1000, + } + t2 = cpu.TimesStat{ + User: 50, System: 25, Nice: 5, Iowait: 10, + Irq: 2, Softirq: 5, Steal: 0, Guest: 0, GuestNice: 0, Idle: 500, + } + + usage = collector.calculateProcessCPUUsage(t1, t2) + if usage != 0.0 { + t.Errorf("Expected 0.0 for decreasing times, got %f", usage) + } + + // Test with equal total times (should return 0) + t1 = cpu.TimesStat{ + User: 100, System: 50, Nice: 10, Iowait: 20, + Irq: 5, Softirq: 10, Steal: 0, Guest: 0, GuestNice: 0, Idle: 1000, + } + t2 = cpu.TimesStat{ + User: 100, System: 50, Nice: 10, Iowait: 20, + Irq: 5, Softirq: 10, Steal: 0, Guest: 0, GuestNice: 0, Idle: 1000, + } + + usage = collector.calculateProcessCPUUsage(t1, t2) + if usage != 0.0 { + t.Errorf("Expected 0.0 for equal times, got %f", usage) + } +} + +func TestProcessCollector_ListProcessesWithLimit(t *testing.T) { + collector := NewProcessCollector() + ctx := context.Background() + + // Test with limit + req := &types.ProcessListRequest{ + SortBy: "cpu", + Limit: 5, + } + + processes, err := collector.ListProcesses(ctx, req) + if err != nil { + t.Fatalf("Failed to list processes with limit: %v", err) + } + + if len(processes) > 5 { + t.Errorf("Expected at most 5 processes, got %d", len(processes)) + } + + // Test with zero limit (should return all) + req.Limit = 0 + processes, err = collector.ListProcesses(ctx, req) + if err != nil { + t.Fatalf("Failed to list processes with zero limit: %v", err) + } + + if len(processes) == 0 { + t.Error("Expected at least one process") + } +} + +func TestProcessCollector_ListProcessesWithThreads(t *testing.T) { + collector := NewProcessCollector() + ctx := context.Background() + + // Test without threads + req := &types.ProcessListRequest{ + SortBy: "cpu", + Limit: 10, + IncludeThreads: false, + } + + processes, err := collector.ListProcesses(ctx, req) + if err != nil { + t.Fatalf("Failed to list processes without threads: %v", err) + } + + for _, proc := range processes { + if proc.Threads != 0 { + t.Errorf("Expected 0 threads when not requested, got %d", proc.Threads) + } + } + + // Test with threads + req.IncludeThreads = true + processes, err = collector.ListProcesses(ctx, req) + if err != nil { + t.Fatalf("Failed to list processes with threads: %v", err) + } + + for _, proc := range processes { + if proc.Threads < 0 { + t.Errorf("Expected non-negative thread count, got %d", proc.Threads) + } + } +} diff --git a/mcp-servers/go/system-monitor-server/internal/metrics/system.go b/mcp-servers/go/system-monitor-server/internal/metrics/system.go new file mode 100644 index 000000000..b8b050a7e --- /dev/null +++ b/mcp-servers/go/system-monitor-server/internal/metrics/system.go @@ -0,0 +1,251 @@ +package metrics + +import ( + "context" + "fmt" + "time" + + "github.com/IBM/mcp-context-forge/mcp-servers/go/system-monitor-server/pkg/types" + "github.com/shirou/gopsutil/v3/cpu" + "github.com/shirou/gopsutil/v3/disk" + "github.com/shirou/gopsutil/v3/load" + "github.com/shirou/gopsutil/v3/mem" + "github.com/shirou/gopsutil/v3/net" +) + +// SystemCollector handles system metrics collection +type SystemCollector struct { + lastCPUTimes []cpu.TimesStat +} + +// NewSystemCollector creates a new system metrics collector +func NewSystemCollector() *SystemCollector { + return &SystemCollector{} +} + +// GetSystemMetrics collects comprehensive system metrics +func (sc *SystemCollector) GetSystemMetrics(ctx context.Context) (*types.SystemMetrics, error) { + metrics := &types.SystemMetrics{ + Timestamp: time.Now(), + } + + // Collect CPU metrics + if err := sc.collectCPUMetrics(ctx, metrics); err != nil { + return nil, fmt.Errorf("failed to collect CPU metrics: %w", err) + } + + // Collect memory metrics + if err := sc.collectMemoryMetrics(ctx, metrics); err != nil { + return nil, fmt.Errorf("failed to collect memory metrics: %w", err) + } + + // Collect disk metrics + if err := sc.collectDiskMetrics(ctx, metrics); err != nil { + return nil, fmt.Errorf("failed to collect disk metrics: %w", err) + } + + // Collect network metrics + if err := sc.collectNetworkMetrics(ctx, metrics); err != nil { + return nil, fmt.Errorf("failed to collect network metrics: %w", err) + } + + return metrics, nil +} + +// collectCPUMetrics collects CPU usage and load average information +func (sc *SystemCollector) collectCPUMetrics(ctx context.Context, metrics *types.SystemMetrics) error { + // Get CPU usage percentage + cpuPercent, err := cpu.PercentWithContext(ctx, time.Second, false) + if err != nil { + return fmt.Errorf("failed to get CPU percent: %w", err) + } + + // Get load average + loadAvg, err := load.AvgWithContext(ctx) + if err != nil { + return fmt.Errorf("failed to get load average: %w", err) + } + + // Get number of CPU cores + cpuCount, err := cpu.Counts(true) + if err != nil { + return fmt.Errorf("failed to get CPU count: %w", err) + } + + // Get CPU times for more detailed analysis + cpuTimes, err := cpu.TimesWithContext(ctx, false) + if err != nil { + return fmt.Errorf("failed to get CPU times: %w", err) + } + + // Calculate CPU usage from times if we have previous data + var cpuUsage float64 + if len(sc.lastCPUTimes) > 0 && len(cpuTimes) > 0 { + cpuUsage = calculateCPUUsage(sc.lastCPUTimes[0], cpuTimes[0]) + } else if len(cpuPercent) > 0 { + cpuUsage = cpuPercent[0] + } + + metrics.CPU = types.CPUMetrics{ + UsagePercent: cpuUsage, + LoadAvg1: loadAvg.Load1, + LoadAvg5: loadAvg.Load5, + LoadAvg15: loadAvg.Load15, + NumCores: cpuCount, + } + + // Store current CPU times for next calculation + sc.lastCPUTimes = cpuTimes + + return nil +} + +// collectMemoryMetrics collects memory usage information +func (sc *SystemCollector) collectMemoryMetrics(ctx context.Context, metrics *types.SystemMetrics) error { + memInfo, err := mem.VirtualMemoryWithContext(ctx) + if err != nil { + return fmt.Errorf("failed to get memory info: %w", err) + } + + swapInfo, err := mem.SwapMemoryWithContext(ctx) + if err != nil { + return fmt.Errorf("failed to get swap info: %w", err) + } + + metrics.Memory = types.MemoryMetrics{ + Total: memInfo.Total, + Available: memInfo.Available, + Used: memInfo.Used, + Free: memInfo.Free, + UsagePercent: memInfo.UsedPercent, + SwapTotal: swapInfo.Total, + SwapUsed: swapInfo.Used, + SwapFree: swapInfo.Free, + } + + return nil +} + +// collectDiskMetrics collects disk usage information for all mounted filesystems +func (sc *SystemCollector) collectDiskMetrics(ctx context.Context, metrics *types.SystemMetrics) error { + partitions, err := disk.PartitionsWithContext(ctx, false) + if err != nil { + return fmt.Errorf("failed to get disk partitions: %w", err) + } + + var diskMetrics []types.DiskMetrics + + for _, partition := range partitions { + // Skip certain filesystem types that might cause issues + if partition.Fstype == "squashfs" || partition.Fstype == "tmpfs" { + continue + } + + usage, err := disk.UsageWithContext(ctx, partition.Mountpoint) + if err != nil { + // Skip partitions we can't access + continue + } + + diskMetrics = append(diskMetrics, types.DiskMetrics{ + Device: partition.Device, + Mountpoint: partition.Mountpoint, + Fstype: partition.Fstype, + Total: usage.Total, + Free: usage.Free, + Used: usage.Used, + UsagePercent: usage.UsedPercent, + }) + } + + metrics.Disk = diskMetrics + return nil +} + +// collectNetworkMetrics collects network interface information +func (sc *SystemCollector) collectNetworkMetrics(ctx context.Context, metrics *types.SystemMetrics) error { + netStats, err := net.IOCountersWithContext(ctx, true) + if err != nil { + return fmt.Errorf("failed to get network stats: %w", err) + } + + var interfaces []types.NetworkInterface + + for _, stat := range netStats { + // Get interface status + netInterfaces, err := net.InterfacesWithContext(ctx) + if err != nil { + // If we can't get interface status, assume it's up + interfaces = append(interfaces, types.NetworkInterface{ + Name: stat.Name, + BytesSent: stat.BytesSent, + BytesRecv: stat.BytesRecv, + PacketsSent: stat.PacketsSent, + PacketsRecv: stat.PacketsRecv, + ErrIn: stat.Errin, + ErrOut: stat.Errout, + DropIn: stat.Dropin, + DropOut: stat.Dropout, + IsUp: true, + }) + continue + } + + // Find the interface status + isUp := false + for _, netInterface := range netInterfaces { + if netInterface.Name == stat.Name { + isUp = len(netInterface.Flags) > 0 && contains(netInterface.Flags, "up") + break + } + } + + interfaces = append(interfaces, types.NetworkInterface{ + Name: stat.Name, + BytesSent: stat.BytesSent, + BytesRecv: stat.BytesRecv, + PacketsSent: stat.PacketsSent, + PacketsRecv: stat.PacketsRecv, + ErrIn: stat.Errin, + ErrOut: stat.Errout, + DropIn: stat.Dropin, + DropOut: stat.Dropout, + IsUp: isUp, + }) + } + + metrics.Network = types.NetworkMetrics{ + Interfaces: interfaces, + } + + return nil +} + +// calculateCPUUsage calculates CPU usage percentage from CPU times +func calculateCPUUsage(t1, t2 cpu.TimesStat) float64 { + total1 := t1.User + t1.System + t1.Nice + t1.Iowait + t1.Irq + t1.Softirq + t1.Steal + t1.Guest + t1.GuestNice + t1.Idle + total2 := t2.User + t2.System + t2.Nice + t2.Iowait + t2.Irq + t2.Softirq + t2.Steal + t2.Guest + t2.GuestNice + t2.Idle + + if total2 <= total1 { + return 0.0 + } + + idle := t2.Idle - t1.Idle + total := total2 - total1 + + if total == 0 { + return 0.0 + } + + return 100.0 * (total - idle) / total +} + +// contains checks if a slice contains a specific string +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} diff --git a/mcp-servers/go/system-monitor-server/internal/metrics/system_test.go b/mcp-servers/go/system-monitor-server/internal/metrics/system_test.go new file mode 100644 index 000000000..04d3b17cf --- /dev/null +++ b/mcp-servers/go/system-monitor-server/internal/metrics/system_test.go @@ -0,0 +1,364 @@ +package metrics + +import ( + "context" + "testing" + "time" + + "github.com/IBM/mcp-context-forge/mcp-servers/go/system-monitor-server/pkg/types" + "github.com/shirou/gopsutil/v3/cpu" +) + +func TestSystemCollector_GetSystemMetrics(t *testing.T) { + collector := NewSystemCollector() + ctx := context.Background() + + metrics, err := collector.GetSystemMetrics(ctx) + if err != nil { + t.Fatalf("Failed to get system metrics: %v", err) + } + + // Check that metrics are populated + if metrics.Timestamp.IsZero() { + t.Error("Timestamp should not be zero") + } + + // Check CPU metrics + if metrics.CPU.NumCores <= 0 { + t.Error("CPU cores should be greater than 0") + } + + // Check memory metrics + if metrics.Memory.Total == 0 { + t.Error("Total memory should be greater than 0") + } + + // Check that we have at least one disk + if len(metrics.Disk) == 0 { + t.Error("Should have at least one disk") + } + + // Check that we have at least one network interface + if len(metrics.Network.Interfaces) == 0 { + t.Error("Should have at least one network interface") + } +} + +func TestSystemCollector_CPUMetrics(t *testing.T) { + collector := NewSystemCollector() + ctx := context.Background() + + // Test multiple calls to ensure CPU calculation works + _, err := collector.GetSystemMetrics(ctx) + if err != nil { + t.Fatalf("First call failed: %v", err) + } + + // Wait a bit for CPU usage to change + time.Sleep(100 * time.Millisecond) + + metrics, err := collector.GetSystemMetrics(ctx) + if err != nil { + t.Fatalf("Second call failed: %v", err) + } + + // CPU usage should be a valid percentage + if metrics.CPU.UsagePercent < 0 || metrics.CPU.UsagePercent > 100 { + t.Errorf("CPU usage should be between 0 and 100, got %f", metrics.CPU.UsagePercent) + } +} + +func TestCalculateCPUUsage(t *testing.T) { + // Test with identical times (should return 0) + t1 := cpu.TimesStat{ + User: 100, System: 50, Nice: 10, Iowait: 20, + Irq: 5, Softirq: 10, Steal: 0, Guest: 0, GuestNice: 0, Idle: 1000, + } + t2 := t1 + + usage := calculateCPUUsage(t1, t2) + if usage != 0.0 { + t.Errorf("Expected 0.0 for identical times, got %f", usage) + } + + // Test with different times + t2.Idle = 900 // 100 less idle time + t2.User = 150 // More user time + usage = calculateCPUUsage(t1, t2) + if usage < 0 || usage > 100 { + t.Errorf("Expected usage between 0 and 100, got %f", usage) + } +} + +func TestContains(t *testing.T) { + slice := []string{"up", "running", "active"} + + if !contains(slice, "up") { + t.Error("Should contain 'up'") + } + + if contains(slice, "down") { + t.Error("Should not contain 'down'") + } + + if contains(slice, "UP") { + t.Error("Should be case sensitive") + } +} + +func TestSystemCollector_CollectCPUMetrics(t *testing.T) { + collector := NewSystemCollector() + ctx := context.Background() + + metrics := &types.SystemMetrics{} + err := collector.collectCPUMetrics(ctx, metrics) + if err != nil { + t.Fatalf("Failed to collect CPU metrics: %v", err) + } + + // Check that CPU metrics are populated + if metrics.CPU.NumCores <= 0 { + t.Error("CPU cores should be greater than 0") + } + + // CPU usage should be a valid percentage + if metrics.CPU.UsagePercent < 0 || metrics.CPU.UsagePercent > 100 { + t.Errorf("CPU usage should be between 0 and 100, got %f", metrics.CPU.UsagePercent) + } + + // Load averages should be non-negative + if metrics.CPU.LoadAvg1 < 0 { + t.Error("LoadAvg1 should be non-negative") + } + if metrics.CPU.LoadAvg5 < 0 { + t.Error("LoadAvg5 should be non-negative") + } + if metrics.CPU.LoadAvg15 < 0 { + t.Error("LoadAvg15 should be non-negative") + } +} + +func TestSystemCollector_CollectMemoryMetrics(t *testing.T) { + collector := NewSystemCollector() + ctx := context.Background() + + metrics := &types.SystemMetrics{} + err := collector.collectMemoryMetrics(ctx, metrics) + if err != nil { + t.Fatalf("Failed to collect memory metrics: %v", err) + } + + // Check that memory metrics are populated + if metrics.Memory.Total == 0 { + t.Error("Total memory should be greater than 0") + } + + if metrics.Memory.Available == 0 { + t.Error("Available memory should be greater than 0") + } + + if metrics.Memory.Used == 0 { + t.Error("Used memory should be greater than 0") + } + + if metrics.Memory.Free == 0 { + t.Error("Free memory should be greater than 0") + } + + // Usage percentage should be valid + if metrics.Memory.UsagePercent < 0 || metrics.Memory.UsagePercent > 100 { + t.Errorf("Memory usage should be between 0 and 100, got %f", metrics.Memory.UsagePercent) + } + + // Swap metrics should be non-negative + if metrics.Memory.SwapTotal < 0 { + t.Error("SwapTotal should be non-negative") + } + if metrics.Memory.SwapUsed < 0 { + t.Error("SwapUsed should be non-negative") + } + if metrics.Memory.SwapFree < 0 { + t.Error("SwapFree should be non-negative") + } +} + +func TestSystemCollector_CollectDiskMetrics(t *testing.T) { + collector := NewSystemCollector() + ctx := context.Background() + + metrics := &types.SystemMetrics{} + err := collector.collectDiskMetrics(ctx, metrics) + if err != nil { + t.Fatalf("Failed to collect disk metrics: %v", err) + } + + // Should have at least one disk + if len(metrics.Disk) == 0 { + t.Error("Should have at least one disk") + } + + // Check each disk metric + for i, disk := range metrics.Disk { + if disk.Device == "" { + t.Errorf("Disk %d should have a device name", i) + } + if disk.Mountpoint == "" { + t.Errorf("Disk %d should have a mountpoint", i) + } + if disk.Fstype == "" { + t.Errorf("Disk %d should have a filesystem type", i) + } + // Some disks might have 0 total size (e.g., special filesystems) + if disk.Total < 0 { + t.Errorf("Disk %d should have non-negative total size", i) + } + if disk.Free < 0 { + t.Errorf("Disk %d should have non-negative free space", i) + } + if disk.Used < 0 { + t.Errorf("Disk %d should have non-negative used space", i) + } + if disk.UsagePercent < 0 || disk.UsagePercent > 100 { + t.Errorf("Disk %d usage should be between 0 and 100, got %f", i, disk.UsagePercent) + } + } +} + +func TestSystemCollector_CollectNetworkMetrics(t *testing.T) { + collector := NewSystemCollector() + ctx := context.Background() + + metrics := &types.SystemMetrics{} + err := collector.collectNetworkMetrics(ctx, metrics) + if err != nil { + t.Fatalf("Failed to collect network metrics: %v", err) + } + + // Should have at least one network interface + if len(metrics.Network.Interfaces) == 0 { + t.Error("Should have at least one network interface") + } + + // Check each network interface + for i, iface := range metrics.Network.Interfaces { + if iface.Name == "" { + t.Errorf("Interface %d should have a name", i) + } + if iface.BytesSent < 0 { + t.Errorf("Interface %d should have non-negative bytes sent", i) + } + if iface.BytesRecv < 0 { + t.Errorf("Interface %d should have non-negative bytes received", i) + } + if iface.PacketsSent < 0 { + t.Errorf("Interface %d should have non-negative packets sent", i) + } + if iface.PacketsRecv < 0 { + t.Errorf("Interface %d should have non-negative packets received", i) + } + if iface.ErrIn < 0 { + t.Errorf("Interface %d should have non-negative input errors", i) + } + if iface.ErrOut < 0 { + t.Errorf("Interface %d should have non-negative output errors", i) + } + if iface.DropIn < 0 { + t.Errorf("Interface %d should have non-negative input drops", i) + } + if iface.DropOut < 0 { + t.Errorf("Interface %d should have non-negative output drops", i) + } + } +} + +func TestCalculateCPUUsageEdgeCases(t *testing.T) { + // Test with zero times + t1 := cpu.TimesStat{} + t2 := cpu.TimesStat{} + + usage := calculateCPUUsage(t1, t2) + if usage != 0.0 { + t.Errorf("Expected 0.0 for zero times, got %f", usage) + } + + // Test with decreasing times (should return 0) + t1 = cpu.TimesStat{ + User: 100, System: 50, Nice: 10, Iowait: 20, + Irq: 5, Softirq: 10, Steal: 0, Guest: 0, GuestNice: 0, Idle: 1000, + } + t2 = cpu.TimesStat{ + User: 50, System: 25, Nice: 5, Iowait: 10, + Irq: 2, Softirq: 5, Steal: 0, Guest: 0, GuestNice: 0, Idle: 500, + } + + usage = calculateCPUUsage(t1, t2) + if usage != 0.0 { + t.Errorf("Expected 0.0 for decreasing times, got %f", usage) + } + + // Test with equal total times (should return 0) + t1 = cpu.TimesStat{ + User: 100, System: 50, Nice: 10, Iowait: 20, + Irq: 5, Softirq: 10, Steal: 0, Guest: 0, GuestNice: 0, Idle: 1000, + } + t2 = cpu.TimesStat{ + User: 100, System: 50, Nice: 10, Iowait: 20, + Irq: 5, Softirq: 10, Steal: 0, Guest: 0, GuestNice: 0, Idle: 1000, + } + + usage = calculateCPUUsage(t1, t2) + if usage != 0.0 { + t.Errorf("Expected 0.0 for equal times, got %f", usage) + } +} + +func TestSystemCollector_MultipleCalls(t *testing.T) { + collector := NewSystemCollector() + ctx := context.Background() + + // First call + metrics1, err := collector.GetSystemMetrics(ctx) + if err != nil { + t.Fatalf("First call failed: %v", err) + } + + // Wait a bit + time.Sleep(100 * time.Millisecond) + + // Second call + metrics2, err := collector.GetSystemMetrics(ctx) + if err != nil { + t.Fatalf("Second call failed: %v", err) + } + + // Timestamps should be different + if metrics1.Timestamp.Equal(metrics2.Timestamp) { + t.Error("Timestamps should be different between calls") + } + + // CPU usage might be different + if metrics1.CPU.UsagePercent == metrics2.CPU.UsagePercent { + // This is possible if CPU usage is stable, so we just log it + t.Logf("CPU usage is the same between calls: %f", metrics1.CPU.UsagePercent) + } +} + +func TestSystemCollector_ContextCancellation(t *testing.T) { + collector := NewSystemCollector() + ctx, cancel := context.WithCancel(context.Background()) + + // Cancel the context immediately + cancel() + + // This might fail due to context cancellation, which is expected + metrics, err := collector.GetSystemMetrics(ctx) + if err != nil { + // Context cancellation is expected, so we just log it + t.Logf("GetSystemMetrics with cancelled context failed as expected: %v", err) + return + } + + if metrics == nil { + t.Fatal("Expected non-nil metrics") + } +} diff --git a/mcp-servers/go/system-monitor-server/internal/monitor/health_checker.go b/mcp-servers/go/system-monitor-server/internal/monitor/health_checker.go new file mode 100644 index 000000000..bf314b38f --- /dev/null +++ b/mcp-servers/go/system-monitor-server/internal/monitor/health_checker.go @@ -0,0 +1,220 @@ +package monitor + +import ( + "context" + "fmt" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/IBM/mcp-context-forge/mcp-servers/go/system-monitor-server/pkg/types" +) + +// HealthChecker handles health checking of various services +type HealthChecker struct { + httpClient *http.Client +} + +// NewHealthChecker creates a new health checker +func NewHealthChecker() *HealthChecker { + return &HealthChecker{ + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// CheckServiceHealth performs health checks on the specified services +func (hc *HealthChecker) CheckServiceHealth(ctx context.Context, req *types.HealthCheckRequest) ([]types.HealthCheckResult, error) { + timeout := time.Duration(req.Timeout) * time.Second + if timeout == 0 { + timeout = 10 * time.Second + } + + var results []types.HealthCheckResult + + for _, service := range req.Services { + ctx, cancel := context.WithTimeout(ctx, timeout) + result := hc.checkSingleService(ctx, service) + cancel() + + results = append(results, result) + } + + return results, nil +} + +// checkSingleService performs a health check on a single service +func (hc *HealthChecker) checkSingleService(ctx context.Context, service types.ServiceCheck) types.HealthCheckResult { + startTime := time.Now() + + result := types.HealthCheckResult{ + ServiceName: service.Name, + Timestamp: time.Now(), + } + + switch strings.ToLower(service.Type) { + case "http", "https": + result = hc.checkHTTPService(ctx, service, result) + case "port", "tcp": + result = hc.checkPortService(ctx, service, result) + case "command": + // SECURITY: Command execution removed due to command injection risk + // Users should check process status via list_processes tool instead + result.Status = "unsupported" + result.Message = "command type disabled for security - use list_processes tool to check process status" + case "file": + result = hc.checkFileService(ctx, service, result) + default: + result.Status = "unknown" + result.Message = fmt.Sprintf("unsupported service type: %s", service.Type) + } + + result.ResponseTime = time.Since(startTime).Milliseconds() + return result +} + +// checkHTTPService checks HTTP/HTTPS service health +func (hc *HealthChecker) checkHTTPService(ctx context.Context, service types.ServiceCheck, result types.HealthCheckResult) types.HealthCheckResult { + req, err := http.NewRequestWithContext(ctx, "GET", service.Target, nil) + if err != nil { + result.Status = "unhealthy" + result.Message = fmt.Sprintf("failed to create request: %v", err) + return result + } + + resp, err := hc.httpClient.Do(req) + if err != nil { + result.Status = "unhealthy" + result.Message = fmt.Sprintf("request failed: %v", err) + return result + } + defer resp.Body.Close() + + // Check status code + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + result.Status = "healthy" + result.Message = fmt.Sprintf("HTTP %d", resp.StatusCode) + } else { + result.Status = "unhealthy" + result.Message = fmt.Sprintf("HTTP %d", resp.StatusCode) + } + + // Check expected headers if specified + if service.Expected != nil { + for key, expectedValue := range service.Expected { + actualValue := resp.Header.Get(key) + if actualValue != expectedValue { + result.Status = "unhealthy" + result.Message = fmt.Sprintf("header %s mismatch: expected %s, got %s", key, expectedValue, actualValue) + break + } + } + } + + return result +} + +// checkPortService checks TCP port service health +func (hc *HealthChecker) checkPortService(ctx context.Context, service types.ServiceCheck, result types.HealthCheckResult) types.HealthCheckResult { + conn, err := net.DialTimeout("tcp", service.Target, 5*time.Second) + if err != nil { + result.Status = "unhealthy" + result.Message = fmt.Sprintf("connection failed: %v", err) + return result + } + defer conn.Close() + + result.Status = "healthy" + result.Message = "port is open and accessible" + return result +} + +// checkCommandService is DISABLED for security - command injection vulnerability +// SECURITY: This function previously allowed arbitrary command execution which +// created a critical command injection vulnerability. An attacker could execute +// any system command via the MCP tool API. +// +// Users should use the list_processes tool instead to check if processes are running. +func (hc *HealthChecker) checkCommandService(ctx context.Context, service types.ServiceCheck, result types.HealthCheckResult) types.HealthCheckResult { + result.Status = "unsupported" + result.Message = "command type disabled for security (command injection risk) - use list_processes tool instead" + return result +} + +// checkFileService checks service health by verifying file existence/properties +func (hc *HealthChecker) checkFileService(ctx context.Context, service types.ServiceCheck, result types.HealthCheckResult) types.HealthCheckResult { + // Check if file exists + info, err := os.Stat(service.Target) + if err != nil { + if os.IsNotExist(err) { + result.Status = "unhealthy" + result.Message = "file does not exist" + } else { + result.Status = "unhealthy" + result.Message = fmt.Sprintf("file access error: %v", err) + } + return result + } + + // Check file age if specified + if service.Expected != nil { + if maxAge, exists := service.Expected["max_age"]; exists { + if maxAgeDuration, err := time.ParseDuration(maxAge); err == nil { + if time.Since(info.ModTime()) > maxAgeDuration { + result.Status = "unhealthy" + result.Message = fmt.Sprintf("file is too old: %v", time.Since(info.ModTime())) + return result + } + } + } + + // Check file size if specified + if minSize, exists := service.Expected["min_size"]; exists { + if minSizeBytes, err := parseSize(minSize); err == nil { + if info.Size() < minSizeBytes { + result.Status = "unhealthy" + result.Message = fmt.Sprintf("file too small: %d bytes", info.Size()) + return result + } + } + } + } + + result.Status = "healthy" + result.Message = fmt.Sprintf("file exists and meets criteria: %s", filepath.Base(service.Target)) + return result +} + +// parseSize parses a size string like "1MB", "500KB", etc. +func parseSize(sizeStr string) (int64, error) { + sizeStr = strings.TrimSpace(sizeStr) + sizeStr = strings.ToUpper(sizeStr) + + var multiplier int64 = 1 + var numStr string + + if strings.HasSuffix(sizeStr, "KB") { + multiplier = 1024 + numStr = strings.TrimSuffix(sizeStr, "KB") + } else if strings.HasSuffix(sizeStr, "MB") { + multiplier = 1024 * 1024 + numStr = strings.TrimSuffix(sizeStr, "MB") + } else if strings.HasSuffix(sizeStr, "GB") { + multiplier = 1024 * 1024 * 1024 + numStr = strings.TrimSuffix(sizeStr, "GB") + } else { + numStr = sizeStr + } + + var size int64 + _, err := fmt.Sscanf(numStr, "%d", &size) + if err != nil { + return 0, fmt.Errorf("invalid size format: %s", sizeStr) + } + + return size * multiplier, nil +} diff --git a/mcp-servers/go/system-monitor-server/internal/monitor/health_checker_test.go b/mcp-servers/go/system-monitor-server/internal/monitor/health_checker_test.go new file mode 100644 index 000000000..c6d1b17af --- /dev/null +++ b/mcp-servers/go/system-monitor-server/internal/monitor/health_checker_test.go @@ -0,0 +1,703 @@ +package monitor + +import ( + "context" + "os" + "testing" + + "github.com/IBM/mcp-context-forge/mcp-servers/go/system-monitor-server/pkg/types" +) + +func TestHealthChecker_CheckHTTPService(t *testing.T) { + checker := NewHealthChecker() + ctx := context.Background() + + // Test with a known working HTTP service + service := types.ServiceCheck{ + Name: "test-http", + Type: "http", + Target: "http://httpbin.org/status/200", + } + + req := &types.HealthCheckRequest{ + Services: []types.ServiceCheck{service}, + Timeout: 10, + } + + results, err := checker.CheckServiceHealth(ctx, req) + if err != nil { + t.Fatalf("Failed to check HTTP service: %v", err) + } + + if len(results) != 1 { + t.Fatalf("Expected 1 result, got %d", len(results)) + } + + result := results[0] + // The service might be healthy or unhealthy depending on network conditions + if result.Status != "healthy" && result.Status != "unhealthy" { + t.Errorf("Expected healthy or unhealthy status, got %s", result.Status) + } +} + +func TestHealthChecker_CheckPortService(t *testing.T) { + checker := NewHealthChecker() + ctx := context.Background() + + // Test with a known port (HTTP) + service := types.ServiceCheck{ + Name: "test-port", + Type: "port", + Target: "httpbin.org:80", + } + + req := &types.HealthCheckRequest{ + Services: []types.ServiceCheck{service}, + Timeout: 10, + } + + results, err := checker.CheckServiceHealth(ctx, req) + if err != nil { + t.Fatalf("Failed to check port service: %v", err) + } + + if len(results) != 1 { + t.Fatalf("Expected 1 result, got %d", len(results)) + } + + result := results[0] + // The service might be healthy or unhealthy depending on network conditions + if result.Status != "healthy" && result.Status != "unhealthy" { + t.Errorf("Expected healthy or unhealthy status, got %s", result.Status) + } +} + +func TestHealthChecker_CheckCommandService(t *testing.T) { + checker := NewHealthChecker() + ctx := context.Background() + + // SECURITY: Test that command execution is disabled + service := types.ServiceCheck{ + Name: "test-command", + Type: "command", + Target: "echo 'test'", + Expected: map[string]string{ + "output": "test", + }, + } + + req := &types.HealthCheckRequest{ + Services: []types.ServiceCheck{service}, + Timeout: 5, + } + + results, err := checker.CheckServiceHealth(ctx, req) + if err != nil { + t.Fatalf("Failed to check command service: %v", err) + } + + if len(results) != 1 { + t.Fatalf("Expected 1 result, got %d", len(results)) + } + + result := results[0] + // SECURITY: Command type should be disabled + if result.Status != "unsupported" { + t.Errorf("Expected unsupported status (security disabled), got %s", result.Status) + } +} + +func TestHealthChecker_CheckFileService(t *testing.T) { + checker := NewHealthChecker() + ctx := context.Background() + + // Create a temporary file + tmpFile, err := os.CreateTemp("", "health-test-*.txt") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + defer tmpFile.Close() + + tmpFile.WriteString("test content") + tmpFile.Close() + + // Test file health check + service := types.ServiceCheck{ + Name: "test-file", + Type: "file", + Target: tmpFile.Name(), + Expected: map[string]string{ + "min_size": "1B", + }, + } + + req := &types.HealthCheckRequest{ + Services: []types.ServiceCheck{service}, + Timeout: 5, + } + + results, err := checker.CheckServiceHealth(ctx, req) + if err != nil { + t.Fatalf("Failed to check file service: %v", err) + } + + if len(results) != 1 { + t.Fatalf("Expected 1 result, got %d", len(results)) + } + + result := results[0] + if result.Status != "healthy" { + t.Errorf("Expected healthy status, got %s", result.Status) + } +} + +func TestParseSize(t *testing.T) { + tests := []struct { + input string + expected int64 + hasError bool + }{ + {"1KB", 1024, false}, + {"1MB", 1024 * 1024, false}, + {"1GB", 1024 * 1024 * 1024, false}, + {"500B", 500, false}, + {"invalid", 0, true}, + {"1TB", 1, false}, // Not supported but treated as bytes + } + + for _, test := range tests { + result, err := parseSize(test.input) + if test.hasError { + if err == nil { + t.Errorf("Expected error for input %s", test.input) + } + } else { + if err != nil { + t.Errorf("Unexpected error for input %s: %v", test.input, err) + } + if result != test.expected { + t.Errorf("Expected %d for input %s, got %d", test.expected, test.input, result) + } + } + } +} + +func TestHealthChecker_CheckServiceHealthEmpty(t *testing.T) { + checker := NewHealthChecker() + ctx := context.Background() + + // Test with empty services list + req := &types.HealthCheckRequest{ + Services: []types.ServiceCheck{}, + Timeout: 10, + } + + results, err := checker.CheckServiceHealth(ctx, req) + if err != nil { + t.Fatalf("Failed to check empty services: %v", err) + } + + if len(results) != 0 { + t.Errorf("Expected 0 results for empty services, got %d", len(results)) + } +} + +func TestHealthChecker_CheckServiceHealthTimeout(t *testing.T) { + checker := NewHealthChecker() + ctx := context.Background() + + // Test with very short timeout + req := &types.HealthCheckRequest{ + Services: []types.ServiceCheck{ + { + Name: "test-http", + Type: "http", + Target: "http://httpbin.org/delay/5", // 5 second delay + }, + }, + Timeout: 1, // 1 second timeout + } + + results, err := checker.CheckServiceHealth(ctx, req) + if err != nil { + t.Fatalf("Failed to check services with timeout: %v", err) + } + + if len(results) != 1 { + t.Fatalf("Expected 1 result, got %d", len(results)) + } + + result := results[0] + // Should be unhealthy due to timeout + if result.Status != "unhealthy" { + t.Errorf("Expected unhealthy status due to timeout, got %s", result.Status) + } +} + +func TestHealthChecker_CheckServiceHealthZeroTimeout(t *testing.T) { + checker := NewHealthChecker() + ctx := context.Background() + + // Test with zero timeout (should use default) + // SECURITY: Using command type to verify it's disabled + req := &types.HealthCheckRequest{ + Services: []types.ServiceCheck{ + { + Name: "test-command", + Type: "command", + Target: "echo 'test'", + }, + }, + Timeout: 0, // Zero timeout + } + + results, err := checker.CheckServiceHealth(ctx, req) + if err != nil { + t.Fatalf("Failed to check services with zero timeout: %v", err) + } + + if len(results) != 1 { + t.Fatalf("Expected 1 result, got %d", len(results)) + } + + result := results[0] + // SECURITY: Command type should be disabled + if result.Status != "unsupported" { + t.Errorf("Expected unsupported status (security disabled), got %s", result.Status) + } +} + +func TestHealthChecker_CheckHTTPServiceWithHeaders(t *testing.T) { + checker := NewHealthChecker() + ctx := context.Background() + + // Test HTTP service with expected headers + service := types.ServiceCheck{ + Name: "test-http-headers", + Type: "http", + Target: "http://httpbin.org/headers", + Expected: map[string]string{ + "Content-Type": "application/json", + }, + } + + req := &types.HealthCheckRequest{ + Services: []types.ServiceCheck{service}, + Timeout: 10, + } + + results, err := checker.CheckServiceHealth(ctx, req) + if err != nil { + t.Fatalf("Failed to check HTTP service with headers: %v", err) + } + + if len(results) != 1 { + t.Fatalf("Expected 1 result, got %d", len(results)) + } + + result := results[0] + // The service might be healthy or unhealthy depending on network conditions + if result.Status != "healthy" && result.Status != "unhealthy" { + t.Errorf("Expected healthy or unhealthy status, got %s", result.Status) + } +} + +func TestHealthChecker_CheckCommandServiceEmpty(t *testing.T) { + checker := NewHealthChecker() + ctx := context.Background() + + // SECURITY: Test that command type is disabled even with empty command + service := types.ServiceCheck{ + Name: "test-empty-command", + Type: "command", + Target: "", // Empty command + } + + req := &types.HealthCheckRequest{ + Services: []types.ServiceCheck{service}, + Timeout: 5, + } + + results, err := checker.CheckServiceHealth(ctx, req) + if err != nil { + t.Fatalf("Failed to check empty command service: %v", err) + } + + if len(results) != 1 { + t.Fatalf("Expected 1 result, got %d", len(results)) + } + + result := results[0] + // SECURITY: Command type should be disabled + if result.Status != "unsupported" { + t.Errorf("Expected unsupported status (security disabled), got %s", result.Status) + } +} + +func TestHealthChecker_CheckCommandServiceWithOutput(t *testing.T) { + checker := NewHealthChecker() + ctx := context.Background() + + // SECURITY: Test that command type is disabled + service := types.ServiceCheck{ + Name: "test-command-output", + Type: "command", + Target: "echo 'hello world'", + Expected: map[string]string{ + "output": "hello world", + }, + } + + req := &types.HealthCheckRequest{ + Services: []types.ServiceCheck{service}, + Timeout: 5, + } + + results, err := checker.CheckServiceHealth(ctx, req) + if err != nil { + t.Fatalf("Failed to check command service with output: %v", err) + } + + if len(results) != 1 { + t.Fatalf("Expected 1 result, got %d", len(results)) + } + + result := results[0] + // SECURITY: Command type should be disabled + if result.Status != "unsupported" { + t.Errorf("Expected unsupported status (security disabled), got %s", result.Status) + } +} + +func TestHealthChecker_CheckCommandServiceWithWrongOutput(t *testing.T) { + checker := NewHealthChecker() + ctx := context.Background() + + // SECURITY: Test that command type is disabled + service := types.ServiceCheck{ + Name: "test-command-wrong-output", + Type: "command", + Target: "echo 'hello world'", + Expected: map[string]string{ + "output": "wrong output", + }, + } + + req := &types.HealthCheckRequest{ + Services: []types.ServiceCheck{service}, + Timeout: 5, + } + + results, err := checker.CheckServiceHealth(ctx, req) + if err != nil { + t.Fatalf("Failed to check command service with wrong output: %v", err) + } + + if len(results) != 1 { + t.Fatalf("Expected 1 result, got %d", len(results)) + } + + result := results[0] + // SECURITY: Command type should be disabled + if result.Status != "unsupported" { + t.Errorf("Expected unsupported status (security disabled), got %s", result.Status) + } +} + +func TestHealthChecker_CheckFileServiceWithAge(t *testing.T) { + checker := NewHealthChecker() + ctx := context.Background() + + // Create a temporary file + tmpFile, err := os.CreateTemp("", "health-test-age-*.txt") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + defer tmpFile.Close() + + tmpFile.WriteString("test content") + tmpFile.Close() + + // Test file service with age check + service := types.ServiceCheck{ + Name: "test-file-age", + Type: "file", + Target: tmpFile.Name(), + Expected: map[string]string{ + "max_age": "1h", // 1 hour max age + }, + } + + req := &types.HealthCheckRequest{ + Services: []types.ServiceCheck{service}, + Timeout: 5, + } + + results, err := checker.CheckServiceHealth(ctx, req) + if err != nil { + t.Fatalf("Failed to check file service with age: %v", err) + } + + if len(results) != 1 { + t.Fatalf("Expected 1 result, got %d", len(results)) + } + + result := results[0] + if result.Status != "healthy" { + t.Errorf("Expected healthy status, got %s", result.Status) + } +} + +func TestHealthChecker_CheckFileServiceWithInvalidAge(t *testing.T) { + checker := NewHealthChecker() + ctx := context.Background() + + // Create a temporary file + tmpFile, err := os.CreateTemp("", "health-test-invalid-age-*.txt") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + defer tmpFile.Close() + + tmpFile.WriteString("test content") + tmpFile.Close() + + // Test file service with invalid age format + service := types.ServiceCheck{ + Name: "test-file-invalid-age", + Type: "file", + Target: tmpFile.Name(), + Expected: map[string]string{ + "max_age": "invalid-age", // Invalid age format + }, + } + + req := &types.HealthCheckRequest{ + Services: []types.ServiceCheck{service}, + Timeout: 5, + } + + results, err := checker.CheckServiceHealth(ctx, req) + if err != nil { + t.Fatalf("Failed to check file service with invalid age: %v", err) + } + + if len(results) != 1 { + t.Fatalf("Expected 1 result, got %d", len(results)) + } + + result := results[0] + // Should still be healthy since invalid age format is ignored + if result.Status != "healthy" { + t.Errorf("Expected healthy status, got %s", result.Status) + } +} + +func TestHealthChecker_CheckFileServiceWithSize(t *testing.T) { + checker := NewHealthChecker() + ctx := context.Background() + + // Create a temporary file + tmpFile, err := os.CreateTemp("", "health-test-size-*.txt") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + defer tmpFile.Close() + + content := "test content" + tmpFile.WriteString(content) + tmpFile.Close() + + // Test file service with size check + service := types.ServiceCheck{ + Name: "test-file-size", + Type: "file", + Target: tmpFile.Name(), + Expected: map[string]string{ + "min_size": "1B", // 1 byte minimum + }, + } + + req := &types.HealthCheckRequest{ + Services: []types.ServiceCheck{service}, + Timeout: 5, + } + + results, err := checker.CheckServiceHealth(ctx, req) + if err != nil { + t.Fatalf("Failed to check file service with size: %v", err) + } + + if len(results) != 1 { + t.Fatalf("Expected 1 result, got %d", len(results)) + } + + result := results[0] + if result.Status != "healthy" { + t.Errorf("Expected healthy status, got %s", result.Status) + } +} + +func TestHealthChecker_CheckFileServiceWithTooSmallSize(t *testing.T) { + checker := NewHealthChecker() + ctx := context.Background() + + // Create a temporary file + tmpFile, err := os.CreateTemp("", "health-test-small-size-*.txt") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + defer tmpFile.Close() + + content := "test content" + tmpFile.WriteString(content) + tmpFile.Close() + + // Test file service with size check that's too large + service := types.ServiceCheck{ + Name: "test-file-small-size", + Type: "file", + Target: tmpFile.Name(), + Expected: map[string]string{ + "min_size": "1KB", // 1KB minimum (larger than our file) + }, + } + + req := &types.HealthCheckRequest{ + Services: []types.ServiceCheck{service}, + Timeout: 5, + } + + results, err := checker.CheckServiceHealth(ctx, req) + if err != nil { + t.Fatalf("Failed to check file service with small size: %v", err) + } + + if len(results) != 1 { + t.Fatalf("Expected 1 result, got %d", len(results)) + } + + result := results[0] + if result.Status != "unhealthy" { + t.Errorf("Expected unhealthy status for small file, got %s", result.Status) + } +} + +func TestHealthChecker_CheckFileServiceWithInvalidSize(t *testing.T) { + checker := NewHealthChecker() + ctx := context.Background() + + // Create a temporary file + tmpFile, err := os.CreateTemp("", "health-test-invalid-size-*.txt") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + defer tmpFile.Close() + + tmpFile.WriteString("test content") + tmpFile.Close() + + // Test file service with invalid size format + service := types.ServiceCheck{ + Name: "test-file-invalid-size", + Type: "file", + Target: tmpFile.Name(), + Expected: map[string]string{ + "min_size": "invalid-size", // Invalid size format + }, + } + + req := &types.HealthCheckRequest{ + Services: []types.ServiceCheck{service}, + Timeout: 5, + } + + results, err := checker.CheckServiceHealth(ctx, req) + if err != nil { + t.Fatalf("Failed to check file service with invalid size: %v", err) + } + + if len(results) != 1 { + t.Fatalf("Expected 1 result, got %d", len(results)) + } + + result := results[0] + // Should still be healthy since invalid size format is ignored + if result.Status != "healthy" { + t.Errorf("Expected healthy status, got %s", result.Status) + } +} + +func TestHealthChecker_CheckUnknownServiceType(t *testing.T) { + checker := NewHealthChecker() + ctx := context.Background() + + // Test unknown service type + service := types.ServiceCheck{ + Name: "test-unknown", + Type: "unknown", + Target: "some-target", + } + + req := &types.HealthCheckRequest{ + Services: []types.ServiceCheck{service}, + Timeout: 5, + } + + results, err := checker.CheckServiceHealth(ctx, req) + if err != nil { + t.Fatalf("Failed to check unknown service type: %v", err) + } + + if len(results) != 1 { + t.Fatalf("Expected 1 result, got %d", len(results)) + } + + result := results[0] + if result.Status != "unknown" { + t.Errorf("Expected unknown status, got %s", result.Status) + } +} + +func TestParseSizeEdgeCases(t *testing.T) { + tests := []struct { + input string + expected int64 + hasError bool + }{ + {"", 0, true}, + {"0", 0, false}, + {"0KB", 0, false}, + {"0MB", 0, false}, + {"0GB", 0, false}, + {"1.5KB", 1024, false}, // Decimal parsed as integer (1) * 1024 + {"-1KB", -1024, false}, // Negative parsed as integer (-1) * 1024 + {"1KB ", 1024, false}, // Trailing space (trimmed) + {" 1KB", 1024, false}, // Leading space (trimmed) + {"1kb", 1024, false}, // Lowercase + {"1Kb", 1024, false}, // Mixed case + {"1", 1, false}, // No unit (bytes) + } + + for _, test := range tests { + result, err := parseSize(test.input) + if test.hasError { + if err == nil { + t.Errorf("Expected error for input %s", test.input) + } + } else { + if err != nil { + t.Errorf("Unexpected error for input %s: %v", test.input, err) + } + if result != test.expected { + t.Errorf("Expected %d for input %s, got %d", test.expected, test.input, result) + } + } + } +} diff --git a/mcp-servers/go/system-monitor-server/internal/monitor/log_monitor.go b/mcp-servers/go/system-monitor-server/internal/monitor/log_monitor.go new file mode 100644 index 000000000..8e5b566f1 --- /dev/null +++ b/mcp-servers/go/system-monitor-server/internal/monitor/log_monitor.go @@ -0,0 +1,507 @@ +package monitor + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "github.com/IBM/mcp-context-forge/mcp-servers/go/system-monitor-server/pkg/types" + "github.com/hpcloud/tail" +) + +// LogMonitor handles log file monitoring and tailing +type LogMonitor struct { + rootPath string // Root directory - all file access restricted within this path (empty = no restriction) + allowedPaths []string + maxFileSize int64 +} + +// NewLogMonitor creates a new log monitor +func NewLogMonitor(rootPath string, allowedPaths []string, maxFileSize int64) *LogMonitor { + return &LogMonitor{ + rootPath: rootPath, + allowedPaths: allowedPaths, + maxFileSize: maxFileSize, + } +} + +// TailLogs tails log files with filtering and security controls +func (lm *LogMonitor) TailLogs(ctx context.Context, req *types.LogTailRequest) (*types.LogTailResult, error) { + // Security check: validate file path + if err := lm.validateFilePath(req.FilePath); err != nil { + return nil, fmt.Errorf("file path validation failed: %w", err) + } + + // Check file size if specified + if req.MaxSize > 0 { + if err := lm.checkFileSize(req.FilePath, req.MaxSize); err != nil { + return nil, err + } + } + + // Get file info + _, err := os.Stat(req.FilePath) + if err != nil { + return nil, fmt.Errorf("failed to get file info: %w", err) + } + + // Determine number of lines to read + lines := req.Lines + if lines <= 0 { + lines = 100 // default + } + + var logLines []string + + if req.Follow { + // Use tail library for following + logLines, err = lm.tailFileFollow(ctx, req) + } else { + // Read last N lines from file + logLines, err = lm.readLastLines(ctx, req.FilePath, lines, req.Filter) + } + + if err != nil { + return nil, fmt.Errorf("failed to read log file: %w", err) + } + + return &types.LogTailResult{ + Lines: logLines, + FilePath: req.FilePath, + TotalLines: len(logLines), + Timestamp: time.Now(), + }, nil +} + +// tailFileFollow uses the tail library to follow a file +func (lm *LogMonitor) tailFileFollow(ctx context.Context, req *types.LogTailRequest) ([]string, error) { + // Configure tail + config := tail.Config{ + Follow: true, + ReOpen: true, + MustExist: false, + Poll: true, + Location: &tail.SeekInfo{Offset: 0, Whence: io.SeekEnd}, + } + + // Set number of lines to read initially + if req.Lines > 0 { + config.Location = &tail.SeekInfo{Offset: 0, Whence: io.SeekEnd} + } + + t, err := tail.TailFile(req.FilePath, config) + if err != nil { + return nil, fmt.Errorf("failed to tail file: %w", err) + } + defer t.Stop() + + var lines []string + lineCount := 0 + maxLines := req.Lines + if maxLines <= 0 { + maxLines = 1000 // default max + } + + // Compile filter regex if provided with ReDoS protection + var filterRegex *regexp.Regexp + if req.Filter != "" { + filterRegex, err = lm.validateRegex(req.Filter) + if err != nil { + return nil, fmt.Errorf("invalid filter regex: %w", err) + } + } + + // Set up timeout + timeout := 30 * time.Second + if req.Follow { + timeout = 5 * time.Minute // longer timeout for follow mode + } + + timeoutCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + for { + select { + case <-timeoutCtx.Done(): + return lines, nil + case line, ok := <-t.Lines: + if !ok { + return lines, nil + } + + if line.Err != nil { + return lines, fmt.Errorf("tail error: %w", line.Err) + } + + // Apply filter if specified + if filterRegex != nil && !filterRegex.MatchString(line.Text) { + continue + } + + lines = append(lines, line.Text) + lineCount++ + + // Stop if we've reached the maximum number of lines + if lineCount >= maxLines { + return lines, nil + } + } + } +} + +// readLastLines reads the last N lines from a file +func (lm *LogMonitor) readLastLines(ctx context.Context, filePath string, lines int, filter string) ([]string, error) { + // SECURITY: Check file size BEFORE reading to prevent memory exhaustion + if lm.maxFileSize > 0 { + if err := lm.checkFileSize(filePath, lm.maxFileSize); err != nil { + return nil, err + } + } + + file, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + // Compile filter regex if provided with ReDoS protection + var filterRegex *regexp.Regexp + if filter != "" { + filterRegex, err = lm.validateRegex(filter) + if err != nil { + return nil, fmt.Errorf("invalid filter regex: %w", err) + } + } + + // Read all lines first + var allLines []string + scanner := bufio.NewScanner(file) + + // SECURITY: Limit scanner buffer size to prevent memory exhaustion + const maxScanTokenSize = 10 * 1024 * 1024 // 10MB per line max + buf := make([]byte, maxScanTokenSize) + scanner.Buffer(buf, maxScanTokenSize) + for scanner.Scan() { + line := scanner.Text() + + // Apply filter if specified + if filterRegex != nil && !filterRegex.MatchString(line) { + continue + } + + allLines = append(allLines, line) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + // Return last N lines + start := len(allLines) - lines + if start < 0 { + start = 0 + } + + return allLines[start:], nil +} + +// validateFilePath validates that the file path is allowed +// Security: Resolves symlinks to prevent path traversal attacks and enforces root boundary +func (lm *LogMonitor) validateFilePath(filePath string) error { + // Resolve the absolute path + absPath, err := filepath.Abs(filePath) + if err != nil { + return fmt.Errorf("failed to resolve absolute path: %w", err) + } + + // SECURITY: Resolve symlinks to prevent path traversal via symlink attacks + // This prevents attacks where /var/log/evil -> /etc/passwd + realPath, err := filepath.EvalSymlinks(absPath) + if err != nil { + // File might not exist yet, use abs path but validate parent exists + realPath = absPath + } + + // Clean the path to remove any .. or . components + realPath = filepath.Clean(realPath) + + // SECURITY: Enforce root boundary (chroot-like restriction) + // If rootPath is set, ALL file access must be within this root directory + if lm.rootPath != "" { + // Resolve and clean the root path + rootAbsPath, err := filepath.Abs(lm.rootPath) + if err != nil { + return fmt.Errorf("failed to resolve root path: %w", err) + } + + rootRealPath, err := filepath.EvalSymlinks(rootAbsPath) + if err != nil { + // Root might not exist yet, use abs path + rootRealPath = rootAbsPath + } + rootRealPath = filepath.Clean(rootRealPath) + + // Ensure the path is within the root directory + // Add separator to prevent /opt/root matching /opt/rootmalicious + if !strings.HasPrefix(realPath, rootRealPath+string(filepath.Separator)) && realPath != rootRealPath { + return fmt.Errorf("file path %s is outside root directory %s", realPath, rootRealPath) + } + } + + // Check if the path is in allowed directories + allowed := false + for _, allowedPath := range lm.allowedPaths { + // Resolve allowed path to absolute and evaluate symlinks + allowedAbsPath, err := filepath.Abs(allowedPath) + if err != nil { + continue + } + + allowedRealPath, err := filepath.EvalSymlinks(allowedAbsPath) + if err != nil { + // Allowed path might not exist, use abs path + allowedRealPath = allowedAbsPath + } + + allowedRealPath = filepath.Clean(allowedRealPath) + + // Ensure we're checking directory boundaries properly + // Add separator to prevent /var/log matching /var/logmalicious + if realPath == allowedRealPath || strings.HasPrefix(realPath, allowedRealPath+string(filepath.Separator)) { + allowed = true + break + } + } + + if !allowed { + return fmt.Errorf("file path %s is not in allowed directories: %v", realPath, lm.allowedPaths) + } + + return nil +} + +// validateRegex validates and compiles a regex pattern with ReDoS protection +// Security: Prevents ReDoS attacks by limiting regex complexity +func (lm *LogMonitor) validateRegex(pattern string) (*regexp.Regexp, error) { + // Basic ReDoS protection: limit pattern length + const maxPatternLength = 1000 + if len(pattern) > maxPatternLength { + return nil, fmt.Errorf("regex pattern too long (max %d characters)", maxPatternLength) + } + + // Check for dangerous patterns that could cause ReDoS + // Nested quantifiers like (a+)+ or (a*)* + dangerousPatterns := []string{ + `\(\w*\+\)\+`, // (a+)+ + `\(\w*\*\)\*`, // (a*)* + `\(\w*\+\)\*`, // (a+)* + `\(\w*\*\)\+`, // (a*)+ + } + + for _, dangerous := range dangerousPatterns { + matched, _ := regexp.MatchString(dangerous, pattern) + if matched { + return nil, fmt.Errorf("regex pattern contains potentially dangerous nested quantifiers") + } + } + + // Try to compile with timeout protection + regex, err := regexp.Compile(pattern) + if err != nil { + return nil, fmt.Errorf("invalid regex pattern: %w", err) + } + + return regex, nil +} + +// checkFileSize checks if the file size is within limits +func (lm *LogMonitor) checkFileSize(filePath string, maxSize int64) error { + info, err := os.Stat(filePath) + if err != nil { + return fmt.Errorf("failed to get file info: %w", err) + } + + if info.Size() > maxSize { + return fmt.Errorf("file size %d exceeds maximum allowed size %d", info.Size(), maxSize) + } + + return nil +} + +// AnalyzeLogs analyzes log files for patterns and statistics +func (lm *LogMonitor) AnalyzeLogs(ctx context.Context, filePath string, patterns []string) (map[string]interface{}, error) { + // Security check + if err := lm.validateFilePath(filePath); err != nil { + return nil, err + } + + file, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + lineCount := 0 + patternCounts := make(map[string]int) + errorCount := 0 + warningCount := 0 + infoCount := 0 + + // Compile patterns with ReDoS protection + compiledPatterns := make(map[string]*regexp.Regexp) + for _, pattern := range patterns { + regex, err := lm.validateRegex(pattern) + if err != nil { + continue // skip invalid or dangerous patterns + } + compiledPatterns[pattern] = regex + } + + for scanner.Scan() { + line := scanner.Text() + lineCount++ + + // Count log levels + lineLower := strings.ToLower(line) + if strings.Contains(lineLower, "error") || strings.Contains(lineLower, "err") { + errorCount++ + } else if strings.Contains(lineLower, "warn") { + warningCount++ + } else if strings.Contains(lineLower, "info") { + infoCount++ + } + + // Count pattern matches + for pattern, regex := range compiledPatterns { + if regex.MatchString(line) { + patternCounts[pattern]++ + } + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + return map[string]interface{}{ + "total_lines": lineCount, + "error_count": errorCount, + "warning_count": warningCount, + "info_count": infoCount, + "pattern_counts": patternCounts, + "file_path": filePath, + "analyzed_at": time.Now(), + }, nil +} + +// GetDiskUsage analyzes disk usage for a given path +func (lm *LogMonitor) GetDiskUsage(ctx context.Context, req *types.DiskUsageRequest) (*types.DiskUsageResult, error) { + // Security check + if err := lm.validateFilePath(req.Path); err != nil { + return nil, err + } + + var items []types.DiskUsageItem + totalSize := int64(0) + itemCount := 0 + + err := filepath.Walk(req.Path, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Check depth limit + depth := strings.Count(strings.TrimPrefix(path, req.Path), string(filepath.Separator)) + if req.MaxDepth > 0 && depth > req.MaxDepth { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + + // Check file type filter (only for files) - must check before size filter + if len(req.FileTypes) > 0 { + if info.IsDir() { + // Skip directories when filtering by file type + return nil + } + ext := strings.ToLower(filepath.Ext(path)) + found := false + for _, fileType := range req.FileTypes { + if strings.HasPrefix(ext, "."+strings.ToLower(fileType)) { + found = true + break + } + } + if !found { + return nil + } + } + + // Check minimum size (only for files, not directories) + if req.MinSize > 0 { + if info.IsDir() { + // Skip directories when filtering by size + return nil + } + if info.Size() < req.MinSize { + return nil + } + } + + item := types.DiskUsageItem{ + Path: path, + Size: info.Size(), + IsDir: info.IsDir(), + Modified: info.ModTime(), + Depth: depth, + } + + // Determine file type + if !info.IsDir() { + item.FileType = strings.TrimPrefix(filepath.Ext(path), ".") + } + + items = append(items, item) + totalSize += info.Size() + itemCount++ + + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to walk directory: %w", err) + } + + // Sort items + switch req.SortBy { + case "size": + sort.Slice(items, func(i, j int) bool { + return items[i].Size > items[j].Size + }) + case "name": + sort.Slice(items, func(i, j int) bool { + return items[i].Path < items[j].Path + }) + case "modified": + sort.Slice(items, func(i, j int) bool { + return items[i].Modified.After(items[j].Modified) + }) + } + + return &types.DiskUsageResult{ + Path: req.Path, + TotalSize: totalSize, + ItemCount: itemCount, + Items: items, + Timestamp: time.Now(), + }, nil +} diff --git a/mcp-servers/go/system-monitor-server/internal/monitor/log_monitor_test.go b/mcp-servers/go/system-monitor-server/internal/monitor/log_monitor_test.go new file mode 100644 index 000000000..50d034d9f --- /dev/null +++ b/mcp-servers/go/system-monitor-server/internal/monitor/log_monitor_test.go @@ -0,0 +1,624 @@ +package monitor + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/IBM/mcp-context-forge/mcp-servers/go/system-monitor-server/pkg/types" +) + +func TestNewLogMonitor(t *testing.T) { + rootPath := "" + allowedPaths := []string{"/var/log", "/tmp"} + maxFileSize := int64(1024 * 1024) // 1MB + + lm := NewLogMonitor(rootPath, allowedPaths, maxFileSize) + + if lm == nil { + t.Fatal("NewLogMonitor should not return nil") + } + + if len(lm.allowedPaths) != 2 { + t.Errorf("Expected 2 allowed paths, got %d", len(lm.allowedPaths)) + } + + if lm.allowedPaths[0] != "/var/log" { + t.Errorf("Expected first allowed path /var/log, got %s", lm.allowedPaths[0]) + } + + if lm.allowedPaths[1] != "/tmp" { + t.Errorf("Expected second allowed path /tmp, got %s", lm.allowedPaths[1]) + } + + if lm.maxFileSize != maxFileSize { + t.Errorf("Expected maxFileSize %d, got %d", maxFileSize, lm.maxFileSize) + } +} + +func TestLogMonitorValidateFilePath(t *testing.T) { + // Create a temporary directory for testing + tmpDir, err := os.MkdirTemp("", "log-monitor-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create a subdirectory + subDir := filepath.Join(tmpDir, "subdir") + err = os.Mkdir(subDir, 0755) + if err != nil { + t.Fatalf("Failed to create subdir: %v", err) + } + + // Create a test file + testFile := filepath.Join(subDir, "test.log") + err = os.WriteFile(testFile, []byte("test content"), 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + lm := NewLogMonitor("", []string{tmpDir}, 1024*1024) + + // Test valid file path + err = lm.validateFilePath(testFile) + if err != nil { + t.Errorf("Expected valid file path, got error: %v", err) + } + + // Test file path outside allowed directory + outsideFile := "/etc/passwd" + err = lm.validateFilePath(outsideFile) + if err == nil { + t.Error("Expected error for file outside allowed directory") + } + + // Test relative path + relFile := filepath.Join("subdir", "test.log") + err = lm.validateFilePath(relFile) + if err == nil { + t.Error("Expected error for relative path") + } + + // Test non-existent file (should still validate path) + nonExistentFile := filepath.Join(tmpDir, "nonexistent.log") + err = lm.validateFilePath(nonExistentFile) + if err != nil { + t.Errorf("Expected valid path for non-existent file, got error: %v", err) + } +} + +func TestLogMonitorRootPathRestriction(t *testing.T) { + // Create a root directory for testing + rootDir, err := os.MkdirTemp("", "root-dir-test-*") + if err != nil { + t.Fatalf("Failed to create root dir: %v", err) + } + defer os.RemoveAll(rootDir) + + // Create a directory inside the root + insideDir := filepath.Join(rootDir, "logs") + err = os.Mkdir(insideDir, 0755) + if err != nil { + t.Fatalf("Failed to create inside dir: %v", err) + } + + // Create a test file inside the root + insideFile := filepath.Join(insideDir, "test.log") + err = os.WriteFile(insideFile, []byte("test content"), 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Create log monitor with root restriction + lm := NewLogMonitor(rootDir, []string{insideDir}, 1024*1024) + + // Test file inside root - should be allowed + err = lm.validateFilePath(insideFile) + if err != nil { + t.Errorf("Expected file inside root to be allowed, got error: %v", err) + } + + // Test file outside root - should be denied + outsideFile := "/etc/passwd" + err = lm.validateFilePath(outsideFile) + if err == nil { + t.Error("Expected error for file outside root directory") + } + if err != nil && !strings.Contains(err.Error(), "outside root directory") { + t.Errorf("Expected 'outside root directory' error, got: %v", err) + } + + // Test file in /tmp (outside root) - should be denied even if in allowed paths + tmpFile, err := os.CreateTemp("", "outside-root-test-*.txt") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + tmpFile.Close() + + lm2 := NewLogMonitor(rootDir, []string{"/tmp"}, 1024*1024) + err = lm2.validateFilePath(tmpFile.Name()) + if err == nil { + t.Error("Expected error for file outside root even with allowed path") + } + if err != nil && !strings.Contains(err.Error(), "outside root directory") { + t.Errorf("Expected 'outside root directory' error, got: %v", err) + } + + // Test with empty root path (no restriction) - should allow /tmp + lm3 := NewLogMonitor("", []string{"/tmp"}, 1024*1024) + err = lm3.validateFilePath(tmpFile.Name()) + if err != nil { + t.Errorf("Expected file in /tmp to be allowed with empty root, got error: %v", err) + } +} + +func TestLogMonitorCheckFileSize(t *testing.T) { + // Create a temporary file + tmpFile, err := os.CreateTemp("", "size-test-*.txt") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + defer tmpFile.Close() + + // Write some content + content := strings.Repeat("a", 1000) // 1000 bytes + _, err = tmpFile.WriteString(content) + if err != nil { + t.Fatalf("Failed to write content: %v", err) + } + tmpFile.Close() + + lm := NewLogMonitor("", []string{"/tmp"}, 1024*1024) + + // Test file within size limit + err = lm.checkFileSize(tmpFile.Name(), 2000) + if err != nil { + t.Errorf("Expected no error for file within size limit, got: %v", err) + } + + // Test file exceeding size limit + err = lm.checkFileSize(tmpFile.Name(), 500) + if err == nil { + t.Error("Expected error for file exceeding size limit") + } + + // Test non-existent file + err = lm.checkFileSize("/nonexistent/file.txt", 1000) + if err == nil { + t.Error("Expected error for non-existent file") + } +} + +func TestLogMonitorReadLastLines(t *testing.T) { + // Create a temporary file with multiple lines + tmpFile, err := os.CreateTemp("", "lines-test-*.txt") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + defer tmpFile.Close() + + // Write multiple lines + lines := []string{ + "line 1", + "line 2", + "line 3", + "line 4", + "line 5", + } + for _, line := range lines { + tmpFile.WriteString(line + "\n") + } + tmpFile.Close() + + lm := NewLogMonitor("", []string{"/tmp"}, 1024*1024) + + ctx := context.Background() + + // Test reading last 3 lines + result, err := lm.readLastLines(ctx, tmpFile.Name(), 3, "") + if err != nil { + t.Fatalf("Failed to read last lines: %v", err) + } + + if len(result) != 3 { + t.Errorf("Expected 3 lines, got %d", len(result)) + } + + expected := []string{"line 3", "line 4", "line 5"} + for i, line := range result { + if line != expected[i] { + t.Errorf("Expected line %d to be %s, got %s", i, expected[i], line) + } + } + + // Test reading more lines than available + result, err = lm.readLastLines(ctx, tmpFile.Name(), 10, "") + if err != nil { + t.Fatalf("Failed to read lines: %v", err) + } + + if len(result) != 5 { + t.Errorf("Expected 5 lines, got %d", len(result)) + } + + // Test with filter + result, err = lm.readLastLines(ctx, tmpFile.Name(), 10, "line [3-5]") + if err != nil { + t.Fatalf("Failed to read filtered lines: %v", err) + } + + if len(result) != 3 { + t.Errorf("Expected 3 filtered lines, got %d", len(result)) + } + + // Test with invalid regex + _, err = lm.readLastLines(ctx, tmpFile.Name(), 10, "[invalid") + if err == nil { + t.Error("Expected error for invalid regex") + } +} + +func TestLogMonitorTailLogs(t *testing.T) { + // Create a temporary file in /tmp + tmpFile, err := os.CreateTemp("/tmp", "tail-test-*.txt") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + defer tmpFile.Close() + + // Write some content + content := "test log line 1\ntest log line 2\ntest log line 3\n" + _, err = tmpFile.WriteString(content) + if err != nil { + t.Fatalf("Failed to write content: %v", err) + } + tmpFile.Close() + + lm := NewLogMonitor("", []string{"/tmp"}, 1024*1024) + + ctx := context.Background() + + // Test basic tail request + req := &types.LogTailRequest{ + FilePath: tmpFile.Name(), + Lines: 2, + Follow: false, + } + + result, err := lm.TailLogs(ctx, req) + if err != nil { + t.Fatalf("Failed to tail logs: %v", err) + } + + if result == nil { + t.Fatal("Expected non-nil result") + } + + if result.FilePath != tmpFile.Name() { + t.Errorf("Expected file path %s, got %s", tmpFile.Name(), result.FilePath) + } + + if len(result.Lines) != 2 { + t.Errorf("Expected 2 lines, got %d", len(result.Lines)) + } + + if result.TotalLines != 2 { + t.Errorf("Expected total lines 2, got %d", result.TotalLines) + } + + // Test with invalid file path (outside allowed directory) + req.FilePath = "/etc/passwd" + _, err = lm.TailLogs(ctx, req) + if err == nil { + t.Error("Expected error for file outside allowed directory") + } + + // Test with file size check + req.FilePath = tmpFile.Name() + req.MaxSize = 10 // Very small size + _, err = lm.TailLogs(ctx, req) + if err == nil { + t.Error("Expected error for file exceeding size limit") + } + + // Test with missing file + req.FilePath = "/nonexistent/file.txt" + req.MaxSize = 0 + _, err = lm.TailLogs(ctx, req) + if err == nil { + t.Error("Expected error for missing file") + } +} + +func TestLogMonitorAnalyzeLogs(t *testing.T) { + // Create a temporary file with various log levels in /tmp + tmpFile, err := os.CreateTemp("/tmp", "analyze-test-*.txt") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + defer tmpFile.Close() + + // Write log lines with different levels + logLines := []string{ + "2023-01-01 INFO: Application started", + "2023-01-01 WARN: Low memory warning", + "2023-01-01 ERROR: Database connection failed", + "2023-01-01 INFO: User logged in", + "2023-01-01 ERROR: File not found", + "2023-01-01 WARN: Disk space low", + } + for _, line := range logLines { + tmpFile.WriteString(line + "\n") + } + tmpFile.Close() + + lm := NewLogMonitor("", []string{"/tmp"}, 1024*1024) + + ctx := context.Background() + + // Test analysis with patterns + patterns := []string{ + "ERROR", + "WARN", + "INFO", + "Database", + } + + result, err := lm.AnalyzeLogs(ctx, tmpFile.Name(), patterns) + if err != nil { + t.Fatalf("Failed to analyze logs: %v", err) + } + + if result == nil { + t.Fatal("Expected non-nil result") + } + + // Check total lines + if result["total_lines"] != 6 { + t.Errorf("Expected 6 total lines, got %v", result["total_lines"]) + } + + // Check error count + if result["error_count"] != 2 { + t.Errorf("Expected 2 errors, got %v", result["error_count"]) + } + + // Check warning count + if result["warning_count"] != 2 { + t.Errorf("Expected 2 warnings, got %v", result["warning_count"]) + } + + // Check info count + if result["info_count"] != 2 { + t.Errorf("Expected 2 info messages, got %v", result["info_count"]) + } + + // Check pattern counts + patternCounts, ok := result["pattern_counts"].(map[string]int) + if !ok { + t.Fatal("Expected pattern_counts to be map[string]int") + } + + if patternCounts["ERROR"] != 2 { + t.Errorf("Expected 2 ERROR matches, got %d", patternCounts["ERROR"]) + } + + if patternCounts["WARN"] != 2 { + t.Errorf("Expected 2 WARN matches, got %d", patternCounts["WARN"]) + } + + if patternCounts["INFO"] != 2 { + t.Errorf("Expected 2 INFO matches, got %d", patternCounts["INFO"]) + } + + if patternCounts["Database"] != 1 { + t.Errorf("Expected 1 Database match, got %d", patternCounts["Database"]) + } + + // Test with file outside allowed directory + _, err = lm.AnalyzeLogs(ctx, "/etc/passwd", patterns) + if err == nil { + t.Error("Expected error for file outside allowed directory") + } +} + +func TestLogMonitorGetDiskUsage(t *testing.T) { + // Create a temporary directory structure + tmpDir, err := os.MkdirTemp("", "disk-usage-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create subdirectories and files + subDir1 := filepath.Join(tmpDir, "subdir1") + subDir2 := filepath.Join(tmpDir, "subdir2") + err = os.Mkdir(subDir1, 0755) + if err != nil { + t.Fatalf("Failed to create subdir1: %v", err) + } + err = os.Mkdir(subDir2, 0755) + if err != nil { + t.Fatalf("Failed to create subdir2: %v", err) + } + + // Create files with different sizes + file1 := filepath.Join(subDir1, "file1.txt") + file2 := filepath.Join(subDir2, "file2.log") + file3 := filepath.Join(tmpDir, "file3.txt") + + err = os.WriteFile(file1, []byte("content1"), 0644) + if err != nil { + t.Fatalf("Failed to create file1: %v", err) + } + + err = os.WriteFile(file2, []byte("content2"), 0644) + if err != nil { + t.Fatalf("Failed to create file2: %v", err) + } + + err = os.WriteFile(file3, []byte("content3"), 0644) + if err != nil { + t.Fatalf("Failed to create file3: %v", err) + } + + lm := NewLogMonitor("", []string{tmpDir}, 1024*1024) + + ctx := context.Background() + + // Test basic disk usage + req := &types.DiskUsageRequest{ + Path: tmpDir, + MaxDepth: 2, + MinSize: 0, + SortBy: "size", + } + + result, err := lm.GetDiskUsage(ctx, req) + if err != nil { + t.Fatalf("Failed to get disk usage: %v", err) + } + + if result == nil { + t.Fatal("Expected non-nil result") + } + + if result.Path != tmpDir { + t.Errorf("Expected path %s, got %s", tmpDir, result.Path) + } + + if result.ItemCount < 5 { // At least 2 dirs + 3 files + t.Errorf("Expected at least 5 items, got %d", result.ItemCount) + } + + if result.TotalSize <= 0 { + t.Errorf("Expected positive total size, got %d", result.TotalSize) + } + + // Test with file type filter + req.FileTypes = []string{"txt"} + result, err = lm.GetDiskUsage(ctx, req) + if err != nil { + t.Fatalf("Failed to get disk usage with filter: %v", err) + } + + // Should have fewer items (only .txt files) + if result.ItemCount >= 5 { + t.Errorf("Expected fewer items with file type filter, got %d", result.ItemCount) + } + + // Test with minimum size filter + req.FileTypes = []string{} + req.MinSize = 1000 // Much larger than our file sizes + result, err = lm.GetDiskUsage(ctx, req) + if err != nil { + t.Fatalf("Failed to get disk usage with size filter: %v", err) + } + + // Should have no items (all files are smaller than 100 bytes) + if result.ItemCount != 0 { + t.Errorf("Expected 0 items with size filter, got %d", result.ItemCount) + } + + // Test with depth limit + req.MinSize = 0 + req.MaxDepth = 1 + result, err = lm.GetDiskUsage(ctx, req) + if err != nil { + t.Fatalf("Failed to get disk usage with depth limit: %v", err) + } + + // Should have fewer items (only top level) + if result.ItemCount >= 5 { + t.Errorf("Expected fewer items with depth limit, got %d", result.ItemCount) + } + + // Test with path outside allowed directory + req.Path = "/etc" + _, err = lm.GetDiskUsage(ctx, req) + if err == nil { + t.Error("Expected error for path outside allowed directory") + } +} + +func TestLogMonitorTailLogsFollow(t *testing.T) { + // Create a temporary file in /tmp + tmpFile, err := os.CreateTemp("/tmp", "follow-test-*.txt") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + defer tmpFile.Close() + + lm := NewLogMonitor("", []string{"/tmp"}, 1024*1024) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + // Test follow mode with timeout + req := &types.LogTailRequest{ + FilePath: tmpFile.Name(), + Lines: 10, + Follow: true, + } + + result, err := lm.TailLogs(ctx, req) + if err != nil { + t.Fatalf("Failed to tail logs in follow mode: %v", err) + } + + if result == nil { + t.Fatal("Expected non-nil result") + } + + // In follow mode with timeout, we should get an empty result + if len(result.Lines) != 0 { + t.Errorf("Expected empty lines in follow mode with timeout, got %d", len(result.Lines)) + } +} + +func TestLogMonitorInvalidRegex(t *testing.T) { + // Create a temporary file + tmpFile, err := os.CreateTemp("", "regex-test-*.txt") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + defer tmpFile.Close() + + // Write some content + tmpFile.WriteString("test line\n") + tmpFile.Close() + + lm := NewLogMonitor("", []string{"/tmp"}, 1024*1024) + + ctx := context.Background() + + // Test with invalid regex in readLastLines + _, err = lm.readLastLines(ctx, tmpFile.Name(), 10, "[invalid") + if err == nil { + t.Error("Expected error for invalid regex in readLastLines") + } + + // Test with invalid regex in TailLogs + req := &types.LogTailRequest{ + FilePath: tmpFile.Name(), + Lines: 10, + Follow: false, + Filter: "[invalid", + } + + _, err = lm.TailLogs(ctx, req) + if err == nil { + t.Error("Expected error for invalid regex in TailLogs") + } +} diff --git a/mcp-servers/go/system-monitor-server/pkg/types/metrics.go b/mcp-servers/go/system-monitor-server/pkg/types/metrics.go new file mode 100644 index 000000000..adb0c41f3 --- /dev/null +++ b/mcp-servers/go/system-monitor-server/pkg/types/metrics.go @@ -0,0 +1,189 @@ +package types + +import ( + "time" +) + +// SystemMetrics represents comprehensive system resource usage +type SystemMetrics struct { + CPU CPUMetrics `json:"cpu"` + Memory MemoryMetrics `json:"memory"` + Disk []DiskMetrics `json:"disk"` + Network NetworkMetrics `json:"network"` + Timestamp time.Time `json:"timestamp"` +} + +// CPUMetrics represents CPU usage information +type CPUMetrics struct { + UsagePercent float64 `json:"usage_percent"` + LoadAvg1 float64 `json:"load_avg_1"` + LoadAvg5 float64 `json:"load_avg_5"` + LoadAvg15 float64 `json:"load_avg_15"` + NumCores int `json:"num_cores"` +} + +// MemoryMetrics represents memory usage information +type MemoryMetrics struct { + Total uint64 `json:"total"` + Available uint64 `json:"available"` + Used uint64 `json:"used"` + Free uint64 `json:"free"` + UsagePercent float64 `json:"usage_percent"` + SwapTotal uint64 `json:"swap_total"` + SwapUsed uint64 `json:"swap_used"` + SwapFree uint64 `json:"swap_free"` +} + +// DiskMetrics represents disk usage information for a single disk +type DiskMetrics struct { + Device string `json:"device"` + Mountpoint string `json:"mountpoint"` + Fstype string `json:"fstype"` + Total uint64 `json:"total"` + Free uint64 `json:"free"` + Used uint64 `json:"used"` + UsagePercent float64 `json:"usage_percent"` +} + +// NetworkMetrics represents network interface information +type NetworkMetrics struct { + Interfaces []NetworkInterface `json:"interfaces"` +} + +// NetworkInterface represents a single network interface +type NetworkInterface struct { + Name string `json:"name"` + BytesSent uint64 `json:"bytes_sent"` + BytesRecv uint64 `json:"bytes_recv"` + PacketsSent uint64 `json:"packets_sent"` + PacketsRecv uint64 `json:"packets_recv"` + ErrIn uint64 `json:"err_in"` + ErrOut uint64 `json:"err_out"` + DropIn uint64 `json:"drop_in"` + DropOut uint64 `json:"drop_out"` + IsUp bool `json:"is_up"` +} + +// ProcessListRequest represents parameters for listing processes +type ProcessListRequest struct { + FilterBy string `json:"filter_by,omitempty"` // name, user, pid + FilterValue string `json:"filter_value,omitempty"` + SortBy string `json:"sort_by,omitempty"` // cpu, memory, name + Limit int `json:"limit,omitempty"` + IncludeThreads bool `json:"include_threads,omitempty"` +} + +// ProcessInfo represents information about a single process +type ProcessInfo struct { + PID int32 `json:"pid"` + Name string `json:"name"` + CPUPercent float64 `json:"cpu_percent"` + MemoryPercent float32 `json:"memory_percent"` + MemoryRSS uint64 `json:"memory_rss"` + MemoryVMS uint64 `json:"memory_vms"` + Status string `json:"status"` + CreateTime int64 `json:"create_time"` + Username string `json:"username"` + Command string `json:"command"` + Threads int32 `json:"threads,omitempty"` +} + +// ProcessMonitorRequest represents parameters for monitoring a specific process +type ProcessMonitorRequest struct { + PID int32 `json:"pid,omitempty"` + ProcessName string `json:"process_name,omitempty"` + Duration int `json:"duration"` // seconds + Interval int `json:"interval"` // seconds + AlertThresholds Thresholds `json:"alert_thresholds,omitempty"` +} + +// Thresholds represents alert thresholds for monitoring +type Thresholds struct { + CPUPercent float64 `json:"cpu_percent,omitempty"` + MemoryPercent float64 `json:"memory_percent,omitempty"` + MemoryRSS uint64 `json:"memory_rss,omitempty"` +} + +// ProcessMonitorResult represents the result of process monitoring +type ProcessMonitorResult struct { + ProcessInfo ProcessInfo `json:"process_info"` + Alerts []Alert `json:"alerts,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +// Alert represents a monitoring alert +type Alert struct { + Type string `json:"type"` // cpu, memory, etc. + Message string `json:"message"` + Threshold float64 `json:"threshold"` + Value float64 `json:"value"` + Timestamp time.Time `json:"timestamp"` +} + +// HealthCheckRequest represents parameters for health checking +type HealthCheckRequest struct { + Services []ServiceCheck `json:"services"` + Timeout int `json:"timeout,omitempty"` // seconds +} + +// ServiceCheck represents a single service to check +type ServiceCheck struct { + Name string `json:"name"` + Type string `json:"type"` // port, http, file (command disabled for security) + Target string `json:"target"` + Expected map[string]string `json:"expected,omitempty"` +} + +// HealthCheckResult represents the result of a health check +type HealthCheckResult struct { + ServiceName string `json:"service_name"` + Status string `json:"status"` // healthy, unhealthy, unknown + Message string `json:"message"` + ResponseTime int64 `json:"response_time_ms"` + Timestamp time.Time `json:"timestamp"` +} + +// LogTailRequest represents parameters for tailing log files +type LogTailRequest struct { + FilePath string `json:"file_path"` + Lines int `json:"lines,omitempty"` // number of lines to tail + Follow bool `json:"follow,omitempty"` // continuous monitoring + Filter string `json:"filter,omitempty"` // regex filter + MaxSize int64 `json:"max_size,omitempty"` // max file size to process +} + +// LogTailResult represents the result of log tailing +type LogTailResult struct { + Lines []string `json:"lines"` + FilePath string `json:"file_path"` + TotalLines int `json:"total_lines"` + Timestamp time.Time `json:"timestamp"` +} + +// DiskUsageRequest represents parameters for disk usage analysis +type DiskUsageRequest struct { + Path string `json:"path"` + MaxDepth int `json:"max_depth,omitempty"` + MinSize int64 `json:"min_size,omitempty"` + SortBy string `json:"sort_by,omitempty"` // size, name, modified + FileTypes []string `json:"file_types,omitempty"` +} + +// DiskUsageItem represents a single file or directory in disk usage analysis +type DiskUsageItem struct { + Path string `json:"path"` + Size int64 `json:"size"` + IsDir bool `json:"is_dir"` + Modified time.Time `json:"modified"` + FileType string `json:"file_type,omitempty"` + Depth int `json:"depth"` +} + +// DiskUsageResult represents the result of disk usage analysis +type DiskUsageResult struct { + Path string `json:"path"` + TotalSize int64 `json:"total_size"` + ItemCount int `json:"item_count"` + Items []DiskUsageItem `json:"items"` + Timestamp time.Time `json:"timestamp"` +} diff --git a/mcp-servers/go/system-monitor-server/staticcheck.conf b/mcp-servers/go/system-monitor-server/staticcheck.conf new file mode 100644 index 000000000..914cbda7f --- /dev/null +++ b/mcp-servers/go/system-monitor-server/staticcheck.conf @@ -0,0 +1 @@ +checks = ["all", "-SA1019"] diff --git a/mcp-servers/go/system-monitor-server/test.sh b/mcp-servers/go/system-monitor-server/test.sh new file mode 100755 index 000000000..a66a9cd5e --- /dev/null +++ b/mcp-servers/go/system-monitor-server/test.sh @@ -0,0 +1,138 @@ +#!/bin/bash + +# System Monitor Server Test Script +# This script tests the system-monitor-server functionality + +set -e + +echo "🧪 Testing System Monitor Server..." + +# Check if server is running +if ! pgrep -f "system-monitor-server" > /dev/null; then + echo "❌ Server is not running. Please start it first with: ./start.sh -t http" + exit 1 +fi + +# Get the port (default to 8080) +PORT=${1:-8080} +BASE_URL="http://localhost:$PORT" + +echo "🔍 Testing server at $BASE_URL" + +# Test health endpoint +echo "1. Testing health endpoint..." +HEALTH_RESPONSE=$(curl -s "$BASE_URL/health" || echo "Failed") +if [[ "$HEALTH_RESPONSE" == *"healthy"* ]]; then + echo "✅ Health check passed" +else + echo "❌ Health check failed: $HEALTH_RESPONSE" +fi + +# Test info endpoint +echo "2. Testing info endpoint..." +INFO_RESPONSE=$(curl -s "$BASE_URL/info" || echo "Failed") +if [[ "$INFO_RESPONSE" == *"tools"* ]]; then + echo "✅ Info endpoint working" + echo "📋 Available tools: $(echo "$INFO_RESPONSE" | jq -r '.tools[].name' | tr '\n' ' ')" +else + echo "❌ Info endpoint failed: $INFO_RESPONSE" +fi + +# Test version endpoint +echo "3. Testing version endpoint..." +VERSION_RESPONSE=$(curl -s "$BASE_URL/version" || echo "Failed") +if [[ "$VERSION_RESPONSE" == *"version"* ]]; then + echo "✅ Version endpoint working" + echo "📦 Version: $(echo "$VERSION_RESPONSE" | jq -r '.version')" +else + echo "❌ Version endpoint failed: $VERSION_RESPONSE" +fi + +# Test MCP initialization +echo "4. Testing MCP initialization..." +INIT_RESPONSE=$(curl -s -X POST "$BASE_URL/" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{"roots":{"listChanged":true},"sampling":{}}},"id":1}' || echo "Failed") + +if [[ "$INIT_RESPONSE" == *"result"* ]]; then + echo "✅ MCP initialization successful" + SESSION_ID=$(echo "$INIT_RESPONSE" | jq -r '.result.serverInfo.sessionId // empty') + if [ -n "$SESSION_ID" ]; then + echo "🔑 Session ID: $SESSION_ID" + + # Test tools/list with session + echo "5. Testing tools/list..." + TOOLS_RESPONSE=$(curl -s -X POST "$BASE_URL/" \ + -H "Content-Type: application/json" \ + -d "{\"jsonrpc\":\"2.0\",\"method\":\"tools/list\",\"params\":{\"sessionId\":\"$SESSION_ID\"},\"id\":2}" || echo "Failed") + + if [[ "$TOOLS_RESPONSE" == *"tools"* ]]; then + echo "✅ Tools list working" + echo "🛠️ Available tools: $(echo "$TOOLS_RESPONSE" | jq -r '.result.tools[].name' | tr '\n' ' ')" + else + echo "❌ Tools list failed: $TOOLS_RESPONSE" + fi + + # Test get_system_metrics + echo "6. Testing get_system_metrics tool..." + METRICS_RESPONSE=$(curl -s -X POST "$BASE_URL/" \ + -H "Content-Type: application/json" \ + -d "{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{\"name\":\"get_system_metrics\",\"arguments\":{},\"sessionId\":\"$SESSION_ID\"},\"id\":3}" || echo "Failed") + + if [[ "$METRICS_RESPONSE" == *"cpu"* ]]; then + echo "✅ System metrics tool working" + echo "📊 Sample metrics: $(echo "$METRICS_RESPONSE" | jq -r '.result.content[0].text' | head -c 100)..." + else + echo "❌ System metrics tool failed: $METRICS_RESPONSE" + fi + + # Test list_processes + echo "7. Testing list_processes tool..." + PROCESS_RESPONSE=$(curl -s -X POST "$BASE_URL/" \ + -H "Content-Type: application/json" \ + -d "{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{\"name\":\"list_processes\",\"arguments\":{\"limit\":5},\"sessionId\":\"$SESSION_ID\"},\"id\":4}" || echo "Failed") + + if [[ "$PROCESS_RESPONSE" == *"processes"* ]]; then + echo "✅ Process listing tool working" + echo "📋 Found processes: $(echo "$PROCESS_RESPONSE" | jq -r '.result.content[0].text' | head -c 100)..." + else + echo "❌ Process listing tool failed: $PROCESS_RESPONSE" + fi + + # Test get_disk_usage + echo "8. Testing get_disk_usage tool..." + DISK_RESPONSE=$(curl -s -X POST "$BASE_URL/" \ + -H "Content-Type: application/json" \ + -d "{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{\"name\":\"get_disk_usage\",\"arguments\":{},\"sessionId\":\"$SESSION_ID\"},\"id\":5}" || echo "Failed") + + if [[ "$DISK_RESPONSE" == *"disk"* ]]; then + echo "✅ Disk usage tool working" + echo "💾 Disk info: $(echo "$DISK_RESPONSE" | jq -r '.result.content[0].text' | head -c 100)..." + else + echo "❌ Disk usage tool failed: $DISK_RESPONSE" + fi + + else + echo "⚠️ No session ID returned, testing without session..." + + # Test tools/list without session + echo "5. Testing tools/list (no session)..." + TOOLS_RESPONSE=$(curl -s -X POST "$BASE_URL/" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"tools/list","id":2}' || echo "Failed") + + if [[ "$TOOLS_RESPONSE" == *"tools"* ]]; then + echo "✅ Tools list working (no session)" + echo "🛠️ Available tools: $(echo "$TOOLS_RESPONSE" | jq -r '.result.tools[].name' | tr '\n' ' ')" + else + echo "❌ Tools list failed: $TOOLS_RESPONSE" + fi + fi +else + echo "❌ MCP initialization failed: $INIT_RESPONSE" +fi + +echo "" +echo "🎉 Test completed! Check the responses above for any issues." +echo "💡 For manual testing, first initialize:" +echo " curl -X POST $BASE_URL/ -H 'Content-Type: application/json' -d '{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{\"roots\":{\"listChanged\":true},\"sampling\":{}}},\"id\":1}'" diff --git a/mcp-servers/go/system-monitor-server/test_coverage.sh b/mcp-servers/go/system-monitor-server/test_coverage.sh new file mode 100755 index 000000000..9d875b41a --- /dev/null +++ b/mcp-servers/go/system-monitor-server/test_coverage.sh @@ -0,0 +1,218 @@ +#!/bin/bash + +# Test Coverage Script for System Monitor Server +# This script runs all tests and generates a comprehensive coverage report + +set -e + +echo "🧪 Running comprehensive test suite for System Monitor Server..." +echo "================================================================" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Function to print colored output +print_status() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +print_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Check if we're in the right directory +if [ ! -f "go.mod" ]; then + print_error "go.mod not found. Please run this script from the project root directory." + exit 1 +fi + +# Clean previous coverage files +print_status "Cleaning previous coverage files..." +rm -f coverage.out coverage.html coverage.txt + +# Create coverage directory +mkdir -p coverage + +# Run tests with coverage for each package +print_status "Running tests with coverage..." + +# Test main package +print_status "Testing main package..." +go test -v -coverprofile=coverage/main.out -covermode=count ./cmd/server/ || { + print_error "Main package tests failed" + exit 1 +} + +# Test config package +print_status "Testing config package..." +go test -v -coverprofile=coverage/config.out -covermode=count ./internal/config/ || { + print_error "Config package tests failed" + exit 1 +} + +# Test metrics package +print_status "Testing metrics package..." +go test -v -coverprofile=coverage/metrics.out -covermode=count ./internal/metrics/ || { + print_error "Metrics package tests failed" + exit 1 +} + +# Test monitor package +print_status "Testing monitor package..." +go test -v -coverprofile=coverage/monitor.out -covermode=count ./internal/monitor/ || { + print_error "Monitor package tests failed" + exit 1 +} + +# Combine coverage profiles +print_status "Combining coverage profiles..." +echo "mode: count" > coverage.out +tail -n +2 coverage/main.out >> coverage.out +tail -n +2 coverage/config.out >> coverage.out +tail -n +2 coverage/metrics.out >> coverage.out +tail -n +2 coverage/monitor.out >> coverage.out + +# Generate coverage report +print_status "Generating coverage report..." + +# HTML coverage report +go tool cover -html=coverage.out -o coverage/coverage.html + +# Post-process HTML to show only filenames in dropdown +print_status "Post-processing HTML report to show only filenames..." +# Replace full paths with just filenames in option tags +sed -i.bak 's|github\.com/IBM/mcp-context-forge/mcp-servers/go/system-monitor-server/[^/]*/\([^/]*\)\.go|\1.go|g' coverage/coverage.html +# Also handle the case where there might be multiple directory levels +sed -i.bak2 's|github\.com/IBM/mcp-context-forge/mcp-servers/go/system-monitor-server/[^/]*/[^/]*/\([^/]*\)\.go|\1.go|g' coverage/coverage.html +rm -f coverage/coverage.html.bak coverage/coverage.html.bak2 +print_success "HTML coverage report generated: coverage/coverage.html" + +# Text coverage report +go tool cover -func=coverage.out > coverage/coverage.txt +print_success "Text coverage report generated: coverage/coverage.txt" + +# Show coverage summary +print_status "Coverage Summary:" +echo "==================" +go tool cover -func=coverage.out | tail -1 + +# Generate detailed coverage by package +print_status "Coverage by Package:" +echo "=====================" +echo "Main Package:" +go tool cover -func=coverage/main.out | tail -1 +echo "Config Package:" +go tool cover -func=coverage/config.out | tail -1 +echo "Metrics Package:" +go tool cover -func=coverage/metrics.out | tail -1 +echo "Monitor Package:" +go tool cover -func=coverage/monitor.out | tail -1 + + +# Run race detection +print_status "Running race detection tests..." +go test -race ./... > coverage/race.txt 2>&1 || { + print_warning "Race detection found issues (check coverage/race.txt)" +} + +# Run tests with different build tags if any +print_status "Running tests with different build tags..." + +# Run tests with verbose output for debugging +print_status "Running tests with verbose output..." +go test -v ./... > coverage/verbose_tests.txt 2>&1 || { + print_warning "Some tests may have failed (check coverage/verbose_tests.txt)" +} + +# Generate test summary +print_status "Generating test summary..." +cat > coverage/test_summary.md << EOF +# Test Coverage Report + +Generated on: $(date) + +## Overall Coverage +\`\`\` +$(go tool cover -func=coverage.out | tail -1) +\`\`\` + +## Package Coverage +- **Main Package**: $(go tool cover -func=coverage/main.out | tail -1 | awk '{print $3}') +- **Config Package**: $(go tool cover -func=coverage/config.out | tail -1 | awk '{print $3}') +- **Metrics Package**: $(go tool cover -func=coverage/metrics.out | tail -1 | awk '{print $3}') +- **Monitor Package**: $(go tool cover -func=coverage/monitor.out | tail -1 | awk '{print $3}') + +## Files Generated +- \`coverage.html\` - Interactive HTML coverage report +- \`coverage.txt\` - Text coverage report +- \`benchmarks.txt\` - Benchmark results +- \`race.txt\` - Race detection results +- \`verbose_tests.txt\` - Verbose test output + +## Test Commands Used +\`\`\`bash +# Run all tests with coverage +go test -v -coverprofile=coverage.out -covermode=count ./... + +# Generate HTML report +go tool cover -html=coverage.out -o coverage.html + +# Generate text report +go tool cover -func=coverage.out > coverage.txt + +# Run benchmarks +go test -bench=. -benchmem ./... + +# Run race detection +go test -race ./... +\`\`\` + +## Coverage Goals +- **Target**: > 80% overall coverage +- **Critical Paths**: > 90% coverage for main handlers and core logic +- **Edge Cases**: All error paths should be tested + +## Notes +- Some tests may fail due to system-specific behavior (e.g., process access) +- Network-dependent tests may fail in isolated environments +- File system tests use temporary files and should clean up automatically +EOF + +print_success "Test summary generated: coverage/test_summary.md" + +# Check if coverage meets minimum threshold +COVERAGE=$(go tool cover -func=coverage.out | tail -1 | awk '{print $3}' | sed 's/%//') +THRESHOLD=80 + +if (( $(echo "$COVERAGE >= $THRESHOLD" | bc -l) )); then + print_success "Coverage $COVERAGE% meets minimum threshold of $THRESHOLD%" +else + print_warning "Coverage $COVERAGE% is below minimum threshold of $THRESHOLD%" +fi + +# Show files that need more coverage +print_status "Files with low coverage:" +go tool cover -func=coverage.out | awk '$3 < 80 {print $1 " " $3 "%"}' | head -10 + +print_success "Test coverage analysis complete!" +print_status "Open coverage/coverage.html in your browser to view the detailed coverage report." + +# List all generated files +echo "" +print_status "Generated files:" +ls -la coverage/ + +echo "" +print_success "🎉 Test coverage report generation complete!"