From 64d3ef26cb8ae5a59c3c24554d33be24f98fd111 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Sun, 26 Oct 2025 20:18:37 -0400 Subject: [PATCH 1/7] Add Docker support with Jib and GitHub Actions CI/CD --- .github/workflows/build-and-publish.yml | 309 ++++++++++++++++++++++++ README.md | 273 ++++++++++++++++++++- build.gradle.kts | 126 ++++++++++ gradle/libs.versions.toml | 4 +- 4 files changed, 704 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/build-and-publish.yml diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml new file mode 100644 index 0000000..c9e805b --- /dev/null +++ b/.github/workflows/build-and-publish.yml @@ -0,0 +1,309 @@ +# GitHub Actions Workflow: Build and Publish +# =========================================== +# +# This workflow builds the Solr MCP Server project and publishes Docker images +# to both GitHub Container Registry (GHCR) and Docker Hub. +# +# Workflow Triggers: +# ------------------ +# 1. Push to 'main' branch - Builds, tests, and publishes Docker images +# 2. Version tags (v*) - Builds and publishes release images with version tags +# 3. Pull requests to 'main' - Only builds and tests (no publishing) +# 4. Manual trigger via workflow_dispatch +# +# Jobs: +# ----- +# 1. build: Compiles the JAR, runs tests, and uploads artifacts +# 2. publish-docker: Publishes multi-platform Docker images using Jib +# +# Published Images: +# ---------------- +# - GitHub Container Registry: ghcr.io/OWNER/solr-mcp-server:TAG +# - Docker Hub: DOCKERHUB_USERNAME/solr-mcp-server:TAG +# +# Image Tagging Strategy: +# ---------------------- +# - Main branch: VERSION-SHORT_SHA (e.g., 0.0.1-SNAPSHOT-a1b2c3d) + latest +# - Version tags: VERSION (e.g., 1.0.0) + latest +# +# Required Secrets (for Docker Hub): +# ---------------------------------- +# - DOCKERHUB_USERNAME: Your Docker Hub username +# - DOCKERHUB_TOKEN: Docker Hub access token (https://hub.docker.com/settings/security) +# +# Note: GitHub Container Registry uses GITHUB_TOKEN automatically (no setup needed) + +name: Build and Publish + +on: + push: + branches: + - main + tags: + - 'v*' # Trigger on version tags like v1.0.0, v2.1.3, etc. + pull_request: + branches: + - main + workflow_dispatch: # Allow manual workflow runs from GitHub UI + +env: + JAVA_VERSION: '25' + JAVA_DISTRIBUTION: 'temurin' + +jobs: + # ============================================================================ + # Job 1: Build JAR + # ============================================================================ + # This job compiles the project, runs tests, and generates build artifacts. + # It runs on all triggers (push, PR, tags, manual). + # + # Outputs: + # - Spring Boot JAR with all dependencies (fat JAR) + # - Plain JAR without dependencies + # - JUnit test results + # - JaCoCo code coverage reports + # ============================================================================ + build: + name: Build JAR + runs-on: ubuntu-latest + + steps: + # Checkout the repository code + - name: Checkout code + uses: actions/checkout@v4 + + # Set up Java Development Kit + # Uses Temurin (Eclipse Adoptium) distribution of OpenJDK 25 + # Gradle cache is enabled to speed up subsequent builds + - name: Set up JDK ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: ${{ env.JAVA_DISTRIBUTION }} + cache: 'gradle' + + # Make the Gradle wrapper executable + # Required on Unix-based systems (Linux, macOS) + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + # Build the project with Gradle + # This runs: compilation, tests, spotless formatting, error-prone checks, + # JaCoCo coverage, and creates the JAR files + - name: Build with Gradle + run: ./gradlew build + + # Upload the compiled JAR files as workflow artifacts + # These can be downloaded from the GitHub Actions UI + # Artifacts are retained for 7 days + - name: Upload JAR artifact + uses: actions/upload-artifact@v4 + with: + name: solr-mcp-server-jar + path: build/libs/solr-mcp-server-*.jar + retention-days: 7 + + # Upload JUnit test results + # if: always() ensures this runs even if the build fails + # This allows viewing test results for failed builds + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: build/test-results/ + retention-days: 7 + + # Upload JaCoCo code coverage report + # if: always() ensures this runs even if tests fail + # Coverage reports help identify untested code paths + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: build/reports/jacoco/ + retention-days: 7 + + # ============================================================================ + # Job 2: Publish Docker Images + # ============================================================================ + # This job builds multi-platform Docker images using Jib and publishes them + # to GitHub Container Registry (GHCR) and Docker Hub. + # + # This job: + # - Only runs after 'build' job succeeds (needs: build) + # - Skips for pull requests (only runs on push to main and tags) + # - Uses Jib to build without requiring Docker daemon + # - Supports multi-platform: linux/amd64 and linux/arm64 + # - Publishes to both GHCR (always) and Docker Hub (if secrets configured) + # + # Security Note: + # - Secrets are passed to Jib CLI arguments for authentication + # - This is required for registry authentication and is handled securely + # - GitHub Actions masks secret values in logs automatically + # ============================================================================ + publish-docker: + name: Publish Docker Images + runs-on: ubuntu-latest + needs: build # Wait for build job to complete successfully + if: github.event_name != 'pull_request' # Skip for PRs + + # Grant permissions for GHCR publishing + # contents:read - Read repository contents + # packages:write - Publish to GitHub Container Registry + permissions: + contents: read + packages: write + + steps: + # Checkout the repository code + - name: Checkout code + uses: actions/checkout@v4 + + # Set up Java for running Jib + # Jib doesn't require Docker but needs Java to run + - name: Set up JDK ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: ${{ env.JAVA_DISTRIBUTION }} + cache: 'gradle' + + # Make Gradle wrapper executable + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + # Extract version and determine image tags + # Outputs: + # - version: Project version from build.gradle.kts + # - tags: Comma-separated list of Docker tags to apply + # - is_release: Whether this is a release build (from version tag) + - name: Extract metadata + id: meta + run: | + # Get version from build.gradle.kts + VERSION=$(grep '^version = ' build.gradle.kts | sed 's/version = "\(.*\)"/\1/') + echo "version=$VERSION" >> $GITHUB_OUTPUT + + # Determine image tags based on trigger type + if [[ "${{ github.ref }}" == refs/tags/v* ]]; then + # For version tags (e.g., v1.0.0), use semantic version + TAG_VERSION=${GITHUB_REF#refs/tags/v} + echo "tags=$TAG_VERSION,latest" >> $GITHUB_OUTPUT + echo "is_release=true" >> $GITHUB_OUTPUT + else + # For main branch, append short commit SHA for traceability + SHORT_SHA=$(echo ${{ github.sha }} | cut -c1-7) + echo "tags=$VERSION-$SHORT_SHA,latest" >> $GITHUB_OUTPUT + echo "is_release=false" >> $GITHUB_OUTPUT + fi + + # Authenticate to GitHub Container Registry + # Uses built-in GITHUB_TOKEN (no configuration needed) + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Authenticate to Docker Hub + # Requires DOCKERHUB_USERNAME and DOCKERHUB_TOKEN secrets + # This step will fail silently if secrets are not configured + # Create a Docker Hub access token, then add two GitHub Actions secrets named `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN`. + # + # Steps (web UI) + # - Create Docker Hub token: + # - Visit `https://hub.docker.com` + # - Account → Settings → Security → New Access Token + # - Copy the generated token (you can’t view it again). + # - Add secrets to the repository: + # - In GitHub, open the repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret` + # - Add secret `DOCKERHUB_USERNAME` with your Docker Hub username. + # - Add secret `DOCKERHUB_TOKEN` with the token from Docker Hub. + # + # Optional + # - To make secrets available to multiple repos, add them at the organization level: Org → `Settings` → `Secrets and variables` → `Actions`. + # - You can also add environment-level secrets if you use GitHub Environments. + # + # CLI example (GitHub CLI) + # ```bash + # gh secret set DOCKERHUB_USERNAME --body "your-docker-username" + # gh secret set DOCKERHUB_TOKEN --body "your-docker-access-token" + # ``` + # + # Note: `GITHUB_TOKEN` is provided automatically for GHCR; do not store it manually. + # - name: Log in to Docker Hub + # uses: docker/login-action@v3 + # with: + # username: ${{ secrets.DOCKERHUB_USERNAME }} + # password: ${{ secrets.DOCKERHUB_TOKEN }} + + # Convert repository owner to lowercase + # Required because container registry names must be lowercase + # Example: "Apache" -> "apache" + - name: Determine repository owner (lowercase) + id: repo + run: | + echo "owner_lc=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT + + # Build and publish images to GitHub Container Registry + # Uses Jib Gradle plugin to build multi-platform images + # Jib creates optimized, layered images without Docker daemon + # Each tag is built and pushed separately + - name: Build and publish to GitHub Container Registry + run: | + TAGS="${{ steps.meta.outputs.tags }}" + IFS=',' read -ra TAG_ARRAY <<< "$TAGS" + + # Build and push each tag to GHCR + # Jib automatically handles multi-platform builds (amd64, arm64) + for TAG in "${TAG_ARRAY[@]}"; do + echo "Building and pushing ghcr.io/${{ steps.repo.outputs.owner_lc }}/solr-mcp-server:$TAG" + ./gradlew jib \ + -Djib.to.image=ghcr.io/${{ steps.repo.outputs.owner_lc }}/solr-mcp-server:$TAG \ + -Djib.to.auth.username=${{ github.actor }} \ + -Djib.to.auth.password=${{ secrets.GITHUB_TOKEN }} + done + + # Build and publish images to Docker Hub + # Only runs if Docker Hub secrets are configured + # Gracefully skips if secrets are not available + - name: Build and publish to Docker Hub + if: secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' + run: | + TAGS="${{ steps.meta.outputs.tags }}" + IFS=',' read -ra TAG_ARRAY <<< "$TAGS" + + # Build and push each tag to Docker Hub + for TAG in "${TAG_ARRAY[@]}"; do + echo "Building and pushing ${{ secrets.DOCKERHUB_USERNAME }}/solr-mcp-server:$TAG" + ./gradlew jib \ + -Djib.to.image=${{ secrets.DOCKERHUB_USERNAME }}/solr-mcp-server:$TAG \ + -Djib.to.auth.username=${{ secrets.DOCKERHUB_USERNAME }} \ + -Djib.to.auth.password=${{ secrets.DOCKERHUB_TOKEN }} + done + + # Create a summary of published images + # Displayed in the GitHub Actions workflow summary page + # Makes it easy to see which images were published and their tags + - name: Summary + run: | + echo "### Docker Images Published :rocket:" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "#### GitHub Container Registry" >> $GITHUB_STEP_SUMMARY + TAGS="${{ steps.meta.outputs.tags }}" + IFS=',' read -ra TAG_ARRAY <<< "$TAGS" + for TAG in "${TAG_ARRAY[@]}"; do + echo "- \`ghcr.io/${{ steps.repo.outputs.owner_lc }}/solr-mcp-server:$TAG\`" >> $GITHUB_STEP_SUMMARY + done + + # Only show Docker Hub section if secrets are configured + if [[ "${{ secrets.DOCKERHUB_USERNAME }}" != "" ]]; then + echo "" >> $GITHUB_STEP_SUMMARY + echo "#### Docker Hub" >> $GITHUB_STEP_SUMMARY + for TAG in "${TAG_ARRAY[@]}"; do + echo "- \`${{ secrets.DOCKERHUB_USERNAME }}/solr-mcp-server:$TAG\`" >> $GITHUB_STEP_SUMMARY + done + fi \ No newline at end of file diff --git a/README.md b/README.md index 78188a3..457b243 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,18 @@ +[![Project Status: Incubating](https://img.shields.io/badge/status-incubating-yellow.svg)](https://github.com/apache/solr-mcp) + # Solr MCP Server -A Spring AI Model Context Protocol (MCP) server that provides tools for interacting with Apache Solr. This server enables AI assistants like Claude to search, index, and manage Solr collections through the MCP protocol. +A Spring AI Model Context Protocol (MCP) server that provides tools for interacting with Apache Solr. This server +enables AI assistants like Claude to search, index, and manage Solr collections through the MCP protocol. ## Overview -This project provides a set of tools that allow AI assistants to interact with Apache Solr, a powerful open-source search platform. By implementing the Spring AI MCP protocol, these tools can be used by any MCP-compatible client, including Claude Desktop. The project uses SolrJ, the official Java client for Solr, to communicate with Solr instances. +This project provides a set of tools that allow AI assistants to interact with Apache Solr, a powerful open-source +search platform. By implementing the Spring AI MCP protocol, these tools can be used by any MCP-compatible client, +including Claude Desktop. The project uses SolrJ, the official Java client for Solr, to communicate with Solr instances. The server provides the following capabilities: + - Search Solr collections with advanced query options - Index documents into Solr collections - Manage and monitor Solr collections @@ -43,6 +49,7 @@ docker-compose up -d ``` This will start a Solr instance in SolrCloud mode with ZooKeeper and create two sample collections: + - `books` - A collection with sample book data - `films` - A collection with sample film data @@ -67,6 +74,127 @@ The build produces two JAR files in `build/libs/`: - `solr-mcp-server-0.0.1-SNAPSHOT.jar` - Executable JAR with all dependencies (fat JAR) - `solr-mcp-server-0.0.1-SNAPSHOT-plain.jar` - Plain JAR without dependencies +### 4. Building Docker Images (Optional) + +This project uses [Jib](https://github.com/GoogleContainerTools/jib) to build optimized Docker images without requiring +Docker installed. Jib creates layered images for faster rebuilds and smaller image sizes. + +#### Option 1: Build to Docker Daemon (Recommended) + +Build directly to your local Docker daemon (requires Docker installed): + +```bash +./gradlew jibDockerBuild +``` + +This creates a local Docker image: `solr-mcp-server:0.0.1-SNAPSHOT` + +Verify the image: + +```bash +docker images | grep solr-mcp-server +``` + +#### Option 2: Build to Tar File (No Docker Required) + +Build to a tar file without Docker installed: + +```bash +./gradlew jibBuildTar +``` + +This creates `build/jib-image.tar`. Load it into Docker: + +```bash +docker load < build/jib-image.tar +``` + +#### Option 3: Push to Docker Hub + +Authenticate with Docker Hub and push: + +```bash +# Login to Docker Hub +docker login + +# Build and push +./gradlew jib -Djib.to.image=YOUR_DOCKERHUB_USERNAME/solr-mcp-server:0.0.1-SNAPSHOT +``` + +#### Option 4: Push to GitHub Container Registry + +Authenticate with GitHub Container Registry and push: + +```bash +# Create a Personal Access Token (classic) with write:packages scope at: +# https://github.com/settings/tokens + +# Login to GitHub Container Registry +export GITHUB_TOKEN=YOUR_GITHUB_TOKEN +echo $GITHUB_TOKEN | docker login ghcr.io -u YOUR_GITHUB_USERNAME --password-stdin + +# Build and push +./gradlew jib -Djib.to.image=ghcr.io/YOUR_GITHUB_USERNAME/solr-mcp-server:0.0.1-SNAPSHOT +``` + +#### Multi-Platform Support + +The Docker images are built with multi-platform support for: + +- `linux/amd64` (Intel/AMD 64-bit) +- `linux/arm64` (Apple Silicon M1/M2/M3) + +#### Automated Builds with GitHub Actions + +This project includes a GitHub Actions workflow that automatically builds and publishes Docker images to both GitHub +Container Registry and Docker Hub. + +**Triggers:** + +- Push to `main` branch - Builds and publishes images tagged with `version-SHA` and `latest` +- Version tags (e.g., `v1.0.0`) - Builds and publishes images tagged with the version number and `latest` +- Pull requests - Builds and tests only (no publishing) + +**Published Images:** + +- GitHub Container Registry: `ghcr.io/OWNER/solr-mcp-server:TAG` +- Docker Hub: `DOCKERHUB_USERNAME/solr-mcp-server:TAG` + +**Setup for Docker Hub Publishing:** + +To enable Docker Hub publishing, configure these repository secrets: + +1. Go to your GitHub repository Settings > Secrets and variables > Actions +2. Add the following secrets: + - `DOCKERHUB_USERNAME`: Your Docker Hub username + - `DOCKERHUB_TOKEN`: Docker Hub access token (create at https://hub.docker.com/settings/security) + +**Note:** GitHub Container Registry publishing works automatically using the `GITHUB_TOKEN` provided by GitHub Actions. + +#### Running the Docker Container + +Run the container with STDIO mode: + +```bash +docker run -i --rm solr-mcp-server:0.0.1-SNAPSHOT +``` + +Or with custom Solr URL: + +```bash +docker run -i --rm \ + -e SOLR_URL=http://your-solr-host:8983/solr/ \ + solr-mcp-server:0.0.1-SNAPSHOT +``` + +**Note for Linux users:** If you need to connect to Solr running on the host machine, add the `--add-host` flag: + +```bash +docker run -i --rm \ + --add-host=host.docker.internal:host-gateway \ + solr-mcp-server:0.0.1-SNAPSHOT +``` + ## Project Structure The codebase follows a clean, modular architecture organized by functionality: @@ -108,7 +236,7 @@ src/main/java/org/apache/solr/mcp/server/ - **Configuration**: Spring Boot configuration using properties files - `application.properties` - Default configuration - `application-stdio.properties` - STDIO transport profile - - `application-http.properties` - HTTP transport profile + - `application-http.properties` - HTTP transport profile - **Document Creators**: Strategy pattern implementation for parsing different document formats - Automatically sanitizes field names to comply with Solr schema requirements @@ -194,7 +322,9 @@ Parameters: ## Adding to Claude Desktop -To add this MCP server to Claude Desktop: +You can add this MCP server to Claude Desktop using either the JAR file or Docker container. + +### Option 1: Using JAR File 1. Build the project as a standalone JAR: @@ -220,13 +350,118 @@ To add this MCP server to Claude Desktop: "PROFILES": "stdio" } } - } + } } ``` **Note:** Replace `/absolute/path/to/solr-mcp-server` with the actual path to your project directory. -### 4. Restart Claude Desktop & Invoke +### Option 2: Using Docker Container + +1. Build the Docker image: + +```bash +./gradlew jibDockerBuild +``` + +2. In Claude Desktop, go to Settings > Developer > Edit Config + +3. Add the following configuration to your MCP settings: + +```json +{ + "mcpServers": { + "solr-search-mcp": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "solr-mcp-server:0.0.1-SNAPSHOT" + ], + "env": { + "SOLR_URL": "http://localhost:8983/solr/" + } + } + } +} +``` + +**Note for macOS/Windows users:** Docker Desktop automatically provides `host.docker.internal` for accessing services on +the host machine. The container is pre-configured to use this. + +**Note for Linux users:** You need to add the `--add-host` flag to enable communication with services running on the +host: + +```json +{ + "mcpServers": { + "solr-search-mcp": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "--add-host=host.docker.internal:host-gateway", + "solr-mcp-server:0.0.1-SNAPSHOT" + ], + "env": { + "SOLR_URL": "http://host.docker.internal:8983/solr/" + } + } + } +} +``` + +### Using a Public Docker Image + +If you've pushed the image to Docker Hub or GitHub Container Registry, you can use it directly: + +#### Docker Hub + +```json +{ + "mcpServers": { + "solr-search-mcp": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "YOUR_DOCKERHUB_USERNAME/solr-mcp-server:0.0.1-SNAPSHOT" + ], + "env": { + "SOLR_URL": "http://localhost:8983/solr/" + } + } + } +} +``` + +#### GitHub Container Registry + +```json +{ + "mcpServers": { + "solr-search-mcp": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "ghcr.io/YOUR_GITHUB_USERNAME/solr-mcp-server:0.0.1-SNAPSHOT" + ], + "env": { + "SOLR_URL": "http://localhost:8983/solr/" + } + } + } +} +``` + +### Restart Claude Desktop & Invoke + +After configuring, restart Claude Desktop to load the MCP server. ![claude-stdio.png](images/claude-stdio.png) @@ -440,12 +675,36 @@ controls: If you encounter issues: -1. Ensure Solr is running and accessible. By default, the server connects to http://localhost:8983/solr/, but you can set the `SOLR_URL` environment variable to point to a different Solr instance. +1. Ensure Solr is running and accessible. By default, the server connects to http://localhost:8983/solr/, but you can + set the `SOLR_URL` environment variable to point to a different Solr instance. 2. Check the logs for any error messages 3. Verify that the collections exist using the Solr Admin UI 4. If using HTTP mode, ensure the server is running on the expected port (default: 8080) 5. For STDIO mode with Claude Desktop, verify the JAR path is absolute and correct in the configuration +## FAQ + +### Why use Jib instead of Spring Boot Buildpacks? + +This project uses [Jib](https://github.com/GoogleContainerTools/jib) for building Docker images instead of Spring Boot +Buildpacks for a critical compatibility reason: + +**STDIO Mode Compatibility**: Docker images built with Spring Boot Buildpacks were outputting logs and diagnostic +information to stdout, which interfered with the MCP protocol's STDIO transport. The MCP protocol requires a clean +stdout channel for protocol messages - any extraneous output causes connection errors and prevents the server from +working properly with MCP clients like Claude Desktop. + +Jib provides additional benefits: + +- **Clean stdout**: Jib-built images don't pollute stdout with build information or runtime logs +- **No Docker daemon required**: Jib can build images without Docker installed +- **Faster builds**: Layered image building with better caching +- **Smaller images**: More efficient layer organization +- **Multi-platform support**: Easy cross-platform image building for amd64 and arm64 + +If you're building an MCP server with Docker support, ensure your containerization approach maintains a clean stdout +channel when running in STDIO mode. + ## License This project is licensed under the Apache License 2.0. diff --git a/build.gradle.kts b/build.gradle.kts index 96fd723..d0a768b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.spring.dependency.management) jacoco alias(libs.plugins.errorprone) + alias(libs.plugins.jib) } group = "org.apache.solr" @@ -77,4 +78,129 @@ tasks.withType().configureEach { option("NullAway:OnlyNullMarked", "true") // Enable nullness checks only in null-marked code error("NullAway") // bump checks from warnings (default) to errors } +} + +// Jib Plugin Configuration +// ========================= +// Jib is a Gradle plugin that builds optimized Docker images without requiring Docker installed. +// It creates layered images for faster rebuilds and smaller image sizes. +// +// Key features: +// - Multi-platform support (amd64 and arm64) +// - No Docker daemon required +// - Reproducible builds +// - Optimized layering for faster deployments +// +// Building Images: +// ---------------- +// 1. Build to Docker daemon (requires Docker installed): +// ./gradlew jibDockerBuild +// Creates image: solr-mcp-server:0.0.1-SNAPSHOT +// +// 2. Build to local tar file (no Docker required): +// ./gradlew jibBuildTar +// Creates: build/jib-image.tar +// Load with: docker load < build/jib-image.tar +// +// 3. Push to Docker Hub (requires authentication): +// docker login +// ./gradlew jib -Djib.to.image=dockerhub-username/solr-mcp-server:0.0.1-SNAPSHOT +// +// 4. Push to GitHub Container Registry (requires authentication): +// echo $GITHUB_TOKEN | docker login ghcr.io -u GITHUB_USERNAME --password-stdin +// ./gradlew jib -Djib.to.image=ghcr.io/github-username/solr-mcp-server:0.0.1-SNAPSHOT +// +// Authentication: +// --------------- +// For Docker Hub: +// docker login +// +// For GitHub Container Registry: +// Create a Personal Access Token (classic) with write:packages scope at: +// https://github.com/settings/tokens +// Then authenticate: +// echo YOUR_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin +// +// Alternative: Set credentials in ~/.gradle/gradle.properties: +// jib.to.auth.username=YOUR_USERNAME +// jib.to.auth.password=YOUR_TOKEN_OR_PASSWORD +// +// Environment Variables: +// ---------------------- +// The container is pre-configured with: +// - SPRING_DOCKER_COMPOSE_ENABLED=false (Docker Compose disabled in container) +// - SOLR_URL=http://host.docker.internal:8983/solr/ (default Solr connection) +// +// These can be overridden at runtime: +// docker run -e SOLR_URL=http://custom-solr:8983/solr/ solr-mcp-server:0.0.1-SNAPSHOT +jib { + from { + // Use Eclipse Temurin JRE 25 as the base image + // Temurin is the open-source build of OpenJDK from Adoptium + image = "eclipse-temurin:25-jre" + + // Multi-platform support for both AMD64 and ARM64 architectures + // This allows the image to run on x86_64 machines and Apple Silicon (M1/M2/M3) + platforms { + platform { + architecture = "amd64" + os = "linux" + } + platform { + architecture = "arm64" + os = "linux" + } + } + } + + to { + // Default image name (can be overridden with -Djib.to.image=...) + // Format: repository/image-name:tag + image = "solr-mcp-server:${version}" + + // Tags to apply to the image + // The version tag is applied by default, plus "latest" tag + tags = setOf("latest") + } + + container { + // Container environment variables + // These are baked into the image but can be overridden at runtime + environment = mapOf( + // Disable Spring Boot Docker Compose support when running in container + "SPRING_DOCKER_COMPOSE_ENABLED" to "false", + + // Default Solr URL using host.docker.internal to reach host machine + // On Linux, use --add-host=host.docker.internal:host-gateway + "SOLR_URL" to "http://host.docker.internal:8983/solr/" + ) + + // JVM flags for containerized environments + // These optimize the JVM for running in containers + jvmFlags = listOf( + // Use container-aware memory settings + "-XX:+UseContainerSupport", + // Set max RAM percentage (default 75%) + "-XX:MaxRAMPercentage=75.0" + ) + + // Main class to run (auto-detected from Spring Boot plugin) + // mainClass is automatically set by Spring Boot Gradle plugin + + // Port exposures (for documentation purposes) + // The application doesn't expose ports by default (STDIO mode) + // If running in HTTP mode, the port would be 8080 + ports = listOf("8080") + + // Labels for image metadata + labels.set( + mapOf( + "org.opencontainers.image.title" to "Solr MCP Server", + "org.opencontainers.image.description" to "Spring AI MCP Server for Apache Solr", + "org.opencontainers.image.version" to version.toString(), + "org.opencontainers.image.vendor" to "Apache Software Foundation", + "org.opencontainers.image.licenses" to "Apache-2.0" + ) + ) + } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index da81ac9..b1a353d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ spring-boot = "3.5.6" spring-dependency-management = "1.1.7" errorprone-plugin = "4.2.0" +jib = "3.4.5" # Main dependencies spring-ai = "1.1.0-M3" @@ -79,4 +80,5 @@ errorprone = [ [plugins] spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" } spring-dependency-management = { id = "io.spring.dependency-management", version.ref = "spring-dependency-management" } -errorprone = { id = "net.ltgt.errorprone", version.ref = "errorprone-plugin" } \ No newline at end of file +errorprone = { id = "net.ltgt.errorprone", version.ref = "errorprone-plugin" } +jib = { id = "com.google.cloud.tools.jib", version.ref = "jib" } \ No newline at end of file From 4adefba837b5e5653f52c89bd1d1a1d8505a1708 Mon Sep 17 00:00:00 2001 From: Chris Hostetter Date: Mon, 27 Oct 2025 09:22:44 -0700 Subject: [PATCH 2/7] Minimal .asf.yaml to get correct github notifications in place --- .asf.yaml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .asf.yaml diff --git a/.asf.yaml b/.asf.yaml new file mode 100644 index 0000000..1c53387 --- /dev/null +++ b/.asf.yaml @@ -0,0 +1,35 @@ +# https://cwiki.apache.org/confluence/display/INFRA/Git+-+.asf.yaml+features + +github: + description: "Solr MCP Server" + homepage: https://solr.apache.org/ + labels: + - lucene + - solr + - search + - java + - mcp + + enabled_merge_buttons: + squash: true + merge: false + rebase: false + + # TODO: Add to this list for each new minor release + protected_branches: + main: {} + + protected_tags: + - "releases/*" + + autolink_jira: + - SOLR + + collaborators: + - solrbot + +notifications: + commits: commits@solr.apache.org + issues: issues@solr.apache.org + pullrequests: issues@solr.apache.org + jira_options: link label worklog From 0db92d15827f12dbde68a637cd309f252148cd8a Mon Sep 17 00:00:00 2001 From: Aditya Parikh Date: Tue, 28 Oct 2025 17:27:02 -0400 Subject: [PATCH 3/7] Apply spotless plugin (#5) --- build.gradle.kts | 23 +- gradle/libs.versions.toml | 4 +- .../java/org/apache/solr/mcp/server/Main.java | 123 +-- .../solr/mcp/server/config/SolrConfig.java | 136 ++-- .../config/SolrConfigurationProperties.java | 106 +-- .../mcp/server/indexing/IndexingService.java | 347 +++++---- .../documentcreator/CsvDocumentCreator.java | 71 +- .../DocumentProcessingException.java | 25 +- .../documentcreator/FieldNameSanitizer.java | 67 +- .../IndexingDocumentCreator.java | 55 +- .../documentcreator/JsonDocumentCreator.java | 124 +-- .../documentcreator/SolrDocumentCreator.java | 74 +- .../documentcreator/XmlDocumentCreator.java | 171 ++--- .../server/metadata/CollectionService.java | 720 +++++++++--------- .../mcp/server/metadata/CollectionUtils.java | 186 ++--- .../apache/solr/mcp/server/metadata/Dtos.java | 481 ++++++------ .../mcp/server/metadata/SchemaService.java | 184 ++--- .../apache/solr/mcp/server/package-info.java | 2 +- .../mcp/server/search/SearchResponse.java | 91 +-- .../solr/mcp/server/search/SearchService.java | 261 ++++--- .../apache/solr/mcp/server/ClientHttp.java | 3 +- .../apache/solr/mcp/server/ClientStdio.java | 18 +- .../org/apache/solr/mcp/server/MainTest.java | 19 +- .../mcp/server/McpToolRegistrationTest.java | 155 ++-- .../apache/solr/mcp/server/SampleClient.java | 227 +++--- .../server/TestcontainersConfiguration.java | 10 +- .../mcp/server/config/SolrConfigTest.java | 76 +- .../mcp/server/indexing/CsvIndexingTest.java | 102 +-- .../indexing/IndexingServiceDirectTest.java | 57 +- .../server/indexing/IndexingServiceTest.java | 229 +++--- .../mcp/server/indexing/XmlIndexingTest.java | 142 ++-- .../CollectionServiceIntegrationTest.java | 103 ++- .../metadata/CollectionServiceTest.java | 111 +-- .../server/metadata/CollectionUtilsTest.java | 15 +- .../SchemaServiceIntegrationTest.java | 115 +-- .../server/metadata/SchemaServiceTest.java | 86 ++- .../search/SearchServiceDirectTest.java | 53 +- .../mcp/server/search/SearchServiceTest.java | 344 +++++---- 38 files changed, 2780 insertions(+), 2336 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 96fd723..363308b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.spring.dependency.management) jacoco alias(libs.plugins.errorprone) + alias(libs.plugins.spotless) } group = "org.apache.solr" @@ -77,4 +78,24 @@ tasks.withType().configureEach { option("NullAway:OnlyNullMarked", "true") // Enable nullness checks only in null-marked code error("NullAway") // bump checks from warnings (default) to errors } -} \ No newline at end of file +} + +tasks.build { + dependsOn(tasks.spotlessApply) +} + +spotless { + java { + target("src/**/*.java") + googleJavaFormat().aosp().reflowLongStrings() + removeUnusedImports() + trimTrailingWhitespace() + endWithNewline() + importOrder() + formatAnnotations() + } + kotlinGradle { + target("*.gradle.kts") + ktlint() + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index da81ac9..9de2429 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ spring-boot = "3.5.6" spring-dependency-management = "1.1.7" errorprone-plugin = "4.2.0" +spotless = "7.0.2" # Main dependencies spring-ai = "1.1.0-M3" @@ -79,4 +80,5 @@ errorprone = [ [plugins] spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" } spring-dependency-management = { id = "io.spring.dependency-management", version.ref = "spring-dependency-management" } -errorprone = { id = "net.ltgt.errorprone", version.ref = "errorprone-plugin" } \ No newline at end of file +errorprone = { id = "net.ltgt.errorprone", version.ref = "errorprone-plugin" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } \ No newline at end of file diff --git a/src/main/java/org/apache/solr/mcp/server/Main.java b/src/main/java/org/apache/solr/mcp/server/Main.java index 42ec12b..ed4c4d7 100644 --- a/src/main/java/org/apache/solr/mcp/server/Main.java +++ b/src/main/java/org/apache/solr/mcp/server/Main.java @@ -25,57 +25,65 @@ /** * Main Spring Boot application class for the Apache Solr Model Context Protocol (MCP) Server. - * - *

This class serves as the entry point for the Solr MCP Server application, which provides - * a bridge between AI clients (such as Claude Desktop) and Apache Solr search and indexing - * capabilities through the Model Context Protocol specification.

- * - *

Application Architecture:

+ * + *

This class serves as the entry point for the Solr MCP Server application, which provides a + * bridge between AI clients (such as Claude Desktop) and Apache Solr search and indexing + * capabilities through the Model Context Protocol specification. + * + *

Application Architecture: + * *

The application follows a service-oriented architecture where each major Solr operation - * category is encapsulated in its own service class:

+ * category is encapsulated in its own service class: + * *
    - *
  • SearchService: Search operations, faceting, sorting, pagination
  • - *
  • IndexingService: Document indexing, schema-less ingestion, batch processing
  • - *
  • CollectionService: Collection management, metrics, health monitoring
  • - *
  • SchemaService: Schema introspection and field management
  • + *
  • SearchService: Search operations, faceting, sorting, pagination + *
  • IndexingService: Document indexing, schema-less ingestion, batch + * processing + *
  • CollectionService: Collection management, metrics, health monitoring + *
  • SchemaService: Schema introspection and field management *
- * - *

Spring Boot Features:

+ * + *

Spring Boot Features: + * *

    - *
  • Auto-Configuration: Automatic setup of Solr client and service beans
  • - *
  • Property Management: Externalized configuration through application.properties
  • - *
  • Dependency Injection: Automatic wiring of service dependencies
  • - *
  • Component Scanning: Automatic discovery of service classes
  • + *
  • Auto-Configuration: Automatic setup of Solr client and service beans + *
  • Property Management: Externalized configuration through + * application.properties + *
  • Dependency Injection: Automatic wiring of service dependencies + *
  • Component Scanning: Automatic discovery of service classes *
- * - *

Communication Flow:

+ * + *

Communication Flow: + * *

    - *
  1. AI client connects to MCP server via stdio
  2. - *
  3. Client discovers available tools through MCP protocol
  4. - *
  5. Client invokes tools with natural language parameters
  6. - *
  7. Server routes requests to appropriate service methods
  8. - *
  9. Services interact with Solr via SolrJ client library
  10. - *
  11. Results are serialized and returned to AI client
  12. + *
  13. AI client connects to MCP server via stdio + *
  14. Client discovers available tools through MCP protocol + *
  15. Client invokes tools with natural language parameters + *
  16. Server routes requests to appropriate service methods + *
  17. Services interact with Solr via SolrJ client library + *
  18. Results are serialized and returned to AI client *
- * - *

Configuration Requirements:

- *

The application requires the following configuration properties:

+ * + *

Configuration Requirements: + * + *

The application requires the following configuration properties: + * *

{@code
  * # application.properties
  * solr.url=http://localhost:8983
  * }
- * - *

Deployment Considerations:

+ * + *

Deployment Considerations: + * *

    - *
  • Ensure Solr server is running and accessible at configured URL
  • - *
  • Verify network connectivity between MCP server and Solr
  • - *
  • Configure appropriate timeouts for production workloads
  • - *
  • Monitor application logs for connection and performance issues
  • + *
  • Ensure Solr server is running and accessible at configured URL + *
  • Verify network connectivity between MCP server and Solr + *
  • Configure appropriate timeouts for production workloads + *
  • Monitor application logs for connection and performance issues *
* * @version 0.0.1 * @since 0.0.1 - * * @see SearchService * @see IndexingService * @see CollectionService @@ -87,35 +95,36 @@ public class Main { /** * Main application entry point that starts the Spring Boot application. - * - *

This method initializes the Spring application context, configures all - * service beans, establishes Solr connectivity, and begins listening for - * MCP client connections via standard input/output.

- * - *

Startup Process:

+ * + *

This method initializes the Spring application context, configures all service beans, + * establishes Solr connectivity, and begins listening for MCP client connections via standard + * input/output. + * + *

Startup Process: + * *

    - *
  1. Initialize Spring Boot application context
  2. - *
  3. Load configuration properties from various sources
  4. - *
  5. Create and configure SolrClient bean
  6. - *
  7. Initialize all service beans with dependency injection
  8. - *
  9. Register MCP tools from service methods
  10. - *
  11. Start MCP server listening on stdio
  12. + *
  13. Initialize Spring Boot application context + *
  14. Load configuration properties from various sources + *
  15. Create and configure SolrClient bean + *
  16. Initialize all service beans with dependency injection + *
  17. Register MCP tools from service methods + *
  18. Start MCP server listening on stdio *
- * - *

Error Handling:

- *

Startup failures typically indicate configuration issues such as:

+ * + *

Error Handling: + * + *

Startup failures typically indicate configuration issues such as: + * *

    - *
  • Missing or invalid Solr URL configuration
  • - *
  • Network connectivity issues to Solr server
  • - *
  • Missing required dependencies or classpath issues
  • + *
  • Missing or invalid Solr URL configuration + *
  • Network connectivity issues to Solr server + *
  • Missing required dependencies or classpath issues *
- * + * * @param args command-line arguments passed to the application - * * @see SpringApplication#run(Class, String...) */ public static void main(String[] args) { SpringApplication.run(Main.class, args); } - -} \ No newline at end of file +} diff --git a/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java b/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java index 610817e..d4e49cb 100644 --- a/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java +++ b/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java @@ -16,63 +16,67 @@ */ package org.apache.solr.mcp.server.config; +import java.util.concurrent.TimeUnit; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.impl.Http2SolrClient; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import java.util.concurrent.TimeUnit; - /** * Spring Configuration class for Apache Solr client setup and connection management. - * - *

This configuration class is responsible for creating and configuring the SolrJ client - * that serves as the primary interface for communication with Apache Solr servers. It handles - * URL normalization, connection parameters, and timeout configurations to ensure reliable - * connectivity for the MCP server operations.

- * - *

Configuration Features:

+ * + *

This configuration class is responsible for creating and configuring the SolrJ client that + * serves as the primary interface for communication with Apache Solr servers. It handles URL + * normalization, connection parameters, and timeout configurations to ensure reliable connectivity + * for the MCP server operations. + * + *

Configuration Features: + * *

    - *
  • Automatic URL Normalization: Ensures proper Solr URL formatting
  • - *
  • Connection Timeout Management: Configurable timeouts for reliability
  • - *
  • Property Integration: Uses externalized configuration through properties
  • - *
  • Production-Ready Defaults: Optimized timeout values for production use
  • + *
  • Automatic URL Normalization: Ensures proper Solr URL formatting + *
  • Connection Timeout Management: Configurable timeouts for reliability + *
  • Property Integration: Uses externalized configuration through properties + *
  • Production-Ready Defaults: Optimized timeout values for production use *
- * - *

URL Processing:

- *

The configuration automatically normalizes Solr URLs to ensure proper communication:

+ * + *

URL Processing: + * + *

The configuration automatically normalizes Solr URLs to ensure proper communication: + * *

    - *
  • Adds trailing slashes if missing
  • - *
  • Appends "/solr/" path if not present in the URL
  • - *
  • Handles various URL formats (with/without protocols, paths, etc.)
  • + *
  • Adds trailing slashes if missing + *
  • Appends "/solr/" path if not present in the URL + *
  • Handles various URL formats (with/without protocols, paths, etc.) *
- * - *

Connection Parameters:

+ * + *

Connection Parameters: + * *

    - *
  • Connection Timeout: 10 seconds (10,000ms) for establishing connections
  • - *
  • Socket Timeout: 60 seconds (60,000ms) for read operations
  • + *
  • Connection Timeout: 10 seconds (10,000ms) for establishing connections + *
  • Socket Timeout: 60 seconds (60,000ms) for read operations *
- * - *

Configuration Example:

+ * + *

Configuration Example: + * *

{@code
  * # application.properties
  * solr.url=http://localhost:8983
- * 
+ *
  * # Results in normalized URL: http://localhost:8983/solr/
  * }
- * - *

Supported URL Formats:

+ * + *

Supported URL Formats: + * *

    - *
  • {@code http://localhost:8983} → {@code http://localhost:8983/solr/}
  • - *
  • {@code http://localhost:8983/} → {@code http://localhost:8983/solr/}
  • - *
  • {@code http://localhost:8983/solr} → {@code http://localhost:8983/solr/}
  • - *
  • {@code http://localhost:8983/solr/} → {@code http://localhost:8983/solr/} (unchanged)
  • + *
  • {@code http://localhost:8983} → {@code http://localhost:8983/solr/} + *
  • {@code http://localhost:8983/} → {@code http://localhost:8983/solr/} + *
  • {@code http://localhost:8983/solr} → {@code http://localhost:8983/solr/} + *
  • {@code http://localhost:8983/solr/} → {@code http://localhost:8983/solr/} (unchanged) *
* * @version 0.0.1 * @since 0.0.1 - * * @see SolrConfigurationProperties * @see Http2SolrClient * @see org.springframework.boot.context.properties.EnableConfigurationProperties @@ -87,44 +91,48 @@ public class SolrConfig { /** * Creates and configures a SolrClient bean for Apache Solr communication. - * - *

This method serves as the primary factory for creating SolrJ client instances - * that are used throughout the application for all Solr operations. It performs - * automatic URL normalization and applies production-ready timeout configurations.

- * - *

URL Normalization Process:

+ * + *

This method serves as the primary factory for creating SolrJ client instances that are + * used throughout the application for all Solr operations. It performs automatic URL + * normalization and applies production-ready timeout configurations. + * + *

URL Normalization Process: + * *

    - *
  1. Trailing Slash: Ensures URL ends with "/"
  2. - *
  3. Solr Path: Appends "/solr/" if not already present
  4. - *
  5. Validation: Checks for proper Solr endpoint format
  6. + *
  7. Trailing Slash: Ensures URL ends with "/" + *
  8. Solr Path: Appends "/solr/" if not already present + *
  9. Validation: Checks for proper Solr endpoint format *
- * - *

Connection Configuration:

+ * + *

Connection Configuration: + * *

    - *
  • Connection Timeout: 10,000ms - Time to establish initial connection
  • - *
  • Socket Timeout: 60,000ms - Time to wait for data/response
  • + *
  • Connection Timeout: 10,000ms - Time to establish initial connection + *
  • Socket Timeout: 60,000ms - Time to wait for data/response *
- * - *

Client Type:

- *

Creates an {@code HttpSolrClient} configured for standard HTTP-based communication - * with Solr servers. This client type is suitable for both standalone Solr instances - * and SolrCloud deployments when used with load balancers.

- * - *

Error Handling:

- *

URL normalization is defensive and handles various input formats gracefully. - * Invalid URLs or connection failures will be caught during application startup - * or first usage, providing clear error messages for troubleshooting.

- * - *

Production Considerations:

+ * + *

Client Type: + * + *

Creates an {@code HttpSolrClient} configured for standard HTTP-based communication with + * Solr servers. This client type is suitable for both standalone Solr instances and SolrCloud + * deployments when used with load balancers. + * + *

Error Handling: + * + *

URL normalization is defensive and handles various input formats gracefully. Invalid URLs + * or connection failures will be caught during application startup or first usage, providing + * clear error messages for troubleshooting. + * + *

Production Considerations: + * *

    - *
  • Timeout values are optimized for production workloads
  • - *
  • Connection pooling is handled by the HttpSolrClient internally
  • - *
  • Client is thread-safe and suitable for concurrent operations
  • + *
  • Timeout values are optimized for production workloads + *
  • Connection pooling is handled by the HttpSolrClient internally + *
  • Client is thread-safe and suitable for concurrent operations *
- * + * * @param properties the injected Solr configuration properties containing connection URL * @return configured SolrClient instance ready for use in application services - * * @see Http2SolrClient.Builder * @see SolrConfigurationProperties#url() */ @@ -153,4 +161,4 @@ SolrClient solrClient(SolrConfigurationProperties properties) { .withIdleTimeout(SOCKET_TIMEOUT_MS, TimeUnit.MILLISECONDS) .build(); } -} \ No newline at end of file +} diff --git a/src/main/java/org/apache/solr/mcp/server/config/SolrConfigurationProperties.java b/src/main/java/org/apache/solr/mcp/server/config/SolrConfigurationProperties.java index 20f2fb5..abbaf2f 100644 --- a/src/main/java/org/apache/solr/mcp/server/config/SolrConfigurationProperties.java +++ b/src/main/java/org/apache/solr/mcp/server/config/SolrConfigurationProperties.java @@ -20,76 +20,78 @@ /** * Spring Boot Configuration Properties record for Apache Solr connection settings. - * - *

This immutable configuration record encapsulates all external configuration - * properties required for establishing and maintaining connections to Apache Solr - * servers. It follows Spring Boot's type-safe configuration properties pattern - * using Java records for enhanced immutability and reduced boilerplate.

- * - *

Configuration Binding:

- *

This record automatically binds to configuration properties with the "solr" - * prefix from various configuration sources including:

+ * + *

This immutable configuration record encapsulates all external configuration properties + * required for establishing and maintaining connections to Apache Solr servers. It follows Spring + * Boot's type-safe configuration properties pattern using Java records for enhanced immutability + * and reduced boilerplate. + * + *

Configuration Binding: + * + *

This record automatically binds to configuration properties with the "solr" prefix from + * various configuration sources including: + * *

    - *
  • application.properties: {@code solr.url=http://localhost:8983}
  • - *
  • application.yml: {@code solr: url: http://localhost:8983}
  • - *
  • Environment Variables: {@code SOLR_URL=http://localhost:8983}
  • - *
  • Command Line Arguments: {@code --solr.url=http://localhost:8983}
  • + *
  • application.properties: {@code solr.url=http://localhost:8983} + *
  • application.yml: {@code solr: url: http://localhost:8983} + *
  • Environment Variables: {@code SOLR_URL=http://localhost:8983} + *
  • Command Line Arguments: {@code --solr.url=http://localhost:8983} *
- * - *

Record Benefits:

+ * + *

Record Benefits: + * *

    - *
  • Immutability: Properties cannot be modified after construction
  • - *
  • Type Safety: Compile-time validation of property types
  • - *
  • Automatic Generation: Constructor, getters, equals, hashCode, toString
  • - *
  • Validation Support: Compatible with Spring Boot validation annotations
  • + *
  • Immutability: Properties cannot be modified after construction + *
  • Type Safety: Compile-time validation of property types + *
  • Automatic Generation: Constructor, getters, equals, hashCode, toString + *
  • Validation Support: Compatible with Spring Boot validation annotations *
- * - *

URL Format Requirements:

- *

The Solr URL should point to the base Solr server endpoint. The configuration - * system will automatically normalize URLs to ensure proper formatting:

+ * + *

URL Format Requirements: + * + *

The Solr URL should point to the base Solr server endpoint. The configuration system will + * automatically normalize URLs to ensure proper formatting: + * *

    - *
  • Valid Examples:
  • - *
      - *
    • {@code http://localhost:8983}
    • - *
    • {@code http://localhost:8983/}
    • - *
    • {@code http://localhost:8983/solr}
    • - *
    • {@code http://localhost:8983/solr/}
    • - *
    • {@code https://solr.example.com:8983}
    • - *
    + *
  • Valid Examples: + *
      + *
    • {@code http://localhost:8983} + *
    • {@code http://localhost:8983/} + *
    • {@code http://localhost:8983/solr} + *
    • {@code http://localhost:8983/solr/} + *
    • {@code https://solr.example.com:8983} + *
    *
- * - *

Environment-Specific Configuration:

+ * + *

Environment-Specific Configuration: + * *

{@code
  * # Development
  * solr.url=http://localhost:8983
- * 
- * # Staging  
+ *
+ * # Staging
  * solr.url=http://solr-staging.company.com:8983
- * 
+ *
  * # Production
  * solr.url=https://solr-prod.company.com:8983
  * }
- * - *

Integration with Dependency Injection:

- *

This record is automatically instantiated by Spring Boot's configuration - * properties mechanism and can be injected into any Spring-managed component - * that requires Solr connection information.

- * - *

Validation Considerations:

- *

While basic validation is handled by the configuration system, additional - * URL validation and normalization occurs in the {@link SolrConfig} class - * during SolrClient bean creation.

- * - * @param url the base URL of the Apache Solr server (required, non-null) * + *

Integration with Dependency Injection: + * + *

This record is automatically instantiated by Spring Boot's configuration properties mechanism + * and can be injected into any Spring-managed component that requires Solr connection information. + * + *

Validation Considerations: + * + *

While basic validation is handled by the configuration system, additional URL validation and + * normalization occurs in the {@link SolrConfig} class during SolrClient bean creation. + * + * @param url the base URL of the Apache Solr server (required, non-null) * @version 0.0.1 * @since 0.0.1 - * * @see SolrConfig * @see org.springframework.boot.context.properties.ConfigurationProperties * @see org.springframework.boot.context.properties.EnableConfigurationProperties */ @ConfigurationProperties(prefix = "solr") -public record SolrConfigurationProperties(String url) { - -} \ No newline at end of file +public record SolrConfigurationProperties(String url) {} diff --git a/src/main/java/org/apache/solr/mcp/server/indexing/IndexingService.java b/src/main/java/org/apache/solr/mcp/server/indexing/IndexingService.java index 5882955..c11b1d3 100644 --- a/src/main/java/org/apache/solr/mcp/server/indexing/IndexingService.java +++ b/src/main/java/org/apache/solr/mcp/server/indexing/IndexingService.java @@ -16,6 +16,9 @@ */ package org.apache.solr.mcp.server.indexing; +import java.io.IOException; +import java.util.List; +import javax.xml.parsers.ParserConfigurationException; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.SolrServerException; import org.apache.solr.common.SolrInputDocument; @@ -25,51 +28,54 @@ import org.springframework.stereotype.Service; import org.xml.sax.SAXException; -import javax.xml.parsers.ParserConfigurationException; -import java.io.IOException; -import java.util.List; - /** * Spring Service providing comprehensive document indexing capabilities for Apache Solr collections * through Model Context Protocol (MCP) integration. * - *

This service handles the conversion of JSON, CSV, and XML documents into Solr-compatible format and manages - * the indexing process with robust error handling and batch processing capabilities. It employs a - * schema-less approach where Solr automatically detects field types, eliminating the need for - * predefined schema configuration.

- * - *

Core Features:

+ *

This service handles the conversion of JSON, CSV, and XML documents into Solr-compatible + * format and manages the indexing process with robust error handling and batch processing + * capabilities. It employs a schema-less approach where Solr automatically detects field types, + * eliminating the need for predefined schema configuration. + * + *

Core Features: + * *

    - *
  • Schema-less Indexing: Automatic field type detection by Solr
  • - *
  • JSON Processing: Support for complex nested JSON documents
  • - *
  • CSV Processing: Support for comma-separated value files with headers
  • - *
  • XML Processing: Support for XML documents with element flattening and attribute handling
  • - *
  • Batch Processing: Efficient bulk indexing with configurable batch sizes
  • - *
  • Error Resilience: Individual document fallback when batch operations fail
  • - *
  • Field Sanitization: Automatic cleanup of field names for Solr compatibility
  • + *
  • Schema-less Indexing: Automatic field type detection by Solr + *
  • JSON Processing: Support for complex nested JSON documents + *
  • CSV Processing: Support for comma-separated value files with headers + *
  • XML Processing: Support for XML documents with element flattening and + * attribute handling + *
  • Batch Processing: Efficient bulk indexing with configurable batch sizes + *
  • Error Resilience: Individual document fallback when batch operations fail + *
  • Field Sanitization: Automatic cleanup of field names for Solr + * compatibility *
- * - *

MCP Tool Integration:

+ * + *

MCP Tool Integration: + * *

The service exposes indexing functionality as MCP tools that can be invoked by AI clients * through natural language requests. This enables seamless document ingestion workflows from - * external data sources.

- * - *

JSON Document Processing:

+ * external data sources. + * + *

JSON Document Processing: + * *

The service processes JSON documents by flattening nested objects using underscore notation * (e.g., "user.name" becomes "user_name") and handles arrays by converting them to multi-valued - * fields that Solr natively supports.

- * - *

Batch Processing Strategy:

+ * fields that Solr natively supports. + * + *

Batch Processing Strategy: + * *

Uses configurable batch sizes (default 1000 documents) for optimal performance. If a batch - * fails, the service automatically retries by indexing documents individually to identify and - * skip problematic documents while preserving valid ones.

- * - *

Example Usage:

+ * fails, the service automatically retries by indexing documents individually to identify and skip + * problematic documents while preserving valid ones. + * + *

Example Usage: + * *

{@code
  * // Index JSON array of documents
  * String jsonData = "[{\"title\":\"Document 1\",\"content\":\"Content here\"}]";
  * indexingService.indexDocuments("my_collection", jsonData);
- * 
+ *
  * // Programmatic document creation and indexing
  * List docs = indexingService.createSchemalessDocuments(jsonData);
  * int successful = indexingService.indexDocuments("my_collection", docs);
@@ -77,7 +83,6 @@
  *
  * @version 0.0.1
  * @since 0.0.1
- * 
  * @see SolrInputDocument
  * @see SolrClient
  * @see org.springframework.ai.tool.annotation.Tool
@@ -90,160 +95,173 @@ public class IndexingService {
     /** SolrJ client for communicating with Solr server */
     private final SolrClient solrClient;
 
-    /**
-     * Service for creating SolrInputDocument objects from various data formats
-     */
+    /** Service for creating SolrInputDocument objects from various data formats */
     private final IndexingDocumentCreator indexingDocumentCreator;
 
     /**
      * Constructs a new IndexingService with the required dependencies.
-     * 
-     * 

This constructor is automatically called by Spring's dependency injection - * framework during application startup, providing the service with the necessary - * Solr client and configuration components.

- * - * @param solrClient the SolrJ client instance for communicating with Solr * + *

This constructor is automatically called by Spring's dependency injection framework during + * application startup, providing the service with the necessary Solr client and configuration + * components. + * + * @param solrClient the SolrJ client instance for communicating with Solr * @see SolrClient */ - public IndexingService(SolrClient solrClient, - IndexingDocumentCreator indexingDocumentCreator) { + public IndexingService(SolrClient solrClient, IndexingDocumentCreator indexingDocumentCreator) { this.solrClient = solrClient; this.indexingDocumentCreator = indexingDocumentCreator; } /** * Indexes documents from a JSON string into a specified Solr collection. - * - *

This method serves as the primary entry point for document indexing operations - * and is exposed as an MCP tool for AI client interactions. It processes JSON data - * containing document arrays and indexes them using a schema-less approach.

- * - *

Supported JSON Formats:

+ * + *

This method serves as the primary entry point for document indexing operations and is + * exposed as an MCP tool for AI client interactions. It processes JSON data containing document + * arrays and indexes them using a schema-less approach. + * + *

Supported JSON Formats: + * *

    - *
  • Document Array: {@code [{"field1":"value1"},{"field2":"value2"}]}
  • - *
  • Nested Objects: Automatically flattened with underscore notation
  • - *
  • Multi-valued Fields: Arrays converted to Solr multi-valued fields
  • + *
  • Document Array: {@code [{"field1":"value1"},{"field2":"value2"}]} + *
  • Nested Objects: Automatically flattened with underscore notation + *
  • Multi-valued Fields: Arrays converted to Solr multi-valued fields *
- * - *

Processing Workflow:

+ * + *

Processing Workflow: + * *

    - *
  1. Parse JSON string into structured documents
  2. - *
  3. Convert to schema-less SolrInputDocument objects
  4. - *
  5. Execute batch indexing with error handling
  6. - *
  7. Commit changes to make documents searchable
  8. + *
  9. Parse JSON string into structured documents + *
  10. Convert to schema-less SolrInputDocument objects + *
  11. Execute batch indexing with error handling + *
  12. Commit changes to make documents searchable *
- * - *

MCP Tool Usage:

- *

AI clients can invoke this method with natural language requests like - * "index these documents into my_collection" or "add this JSON data to the search index".

- * - *

Error Handling:

- *

If indexing fails, the method attempts individual document processing to maximize - * the number of successfully indexed documents. Detailed error information is logged - * for troubleshooting purposes.

- * + * + *

MCP Tool Usage: + * + *

AI clients can invoke this method with natural language requests like "index these + * documents into my_collection" or "add this JSON data to the search index". + * + *

Error Handling: + * + *

If indexing fails, the method attempts individual document processing to maximize the + * number of successfully indexed documents. Detailed error information is logged for + * troubleshooting purposes. + * * @param collection the name of the Solr collection to index documents into * @param json JSON string containing an array of documents to index - * * @throws IOException if there are critical errors in JSON parsing or Solr communication * @throws SolrServerException if Solr server encounters errors during indexing - * * @see IndexingDocumentCreator#createSchemalessDocumentsFromJson(String) * @see #indexDocuments(String, List) */ - @McpTool(name = "index_json_documents", description = "Index documents from json String into Solr collection") + @McpTool( + name = "index_json_documents", + description = "Index documents from json String into Solr collection") public void indexJsonDocuments( @McpToolParam(description = "Solr collection to index into") String collection, - @McpToolParam(description = "JSON string containing documents to index") String json) throws IOException, SolrServerException { - List schemalessDoc = indexingDocumentCreator.createSchemalessDocumentsFromJson(json); + @McpToolParam(description = "JSON string containing documents to index") String json) + throws IOException, SolrServerException { + List schemalessDoc = + indexingDocumentCreator.createSchemalessDocumentsFromJson(json); indexDocuments(collection, schemalessDoc); } - /** * Indexes documents from a CSV string into a specified Solr collection. - * - *

This method serves as the primary entry point for CSV document indexing operations - * and is exposed as an MCP tool for AI client interactions. It processes CSV data - * with headers and indexes them using a schema-less approach.

- * - *

Supported CSV Formats:

+ * + *

This method serves as the primary entry point for CSV document indexing operations and is + * exposed as an MCP tool for AI client interactions. It processes CSV data with headers and + * indexes them using a schema-less approach. + * + *

Supported CSV Formats: + * *

    - *
  • Header Row Required: First row must contain column names
  • - *
  • Comma Delimited: Standard CSV format with comma separators
  • - *
  • Mixed Data Types: Automatic type detection by Solr
  • + *
  • Header Row Required: First row must contain column names + *
  • Comma Delimited: Standard CSV format with comma separators + *
  • Mixed Data Types: Automatic type detection by Solr *
- * - *

Processing Workflow:

+ * + *

Processing Workflow: + * *

    - *
  1. Parse CSV string to extract headers and data rows
  2. - *
  3. Convert to schema-less SolrInputDocument objects
  4. - *
  5. Execute batch indexing with error handling
  6. - *
  7. Commit changes to make documents searchable
  8. + *
  9. Parse CSV string to extract headers and data rows + *
  10. Convert to schema-less SolrInputDocument objects + *
  11. Execute batch indexing with error handling + *
  12. Commit changes to make documents searchable *
- * - *

MCP Tool Usage:

- *

AI clients can invoke this method with natural language requests like - * "index this CSV data into my_collection" or "add these CSV records to the search index".

- * - *

Error Handling:

- *

If indexing fails, the method attempts individual document processing to maximize - * the number of successfully indexed documents. Detailed error information is logged - * for troubleshooting purposes.

- * + * + *

MCP Tool Usage: + * + *

AI clients can invoke this method with natural language requests like "index this CSV data + * into my_collection" or "add these CSV records to the search index". + * + *

Error Handling: + * + *

If indexing fails, the method attempts individual document processing to maximize the + * number of successfully indexed documents. Detailed error information is logged for + * troubleshooting purposes. + * * @param collection the name of the Solr collection to index documents into * @param csv CSV string containing documents to index (first row must be headers) - * * @throws IOException if there are critical errors in CSV parsing or Solr communication * @throws SolrServerException if Solr server encounters errors during indexing - * * @see IndexingDocumentCreator#createSchemalessDocumentsFromCsv(String) * @see #indexDocuments(String, List) */ - @McpTool(name = "index_csv_documents", description = "Index documents from CSV string into Solr collection") + @McpTool( + name = "index_csv_documents", + description = "Index documents from CSV string into Solr collection") public void indexCsvDocuments( @McpToolParam(description = "Solr collection to index into") String collection, - @McpToolParam(description = "CSV string containing documents to index") String csv) throws IOException, SolrServerException { - List schemalessDoc = indexingDocumentCreator.createSchemalessDocumentsFromCsv(csv); + @McpToolParam(description = "CSV string containing documents to index") String csv) + throws IOException, SolrServerException { + List schemalessDoc = + indexingDocumentCreator.createSchemalessDocumentsFromCsv(csv); indexDocuments(collection, schemalessDoc); } /** * Indexes documents from an XML string into a specified Solr collection. * - *

This method serves as the primary entry point for XML document indexing operations - * and is exposed as an MCP tool for AI client interactions. It processes XML data - * with nested elements and attributes, indexing them using a schema-less approach.

+ *

This method serves as the primary entry point for XML document indexing operations and is + * exposed as an MCP tool for AI client interactions. It processes XML data with nested elements + * and attributes, indexing them using a schema-less approach. + * + *

Supported XML Formats: * - *

Supported XML Formats:

*
    - *
  • Single Document: Root element treated as one document
  • - *
  • Multiple Documents: Child elements with 'doc', 'item', or 'record' names treated as separate documents
  • - *
  • Nested Elements: Automatically flattened with underscore notation
  • - *
  • Attributes: Converted to fields with "_attr" suffix
  • - *
  • Mixed Data Types: Automatic type detection by Solr
  • + *
  • Single Document: Root element treated as one document + *
  • Multiple Documents: Child elements with 'doc', 'item', or 'record' + * names treated as separate documents + *
  • Nested Elements: Automatically flattened with underscore notation + *
  • Attributes: Converted to fields with "_attr" suffix + *
  • Mixed Data Types: Automatic type detection by Solr *
* - *

Processing Workflow:

+ *

Processing Workflow: + * *

    - *
  1. Parse XML string to extract elements and attributes
  2. - *
  3. Flatten nested structures using underscore notation
  4. - *
  5. Convert to schema-less SolrInputDocument objects
  6. - *
  7. Execute batch indexing with error handling
  8. - *
  9. Commit changes to make documents searchable
  10. + *
  11. Parse XML string to extract elements and attributes + *
  12. Flatten nested structures using underscore notation + *
  13. Convert to schema-less SolrInputDocument objects + *
  14. Execute batch indexing with error handling + *
  15. Commit changes to make documents searchable *
* - *

MCP Tool Usage:

- *

AI clients can invoke this method with natural language requests like - * "index this XML data into my_collection" or "add these XML records to the search index".

+ *

MCP Tool Usage: + * + *

AI clients can invoke this method with natural language requests like "index this XML data + * into my_collection" or "add these XML records to the search index". + * + *

Error Handling: + * + *

If indexing fails, the method attempts individual document processing to maximize the + * number of successfully indexed documents. Detailed error information is logged for + * troubleshooting purposes. * - *

Error Handling:

- *

If indexing fails, the method attempts individual document processing to maximize - * the number of successfully indexed documents. Detailed error information is logged - * for troubleshooting purposes.

+ *

Example XML Processing: * - *

Example XML Processing:

*
{@code
      * Input:
      * 
@@ -259,7 +277,7 @@ public void indexCsvDocuments(
      * }
* * @param collection the name of the Solr collection to index documents into - * @param xml XML string containing documents to index + * @param xml XML string containing documents to index * @throws ParserConfigurationException if XML parser configuration fails * @throws SAXException if XML parsing fails due to malformed content * @throws IOException if I/O errors occur during parsing or Solr communication @@ -267,60 +285,67 @@ public void indexCsvDocuments( * @see IndexingDocumentCreator#createSchemalessDocumentsFromXml(String) * @see #indexDocuments(String, List) */ - @McpTool(name = "index_xml_documents", description = "Index documents from XML string into Solr collection") + @McpTool( + name = "index_xml_documents", + description = "Index documents from XML string into Solr collection") public void indexXmlDocuments( @McpToolParam(description = "Solr collection to index into") String collection, - @McpToolParam(description = "XML string containing documents to index") String xml) throws ParserConfigurationException, SAXException, IOException, SolrServerException { - List schemalessDoc = indexingDocumentCreator.createSchemalessDocumentsFromXml(xml); + @McpToolParam(description = "XML string containing documents to index") String xml) + throws ParserConfigurationException, SAXException, IOException, SolrServerException { + List schemalessDoc = + indexingDocumentCreator.createSchemalessDocumentsFromXml(xml); indexDocuments(collection, schemalessDoc); } /** * Indexes a list of SolrInputDocument objects into a Solr collection using batch processing. - * - *

This method implements a robust batch indexing strategy that optimizes performance - * while providing resilience against individual document failures. It processes documents - * in configurable batches and includes fallback mechanisms for error recovery.

- * - *

Batch Processing Strategy:

+ * + *

This method implements a robust batch indexing strategy that optimizes performance while + * providing resilience against individual document failures. It processes documents in + * configurable batches and includes fallback mechanisms for error recovery. + * + *

Batch Processing Strategy: + * *

    - *
  • Batch Size: Configurable (default 1000) for optimal performance
  • - *
  • Error Recovery: Individual document retry on batch failure
  • - *
  • Success Tracking: Accurate count of successfully indexed documents
  • - *
  • Commit Strategy: Single commit after all batches for consistency
  • + *
  • Batch Size: Configurable (default 1000) for optimal performance + *
  • Error Recovery: Individual document retry on batch failure + *
  • Success Tracking: Accurate count of successfully indexed documents + *
  • Commit Strategy: Single commit after all batches for consistency *
- * - *

Error Handling Workflow:

+ * + *

Error Handling Workflow: + * *

    - *
  1. Attempt batch indexing for optimal performance
  2. - *
  3. On batch failure, retry each document individually
  4. - *
  5. Track successful vs failed document counts
  6. - *
  7. Continue processing remaining batches despite failures
  8. - *
  9. Commit all successful changes at the end
  10. + *
  11. Attempt batch indexing for optimal performance + *
  12. On batch failure, retry each document individually + *
  13. Track successful vs failed document counts + *
  14. Continue processing remaining batches despite failures + *
  15. Commit all successful changes at the end *
- * - *

Performance Considerations:

+ * + *

Performance Considerations: + * *

Batch processing significantly improves indexing performance compared to individual - * document operations. The fallback to individual processing ensures maximum document - * ingestion even when some documents have issues.

- * - *

Transaction Behavior:

+ * document operations. The fallback to individual processing ensures maximum document ingestion + * even when some documents have issues. + * + *

Transaction Behavior: + * *

The method commits changes after all batches are processed, making indexed documents * immediately searchable. This ensures atomicity at the operation level while maintaining - * performance through batching.

- * + * performance through batching. + * * @param collection the name of the Solr collection to index into * @param documents list of SolrInputDocument objects to index * @return the number of documents successfully indexed - * * @throws SolrServerException if there are critical errors in Solr communication * @throws IOException if there are critical errors in commit operations - * * @see SolrInputDocument * @see SolrClient#add(String, java.util.Collection) * @see SolrClient#commit(String) */ - public int indexDocuments(String collection, List documents) throws SolrServerException, IOException { + public int indexDocuments(String collection, List documents) + throws SolrServerException, IOException { int successCount = 0; final int batchSize = DEFAULT_BATCH_SIZE; @@ -338,7 +363,8 @@ public int indexDocuments(String collection, List documents) solrClient.add(collection, doc); successCount++; } catch (SolrServerException | IOException | RuntimeException docError) { - // Document failed to index - this is expected behavior for problematic documents + // Document failed to index - this is expected behavior for problematic + // documents // We continue processing the rest of the batch } } @@ -348,5 +374,4 @@ public int indexDocuments(String collection, List documents) solrClient.commit(collection); return successCount; } - -} \ No newline at end of file +} diff --git a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/CsvDocumentCreator.java b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/CsvDocumentCreator.java index 0119fe1..d1f84ea 100644 --- a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/CsvDocumentCreator.java +++ b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/CsvDocumentCreator.java @@ -16,23 +16,22 @@ */ package org.apache.solr.mcp.server.indexing.documentcreator; -import org.apache.commons.csv.CSVFormat; -import org.apache.commons.csv.CSVParser; -import org.apache.commons.csv.CSVRecord; -import org.apache.solr.common.SolrInputDocument; -import org.springframework.stereotype.Component; - import java.io.IOException; import java.io.StringReader; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; +import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVParser; +import org.apache.commons.csv.CSVRecord; +import org.apache.solr.common.SolrInputDocument; +import org.springframework.stereotype.Component; /** * Utility class for processing CSV documents and converting them to SolrInputDocument objects. * - *

This class handles the conversion of CSV documents into Solr-compatible format - * using a schema-less approach where Solr automatically detects field types.

+ *

This class handles the conversion of CSV documents into Solr-compatible format using a + * schema-less approach where Solr automatically detects field types. */ @Component public class CsvDocumentCreator implements SolrDocumentCreator { @@ -42,32 +41,37 @@ public class CsvDocumentCreator implements SolrDocumentCreator { /** * Creates a list of schema-less SolrInputDocument objects from a CSV string. * - *

This method implements a flexible document conversion strategy that allows Solr - * to automatically detect field types without requiring predefined schema configuration. - * It processes CSV data by using the first row as field headers and converting each - * subsequent row into a document.

+ *

This method implements a flexible document conversion strategy that allows Solr to + * automatically detect field types without requiring predefined schema configuration. It + * processes CSV data by using the first row as field headers and converting each subsequent row + * into a document. + * + *

Schema-less Benefits: * - *

Schema-less Benefits:

*
    - *
  • Flexibility: No need to predefine field types in schema
  • - *
  • Rapid Prototyping: Quick iteration on document structures
  • - *
  • Type Detection: Solr automatically infers optimal field types
  • - *
  • Dynamic Fields: Support for varying document structures
  • + *
  • Flexibility: No need to predefine field types in schema + *
  • Rapid Prototyping: Quick iteration on document structures + *
  • Type Detection: Solr automatically infers optimal field types + *
  • Dynamic Fields: Support for varying document structures *
* - *

CSV Processing Rules:

+ *

CSV Processing Rules: + * *

    - *
  • Header Row: First row defines field names, automatically sanitized
  • - *
  • Empty Values: Ignored and not indexed
  • - *
  • Type Detection: Solr handles numeric, boolean, and string types automatically
  • - *
  • Field Sanitization: Column names cleaned for Solr compatibility
  • + *
  • Header Row: First row defines field names, automatically sanitized + *
  • Empty Values: Ignored and not indexed + *
  • Type Detection: Solr handles numeric, boolean, and string types + * automatically + *
  • Field Sanitization: Column names cleaned for Solr compatibility *
* - *

Field Name Sanitization:

- *

Field names are automatically sanitized to ensure Solr compatibility by removing - * special characters and converting to lowercase with underscore separators.

+ *

Field Name Sanitization: + * + *

Field names are automatically sanitized to ensure Solr compatibility by removing special + * characters and converting to lowercase with underscore separators. + * + *

Example Transformation: * - *

Example Transformation:

*
{@code
      * Input CSV:
      * id,name,price,inStock
@@ -79,19 +83,23 @@ public class CsvDocumentCreator implements SolrDocumentCreator {
      *
      * @param csv CSV string containing document data (first row must be headers)
      * @return list of SolrInputDocument objects ready for indexing
-     * @throws DocumentProcessingException if CSV parsing fails, input validation fails, or the structure is invalid
+     * @throws DocumentProcessingException if CSV parsing fails, input validation fails, or the
+     *     structure is invalid
      * @see SolrInputDocument
      * @see FieldNameSanitizer#sanitizeFieldName(String)
      */
     public List create(String csv) throws DocumentProcessingException {
         if (csv.getBytes(StandardCharsets.UTF_8).length > MAX_INPUT_SIZE_BYTES) {
-            throw new DocumentProcessingException("Input too large: exceeds maximum size of " + MAX_INPUT_SIZE_BYTES + " bytes");
+            throw new DocumentProcessingException(
+                    "Input too large: exceeds maximum size of " + MAX_INPUT_SIZE_BYTES + " bytes");
         }
 
         List documents = new ArrayList<>();
 
-        try (CSVParser parser = new CSVParser(new StringReader(csv),
-                CSVFormat.Builder.create().setHeader().setTrim(true).build())) {
+        try (CSVParser parser =
+                new CSVParser(
+                        new StringReader(csv),
+                        CSVFormat.Builder.create().setHeader().setTrim(true).build())) {
             List headers = new ArrayList<>(parser.getHeaderNames());
             headers.replaceAll(FieldNameSanitizer::sanitizeFieldName);
 
@@ -117,5 +125,4 @@ public List create(String csv) throws DocumentProcessingExcep
 
         return documents;
     }
-
-}
\ No newline at end of file
+}
diff --git a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/DocumentProcessingException.java b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/DocumentProcessingException.java
index 850b26f..6d0c22f 100644
--- a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/DocumentProcessingException.java
+++ b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/DocumentProcessingException.java
@@ -20,15 +20,16 @@
  * Exception thrown when document processing operations fail.
  *
  * 

This exception provides a unified error handling mechanism for all document creator - * implementations, wrapping various underlying exceptions while preserving the original - * error context and stack trace information.

+ * implementations, wrapping various underlying exceptions while preserving the original error + * context and stack trace information. + * + *

Common scenarios where this exception is thrown: * - *

Common scenarios where this exception is thrown:

*
    - *
  • Invalid document format or structure
  • - *
  • Document parsing errors (JSON, XML, CSV)
  • - *
  • Input validation failures
  • - *
  • Resource access or I/O errors during processing
  • + *
  • Invalid document format or structure + *
  • Document parsing errors (JSON, XML, CSV) + *
  • Input validation failures + *
  • Resource access or I/O errors during processing *
*/ public class DocumentProcessingException extends RuntimeException { @@ -45,11 +46,11 @@ public DocumentProcessingException(String message) { /** * Constructs a new DocumentProcessingException with the specified detail message and cause. * - *

This constructor is particularly useful for wrapping underlying exceptions - * while providing additional context about the document processing failure.

+ *

This constructor is particularly useful for wrapping underlying exceptions while providing + * additional context about the document processing failure. * * @param message the detail message explaining the error - * @param cause the cause of this exception (which is saved for later retrieval) + * @param cause the cause of this exception (which is saved for later retrieval) */ public DocumentProcessingException(String message, Throwable cause) { super(message, cause); @@ -58,11 +59,11 @@ public DocumentProcessingException(String message, Throwable cause) { /** * Constructs a new DocumentProcessingException with the specified cause. * - *

The detail message is automatically derived from the cause's toString() method.

+ *

The detail message is automatically derived from the cause's toString() method. * * @param cause the cause of this exception (which is saved for later retrieval) */ public DocumentProcessingException(Throwable cause) { super(cause); } -} \ No newline at end of file +} diff --git a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/FieldNameSanitizer.java b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/FieldNameSanitizer.java index 6a51c5a..9879d8c 100644 --- a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/FieldNameSanitizer.java +++ b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/FieldNameSanitizer.java @@ -19,32 +19,34 @@ import java.util.regex.Pattern; /** - * Utility class for sanitizing field names to ensure compatibility with Solr's field naming requirements. + * Utility class for sanitizing field names to ensure compatibility with Solr's field naming + * requirements. * - *

This class provides shared regex patterns and sanitization logic that can be used across - * all document creators to ensure consistent field name handling.

+ *

This class provides shared regex patterns and sanitization logic that can be used across all + * document creators to ensure consistent field name handling. * - *

Solr has specific requirements for field names that must be met to ensure proper - * indexing and searching functionality. This utility transforms arbitrary field names - * into Solr-compliant identifiers.

+ *

Solr has specific requirements for field names that must be met to ensure proper indexing and + * searching functionality. This utility transforms arbitrary field names into Solr-compliant + * identifiers. */ public final class FieldNameSanitizer { /** - * Pattern to match invalid characters in field names. - * Matches any character that is not alphanumeric or underscore. + * Pattern to match invalid characters in field names. Matches any character that is not + * alphanumeric or underscore. */ private static final Pattern INVALID_CHARACTERS_PATTERN = Pattern.compile("[\\W]"); /** - * Pattern to match leading and trailing underscores. - * Uses explicit grouping to make operator precedence clear. + * Pattern to match leading and trailing underscores. Uses explicit grouping to make operator + * precedence clear. */ - private static final Pattern LEADING_TRAILING_UNDERSCORES_PATTERN = Pattern.compile("(^_+)|(_+$)"); + private static final Pattern LEADING_TRAILING_UNDERSCORES_PATTERN = + Pattern.compile("(^_+)|(_+$)"); /** - * Pattern to match multiple consecutive underscores. - * Matches two or more consecutive underscores to collapse them into one. + * Pattern to match multiple consecutive underscores. Matches two or more consecutive + * underscores to collapse them into one. */ private static final Pattern MULTIPLE_UNDERSCORES_PATTERN = Pattern.compile("_{2,}"); @@ -56,32 +58,39 @@ private FieldNameSanitizer() { /** * Sanitizes field names to ensure they are compatible with Solr's field naming requirements. * - *

Sanitization Rules:

+ *

Sanitization Rules: + * *

    - *
  • Case Conversion: All characters converted to lowercase
  • - *
  • Character Replacement: Non-alphanumeric characters replaced with underscores
  • - *
  • Edge Trimming: Leading and trailing underscores removed
  • - *
  • Duplicate Compression: Multiple consecutive underscores collapsed to single
  • - *
  • Numeric Prefix: Field names starting with numbers get "field_" prefix
  • + *
  • Case Conversion: All characters converted to lowercase + *
  • Character Replacement: Non-alphanumeric characters replaced with + * underscores + *
  • Edge Trimming: Leading and trailing underscores removed + *
  • Duplicate Compression: Multiple consecutive underscores collapsed to + * single + *
  • Numeric Prefix: Field names starting with numbers get "field_" prefix *
* - *

Example Transformations:

+ *

Example Transformations: + * *

    - *
  • "User-Name" → "user_name"
  • - *
  • "product.price" → "product_price"
  • - *
  • "__field__name__" → "field_name"
  • - *
  • "Field123@Test" → "field123_test"
  • - *
  • "123field" → "field_123field"
  • + *
  • "User-Name" → "user_name" + *
  • "product.price" → "product_price" + *
  • "__field__name__" → "field_name" + *
  • "Field123@Test" → "field123_test" + *
  • "123field" → "field_123field" *
* * @param fieldName the original field name to sanitize - * @return sanitized field name compatible with Solr requirements, or "field" if input is null/empty - * @see Solr Field Guide + * @return sanitized field name compatible with Solr requirements, or "field" if input is + * null/empty + * @see Solr + * Field Guide */ public static String sanitizeFieldName(String fieldName) { // Convert to lowercase and replace invalid characters with underscores - String sanitized = INVALID_CHARACTERS_PATTERN.matcher(fieldName.toLowerCase()).replaceAll("_"); + String sanitized = + INVALID_CHARACTERS_PATTERN.matcher(fieldName.toLowerCase()).replaceAll("_"); // Remove leading/trailing underscores and collapse multiple underscores sanitized = LEADING_TRAILING_UNDERSCORES_PATTERN.matcher(sanitized).replaceAll(""); @@ -99,4 +108,4 @@ public static String sanitizeFieldName(String fieldName) { return sanitized; } -} \ No newline at end of file +} diff --git a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/IndexingDocumentCreator.java b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/IndexingDocumentCreator.java index 7d16a0c..5c9e595 100644 --- a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/IndexingDocumentCreator.java +++ b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/IndexingDocumentCreator.java @@ -16,27 +16,29 @@ */ package org.apache.solr.mcp.server.indexing.documentcreator; +import java.nio.charset.StandardCharsets; +import java.util.List; import org.apache.solr.common.SolrInputDocument; import org.apache.solr.mcp.server.indexing.IndexingService; import org.springframework.stereotype.Service; -import java.nio.charset.StandardCharsets; -import java.util.List; - /** * Spring Service responsible for creating SolrInputDocument objects from various data formats. * - *

This service handles the conversion of JSON, CSV, and XML documents into Solr-compatible format - * using a schema-less approach where Solr automatically detects field types, eliminating the need for - * predefined schema configuration.

+ *

This service handles the conversion of JSON, CSV, and XML documents into Solr-compatible + * format using a schema-less approach where Solr automatically detects field types, eliminating the + * need for predefined schema configuration. + * + *

Core Features: * - *

Core Features:

*
    - *
  • Schema-less Document Creation: Automatic field type detection by Solr
  • - *
  • JSON Processing: Support for complex nested JSON documents
  • - *
  • CSV Processing: Support for comma-separated value files with headers
  • - *
  • XML Processing: Support for XML documents with element flattening and attribute handling
  • - *
  • Field Sanitization: Automatic cleanup of field names for Solr compatibility
  • + *
  • Schema-less Document Creation: Automatic field type detection by Solr + *
  • JSON Processing: Support for complex nested JSON documents + *
  • CSV Processing: Support for comma-separated value files with headers + *
  • XML Processing: Support for XML documents with element flattening and + * attribute handling + *
  • Field Sanitization: Automatic cleanup of field names for Solr + * compatibility *
* * @version 0.0.1 @@ -55,9 +57,10 @@ public class IndexingDocumentCreator { private final JsonDocumentCreator jsonDocumentCreator; - public IndexingDocumentCreator(XmlDocumentCreator xmlDocumentCreator, - CsvDocumentCreator csvDocumentCreator, - JsonDocumentCreator jsonDocumentCreator) { + public IndexingDocumentCreator( + XmlDocumentCreator xmlDocumentCreator, + CsvDocumentCreator csvDocumentCreator, + JsonDocumentCreator jsonDocumentCreator) { this.xmlDocumentCreator = xmlDocumentCreator; this.csvDocumentCreator = csvDocumentCreator; this.jsonDocumentCreator = jsonDocumentCreator; @@ -66,35 +69,37 @@ public IndexingDocumentCreator(XmlDocumentCreator xmlDocumentCreator, /** * Creates a list of schema-less SolrInputDocument objects from a JSON string. * - *

This method delegates JSON processing to the JsonDocumentProcessor utility class.

+ *

This method delegates JSON processing to the JsonDocumentProcessor utility class. * * @param json JSON string containing document data (must be an array) * @return list of SolrInputDocument objects ready for indexing * @throws DocumentProcessingException if JSON parsing fails or the structure is invalid * @see JsonDocumentCreator */ - public List createSchemalessDocumentsFromJson(String json) throws DocumentProcessingException { + public List createSchemalessDocumentsFromJson(String json) + throws DocumentProcessingException { return jsonDocumentCreator.create(json); } /** * Creates a list of schema-less SolrInputDocument objects from a CSV string. * - *

This method delegates CSV processing to the CsvDocumentProcessor utility class.

+ *

This method delegates CSV processing to the CsvDocumentProcessor utility class. * * @param csv CSV string containing document data (first row must be headers) * @return list of SolrInputDocument objects ready for indexing * @throws DocumentProcessingException if CSV parsing fails or the structure is invalid * @see CsvDocumentCreator */ - public List createSchemalessDocumentsFromCsv(String csv) throws DocumentProcessingException { + public List createSchemalessDocumentsFromCsv(String csv) + throws DocumentProcessingException { return csvDocumentCreator.create(csv); } /** * Creates a list of schema-less SolrInputDocument objects from an XML string. * - *

This method delegates XML processing to the XmlDocumentProcessor utility class.

+ *

This method delegates XML processing to the XmlDocumentProcessor utility class. * * @param xml XML string containing document data * @return list of SolrInputDocument objects ready for indexing @@ -111,10 +116,14 @@ public List createSchemalessDocumentsFromXml(String xml) byte[] xmlBytes = xml.getBytes(StandardCharsets.UTF_8); if (xmlBytes.length > MAX_XML_SIZE_BYTES) { - throw new IllegalArgumentException("XML document too large: " + xmlBytes.length + " bytes (max: " + MAX_XML_SIZE_BYTES + ")"); + throw new IllegalArgumentException( + "XML document too large: " + + xmlBytes.length + + " bytes (max: " + + MAX_XML_SIZE_BYTES + + ")"); } return xmlDocumentCreator.create(xml); } - -} \ No newline at end of file +} diff --git a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/JsonDocumentCreator.java b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/JsonDocumentCreator.java index 0862c4e..266ebd7 100644 --- a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/JsonDocumentCreator.java +++ b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/JsonDocumentCreator.java @@ -18,21 +18,20 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import org.apache.solr.common.SolrInputDocument; -import org.springframework.stereotype.Component; - import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Set; +import org.apache.solr.common.SolrInputDocument; +import org.springframework.stereotype.Component; /** * Utility class for processing JSON documents and converting them to SolrInputDocument objects. * - *

This class handles the conversion of JSON documents into Solr-compatible format - * using a schema-less approach where Solr automatically detects field types.

+ *

This class handles the conversion of JSON documents into Solr-compatible format using a + * schema-less approach where Solr automatically detects field types. */ @Component public class JsonDocumentCreator implements SolrDocumentCreator { @@ -42,32 +41,37 @@ public class JsonDocumentCreator implements SolrDocumentCreator { /** * Creates a list of schema-less SolrInputDocument objects from a JSON string. * - *

This method implements a flexible document conversion strategy that allows Solr - * to automatically detect field types without requiring predefined schema configuration. - * It processes complex JSON structures by flattening nested objects and handling arrays - * appropriately for Solr's multi-valued field support.

+ *

This method implements a flexible document conversion strategy that allows Solr to + * automatically detect field types without requiring predefined schema configuration. It + * processes complex JSON structures by flattening nested objects and handling arrays + * appropriately for Solr's multi-valued field support. + * + *

Schema-less Benefits: * - *

Schema-less Benefits:

*
    - *
  • Flexibility: No need to predefine field types in schema
  • - *
  • Rapid Prototyping: Quick iteration on document structures
  • - *
  • Type Detection: Solr automatically infers optimal field types
  • - *
  • Dynamic Fields: Support for varying document structures
  • + *
  • Flexibility: No need to predefine field types in schema + *
  • Rapid Prototyping: Quick iteration on document structures + *
  • Type Detection: Solr automatically infers optimal field types + *
  • Dynamic Fields: Support for varying document structures *
* - *

JSON Processing Rules:

+ *

JSON Processing Rules: + * *

    - *
  • Nested Objects: Flattened using underscore notation (e.g., "user.name" → "user_name")
  • - *
  • Arrays: Non-object arrays converted to multi-valued fields
  • - *
  • Null Values: Ignored and not indexed
  • - *
  • Object Arrays: Skipped to avoid complex nested structures
  • + *
  • Nested Objects: Flattened using underscore notation (e.g., "user.name" + * → "user_name") + *
  • Arrays: Non-object arrays converted to multi-valued fields + *
  • Null Values: Ignored and not indexed + *
  • Object Arrays: Skipped to avoid complex nested structures *
* - *

Field Name Sanitization:

- *

Field names are automatically sanitized to ensure Solr compatibility by removing - * special characters and converting to lowercase with underscore separators.

+ *

Field Name Sanitization: + * + *

Field names are automatically sanitized to ensure Solr compatibility by removing special + * characters and converting to lowercase with underscore separators. + * + *

Example Transformations: * - *

Example Transformations:

*
{@code
      * Input:  {"user":{"name":"John","age":30},"tags":["tech","java"]}
      * Output: {user_name:"John", user_age:30, tags:["tech","java"]}
@@ -75,14 +79,16 @@ public class JsonDocumentCreator implements SolrDocumentCreator {
      *
      * @param json JSON string containing document data (must be an array)
      * @return list of SolrInputDocument objects ready for indexing
-     * @throws DocumentProcessingException if JSON parsing fails, input validation fails, or the structure is invalid
+     * @throws DocumentProcessingException if JSON parsing fails, input validation fails, or the
+     *     structure is invalid
      * @see SolrInputDocument
      * @see #addAllFieldsFlat(SolrInputDocument, JsonNode, String)
      * @see FieldNameSanitizer#sanitizeFieldName(String)
      */
     public List create(String json) throws DocumentProcessingException {
         if (json.getBytes(StandardCharsets.UTF_8).length > MAX_INPUT_SIZE_BYTES) {
-            throw new DocumentProcessingException("Input too large: exceeds maximum size of " + MAX_INPUT_SIZE_BYTES + " bytes");
+            throw new DocumentProcessingException(
+                    "Input too large: exceeds maximum size of " + MAX_INPUT_SIZE_BYTES + " bytes");
         }
 
         List documents = new ArrayList<>();
@@ -110,36 +116,41 @@ public List create(String json) throws DocumentProcessingExce
     /**
      * Recursively flattens JSON nodes and adds them as fields to a SolrInputDocument.
      *
-     * 

This method implements the core logic for converting nested JSON structures - * into flat field names that Solr can efficiently index and search. It handles - * various JSON node types appropriately while maintaining data integrity.

+ *

This method implements the core logic for converting nested JSON structures into flat + * field names that Solr can efficiently index and search. It handles various JSON node types + * appropriately while maintaining data integrity. + * + *

Processing Logic: * - *

Processing Logic:

*
    - *
  • Null Values: Skipped to avoid indexing empty fields
  • - *
  • Arrays: Non-object items converted to multi-valued fields
  • - *
  • Objects: Recursively flattened with prefix concatenation
  • - *
  • Primitives: Directly added with appropriate type conversion
  • + *
  • Null Values: Skipped to avoid indexing empty fields + *
  • Arrays: Non-object items converted to multi-valued fields + *
  • Objects: Recursively flattened with prefix concatenation + *
  • Primitives: Directly added with appropriate type conversion *
* - * @param doc the SolrInputDocument to add fields to - * @param node the JSON node to process + * @param doc the SolrInputDocument to add fields to + * @param node the JSON node to process * @param prefix current field name prefix for nested object flattening * @see #convertJsonValue(JsonNode) * @see FieldNameSanitizer#sanitizeFieldName(String) */ private void addAllFieldsFlat(SolrInputDocument doc, JsonNode node, String prefix) { Set> fields = node.properties(); - fields.forEach(field -> processFieldValue(doc, field.getValue(), - FieldNameSanitizer.sanitizeFieldName(prefix + field.getKey()))); + fields.forEach( + field -> + processFieldValue( + doc, + field.getValue(), + FieldNameSanitizer.sanitizeFieldName(prefix + field.getKey()))); } /** - * Processes the provided field value and adds it to the given SolrInputDocument. - * Handles cases where the field value is an array, object, or a simple value. + * Processes the provided field value and adds it to the given SolrInputDocument. Handles cases + * where the field value is an array, object, or a simple value. * - * @param doc the SolrInputDocument to which the field value will be added - * @param value the JsonNode representing the field value to be processed + * @param doc the SolrInputDocument to which the field value will be added + * @param value the JsonNode representing the field value to be processed * @param fieldName the name of the field to be added to the SolrInputDocument */ private void processFieldValue(SolrInputDocument doc, JsonNode value, String fieldName) { @@ -157,12 +168,13 @@ private void processFieldValue(SolrInputDocument doc, JsonNode value, String fie } /** - * Processes a JSON array field and adds its non-object elements to the specified field - * in the given SolrInputDocument. + * Processes a JSON array field and adds its non-object elements to the specified field in the + * given SolrInputDocument. * - * @param doc the SolrInputDocument to which the processed field will be added + * @param doc the SolrInputDocument to which the processed field will be added * @param arrayValue the JSON array node to process - * @param fieldName the name of the field in the SolrInputDocument to which the array values will be added + * @param fieldName the name of the field in the SolrInputDocument to which the array values + * will be added */ private void processArrayField(SolrInputDocument doc, JsonNode arrayValue, String fieldName) { List values = new ArrayList<>(); @@ -179,17 +191,18 @@ private void processArrayField(SolrInputDocument doc, JsonNode arrayValue, Strin /** * Converts a JsonNode value to the appropriate Java object type for Solr indexing. * - *

This method provides type-aware conversion of JSON values to their corresponding - * Java types, ensuring that Solr receives properly typed data for optimal field - * type detection and indexing performance.

+ *

This method provides type-aware conversion of JSON values to their corresponding Java + * types, ensuring that Solr receives properly typed data for optimal field type detection and + * indexing performance. + * + *

Supported Type Conversions: * - *

Supported Type Conversions:

*
    - *
  • Boolean: JSON boolean → Java Boolean
  • - *
  • Integer: JSON number (int range) → Java Integer
  • - *
  • Long: JSON number (long range) → Java Long
  • - *
  • Double: JSON number (decimal) → Java Double
  • - *
  • String: All other values → Java String
  • + *
  • Boolean: JSON boolean → Java Boolean + *
  • Integer: JSON number (int range) → Java Integer + *
  • Long: JSON number (long range) → Java Long + *
  • Double: JSON number (decimal) → Java Double + *
  • String: All other values → Java String *
* * @param value the JsonNode value to convert @@ -203,5 +216,4 @@ private Object convertJsonValue(JsonNode value) { if (value.isInt()) return value.asInt(); return value.asText(); } - -} \ No newline at end of file +} diff --git a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/SolrDocumentCreator.java b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/SolrDocumentCreator.java index cd5072c..35ef4e5 100644 --- a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/SolrDocumentCreator.java +++ b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/SolrDocumentCreator.java @@ -16,34 +16,38 @@ */ package org.apache.solr.mcp.server.indexing.documentcreator; -import org.apache.solr.common.SolrInputDocument; - import java.util.List; +import org.apache.solr.common.SolrInputDocument; /** * Interface defining the contract for creating SolrInputDocument objects from various data formats. * - *

This interface provides a unified abstraction for converting different document formats - * (JSON, CSV, XML, etc.) into Solr-compatible SolrInputDocument objects. Implementations - * handle format-specific parsing and field sanitization to ensure proper Solr indexing.

+ *

This interface provides a unified abstraction for converting different document formats (JSON, + * CSV, XML, etc.) into Solr-compatible SolrInputDocument objects. Implementations handle + * format-specific parsing and field sanitization to ensure proper Solr indexing. + * + *

Design Principles: * - *

Design Principles:

*
    - *
  • Format Agnostic: Common interface for all document types
  • - *
  • Schema-less Processing: Supports dynamic field creation without predefined schema
  • - *
  • Error Handling: Consistent exception handling across implementations
  • - *
  • Field Sanitization: Automatic cleanup of field names for Solr compatibility
  • + *
  • Format Agnostic: Common interface for all document types + *
  • Schema-less Processing: Supports dynamic field creation without predefined + * schema + *
  • Error Handling: Consistent exception handling across implementations + *
  • Field Sanitization: Automatic cleanup of field names for Solr + * compatibility *
* - *

Implementation Guidelines:

+ *

Implementation Guidelines: + * *

    - *
  • Handle null or empty input gracefully
  • - *
  • Sanitize field names using {@link FieldNameSanitizer}
  • - *
  • Preserve original data types where possible
  • - *
  • Throw {@link DocumentProcessingException} for processing errors
  • + *
  • Handle null or empty input gracefully + *
  • Sanitize field names using {@link FieldNameSanitizer} + *
  • Preserve original data types where possible + *
  • Throw {@link DocumentProcessingException} for processing errors *
* - *

Usage Example:

+ *

Usage Example: + * *

{@code
  * SolrDocumentCreator creator = new JsonDocumentCreator();
  * String jsonData = "[{\"title\":\"Document 1\",\"content\":\"Content here\"}]";
@@ -61,32 +65,36 @@ public interface SolrDocumentCreator {
     /**
      * Creates a list of SolrInputDocument objects from the provided content string.
      *
-     * 

This method parses the input content according to the specific format handled by - * the implementing class (JSON, CSV, XML, etc.) and converts it into a list of - * SolrInputDocument objects ready for indexing.

+ *

This method parses the input content according to the specific format handled by the + * implementing class (JSON, CSV, XML, etc.) and converts it into a list of SolrInputDocument + * objects ready for indexing. + * + *

Processing Behavior: * - *

Processing Behavior:

*
    - *
  • Field Sanitization: All field names are sanitized for Solr compatibility
  • - *
  • Type Preservation: Original data types are maintained where possible
  • - *
  • Multiple Documents: Single content string may produce multiple documents
  • - *
  • Error Handling: Invalid content results in DocumentProcessingException
  • + *
  • Field Sanitization: All field names are sanitized for Solr + * compatibility + *
  • Type Preservation: Original data types are maintained where possible + *
  • Multiple Documents: Single content string may produce multiple + * documents + *
  • Error Handling: Invalid content results in DocumentProcessingException *
* - *

Input Validation:

+ *

Input Validation: + * *

    - *
  • Null input should be handled gracefully (implementation-dependent)
  • - *
  • Empty input should return empty list
  • - *
  • Malformed content should throw DocumentProcessingException
  • + *
  • Null input should be handled gracefully (implementation-dependent) + *
  • Empty input should return empty list + *
  • Malformed content should throw DocumentProcessingException *
* * @param content the content string to be parsed and converted to SolrInputDocument objects. - * The format depends on the implementing class (JSON array, CSV data, XML, etc.) - * @return a list of SolrInputDocument objects created from the parsed content. - * Returns empty list if content is empty or contains no valid documents + * The format depends on the implementing class (JSON array, CSV data, XML, etc.) + * @return a list of SolrInputDocument objects created from the parsed content. Returns empty + * list if content is empty or contains no valid documents * @throws DocumentProcessingException if the content cannot be parsed or converted due to - * format errors, invalid structure, or processing failures - * @throws IllegalArgumentException if content is null (implementation-dependent) + * format errors, invalid structure, or processing failures + * @throws IllegalArgumentException if content is null (implementation-dependent) */ List create(String content) throws DocumentProcessingException; } diff --git a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/XmlDocumentCreator.java b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/XmlDocumentCreator.java index 6c20b2a..827d574 100644 --- a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/XmlDocumentCreator.java +++ b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/XmlDocumentCreator.java @@ -16,18 +16,6 @@ */ package org.apache.solr.mcp.server.indexing.documentcreator; -import org.apache.solr.common.SolrInputDocument; -import org.springframework.stereotype.Component; -import org.w3c.dom.Document; -import org.w3c.dom.Element; -import org.w3c.dom.Node; -import org.w3c.dom.NodeList; -import org.xml.sax.SAXException; - -import javax.xml.XMLConstants; -import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.parsers.ParserConfigurationException; import java.io.ByteArrayInputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -35,12 +23,23 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import org.apache.solr.common.SolrInputDocument; +import org.springframework.stereotype.Component; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; /** * Utility class for processing XML documents and converting them to SolrInputDocument objects. * - *

This class handles the conversion of XML documents into Solr-compatible format - * using a schema-less approach where Solr automatically detects field types.

+ *

This class handles the conversion of XML documents into Solr-compatible format using a + * schema-less approach where Solr automatically detects field types. */ @Component public class XmlDocumentCreator implements SolrDocumentCreator { @@ -48,17 +47,18 @@ public class XmlDocumentCreator implements SolrDocumentCreator { /** * Creates a list of SolrInputDocument objects from XML content. * - *

This method parses the XML and creates documents based on the structure: - * - If the XML has multiple child elements with the same tag name (indicating repeated structures), - * each child element becomes a separate document - * - Otherwise, the entire XML structure is treated as a single document

+ *

This method parses the XML and creates documents based on the structure: - If the XML has + * multiple child elements with the same tag name (indicating repeated structures), each child + * element becomes a separate document - Otherwise, the entire XML structure is treated as a + * single document * - *

This approach is flexible and doesn't rely on hardcoded element names, - * allowing it to work with any XML structure.

+ *

This approach is flexible and doesn't rely on hardcoded element names, allowing it to work + * with any XML structure. * * @param xml the XML content to process * @return list of SolrInputDocument objects ready for indexing - * @throws DocumentProcessingException if XML parsing fails, parser configuration fails, or structural errors occur + * @throws DocumentProcessingException if XML parsing fails, parser configuration fails, or + * structural errors occur */ public List create(String xml) throws DocumentProcessingException { try { @@ -67,26 +67,26 @@ public List create(String xml) throws DocumentProcessingExcep } catch (ParserConfigurationException e) { throw new DocumentProcessingException("Failed to configure XML parser", e); } catch (SAXException e) { - throw new DocumentProcessingException("Failed to parse XML document: structural error", e); + throw new DocumentProcessingException( + "Failed to parse XML document: structural error", e); } catch (IOException e) { throw new DocumentProcessingException("Failed to read XML document", e); } } - /** - * Parses XML string into a DOM Element. - */ - private Element parseXmlDocument(String xml) throws ParserConfigurationException, SAXException, IOException { + /** Parses XML string into a DOM Element. */ + private Element parseXmlDocument(String xml) + throws ParserConfigurationException, SAXException, IOException { DocumentBuilderFactory factory = createSecureDocumentBuilderFactory(); DocumentBuilder builder = factory.newDocumentBuilder(); - Document doc = builder.parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + Document doc = + builder.parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); return doc.getDocumentElement(); } - /** - * Creates a secure DocumentBuilderFactory with XXE protection. - */ - private DocumentBuilderFactory createSecureDocumentBuilderFactory() throws ParserConfigurationException { + /** Creates a secure DocumentBuilderFactory with XXE protection. */ + private DocumentBuilderFactory createSecureDocumentBuilderFactory() + throws ParserConfigurationException { DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); @@ -97,12 +97,10 @@ private DocumentBuilderFactory createSecureDocumentBuilderFactory() throws Parse return factory; } - /** - * Processes the root element and determines document structure strategy. - */ + /** Processes the root element and determines document structure strategy. */ private List processRootElement(Element rootElement) { List childElements = extractChildElements(rootElement); - + if (shouldTreatChildrenAsDocuments(childElements)) { return createDocumentsFromChildren(childElements); } else { @@ -110,42 +108,36 @@ private List processRootElement(Element rootElement) { } } - /** - * Extracts child elements from the root element. - */ + /** Extracts child elements from the root element. */ private List extractChildElements(Element rootElement) { NodeList children = rootElement.getChildNodes(); List childElements = new ArrayList<>(); - + for (int i = 0; i < children.getLength(); i++) { if (children.item(i).getNodeType() == Node.ELEMENT_NODE) { childElements.add((Element) children.item(i)); } } - + return childElements; } - /** - * Determines if child elements should be treated as separate documents. - */ + /** Determines if child elements should be treated as separate documents. */ private boolean shouldTreatChildrenAsDocuments(List childElements) { Map childElementCounts = new HashMap<>(); - + for (Element child : childElements) { String tagName = child.getTagName(); childElementCounts.put(tagName, childElementCounts.getOrDefault(tagName, 0) + 1); } - + return childElementCounts.values().stream().anyMatch(count -> count > 1); } - /** - * Creates documents from child elements (multiple documents strategy). - */ + /** Creates documents from child elements (multiple documents strategy). */ private List createDocumentsFromChildren(List childElements) { List documents = new ArrayList<>(); - + for (Element childElement : childElements) { SolrInputDocument solrDoc = new SolrInputDocument(); addXmlElementFields(solrDoc, childElement, ""); @@ -153,51 +145,51 @@ private List createDocumentsFromChildren(List childE documents.add(solrDoc); } } - + return documents; } - /** - * Creates a single document from the root element. - */ + /** Creates a single document from the root element. */ private List createSingleDocument(Element rootElement) { List documents = new ArrayList<>(); SolrInputDocument solrDoc = new SolrInputDocument(); addXmlElementFields(solrDoc, rootElement, ""); - + if (!solrDoc.isEmpty()) { documents.add(solrDoc); } - + return documents; } /** * Recursively processes XML elements and adds them as fields to a SolrInputDocument. * - *

This method implements the core logic for converting nested XML structures - * into flat field names that Solr can efficiently index and search. It handles - * both element content and attributes while maintaining data integrity.

+ *

This method implements the core logic for converting nested XML structures into flat field + * names that Solr can efficiently index and search. It handles both element content and + * attributes while maintaining data integrity. + * + *

Processing Logic: * - *

Processing Logic:

*
    - *
  • Attributes: Converted to fields with "_attr" suffix
  • - *
  • Text Content: Element text content indexed directly
  • - *
  • Child Elements: Recursively processed with prefix concatenation
  • - *
  • Empty Elements: Skipped to avoid indexing empty fields
  • - *
  • Repeated Elements: Combined into multi-valued fields
  • + *
  • Attributes: Converted to fields with "_attr" suffix + *
  • Text Content: Element text content indexed directly + *
  • Child Elements: Recursively processed with prefix concatenation + *
  • Empty Elements: Skipped to avoid indexing empty fields + *
  • Repeated Elements: Combined into multi-valued fields *
* - *

Field Naming Convention:

+ *

Field Naming Convention: + * *

    - *
  • Nested elements: parent_child (e.g., author_name)
  • - *
  • Attributes: elementname_attr (e.g., id_attr)
  • - *
  • All field names are sanitized for Solr compatibility
  • + *
  • Nested elements: parent_child (e.g., author_name) + *
  • Attributes: elementname_attr (e.g., id_attr) + *
  • All field names are sanitized for Solr compatibility *
* - * @param doc the SolrInputDocument to add fields to + * @param doc the SolrInputDocument to add fields to * @param element the XML element to process - * @param prefix current field name prefix for nested element flattening + * @param prefix current field name prefix for nested element flattening * @see FieldNameSanitizer#sanitizeFieldName(String) */ private void addXmlElementFields(SolrInputDocument doc, Element element, String prefix) { @@ -213,10 +205,9 @@ private void addXmlElementFields(SolrInputDocument doc, Element element, String processXmlChildElements(doc, children, currentPrefix); } - /** - * Processes XML element attributes and adds them as fields to the document. - */ - private void processXmlAttributes(SolrInputDocument doc, Element element, String prefix, String currentPrefix) { + /** Processes XML element attributes and adds them as fields to the document. */ + private void processXmlAttributes( + SolrInputDocument doc, Element element, String prefix, String currentPrefix) { if (!element.hasAttributes()) { return; } @@ -233,9 +224,7 @@ private void processXmlAttributes(SolrInputDocument doc, Element element, String } } - /** - * Checks if the node list contains any child elements. - */ + /** Checks if the node list contains any child elements. */ private boolean hasChildElements(NodeList children) { for (int i = 0; i < children.getLength(); i++) { if (children.item(i).getNodeType() == Node.ELEMENT_NODE) { @@ -245,12 +234,14 @@ private boolean hasChildElements(NodeList children) { return false; } - /** - * Processes XML text content and adds it as a field to the document. - */ - private void processXmlTextContent(SolrInputDocument doc, String elementName, - String currentPrefix, String prefix, boolean hasChildElements, - NodeList children) { + /** Processes XML text content and adds it as a field to the document. */ + private void processXmlTextContent( + SolrInputDocument doc, + String elementName, + String currentPrefix, + String prefix, + boolean hasChildElements, + NodeList children) { String textContent = extractTextContent(children); if (!textContent.isEmpty()) { String fieldName = prefix.isEmpty() ? elementName : currentPrefix; @@ -258,9 +249,7 @@ private void processXmlTextContent(SolrInputDocument doc, String elementName, } } - /** - * Extracts text content from child nodes. - */ + /** Extracts text content from child nodes. */ private String extractTextContent(NodeList children) { StringBuilder textContent = new StringBuilder(); @@ -277,10 +266,9 @@ private String extractTextContent(NodeList children) { return textContent.toString().trim(); } - /** - * Recursively processes XML child elements. - */ - private void processXmlChildElements(SolrInputDocument doc, NodeList children, String currentPrefix) { + /** Recursively processes XML child elements. */ + private void processXmlChildElements( + SolrInputDocument doc, NodeList children, String currentPrefix) { for (int i = 0; i < children.getLength(); i++) { Node child = children.item(i); if (child.getNodeType() == Node.ELEMENT_NODE) { @@ -288,5 +276,4 @@ private void processXmlChildElements(SolrInputDocument doc, NodeList children, S } } } - -} \ No newline at end of file +} diff --git a/src/main/java/org/apache/solr/mcp/server/metadata/CollectionService.java b/src/main/java/org/apache/solr/mcp/server/metadata/CollectionService.java index 3b02ae3..5f42008 100644 --- a/src/main/java/org/apache/solr/mcp/server/metadata/CollectionService.java +++ b/src/main/java/org/apache/solr/mcp/server/metadata/CollectionService.java @@ -16,6 +16,12 @@ */ package org.apache.solr.mcp.server.metadata; +import static org.apache.solr.mcp.server.metadata.CollectionUtils.*; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.SolrQuery; import org.apache.solr.client.solrj.SolrRequest; @@ -34,64 +40,68 @@ import org.springaicommunity.mcp.annotation.McpToolParam; import org.springframework.stereotype.Service; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; - -import static org.apache.solr.mcp.server.metadata.CollectionUtils.*; - /** - * Spring Service providing comprehensive Solr collection management and monitoring capabilities - * for Model Context Protocol (MCP) clients. - * - *

This service acts as the primary interface for collection-level operations in the Solr MCP Server, - * providing tools for collection discovery, metrics gathering, health monitoring, and performance analysis. - * It bridges the gap between MCP clients (like Claude Desktop) and Apache Solr through the SolrJ client library.

- * - *

Core Capabilities:

+ * Spring Service providing comprehensive Solr collection management and monitoring capabilities for + * Model Context Protocol (MCP) clients. + * + *

This service acts as the primary interface for collection-level operations in the Solr MCP + * Server, providing tools for collection discovery, metrics gathering, health monitoring, and + * performance analysis. It bridges the gap between MCP clients (like Claude Desktop) and Apache + * Solr through the SolrJ client library. + * + *

Core Capabilities: + * *

    - *
  • Collection Discovery: Lists available collections/cores with automatic SolrCloud vs standalone detection
  • - *
  • Performance Monitoring: Comprehensive metrics collection including index, query, cache, and handler statistics
  • - *
  • Health Monitoring: Real-time health checks with availability and performance indicators
  • - *
  • Shard-Aware Operations: Intelligent handling of SolrCloud shard names and collection name extraction
  • + *
  • Collection Discovery: Lists available collections/cores with automatic + * SolrCloud vs standalone detection + *
  • Performance Monitoring: Comprehensive metrics collection including index, + * query, cache, and handler statistics + *
  • Health Monitoring: Real-time health checks with availability and + * performance indicators + *
  • Shard-Aware Operations: Intelligent handling of SolrCloud shard names and + * collection name extraction *
* - *

Implementation Details:

- *

This class uses extensively documented constants for all API parameters, field names, and paths to ensure - * maintainability and reduce the risk of typos. All string literals have been replaced with well-named constants - * that are organized by category (API parameters, response parsing keys, handler paths, statistics fields, etc.).

- * - *

MCP Tool Integration:

- *

Methods annotated with {@code @McpTool} are automatically exposed as MCP tools that can be invoked - * by AI clients. These tools provide natural language interfaces to Solr operations.

- * - *

Supported Solr Deployments:

+ *

Implementation Details: + * + *

This class uses extensively documented constants for all API parameters, field names, and + * paths to ensure maintainability and reduce the risk of typos. All string literals have been + * replaced with well-named constants that are organized by category (API parameters, response + * parsing keys, handler paths, statistics fields, etc.). + * + *

MCP Tool Integration: + * + *

Methods annotated with {@code @McpTool} are automatically exposed as MCP tools that can be + * invoked by AI clients. These tools provide natural language interfaces to Solr operations. + * + *

Supported Solr Deployments: + * *

    - *
  • SolrCloud: Distributed mode using Collections API
  • - *
  • Standalone: Single-node mode using Core Admin API
  • + *
  • SolrCloud: Distributed mode using Collections API + *
  • Standalone: Single-node mode using Core Admin API *
- * - *

Error Handling:

+ * + *

Error Handling: + * *

The service implements robust error handling with graceful degradation. Failed operations * return null values rather than throwing exceptions (except where validation requires it), - * allowing partial metrics collection when some endpoints are unavailable.

- * - *

Example Usage:

+ * allowing partial metrics collection when some endpoints are unavailable. + * + *

Example Usage: + * *

{@code
  * // List all available collections
  * List collections = collectionService.listCollections();
- * 
+ *
  * // Get comprehensive metrics for a collection
  * SolrMetrics metrics = collectionService.getCollectionStats("my_collection");
- * 
+ *
  * // Check collection health
  * SolrHealthStatus health = collectionService.checkHealth("my_collection");
  * }
* * @version 0.0.1 * @since 0.0.1 - * * @see SolrMetrics * @see SolrHealthStatus * @see org.apache.solr.client.solrj.SolrClient @@ -103,14 +113,10 @@ public class CollectionService { // Constants for API Parameters and Paths // ======================================== - /** - * Category parameter value for cache-related MBeans requests - */ + /** Category parameter value for cache-related MBeans requests */ private static final String CACHE_CATEGORY = "CACHE"; - /** - * Category parameter value for query handler MBeans requests - */ + /** Category parameter value for query handler MBeans requests */ private static final String QUERY_HANDLER_CATEGORY = "QUERYHANDLER"; /** Combined category parameter value for both query and update handler MBeans requests */ @@ -221,12 +227,11 @@ public class CollectionService { /** * Constructs a new CollectionService with the required dependencies. - * - *

This constructor is automatically called by Spring's dependency injection - * framework during application startup.

- * - * @param solrClient the SolrJ client instance for communicating with Solr * + *

This constructor is automatically called by Spring's dependency injection framework during + * application startup. + * + * @param solrClient the SolrJ client instance for communicating with Solr * @see SolrClient * @see SolrConfigurationProperties */ @@ -236,28 +241,30 @@ public CollectionService(SolrClient solrClient) { /** * Lists all available Solr collections or cores in the cluster. - * - *

This method automatically detects the Solr deployment type and uses the appropriate API:

+ * + *

This method automatically detects the Solr deployment type and uses the appropriate API: + * *

    - *
  • SolrCloud: Uses Collections API to list distributed collections
  • - *
  • Standalone: Uses Core Admin API to list individual cores
  • + *
  • SolrCloud: Uses Collections API to list distributed collections + *
  • Standalone: Uses Core Admin API to list individual cores *
- * - *

In SolrCloud environments, the returned names may include shard identifiers - * (e.g., "films_shard1_replica_n1"). Use {@link #extractCollectionName(String)} - * to get the base collection name if needed.

- * - *

Error Handling:

- *

If the operation fails due to connectivity issues or API errors, an empty list - * is returned rather than throwing an exception, allowing the application to continue - * functioning with degraded capabilities.

- * - *

MCP Tool Usage:

- *

This method is exposed as an MCP tool and can be invoked by AI clients with - * natural language requests like "list all collections" or "show me available databases".

- * + * + *

In SolrCloud environments, the returned names may include shard identifiers (e.g., + * "films_shard1_replica_n1"). Use {@link #extractCollectionName(String)} to get the base + * collection name if needed. + * + *

Error Handling: + * + *

If the operation fails due to connectivity issues or API errors, an empty list is returned + * rather than throwing an exception, allowing the application to continue functioning with + * degraded capabilities. + * + *

MCP Tool Usage: + * + *

This method is exposed as an MCP tool and can be invoked by AI clients with natural + * language requests like "list all collections" or "show me available databases". + * * @return a list of collection/core names, or an empty list if unable to retrieve them - * * @see CollectionAdminRequest.List * @see CoreAdminRequest */ @@ -270,7 +277,8 @@ public List listCollections() { CollectionAdminResponse response = request.process(solrClient); @SuppressWarnings("unchecked") - List collections = (List) response.getResponse().get(COLLECTIONS_KEY); + List collections = + (List) response.getResponse().get(COLLECTIONS_KEY); return collections != null ? collections : new ArrayList<>(); } else { // For standalone Solr - use Core Admin API @@ -292,46 +300,53 @@ public List listCollections() { /** * Retrieves comprehensive performance metrics and statistics for a specified Solr collection. - * + * *

This method aggregates metrics from multiple Solr endpoints to provide a complete - * performance profile including index health, query performance, cache utilization, - * and request handler statistics.

- * - *

Collected Metrics:

+ * performance profile including index health, query performance, cache utilization, and request + * handler statistics. + * + *

Collected Metrics: + * *

    - *
  • Index Statistics: Document counts, segment information (via Luke handler)
  • - *
  • Query Performance: Response times, result counts, relevance scores
  • - *
  • Cache Utilization: Hit ratios, eviction rates for all cache types
  • - *
  • Handler Performance: Request volumes, error rates, throughput metrics
  • + *
  • Index Statistics: Document counts, segment information (via Luke + * handler) + *
  • Query Performance: Response times, result counts, relevance scores + *
  • Cache Utilization: Hit ratios, eviction rates for all cache types + *
  • Handler Performance: Request volumes, error rates, throughput metrics *
- * - *

Collection Name Handling:

+ * + *

Collection Name Handling: + * *

Supports both collection names and shard names. If a shard name like - * "films_shard1_replica_n1" is provided, it will be automatically converted - * to the base collection name "films" for API calls.

- * - *

Validation:

- *

The method validates that the specified collection exists before attempting - * to collect metrics. If the collection is not found, an {@code IllegalArgumentException} - * is thrown with a descriptive error message.

- * - *

MCP Tool Usage:

+ * "films_shard1_replica_n1" is provided, it will be automatically converted to the base + * collection name "films" for API calls. + * + *

Validation: + * + *

The method validates that the specified collection exists before attempting to collect + * metrics. If the collection is not found, an {@code IllegalArgumentException} is thrown with a + * descriptive error message. + * + *

MCP Tool Usage: + * *

Exposed as an MCP tool for natural language queries like "get metrics for my_collection" - * or "show me performance stats for the search index".

- * - * @param collection the name of the collection to analyze (supports both collection and shard names) - * @return comprehensive metrics object containing all collected statistics + * or "show me performance stats for the search index". * + * @param collection the name of the collection to analyze (supports both collection and shard + * names) + * @return comprehensive metrics object containing all collected statistics * @throws IllegalArgumentException if the specified collection does not exist * @throws SolrServerException if there are errors communicating with Solr * @throws IOException if there are I/O errors during communication - * * @see SolrMetrics * @see LukeRequest * @see #extractCollectionName(String) */ @McpTool(description = "Get stats/metrics on a Solr collection") - public SolrMetrics getCollectionStats(@McpToolParam(description = "Solr collection to get stats/metrics for") String collection) throws SolrServerException, IOException { + public SolrMetrics getCollectionStats( + @McpToolParam(description = "Solr collection to get stats/metrics for") + String collection) + throws SolrServerException, IOException { // Extract actual collection name from shard name if needed String actualCollection = extractCollectionName(collection); @@ -346,38 +361,38 @@ public SolrMetrics getCollectionStats(@McpToolParam(description = "Solr collecti LukeResponse lukeResponse = lukeRequest.process(solrClient, actualCollection); // Query performance metrics - QueryResponse statsResponse = solrClient.query(actualCollection, - new SolrQuery(ALL_DOCUMENTS_QUERY).setRows(0)); + QueryResponse statsResponse = + solrClient.query(actualCollection, new SolrQuery(ALL_DOCUMENTS_QUERY).setRows(0)); return new SolrMetrics( buildIndexStats(lukeResponse), buildQueryStats(statsResponse), getCacheMetrics(actualCollection), getHandlerMetrics(actualCollection), - new Date() - ); + new Date()); } /** * Builds an IndexStats object from a Solr Luke response containing index metadata. - * - *

The Luke handler provides low-level Lucene index information including document - * counts, segment details, and field statistics. This method extracts the essential - * index health metrics for monitoring and analysis.

- * - *

Extracted Metrics:

+ * + *

The Luke handler provides low-level Lucene index information including document counts, + * segment details, and field statistics. This method extracts the essential index health + * metrics for monitoring and analysis. + * + *

Extracted Metrics: + * *

    - *
  • numDocs: Total number of documents excluding deleted ones
  • - *
  • segmentCount: Number of Lucene segments (performance indicator)
  • + *
  • numDocs: Total number of documents excluding deleted ones + *
  • segmentCount: Number of Lucene segments (performance indicator) *
- * - *

Performance Implications:

- *

High segment counts may indicate the need for index optimization to improve - * search performance. The optimal segment count depends on index size and update frequency.

- * + * + *

Performance Implications: + * + *

High segment counts may indicate the need for index optimization to improve search + * performance. The optimal segment count depends on index size and update frequency. + * * @param lukeResponse the Luke response containing raw index information * @return IndexStats object with extracted and formatted metrics - * * @see IndexStats * @see LukeResponse */ @@ -387,34 +402,32 @@ public IndexStats buildIndexStats(LukeResponse lukeResponse) { // Extract index information using helper methods Integer segmentCount = getInteger(indexInfo, SEGMENT_COUNT_KEY); - return new IndexStats( - lukeResponse.getNumDocs(), - segmentCount - ); + return new IndexStats(lukeResponse.getNumDocs(), segmentCount); } /** * Builds a QueryStats object from a Solr query response containing performance metrics. - * - *

Extracts key performance indicators from a query execution including timing, - * result characteristics, and relevance scoring information. These metrics help - * identify query performance patterns and optimization opportunities.

- * - *

Extracted Metrics:

+ * + *

Extracts key performance indicators from a query execution including timing, result + * characteristics, and relevance scoring information. These metrics help identify query + * performance patterns and optimization opportunities. + * + *

Extracted Metrics: + * *

    - *
  • queryTime: Execution time in milliseconds
  • - *
  • totalResults: Total matching documents found
  • - *
  • start: Pagination offset (0-based)
  • - *
  • maxScore: Highest relevance score in results
  • + *
  • queryTime: Execution time in milliseconds + *
  • totalResults: Total matching documents found + *
  • start: Pagination offset (0-based) + *
  • maxScore: Highest relevance score in results *
- * - *

Performance Analysis:

- *

Query time metrics help identify slow queries that may need optimization, - * while result counts and scores provide insight into search effectiveness.

- * + * + *

Performance Analysis: + * + *

Query time metrics help identify slow queries that may need optimization, while result + * counts and scores provide insight into search effectiveness. + * * @param response the query response containing performance and result metadata * @return QueryStats object with extracted performance metrics - * * @see QueryStats * @see QueryResponse */ @@ -424,39 +437,40 @@ public QueryStats buildQueryStats(QueryResponse response) { response.getQTime(), response.getResults().getNumFound(), response.getResults().getStart(), - response.getResults().getMaxScore() - ); + response.getResults().getMaxScore()); } /** * Retrieves cache performance metrics for all cache types in a Solr collection. - * - *

Collects detailed cache utilization statistics from Solr's MBeans endpoint, - * providing insights into cache effectiveness and memory usage patterns. Cache - * performance directly impacts query response times and system efficiency.

- * - *

Monitored Cache Types:

+ * + *

Collects detailed cache utilization statistics from Solr's MBeans endpoint, providing + * insights into cache effectiveness and memory usage patterns. Cache performance directly + * impacts query response times and system efficiency. + * + *

Monitored Cache Types: + * *

    - *
  • Query Result Cache: Caches complete query results for identical searches
  • - *
  • Document Cache: Caches retrieved document field data
  • - *
  • Filter Cache: Caches filter query results for faceting and filtering
  • + *
  • Query Result Cache: Caches complete query results for identical + * searches + *
  • Document Cache: Caches retrieved document field data + *
  • Filter Cache: Caches filter query results for faceting and filtering *
- * - *

Key Performance Indicators:

+ * + *

Key Performance Indicators: + * *

    - *
  • Hit Ratio: Cache effectiveness (higher is better)
  • - *
  • Evictions: Memory pressure indicator
  • - *
  • Size: Current cache utilization
  • + *
  • Hit Ratio: Cache effectiveness (higher is better) + *
  • Evictions: Memory pressure indicator + *
  • Size: Current cache utilization *
- * - *

Error Handling:

- *

Returns {@code null} if cache statistics cannot be retrieved or if all - * cache types are empty/unavailable. This allows graceful degradation when - * cache monitoring is not available.

- * + * + *

Error Handling: + * + *

Returns {@code null} if cache statistics cannot be retrieved or if all cache types are + * empty/unavailable. This allows graceful degradation when cache monitoring is not available. + * * @param collection the collection name to retrieve cache metrics for * @return CacheStats object with all cache performance metrics, or null if unavailable - * * @see CacheStats * @see CacheInfo * @see #extractCacheStats(NamedList) @@ -480,11 +494,8 @@ public CacheStats getCacheMetrics(String collection) { String path = "/" + actualCollection + ADMIN_MBEANS_PATH; - GenericSolrRequest request = new GenericSolrRequest( - SolrRequest.METHOD.GET, - path, - params - ); + GenericSolrRequest request = + new GenericSolrRequest(SolrRequest.METHOD.GET, path, params); NamedList response = solrClient.request(request); CacheStats stats = extractCacheStats(response); @@ -502,45 +513,45 @@ public CacheStats getCacheMetrics(String collection) { /** * Checks if cache statistics are empty or contain no meaningful data. - * - *

Used to determine whether cache metrics are worth returning to clients. - * Empty cache stats typically indicate that caches are not configured or - * not yet populated with data.

- * + * + *

Used to determine whether cache metrics are worth returning to clients. Empty cache stats + * typically indicate that caches are not configured or not yet populated with data. + * * @param stats the cache statistics to evaluate * @return true if the stats are null or all cache types are null */ private boolean isCacheStatsEmpty(CacheStats stats) { - return stats == null || - (stats.queryResultCache() == null && - stats.documentCache() == null && - stats.filterCache() == null); + return stats == null + || (stats.queryResultCache() == null + && stats.documentCache() == null + && stats.filterCache() == null); } /** * Extracts cache performance statistics from Solr MBeans response data. - * - *

Parses the raw MBeans response to extract structured cache performance - * metrics for all available cache types. Each cache type provides detailed - * statistics including hit ratios, eviction rates, and current utilization.

- * - *

Parsed Cache Types:

+ * + *

Parses the raw MBeans response to extract structured cache performance metrics for all + * available cache types. Each cache type provides detailed statistics including hit ratios, + * eviction rates, and current utilization. + * + *

Parsed Cache Types: + * *

    - *
  • queryResultCache - Complete query result caching
  • - *
  • documentCache - Retrieved document data caching
  • - *
  • filterCache - Filter query result caching
  • + *
  • queryResultCache - Complete query result caching + *
  • documentCache - Retrieved document data caching + *
  • filterCache - Filter query result caching *
- * - *

For each cache type, the following metrics are extracted:

+ * + *

For each cache type, the following metrics are extracted: + * *

    - *
  • lookups, hits, hitratio - Performance effectiveness
  • - *
  • inserts, evictions - Memory management patterns
  • - *
  • size - Current utilization
  • + *
  • lookups, hits, hitratio - Performance effectiveness + *
  • inserts, evictions - Memory management patterns + *
  • size - Current utilization *
- * + * * @param mbeans the raw MBeans response from Solr admin endpoint * @return CacheStats object containing parsed metrics for all cache types - * * @see CacheStats * @see CacheInfo */ @@ -555,18 +566,19 @@ private CacheStats extractCacheStats(NamedList mbeans) { if (caches != null) { // Query result cache @SuppressWarnings("unchecked") - NamedList queryResultCache = (NamedList) caches.get(QUERY_RESULT_CACHE_KEY); + NamedList queryResultCache = + (NamedList) caches.get(QUERY_RESULT_CACHE_KEY); if (queryResultCache != null) { @SuppressWarnings("unchecked") NamedList stats = (NamedList) queryResultCache.get(STATS_KEY); - queryResultCacheInfo = new CacheInfo( - getLong(stats, LOOKUPS_FIELD), - getLong(stats, HITS_FIELD), - getFloat(stats, HITRATIO_FIELD), - getLong(stats, INSERTS_FIELD), - getLong(stats, EVICTIONS_FIELD), - getLong(stats, SIZE_FIELD) - ); + queryResultCacheInfo = + new CacheInfo( + getLong(stats, LOOKUPS_FIELD), + getLong(stats, HITS_FIELD), + getFloat(stats, HITRATIO_FIELD), + getLong(stats, INSERTS_FIELD), + getLong(stats, EVICTIONS_FIELD), + getLong(stats, SIZE_FIELD)); } // Document cache @@ -575,14 +587,14 @@ private CacheStats extractCacheStats(NamedList mbeans) { if (documentCache != null) { @SuppressWarnings("unchecked") NamedList stats = (NamedList) documentCache.get(STATS_KEY); - documentCacheInfo = new CacheInfo( - getLong(stats, LOOKUPS_FIELD), - getLong(stats, HITS_FIELD), - getFloat(stats, HITRATIO_FIELD), - getLong(stats, INSERTS_FIELD), - getLong(stats, EVICTIONS_FIELD), - getLong(stats, SIZE_FIELD) - ); + documentCacheInfo = + new CacheInfo( + getLong(stats, LOOKUPS_FIELD), + getLong(stats, HITS_FIELD), + getFloat(stats, HITRATIO_FIELD), + getLong(stats, INSERTS_FIELD), + getLong(stats, EVICTIONS_FIELD), + getLong(stats, SIZE_FIELD)); } // Filter cache @@ -591,14 +603,14 @@ private CacheStats extractCacheStats(NamedList mbeans) { if (filterCache != null) { @SuppressWarnings("unchecked") NamedList stats = (NamedList) filterCache.get(STATS_KEY); - filterCacheInfo = new CacheInfo( - getLong(stats, LOOKUPS_FIELD), - getLong(stats, HITS_FIELD), - getFloat(stats, HITRATIO_FIELD), - getLong(stats, INSERTS_FIELD), - getLong(stats, EVICTIONS_FIELD), - getLong(stats, SIZE_FIELD) - ); + filterCacheInfo = + new CacheInfo( + getLong(stats, LOOKUPS_FIELD), + getLong(stats, HITS_FIELD), + getFloat(stats, HITRATIO_FIELD), + getLong(stats, INSERTS_FIELD), + getLong(stats, EVICTIONS_FIELD), + getLong(stats, SIZE_FIELD)); } } @@ -607,32 +619,36 @@ private CacheStats extractCacheStats(NamedList mbeans) { /** * Retrieves request handler performance metrics for core Solr operations. - * - *

Collects detailed performance statistics for the primary request handlers - * that process search and update operations. Handler metrics provide insights - * into system throughput, error rates, and response time characteristics.

- * - *

Monitored Handlers:

+ * + *

Collects detailed performance statistics for the primary request handlers that process + * search and update operations. Handler metrics provide insights into system throughput, error + * rates, and response time characteristics. + * + *

Monitored Handlers: + * *

    - *
  • Select Handler ({@value #SELECT_HANDLER_PATH}): Processes search and query requests
  • - *
  • Update Handler ({@value #UPDATE_HANDLER_PATH}): Processes document indexing operations
  • + *
  • Select Handler ({@value #SELECT_HANDLER_PATH}): Processes search and + * query requests + *
  • Update Handler ({@value #UPDATE_HANDLER_PATH}): Processes document + * indexing operations *
- * - *

Performance Metrics:

+ * + *

Performance Metrics: + * *

    - *
  • Request Volume: Total requests processed
  • - *
  • Error Rates: Failed request counts and timeouts
  • - *
  • Performance: Average response times and throughput
  • + *
  • Request Volume: Total requests processed + *
  • Error Rates: Failed request counts and timeouts + *
  • Performance: Average response times and throughput *
- * - *

Error Handling:

- *

Returns {@code null} if handler statistics cannot be retrieved or if - * no meaningful handler data is available. This allows graceful degradation - * when handler monitoring endpoints are not accessible.

- * + * + *

Error Handling: + * + *

Returns {@code null} if handler statistics cannot be retrieved or if no meaningful handler + * data is available. This allows graceful degradation when handler monitoring endpoints are not + * accessible. + * * @param collection the collection name to retrieve handler metrics for * @return HandlerStats object with performance metrics for all handlers, or null if unavailable - * * @see HandlerStats * @see HandlerInfo * @see #extractHandlerStats(NamedList) @@ -655,11 +671,8 @@ public HandlerStats getHandlerMetrics(String collection) { String path = "/" + actualCollection + ADMIN_MBEANS_PATH; - GenericSolrRequest request = new GenericSolrRequest( - SolrRequest.METHOD.GET, - path, - params - ); + GenericSolrRequest request = + new GenericSolrRequest(SolrRequest.METHOD.GET, path, params); NamedList response = solrClient.request(request); HandlerStats stats = extractHandlerStats(response); @@ -677,42 +690,42 @@ public HandlerStats getHandlerMetrics(String collection) { /** * Checks if handler statistics are empty or contain no meaningful data. - * - *

Used to determine whether handler metrics are worth returning to clients. - * Empty handler stats typically indicate that handlers haven't processed any - * requests yet or statistics collection is not enabled.

- * + * + *

Used to determine whether handler metrics are worth returning to clients. Empty handler + * stats typically indicate that handlers haven't processed any requests yet or statistics + * collection is not enabled. + * * @param stats the handler statistics to evaluate * @return true if the stats are null or all handler types are null */ private boolean isHandlerStatsEmpty(HandlerStats stats) { - return stats == null || - (stats.selectHandler() == null && stats.updateHandler() == null); + return stats == null || (stats.selectHandler() == null && stats.updateHandler() == null); } /** * Extracts request handler performance statistics from Solr MBeans response data. - * - *

Parses the raw MBeans response to extract structured handler performance - * metrics for query and update operations. Each handler provides detailed - * statistics about request processing including volume, errors, and timing.

- * - *

Parsed Handler Types:

+ * + *

Parses the raw MBeans response to extract structured handler performance metrics for query + * and update operations. Each handler provides detailed statistics about request processing + * including volume, errors, and timing. + * + *

Parsed Handler Types: + * *

    - *
  • /select - Search and query request handler
  • - *
  • /update - Document indexing request handler
  • + *
  • /select - Search and query request handler + *
  • /update - Document indexing request handler *
- * - *

For each handler type, the following metrics are extracted:

+ * + *

For each handler type, the following metrics are extracted: + * *

    - *
  • requests, errors, timeouts - Volume and reliability
  • - *
  • totalTime, avgTimePerRequest - Performance characteristics
  • - *
  • avgRequestsPerSecond - Throughput capacity
  • + *
  • requests, errors, timeouts - Volume and reliability + *
  • totalTime, avgTimePerRequest - Performance characteristics + *
  • avgRequestsPerSecond - Throughput capacity *
- * + * * @param mbeans the raw MBeans response from Solr admin endpoint * @return HandlerStats object containing parsed metrics for all handler types - * * @see HandlerStats * @see HandlerInfo */ @@ -726,62 +739,65 @@ private HandlerStats extractHandlerStats(NamedList mbeans) { if (queryHandlers != null) { // Select handler @SuppressWarnings("unchecked") - NamedList selectHandler = (NamedList) queryHandlers.get(SELECT_HANDLER_PATH); + NamedList selectHandler = + (NamedList) queryHandlers.get(SELECT_HANDLER_PATH); if (selectHandler != null) { @SuppressWarnings("unchecked") NamedList stats = (NamedList) selectHandler.get(STATS_KEY); - selectHandlerInfo = new HandlerInfo( - getLong(stats, REQUESTS_FIELD), - getLong(stats, ERRORS_FIELD), - getLong(stats, TIMEOUTS_FIELD), - getLong(stats, TOTAL_TIME_FIELD), - getFloat(stats, AVG_TIME_PER_REQUEST_FIELD), - getFloat(stats, AVG_REQUESTS_PER_SECOND_FIELD) - ); + selectHandlerInfo = + new HandlerInfo( + getLong(stats, REQUESTS_FIELD), + getLong(stats, ERRORS_FIELD), + getLong(stats, TIMEOUTS_FIELD), + getLong(stats, TOTAL_TIME_FIELD), + getFloat(stats, AVG_TIME_PER_REQUEST_FIELD), + getFloat(stats, AVG_REQUESTS_PER_SECOND_FIELD)); } // Update handler @SuppressWarnings("unchecked") - NamedList updateHandler = (NamedList) queryHandlers.get(UPDATE_HANDLER_PATH); + NamedList updateHandler = + (NamedList) queryHandlers.get(UPDATE_HANDLER_PATH); if (updateHandler != null) { @SuppressWarnings("unchecked") NamedList stats = (NamedList) updateHandler.get(STATS_KEY); - updateHandlerInfo = new HandlerInfo( - getLong(stats, REQUESTS_FIELD), - getLong(stats, ERRORS_FIELD), - getLong(stats, TIMEOUTS_FIELD), - getLong(stats, TOTAL_TIME_FIELD), - getFloat(stats, AVG_TIME_PER_REQUEST_FIELD), - getFloat(stats, AVG_REQUESTS_PER_SECOND_FIELD) - ); + updateHandlerInfo = + new HandlerInfo( + getLong(stats, REQUESTS_FIELD), + getLong(stats, ERRORS_FIELD), + getLong(stats, TIMEOUTS_FIELD), + getLong(stats, TOTAL_TIME_FIELD), + getFloat(stats, AVG_TIME_PER_REQUEST_FIELD), + getFloat(stats, AVG_REQUESTS_PER_SECOND_FIELD)); } } return new HandlerStats(selectHandlerInfo, updateHandlerInfo); } - /** * Extracts the actual collection name from a shard name in SolrCloud environments. - * + * *

In SolrCloud deployments, collection operations often return shard names that include - * replica and shard identifiers (e.g., "films_shard1_replica_n1"). This method extracts - * the base collection name ("films") for use in API calls that require the collection name.

- * - *

Extraction Logic:

+ * replica and shard identifiers (e.g., "films_shard1_replica_n1"). This method extracts the + * base collection name ("films") for use in API calls that require the collection name. + * + *

Extraction Logic: + * *

    - *
  • Detects shard patterns containing the {@value #SHARD_SUFFIX} suffix
  • - *
  • Returns the substring before the shard identifier
  • - *
  • Returns the original string if no shard pattern is detected
  • + *
  • Detects shard patterns containing the {@value #SHARD_SUFFIX} suffix + *
  • Returns the substring before the shard identifier + *
  • Returns the original string if no shard pattern is detected *
- * - *

Examples:

+ * + *

Examples: + * *

    - *
  • "films_shard1_replica_n1" → "films"
  • - *
  • "products_shard2_replica_n3" → "products"
  • - *
  • "simple_collection" → "simple_collection" (unchanged)
  • + *
  • "films_shard1_replica_n1" → "films" + *
  • "products_shard2_replica_n3" → "products" + *
  • "simple_collection" → "simple_collection" (unchanged) *
- * + * * @param collectionOrShard the collection or shard name to parse * @return the extracted collection name, or the original string if no shard pattern found */ @@ -803,27 +819,29 @@ String extractCollectionName(String collectionOrShard) { /** * Validates that a specified collection exists in the Solr cluster. - * - *

Performs collection existence validation by checking against the list of - * available collections. Supports both exact collection name matches and - * shard-based matching for SolrCloud environments.

- * - *

Validation Strategy:

+ * + *

Performs collection existence validation by checking against the list of available + * collections. Supports both exact collection name matches and shard-based matching for + * SolrCloud environments. + * + *

Validation Strategy: + * *

    - *
  1. Exact Match: Checks if the collection name exists exactly
  2. - *
  3. Shard Match: Checks if any shards start with "collection{@value #SHARD_SUFFIX}" pattern
  4. + *
  5. Exact Match: Checks if the collection name exists exactly + *
  6. Shard Match: Checks if any shards start with "collection{@value + * #SHARD_SUFFIX}" pattern *
- * - *

This dual approach ensures compatibility with both standalone Solr - * (which returns core names directly) and SolrCloud (which may return shard names).

- * - *

Error Handling:

- *

Returns {@code false} if validation fails due to communication errors, - * allowing calling methods to handle missing collections appropriately.

- * + * + *

This dual approach ensures compatibility with both standalone Solr (which returns core + * names directly) and SolrCloud (which may return shard names). + * + *

Error Handling: + * + *

Returns {@code false} if validation fails due to communication errors, allowing calling + * methods to handle missing collections appropriately. + * * @param collection the collection name to validate * @return true if the collection exists (either exact or shard match), false otherwise - * * @see #listCollections() * @see #extractCollectionName(String) */ @@ -836,9 +854,10 @@ private boolean validateCollectionExists(String collection) { return true; } - // Check if any of the returned collections start with the collection name (for shard names) - boolean shardMatch = collections.stream() - .anyMatch(c -> c.startsWith(collection + SHARD_SUFFIX)); + // Check if any of the returned collections start with the collection name (for shard + // names) + boolean shardMatch = + collections.stream().anyMatch(c -> c.startsWith(collection + SHARD_SUFFIX)); return shardMatch; } catch (Exception e) { @@ -848,48 +867,53 @@ private boolean validateCollectionExists(String collection) { /** * Performs a comprehensive health check on a Solr collection. - * - *

Evaluates collection availability and performance by executing a ping operation - * and basic query to gather health indicators. This method provides a quick way to - * determine if a collection is operational and responding to requests.

- * - *

Health Check Components:

+ * + *

Evaluates collection availability and performance by executing a ping operation and basic + * query to gather health indicators. This method provides a quick way to determine if a + * collection is operational and responding to requests. + * + *

Health Check Components: + * *

    - *
  • Availability: Collection responds to ping requests
  • - *
  • Performance: Response time measurement
  • - *
  • Content: Document count verification using universal query ({@value #ALL_DOCUMENTS_QUERY})
  • - *
  • Timestamp: When the check was performed
  • + *
  • Availability: Collection responds to ping requests + *
  • Performance: Response time measurement + *
  • Content: Document count verification using universal query ({@value + * #ALL_DOCUMENTS_QUERY}) + *
  • Timestamp: When the check was performed *
- * - *

Success Criteria:

- *

A collection is considered healthy if both the ping operation and a basic - * query complete successfully without exceptions. Performance metrics are collected - * during the health check process.

- * - *

Failure Handling:

- *

If the health check fails, a status object is returned with {@code isHealthy=false} - * and the error message describing the failure reason. This allows monitoring - * systems to identify specific issues.

- * - *

MCP Tool Usage:

- *

Exposed as an MCP tool for natural language health queries like - * "check if my_collection is healthy" or "is the search index working properly".

- * + * + *

Success Criteria: + * + *

A collection is considered healthy if both the ping operation and a basic query complete + * successfully without exceptions. Performance metrics are collected during the health check + * process. + * + *

Failure Handling: + * + *

If the health check fails, a status object is returned with {@code isHealthy=false} and + * the error message describing the failure reason. This allows monitoring systems to identify + * specific issues. + * + *

MCP Tool Usage: + * + *

Exposed as an MCP tool for natural language health queries like "check if my_collection is + * healthy" or "is the search index working properly". + * * @param collection the name of the collection to health check * @return SolrHealthStatus object containing health assessment results - * * @see SolrHealthStatus * @see SolrPingResponse */ @McpTool(description = "Check health of a Solr collection") - public SolrHealthStatus checkHealth(@McpToolParam(description = "Solr collection") String collection) { + public SolrHealthStatus checkHealth( + @McpToolParam(description = "Solr collection") String collection) { try { // Ping Solr SolrPingResponse pingResponse = solrClient.ping(collection); // Get basic stats - QueryResponse statsResponse = solrClient.query(collection, - new SolrQuery(ALL_DOCUMENTS_QUERY).setRows(0)); + QueryResponse statsResponse = + solrClient.query(collection, new SolrQuery(ALL_DOCUMENTS_QUERY).setRows(0)); return new SolrHealthStatus( true, @@ -899,21 +923,11 @@ public SolrHealthStatus checkHealth(@McpToolParam(description = "Solr collection new Date(), null, null, - null - ); + null); } catch (Exception e) { return new SolrHealthStatus( - false, - e.getMessage(), - null, - null, - new Date(), - null, - null, - null - ); + false, e.getMessage(), null, null, new Date(), null, null, null); } } - } diff --git a/src/main/java/org/apache/solr/mcp/server/metadata/CollectionUtils.java b/src/main/java/org/apache/solr/mcp/server/metadata/CollectionUtils.java index bdcb0f9..fe3c621 100644 --- a/src/main/java/org/apache/solr/mcp/server/metadata/CollectionUtils.java +++ b/src/main/java/org/apache/solr/mcp/server/metadata/CollectionUtils.java @@ -19,35 +19,38 @@ import org.apache.solr.common.util.NamedList; /** - * Utility class providing type-safe helper methods for extracting values from Apache Solr NamedList objects. - * - *

This utility class simplifies the process of working with Solr's {@code NamedList} response format - * by providing robust type conversion methods that handle various data formats and edge cases commonly - * encountered when processing Solr admin and query responses.

- * - *

Key Benefits:

+ * Utility class providing type-safe helper methods for extracting values from Apache Solr NamedList + * objects. + * + *

This utility class simplifies the process of working with Solr's {@code NamedList} response + * format by providing robust type conversion methods that handle various data formats and edge + * cases commonly encountered when processing Solr admin and query responses. + * + *

Key Benefits: + * *

    - *
  • Type Safety: Automatic conversion with proper error handling
  • - *
  • Null Safety: Graceful handling of missing or null values
  • - *
  • Format Flexibility: Support for multiple input data types
  • - *
  • Error Resilience: Defensive programming against malformed data
  • + *
  • Type Safety: Automatic conversion with proper error handling + *
  • Null Safety: Graceful handling of missing or null values + *
  • Format Flexibility: Support for multiple input data types + *
  • Error Resilience: Defensive programming against malformed data *
- * - *

Common Use Cases:

+ * + *

Common Use Cases: + * *

    - *
  • Extracting metrics from Solr MBeans responses
  • - *
  • Processing Luke handler index statistics
  • - *
  • Converting admin API response values to typed objects
  • - *
  • Handling cache and handler performance metrics
  • + *
  • Extracting metrics from Solr MBeans responses + *
  • Processing Luke handler index statistics + *
  • Converting admin API response values to typed objects + *
  • Handling cache and handler performance metrics *
- * - *

Thread Safety:

- *

All methods in this utility class are stateless and thread-safe, making them - * suitable for use in concurrent environments and Spring service beans.

+ * + *

Thread Safety: + * + *

All methods in this utility class are stateless and thread-safe, making them suitable for use + * in concurrent environments and Spring service beans. * * @version 0.0.1 * @since 0.0.1 - * * @see org.apache.solr.common.util.NamedList * @see CollectionService */ @@ -55,33 +58,35 @@ public class CollectionUtils { /** * Extracts a Long value from a NamedList using the specified key with robust type conversion. - * + * *

This method provides flexible extraction of Long values from Solr NamedList responses, * handling various input formats that may be returned by different Solr endpoints. It performs - * safe type conversion with appropriate error handling for malformed data.

- * - *

Supported Input Types:

+ * safe type conversion with appropriate error handling for malformed data. + * + *

Supported Input Types: + * *

    - *
  • Number instances: Integer, Long, Double, Float, BigInteger, BigDecimal
  • - *
  • String representations: Numeric strings that can be parsed as Long
  • - *
  • Null values: Returns null without throwing exceptions
  • + *
  • Number instances: Integer, Long, Double, Float, BigInteger, BigDecimal + *
  • String representations: Numeric strings that can be parsed as Long + *
  • Null values: Returns null without throwing exceptions *
- * - *

Error Handling:

+ * + *

Error Handling: + * *

Returns {@code null} for missing keys, null values, or unparseable strings rather than - * throwing exceptions, enabling graceful degradation in metrics collection scenarios.

- * - *

Common Use Cases:

+ * throwing exceptions, enabling graceful degradation in metrics collection scenarios. + * + *

Common Use Cases: + * *

    - *
  • Cache statistics: hits, lookups, evictions, size
  • - *
  • Handler metrics: request counts, error counts, timeouts
  • - *
  • Index statistics: document counts, segment information
  • + *
  • Cache statistics: hits, lookups, evictions, size + *
  • Handler metrics: request counts, error counts, timeouts + *
  • Index statistics: document counts, segment information *
* * @param response the NamedList containing the data to extract from * @param key the key to look up in the NamedList * @return the Long value if found and convertible, null otherwise - * * @see Number#longValue() * @see Long#parseLong(String) */ @@ -101,37 +106,42 @@ public static Long getLong(NamedList response, String key) { } /** - * Extracts a Float value from a NamedList using the specified key with automatic type conversion. - * + * Extracts a Float value from a NamedList using the specified key with automatic type + * conversion. + * *

This method provides convenient extraction of Float values from Solr NamedList responses, * commonly used for extracting percentage values, ratios, and performance metrics. It assumes - * that missing values should be treated as zero, which is appropriate for most metric scenarios.

- * - *

Type Conversion:

+ * that missing values should be treated as zero, which is appropriate for most metric + * scenarios. + * + *

Type Conversion: + * *

Automatically converts any Number instance to Float using the {@link Number#floatValue()} - * method, ensuring compatibility with various numeric types returned by Solr.

- * - *

Default Value Behavior:

+ * method, ensuring compatibility with various numeric types returned by Solr. + * + *

Default Value Behavior: + * *

Returns {@code 0.0f} for missing or null values, which is typically the desired behavior - * for metrics like hit ratios, performance averages, and statistical calculations where - * missing data should be interpreted as zero.

- * - *

Common Use Cases:

+ * for metrics like hit ratios, performance averages, and statistical calculations where missing + * data should be interpreted as zero. + * + *

Common Use Cases: + * *

    - *
  • Cache hit ratios and performance percentages
  • - *
  • Average response times and throughput metrics
  • - *
  • Statistical calculations and performance indicators
  • + *
  • Cache hit ratios and performance percentages + *
  • Average response times and throughput metrics + *
  • Statistical calculations and performance indicators *
- * - *

Note:

- *

This method differs from {@link #getLong(NamedList, String)} by returning a default - * value instead of null, which is more appropriate for Float metrics that represent - * rates, ratios, or averages.

+ * + *

Note: + * + *

This method differs from {@link #getLong(NamedList, String)} by returning a default value + * instead of null, which is more appropriate for Float metrics that represent rates, ratios, or + * averages. * * @param stats the NamedList containing the metric data to extract from * @param key the key to look up in the NamedList * @return the Float value if found, or 0.0f if the key doesn't exist or value is null - * * @see Number#floatValue() */ public static Float getFloat(NamedList stats, String key) { @@ -140,43 +150,48 @@ public static Float getFloat(NamedList stats, String key) { } /** - * Extracts an Integer value from a NamedList using the specified key with robust type conversion. - * + * Extracts an Integer value from a NamedList using the specified key with robust type + * conversion. + * *

This method provides flexible extraction of Integer values from Solr NamedList responses, * handling various input formats that may be returned by different Solr endpoints. It performs - * safe type conversion with appropriate error handling for malformed data.

- * - *

Supported Input Types:

+ * safe type conversion with appropriate error handling for malformed data. + * + *

Supported Input Types: + * *

    - *
  • Number instances: Integer, Long, Double, Float, BigInteger, BigDecimal
  • - *
  • String representations: Numeric strings that can be parsed as Integer
  • - *
  • Null values: Returns null without throwing exceptions
  • + *
  • Number instances: Integer, Long, Double, Float, BigInteger, BigDecimal + *
  • String representations: Numeric strings that can be parsed as Integer + *
  • Null values: Returns null without throwing exceptions *
- * - *

Type Conversion Strategy:

- *

For Number instances, uses {@link Number#intValue()} which truncates decimal values. - * For string values, attempts parsing with {@link Integer#parseInt(String)} and returns - * null if parsing fails rather than throwing an exception.

- * - *

Error Handling:

+ * + *

Type Conversion Strategy: + * + *

For Number instances, uses {@link Number#intValue()} which truncates decimal values. For + * string values, attempts parsing with {@link Integer#parseInt(String)} and returns null if + * parsing fails rather than throwing an exception. + * + *

Error Handling: + * *

Returns {@code null} for missing keys, null values, or unparseable strings rather than - * throwing exceptions, enabling graceful degradation in metrics collection scenarios.

- * - *

Common Use Cases:

+ * throwing exceptions, enabling graceful degradation in metrics collection scenarios. + * + *

Common Use Cases: + * *

    - *
  • Index segment counts and document counts (when within Integer range)
  • - *
  • Configuration values and small numeric metrics
  • - *
  • Count-based statistics that don't exceed Integer.MAX_VALUE
  • + *
  • Index segment counts and document counts (when within Integer range) + *
  • Configuration values and small numeric metrics + *
  • Count-based statistics that don't exceed Integer.MAX_VALUE *
- * - *

Range Considerations:

- *

For large values that may exceed Integer range, consider using {@link #getLong(NamedList, String)} - * instead to avoid truncation or overflow issues.

+ * + *

Range Considerations: + * + *

For large values that may exceed Integer range, consider using {@link #getLong(NamedList, + * String)} instead to avoid truncation or overflow issues. * * @param response the NamedList containing the data to extract from * @param key the key to look up in the NamedList * @return the Integer value if found and convertible, null otherwise - * * @see Number#intValue() * @see Integer#parseInt(String) * @see #getLong(NamedList, String) @@ -195,5 +210,4 @@ public static Integer getInteger(NamedList response, String key) { return null; } } - } diff --git a/src/main/java/org/apache/solr/mcp/server/metadata/Dtos.java b/src/main/java/org/apache/solr/mcp/server/metadata/Dtos.java index b941391..7d1fbf7 100644 --- a/src/main/java/org/apache/solr/mcp/server/metadata/Dtos.java +++ b/src/main/java/org/apache/solr/mcp/server/metadata/Dtos.java @@ -19,22 +19,23 @@ import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; - import java.util.Date; /** * Data Transfer Objects (DTOs) for the Apache Solr MCP Server. * - *

This package contains all the data transfer objects used to serialize and deserialize - * Solr metrics, search results, and health status information for Model Context Protocol (MCP) clients. - * All DTOs use Java records for immutability and Jackson annotations for JSON serialization.

+ *

This package contains all the data transfer objects used to serialize and deserialize Solr + * metrics, search results, and health status information for Model Context Protocol (MCP) clients. + * All DTOs use Java records for immutability and Jackson annotations for JSON serialization. + * + *

Key Features: * - *

Key Features:

*
    - *
  • Automatic null value exclusion from JSON output using {@code @JsonInclude(JsonInclude.Include.NON_NULL)}
  • - *
  • Resilient JSON parsing with {@code @JsonIgnoreProperties(ignoreUnknown = true)}
  • - *
  • Immutable data structures using Java records
  • - *
  • ISO 8601 timestamp formatting for consistent date serialization
  • + *
  • Automatic null value exclusion from JSON output using + * {@code @JsonInclude(JsonInclude.Include.NON_NULL)} + *
  • Resilient JSON parsing with {@code @JsonIgnoreProperties(ignoreUnknown = true)} + *
  • Immutable data structures using Java records + *
  • ISO 8601 timestamp formatting for consistent date serialization *
* * @version 0.0.1 @@ -43,29 +44,31 @@ /** * Top-level container for comprehensive Solr collection metrics. - * - *

This class aggregates various types of Solr performance and operational metrics - * including index statistics, query performance, cache utilization, and request handler metrics. - * It serves as the primary response object for collection monitoring and analysis tools.

- * - *

The metrics are collected from multiple Solr admin endpoints and MBeans to provide - * a comprehensive view of collection health and performance characteristics.

- * - *

Null-Safe Design:

+ * + *

This class aggregates various types of Solr performance and operational metrics including + * index statistics, query performance, cache utilization, and request handler metrics. It serves as + * the primary response object for collection monitoring and analysis tools. + * + *

The metrics are collected from multiple Solr admin endpoints and MBeans to provide a + * comprehensive view of collection health and performance characteristics. + * + *

Null-Safe Design: + * *

Individual metric components (cache stats, handler stats) may be null if the corresponding - * data is unavailable or empty. Always check for null values before accessing nested properties.

- * - *

Example usage:

+ * data is unavailable or empty. Always check for null values before accessing nested properties. + * + *

Example usage: + * *

{@code
  * SolrMetrics metrics = collectionService.getCollectionStats("my_collection");
  * System.out.println("Documents: " + metrics.getIndexStats().getNumDocs());
- * 
+ *
  * // Safe null checking for optional metrics
  * if (metrics.getCacheStats() != null && metrics.getCacheStats().getQueryResultCache() != null) {
  *     System.out.println("Cache hit ratio: " + metrics.getCacheStats().getQueryResultCache().getHitratio());
  * }
  * }
- * + * * @see IndexStats * @see QueryStats * @see CacheStats @@ -74,299 +77,299 @@ @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) record SolrMetrics( - /** Index-related statistics including document counts and segment information */ - IndexStats indexStats, + /** Index-related statistics including document counts and segment information */ + IndexStats indexStats, - /** Query performance metrics from the most recent search operations */ - QueryStats queryStats, + /** Query performance metrics from the most recent search operations */ + QueryStats queryStats, - /** Cache utilization statistics for query result, document, and filter caches (may be null) */ - CacheStats cacheStats, + /** + * Cache utilization statistics for query result, document, and filter caches (may be null) + */ + CacheStats cacheStats, - /** Request handler performance metrics for select and update operations (may be null) */ - HandlerStats handlerStats, + /** Request handler performance metrics for select and update operations (may be null) */ + HandlerStats handlerStats, - /** Timestamp when these metrics were collected, formatted as ISO 8601 */ - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") - Date timestamp -) { -} + /** Timestamp when these metrics were collected, formatted as ISO 8601 */ + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") + Date timestamp) {} /** * Lucene index statistics for a Solr collection. - * - *

Provides essential information about the underlying Lucene index structure - * and document composition. These metrics are retrieved using Solr's Luke request handler - * which exposes Lucene-level index information.

- * - *

Available Metrics:

+ * + *

Provides essential information about the underlying Lucene index structure and document + * composition. These metrics are retrieved using Solr's Luke request handler which exposes + * Lucene-level index information. + * + *

Available Metrics: + * *

    - *
  • numDocs: Total number of documents excluding deleted documents
  • - *
  • segmentCount: Number of Lucene segments (affects search performance)
  • + *
  • numDocs: Total number of documents excluding deleted documents + *
  • segmentCount: Number of Lucene segments (affects search performance) *
- * - *

Performance Implications:

- *

High segment counts may indicate the need for index optimization to improve - * search performance. The optimal segment count depends on index size and update frequency.

- * + * + *

Performance Implications: + * + *

High segment counts may indicate the need for index optimization to improve search + * performance. The optimal segment count depends on index size and update frequency. + * * @see org.apache.solr.client.solrj.request.LukeRequest */ @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) record IndexStats( - /** Total number of documents in the index (excluding deleted documents) */ - Integer numDocs, + /** Total number of documents in the index (excluding deleted documents) */ + Integer numDocs, - /** Number of Lucene segments in the index (lower numbers generally indicate better performance) */ - Integer segmentCount -) { -} + /** + * Number of Lucene segments in the index (lower numbers generally indicate better + * performance) + */ + Integer segmentCount) {} /** * Field-level statistics for individual Solr schema fields. - * - *

Provides detailed information about how individual fields are utilized within - * the Solr index. This information helps with schema optimization and understanding - * field usage patterns.

- * - *

Statistics include:

+ * + *

Provides detailed information about how individual fields are utilized within the Solr index. + * This information helps with schema optimization and understanding field usage patterns. + * + *

Statistics include: + * *

    - *
  • type: Solr field type (e.g., "text_general", "int", "date")
  • - *
  • docs: Number of documents containing this field
  • - *
  • distinct: Number of unique values for this field
  • + *
  • type: Solr field type (e.g., "text_general", "int", "date") + *
  • docs: Number of documents containing this field + *
  • distinct: Number of unique values for this field *
- * - *

Analysis Insights:

- *

High cardinality fields (high distinct values) may require special indexing - * considerations, while sparsely populated fields (low docs count) might benefit - * from different storage strategies.

- * - *

Note: This class is currently unused in the collection statistics - * but is available for future field-level analysis features.

+ * + *

Analysis Insights: + * + *

High cardinality fields (high distinct values) may require special indexing considerations, + * while sparsely populated fields (low docs count) might benefit from different storage strategies. + * + *

Note: This class is currently unused in the collection statistics but is + * available for future field-level analysis features. */ @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) record FieldStats( - /** Solr field type as defined in the schema configuration */ - String type, + /** Solr field type as defined in the schema configuration */ + String type, - /** Number of documents in the index that contain this field */ - Integer docs, + /** Number of documents in the index that contain this field */ + Integer docs, - /** Number of unique/distinct values for this field across all documents */ - Integer distinct -) { -} + /** Number of unique/distinct values for this field across all documents */ + Integer distinct) {} /** * Query execution performance metrics from Solr search operations. - * - *

Captures performance characteristics and result metadata from the most recent - * query execution. These metrics help identify query performance patterns and - * potential optimization opportunities.

- * - *

Available Metrics:

+ * + *

Captures performance characteristics and result metadata from the most recent query execution. + * These metrics help identify query performance patterns and potential optimization opportunities. + * + *

Available Metrics: + * *

    - *
  • queryTime: Time in milliseconds to execute the query
  • - *
  • totalResults: Total number of matching documents found
  • - *
  • start: Starting offset for pagination
  • - *
  • maxScore: Highest relevance score in the result set
  • + *
  • queryTime: Time in milliseconds to execute the query + *
  • totalResults: Total number of matching documents found + *
  • start: Starting offset for pagination + *
  • maxScore: Highest relevance score in the result set *
- * - *

Performance Analysis:

- *

Query time metrics help identify slow queries that may need optimization, - * while result counts and scores provide insight into search effectiveness and relevance tuning needs.

- * + * + *

Performance Analysis: + * + *

Query time metrics help identify slow queries that may need optimization, while result counts + * and scores provide insight into search effectiveness and relevance tuning needs. + * * @see org.apache.solr.client.solrj.response.QueryResponse */ @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) record QueryStats( - /** Time in milliseconds required to execute the most recent query */ - Integer queryTime, + /** Time in milliseconds required to execute the most recent query */ + Integer queryTime, - /** Total number of documents matching the query criteria */ - Long totalResults, + /** Total number of documents matching the query criteria */ + Long totalResults, - /** Starting position for paginated results (0-based offset) */ - Long start, + /** Starting position for paginated results (0-based offset) */ + Long start, - /** Highest relevance score among the returned documents */ - Float maxScore -) { -} + /** Highest relevance score among the returned documents */ + Float maxScore) {} /** * Solr cache utilization statistics across all cache types. - * - *

Aggregates cache performance metrics for the three primary Solr caches. - * Cache performance directly impacts query response times and system resource - * utilization, making these metrics critical for performance tuning.

- * - *

Monitored Cache Types:

+ * + *

Aggregates cache performance metrics for the three primary Solr caches. Cache performance + * directly impacts query response times and system resource utilization, making these metrics + * critical for performance tuning. + * + *

Monitored Cache Types: + * *

    - *
  • queryResultCache: Caches complete query results
  • - *
  • documentCache: Caches retrieved document data
  • - *
  • filterCache: Caches filter query results
  • + *
  • queryResultCache: Caches complete query results + *
  • documentCache: Caches retrieved document data + *
  • filterCache: Caches filter query results *
- * - *

Cache Analysis:

- *

Poor cache hit ratios may indicate undersized caches or query patterns - * that don't benefit from caching. Cache evictions suggest memory pressure - * or cache size optimization needs.

- * + * + *

Cache Analysis: + * + *

Poor cache hit ratios may indicate undersized caches or query patterns that don't benefit from + * caching. Cache evictions suggest memory pressure or cache size optimization needs. + * * @see CacheInfo */ @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) record CacheStats( - /** Performance metrics for the query result cache */ - CacheInfo queryResultCache, + /** Performance metrics for the query result cache */ + CacheInfo queryResultCache, - /** Performance metrics for the document cache */ - CacheInfo documentCache, + /** Performance metrics for the document cache */ + CacheInfo documentCache, - /** Performance metrics for the filter cache */ - CacheInfo filterCache -) { -} + /** Performance metrics for the filter cache */ + CacheInfo filterCache) {} /** * Detailed performance metrics for individual Solr cache instances. - * - *

Provides comprehensive cache utilization statistics including hit ratios, - * eviction rates, and current size metrics. These metrics are essential for - * cache tuning and memory management optimization.

- * - *

Key Performance Indicators:

+ * + *

Provides comprehensive cache utilization statistics including hit ratios, eviction rates, and + * current size metrics. These metrics are essential for cache tuning and memory management + * optimization. + * + *

Key Performance Indicators: + * *

    - *
  • hitratio: Cache effectiveness (higher is better)
  • - *
  • evictions: Memory pressure indicator
  • - *
  • size: Current cache utilization
  • - *
  • lookups vs hits: Cache request patterns
  • + *
  • hitratio: Cache effectiveness (higher is better) + *
  • evictions: Memory pressure indicator + *
  • size: Current cache utilization + *
  • lookups vs hits: Cache request patterns *
- * - *

Performance Targets:

- *

Optimal cache performance typically shows high hit ratios (>0.80) with - * minimal evictions. High eviction rates suggest cache size increases may - * improve performance.

+ * + *

Performance Targets: + * + *

Optimal cache performance typically shows high hit ratios (>0.80) with minimal evictions. High + * eviction rates suggest cache size increases may improve performance. */ @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) record CacheInfo( - /** Total number of cache lookup requests */ - Long lookups, + /** Total number of cache lookup requests */ + Long lookups, - /** Number of successful cache hits */ - Long hits, + /** Number of successful cache hits */ + Long hits, - /** Cache hit ratio (hits/lookups) - higher values indicate better cache performance */ - Float hitratio, + /** Cache hit ratio (hits/lookups) - higher values indicate better cache performance */ + Float hitratio, - /** Number of new entries added to the cache */ - Long inserts, + /** Number of new entries added to the cache */ + Long inserts, - /** Number of entries removed due to cache size limits (indicates memory pressure) */ - Long evictions, + /** Number of entries removed due to cache size limits (indicates memory pressure) */ + Long evictions, - /** Current number of entries stored in the cache */ - Long size -) { -} + /** Current number of entries stored in the cache */ + Long size) {} /** * Request handler performance statistics for core Solr operations. - * - *

Tracks performance metrics for the primary Solr request handlers that process - * search and update operations. Handler performance directly affects user experience - * and system throughput capacity.

- * - *

Monitored Handlers:

+ * + *

Tracks performance metrics for the primary Solr request handlers that process search and + * update operations. Handler performance directly affects user experience and system throughput + * capacity. + * + *

Monitored Handlers: + * *

    - *
  • selectHandler: Processes search/query requests (/select)
  • - *
  • updateHandler: Processes document indexing requests (/update)
  • + *
  • selectHandler: Processes search/query requests (/select) + *
  • updateHandler: Processes document indexing requests (/update) *
- * - *

Performance Analysis:

- *

Handler metrics help identify bottlenecks in request processing and guide - * capacity planning decisions. High error rates or response times indicate - * potential optimization needs.

- * + * + *

Performance Analysis: + * + *

Handler metrics help identify bottlenecks in request processing and guide capacity planning + * decisions. High error rates or response times indicate potential optimization needs. + * * @see HandlerInfo */ @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) record HandlerStats( - /** Performance metrics for the search/select request handler */ - HandlerInfo selectHandler, + /** Performance metrics for the search/select request handler */ + HandlerInfo selectHandler, - /** Performance metrics for the document update request handler */ - HandlerInfo updateHandler -) { -} + /** Performance metrics for the document update request handler */ + HandlerInfo updateHandler) {} /** * Detailed performance metrics for individual Solr request handlers. - * - *

Provides comprehensive request handler statistics including throughput, - * error rates, and performance characteristics. These metrics are crucial for - * identifying performance bottlenecks and system reliability issues.

- * - *

Performance Metrics:

+ * + *

Provides comprehensive request handler statistics including throughput, error rates, and + * performance characteristics. These metrics are crucial for identifying performance bottlenecks + * and system reliability issues. + * + *

Performance Metrics: + * *

    - *
  • requests: Total volume processed
  • - *
  • errors: Reliability indicator
  • - *
  • avgTimePerRequest: Response time performance
  • - *
  • avgRequestsPerSecond: Throughput capacity
  • + *
  • requests: Total volume processed + *
  • errors: Reliability indicator + *
  • avgTimePerRequest: Response time performance + *
  • avgRequestsPerSecond: Throughput capacity *
- * - *

Health Indicators:

- *

High error rates may indicate system stress or configuration issues. - * Increasing response times suggest capacity limits or optimization needs.

+ * + *

Health Indicators: + * + *

High error rates may indicate system stress or configuration issues. Increasing response times + * suggest capacity limits or optimization needs. */ @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) record HandlerInfo( - /** Total number of requests processed by this handler */ - Long requests, + /** Total number of requests processed by this handler */ + Long requests, - /** Number of requests that resulted in errors */ - Long errors, + /** Number of requests that resulted in errors */ + Long errors, - /** Number of requests that exceeded timeout limits */ - Long timeouts, + /** Number of requests that exceeded timeout limits */ + Long timeouts, - /** Cumulative time spent processing all requests (milliseconds) */ - Long totalTime, + /** Cumulative time spent processing all requests (milliseconds) */ + Long totalTime, - /** Average time per request in milliseconds */ - Float avgTimePerRequest, + /** Average time per request in milliseconds */ + Float avgTimePerRequest, - /** Average throughput in requests per second */ - Float avgRequestsPerSecond -) { -} + /** Average throughput in requests per second */ + Float avgRequestsPerSecond) {} /** * Comprehensive health status assessment for Solr collections. - * - *

Provides a complete health check result including availability status, - * performance metrics, and diagnostic information. This serves as a primary - * monitoring endpoint for collection operational status.

- * - *

Health Assessment Components:

+ * + *

Provides a complete health check result including availability status, performance metrics, + * and diagnostic information. This serves as a primary monitoring endpoint for collection + * operational status. + * + *

Health Assessment Components: + * *

    - *
  • isHealthy: Overall collection availability
  • - *
  • responseTime: Performance indicator
  • - *
  • totalDocuments: Content availability
  • - *
  • errorMessage: Diagnostic information when unhealthy
  • + *
  • isHealthy: Overall collection availability + *
  • responseTime: Performance indicator + *
  • totalDocuments: Content availability + *
  • errorMessage: Diagnostic information when unhealthy *
- * - *

Monitoring Integration:

- *

This DTO is typically used by monitoring systems and dashboards to provide - * real-time collection health status and enable automated alerting on failures.

- * - *

Example usage:

+ * + *

Monitoring Integration: + * + *

This DTO is typically used by monitoring systems and dashboards to provide real-time + * collection health status and enable automated alerting on failures. + * + *

Example usage: + * *

{@code
  * SolrHealthStatus status = collectionService.checkHealth("my_collection");
  * if (!status.isHealthy()) {
@@ -377,29 +380,27 @@ record HandlerInfo(
 @JsonIgnoreProperties(ignoreUnknown = true)
 @JsonInclude(JsonInclude.Include.NON_NULL)
 record SolrHealthStatus(
-    /** Overall health status - true if collection is operational and responding */
-    boolean isHealthy,
+        /** Overall health status - true if collection is operational and responding */
+        boolean isHealthy,
 
-    /** Detailed error message when isHealthy is false, null when healthy */
-    String errorMessage,
+        /** Detailed error message when isHealthy is false, null when healthy */
+        String errorMessage,
 
-    /** Response time in milliseconds for the health check ping request */
-    Long responseTime,
+        /** Response time in milliseconds for the health check ping request */
+        Long responseTime,
 
-    /** Total number of documents currently indexed in the collection */
-    Long totalDocuments,
+        /** Total number of documents currently indexed in the collection */
+        Long totalDocuments,
 
-    /** Timestamp when this health check was performed, formatted as ISO 8601 */
-    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
-    Date lastChecked,
+        /** Timestamp when this health check was performed, formatted as ISO 8601 */
+        @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
+                Date lastChecked,
 
-    /** Name of the collection that was checked */
-    String collection,
+        /** Name of the collection that was checked */
+        String collection,
 
-    /** Version of Solr server (when available) */
-    String solrVersion,
+        /** Version of Solr server (when available) */
+        String solrVersion,
 
-    /** Additional status information or state description */
-    String status
-) {
-}
\ No newline at end of file
+        /** Additional status information or state description */
+        String status) {}
diff --git a/src/main/java/org/apache/solr/mcp/server/metadata/SchemaService.java b/src/main/java/org/apache/solr/mcp/server/metadata/SchemaService.java
index 14c8262..72ac9b0 100644
--- a/src/main/java/org/apache/solr/mcp/server/metadata/SchemaService.java
+++ b/src/main/java/org/apache/solr/mcp/server/metadata/SchemaService.java
@@ -23,56 +23,64 @@
 import org.springframework.stereotype.Service;
 
 /**
- * Spring Service providing schema introspection and management capabilities for Apache Solr collections.
- * 
- * 

This service enables exploration and analysis of Solr collection schemas through the Model Context - * Protocol (MCP), allowing AI clients to understand field definitions, data types, and schema configuration - * for intelligent query construction and data analysis workflows.

- * - *

Core Capabilities:

+ * Spring Service providing schema introspection and management capabilities for Apache Solr + * collections. + * + *

This service enables exploration and analysis of Solr collection schemas through the Model + * Context Protocol (MCP), allowing AI clients to understand field definitions, data types, and + * schema configuration for intelligent query construction and data analysis workflows. + * + *

Core Capabilities: + * *

    - *
  • Schema Retrieval: Complete schema information for any collection
  • - *
  • Field Introspection: Detailed field type and configuration analysis
  • - *
  • Dynamic Field Support: Discovery of dynamic field patterns and rules
  • - *
  • Copy Field Analysis: Understanding of field copying and aggregation rules
  • + *
  • Schema Retrieval: Complete schema information for any collection + *
  • Field Introspection: Detailed field type and configuration analysis + *
  • Dynamic Field Support: Discovery of dynamic field patterns and rules + *
  • Copy Field Analysis: Understanding of field copying and aggregation rules *
- * - *

Schema Information Provided:

+ * + *

Schema Information Provided: + * *

    - *
  • Field Definitions: Names, types, indexing, and storage configurations
  • - *
  • Field Types: Analyzer configurations, tokenization, and filtering rules
  • - *
  • Dynamic Fields: Pattern-based field matching and type assignment
  • - *
  • Copy Fields: Source-to-destination field copying configurations
  • - *
  • Unique Key: Primary key field identification and configuration
  • + *
  • Field Definitions: Names, types, indexing, and storage configurations + *
  • Field Types: Analyzer configurations, tokenization, and filtering rules + *
  • Dynamic Fields: Pattern-based field matching and type assignment + *
  • Copy Fields: Source-to-destination field copying configurations + *
  • Unique Key: Primary key field identification and configuration *
- * - *

MCP Tool Integration:

- *

Schema operations are exposed as MCP tools that AI clients can invoke through natural - * language requests such as "show me the schema for my_collection" or "what fields are - * available for searching in the products index".

- * - *

Use Cases:

+ * + *

MCP Tool Integration: + * + *

Schema operations are exposed as MCP tools that AI clients can invoke through natural language + * requests such as "show me the schema for my_collection" or "what fields are available for + * searching in the products index". + * + *

Use Cases: + * *

    - *
  • Query Planning: Understanding available fields for search construction
  • - *
  • Data Analysis: Identifying field types and capabilities for analytics
  • - *
  • Index Optimization: Analyzing field configurations for performance tuning
  • - *
  • Schema Documentation: Generating documentation from live schema definitions
  • + *
  • Query Planning: Understanding available fields for search construction + *
  • Data Analysis: Identifying field types and capabilities for analytics + *
  • Index Optimization: Analyzing field configurations for performance tuning + *
  • Schema Documentation: Generating documentation from live schema + * definitions *
- * - *

Integration with Other Services:

- *

Schema information complements other MCP services by providing the metadata necessary - * for intelligent search query construction, field validation, and result interpretation.

- * - *

Example Usage:

+ * + *

Integration with Other Services: + * + *

Schema information complements other MCP services by providing the metadata necessary for + * intelligent search query construction, field validation, and result interpretation. + * + *

Example Usage: + * *

{@code
  * // Get complete schema information
  * SchemaRepresentation schema = schemaService.getSchema("products");
- * 
+ *
  * // Analyze field configurations
  * schema.getFields().forEach(field -> {
  *     System.out.println("Field: " + field.getName() + " Type: " + field.getType());
  * });
- * 
+ *
  * // Examine dynamic field patterns
  * schema.getDynamicFields().forEach(dynField -> {
  *     System.out.println("Pattern: " + dynField.getName() + " Type: " + dynField.getType());
@@ -81,7 +89,6 @@
  *
  * @version 0.0.1
  * @since 0.0.1
- * 
  * @see SchemaRepresentation
  * @see org.apache.solr.client.solrj.request.schema.SchemaRequest
  * @see org.springframework.ai.tool.annotation.Tool
@@ -94,13 +101,12 @@ public class SchemaService {
 
     /**
      * Constructs a new SchemaService with the required SolrClient dependency.
-     * 
-     * 

This constructor is automatically called by Spring's dependency injection - * framework during application startup, providing the service with the necessary - * Solr client for schema operations.

- * + * + *

This constructor is automatically called by Spring's dependency injection framework during + * application startup, providing the service with the necessary Solr client for schema + * operations. + * * @param solrClient the SolrJ client instance for communicating with Solr - * * @see SolrClient */ public SchemaService(SolrClient solrClient) { @@ -109,56 +115,61 @@ public SchemaService(SolrClient solrClient) { /** * Retrieves the complete schema definition for a specified Solr collection. - * - *

This method provides comprehensive access to all schema components including - * field definitions, field types, dynamic fields, copy fields, and schema-level - * configuration. The returned schema representation contains all information - * necessary for understanding the collection's data structure and capabilities.

- * - *

Schema Components Included:

+ * + *

This method provides comprehensive access to all schema components including field + * definitions, field types, dynamic fields, copy fields, and schema-level configuration. The + * returned schema representation contains all information necessary for understanding the + * collection's data structure and capabilities. + * + *

Schema Components Included: + * *

    - *
  • Fields: Static field definitions with types and properties
  • - *
  • Field Types: Analyzer configurations and processing rules
  • - *
  • Dynamic Fields: Pattern-based field matching rules
  • - *
  • Copy Fields: Field copying and aggregation configurations
  • - *
  • Unique Key: Primary key field specification
  • - *
  • Schema Attributes: Version, name, and global settings
  • + *
  • Fields: Static field definitions with types and properties + *
  • Field Types: Analyzer configurations and processing rules + *
  • Dynamic Fields: Pattern-based field matching rules + *
  • Copy Fields: Field copying and aggregation configurations + *
  • Unique Key: Primary key field specification + *
  • Schema Attributes: Version, name, and global settings *
- * - *

Field Information Details:

- *

Each field definition includes comprehensive metadata:

+ * + *

Field Information Details: + * + *

Each field definition includes comprehensive metadata: + * *

    - *
  • Name: Field identifier for queries and indexing
  • - *
  • Type: Reference to field type configuration
  • - *
  • Indexed: Whether the field is searchable
  • - *
  • Stored: Whether field values are retrievable
  • - *
  • Multi-valued: Whether multiple values are allowed
  • - *
  • Required: Whether the field must have a value
  • + *
  • Name: Field identifier for queries and indexing + *
  • Type: Reference to field type configuration + *
  • Indexed: Whether the field is searchable + *
  • Stored: Whether field values are retrievable + *
  • Multi-valued: Whether multiple values are allowed + *
  • Required: Whether the field must have a value *
- * - *

MCP Tool Usage:

- *

AI clients can invoke this method with natural language requests such as:

+ * + *

MCP Tool Usage: + * + *

AI clients can invoke this method with natural language requests such as: + * *

    - *
  • "Show me the schema for the products collection"
  • - *
  • "What fields are available in my_index?"
  • - *
  • "Get the field definitions for the search index"
  • + *
  • "Show me the schema for the products collection" + *
  • "What fields are available in my_index?" + *
  • "Get the field definitions for the search index" *
- * - *

Error Handling:

- *

If the collection does not exist or schema retrieval fails, the method - * will throw an exception with details about the failure reason. Common issues - * include collection name typos, permission problems, or Solr connectivity issues.

- * - *

Performance Considerations:

- *

Schema information is typically cached by Solr and retrieval is generally - * fast. However, for applications that frequently access schema information, - * consider implementing client-side caching to reduce network overhead.

- * + * + *

Error Handling: + * + *

If the collection does not exist or schema retrieval fails, the method will throw an + * exception with details about the failure reason. Common issues include collection name typos, + * permission problems, or Solr connectivity issues. + * + *

Performance Considerations: + * + *

Schema information is typically cached by Solr and retrieval is generally fast. However, + * for applications that frequently access schema information, consider implementing client-side + * caching to reduce network overhead. + * * @param collection the name of the Solr collection to retrieve schema information for * @return complete schema representation containing all field and type definitions - * * @throws Exception if collection does not exist, access is denied, or communication fails - * * @see SchemaRepresentation * @see SchemaRequest * @see org.apache.solr.client.solrj.response.schema.SchemaResponse @@ -168,5 +179,4 @@ public SchemaRepresentation getSchema(String collection) throws Exception { SchemaRequest schemaRequest = new SchemaRequest(); return schemaRequest.process(solrClient, collection).getSchemaRepresentation(); } - -} \ No newline at end of file +} diff --git a/src/main/java/org/apache/solr/mcp/server/package-info.java b/src/main/java/org/apache/solr/mcp/server/package-info.java index 7631e3a..0ff54ed 100644 --- a/src/main/java/org/apache/solr/mcp/server/package-info.java +++ b/src/main/java/org/apache/solr/mcp/server/package-info.java @@ -17,4 +17,4 @@ @NullMarked package org.apache.solr.mcp.server; -import org.jspecify.annotations.NullMarked; \ No newline at end of file +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/org/apache/solr/mcp/server/search/SearchResponse.java b/src/main/java/org/apache/solr/mcp/server/search/SearchResponse.java index cee55b3..3dc4e03 100644 --- a/src/main/java/org/apache/solr/mcp/server/search/SearchResponse.java +++ b/src/main/java/org/apache/solr/mcp/server/search/SearchResponse.java @@ -21,60 +21,67 @@ /** * Immutable record representing a structured search response from Apache Solr operations. - * - *

This record encapsulates all essential components of a Solr search result in a - * type-safe, immutable structure that can be easily serialized to JSON for MCP client - * consumption. It provides a clean abstraction over Solr's native response format while - * preserving all critical search metadata and result data.

- * - *

Record Benefits:

+ * + *

This record encapsulates all essential components of a Solr search result in a type-safe, + * immutable structure that can be easily serialized to JSON for MCP client consumption. It provides + * a clean abstraction over Solr's native response format while preserving all critical search + * metadata and result data. + * + *

Record Benefits: + * *

    - *
  • Immutability: Response data cannot be modified after creation
  • - *
  • Type Safety: Compile-time validation of response structure
  • - *
  • JSON Serialization: Automatic conversion to JSON for MCP clients
  • - *
  • Memory Efficiency: Compact representation with minimal overhead
  • + *
  • Immutability: Response data cannot be modified after creation + *
  • Type Safety: Compile-time validation of response structure + *
  • JSON Serialization: Automatic conversion to JSON for MCP clients + *
  • Memory Efficiency: Compact representation with minimal overhead *
- * - *

Search Metadata:

- *

The response includes comprehensive search metadata that helps clients understand - * the query results and implement pagination, relevance analysis, and user interfaces:

+ * + *

Search Metadata: + * + *

The response includes comprehensive search metadata that helps clients understand the query + * results and implement pagination, relevance analysis, and user interfaces: + * *

    - *
  • Total Results: Complete count of matching documents
  • - *
  • Pagination Info: Current offset for result windowing
  • - *
  • Relevance Scoring: Maximum relevance score in the result set
  • + *
  • Total Results: Complete count of matching documents + *
  • Pagination Info: Current offset for result windowing + *
  • Relevance Scoring: Maximum relevance score in the result set *
- * - *

Document Structure:

- *

Documents are represented as flexible key-value maps to accommodate Solr's - * dynamic field capabilities and schema-less operation. Each document map contains - * field names as keys and field values as objects, preserving the original data types - * from Solr (strings, numbers, dates, arrays, etc.).

- * - *

Faceting Support:

- *

Facet information is structured as a nested map hierarchy where the outer map - * represents facet field names and inner maps contain facet values with their - * corresponding document counts. This structure efficiently supports multiple - * faceting strategies including field faceting and range faceting.

- * - *

Usage Examples:

+ * + *

Document Structure: + * + *

Documents are represented as flexible key-value maps to accommodate Solr's dynamic field + * capabilities and schema-less operation. Each document map contains field names as keys and field + * values as objects, preserving the original data types from Solr (strings, numbers, dates, arrays, + * etc.). + * + *

Faceting Support: + * + *

Facet information is structured as a nested map hierarchy where the outer map represents facet + * field names and inner maps contain facet values with their corresponding document counts. This + * structure efficiently supports multiple faceting strategies including field faceting and range + * faceting. + * + *

Usage Examples: + * *

{@code
  * // Access search results
  * SearchResponse response = searchService.search("products", "laptop", null, null, null, 0, 10);
  * System.out.println("Found " + response.numFound() + " products");
- * 
+ *
  * // Iterate through documents
  * for (Map doc : response.documents()) {
  *     System.out.println("Title: " + doc.get("title"));
  *     System.out.println("Price: " + doc.get("price"));
  * }
- * 
+ *
  * // Access facet data
  * Map categoryFacets = response.facets().get("category");
- * categoryFacets.forEach((category, count) -> 
+ * categoryFacets.forEach((category, count) ->
  *     System.out.println(category + ": " + count + " items"));
  * }
- * - *

JSON Serialization Example:

+ * + *

JSON Serialization Example: + * *

{@code
  * {
  *   "numFound": 150,
@@ -90,16 +97,14 @@
  *   }
  * }
  * }
- * + * * @param numFound total number of documents matching the search query across all pages - * @param start zero-based offset indicating the starting position of returned results + * @param start zero-based offset indicating the starting position of returned results * @param maxScore highest relevance score among the returned documents (null if scoring disabled) * @param documents list of document maps containing field names and values for each result * @param facets nested map structure containing facet field names, values, and document counts - * * @version 0.0.1 * @since 0.0.1 - * * @see SearchService#search(String, String, List, List, List, Integer, Integer) * @see org.apache.solr.client.solrj.response.QueryResponse * @see org.apache.solr.common.SolrDocumentList @@ -109,6 +114,4 @@ public record SearchResponse( long start, Float maxScore, List> documents, - Map> facets -) { -} \ No newline at end of file + Map> facets) {} diff --git a/src/main/java/org/apache/solr/mcp/server/search/SearchService.java b/src/main/java/org/apache/solr/mcp/server/search/SearchService.java index 16d02dc..babc472 100644 --- a/src/main/java/org/apache/solr/mcp/server/search/SearchService.java +++ b/src/main/java/org/apache/solr/mcp/server/search/SearchService.java @@ -16,6 +16,10 @@ */ package org.apache.solr.mcp.server.search; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.SolrQuery; import org.apache.solr.client.solrj.SolrServerException; @@ -29,53 +33,51 @@ import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; -import java.io.IOException; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - /** - * Spring Service providing comprehensive search capabilities for Apache Solr collections - * through Model Context Protocol (MCP) integration. - * - *

This service serves as the primary interface for executing search operations against - * Solr collections, offering a rich set of features including text search, filtering, - * faceting, sorting, and pagination. It transforms complex Solr query syntax into - * accessible MCP tools that AI clients can invoke through natural language requests.

- * - *

Core Features:

+ * Spring Service providing comprehensive search capabilities for Apache Solr collections through + * Model Context Protocol (MCP) integration. + * + *

This service serves as the primary interface for executing search operations against Solr + * collections, offering a rich set of features including text search, filtering, faceting, sorting, + * and pagination. It transforms complex Solr query syntax into accessible MCP tools that AI clients + * can invoke through natural language requests. + * + *

Core Features: + * *

    - *
  • Full-Text Search: Advanced text search with relevance scoring
  • - *
  • Filtering: Multi-criteria filtering using Solr filter queries
  • - *
  • Faceting: Dynamic facet generation for result categorization
  • - *
  • Sorting: Flexible result ordering by multiple fields
  • - *
  • Pagination: Efficient handling of large result sets
  • + *
  • Full-Text Search: Advanced text search with relevance scoring + *
  • Filtering: Multi-criteria filtering using Solr filter queries + *
  • Faceting: Dynamic facet generation for result categorization + *
  • Sorting: Flexible result ordering by multiple fields + *
  • Pagination: Efficient handling of large result sets *
- * - *

Dynamic Field Support:

- *

The service handles Solr's dynamic field naming conventions where field names - * include type suffixes that indicate data types and indexing behavior:

+ * + *

Dynamic Field Support: + * + *

The service handles Solr's dynamic field naming conventions where field names include type + * suffixes that indicate data types and indexing behavior: + * *

    - *
  • _s: String fields for exact matching
  • - *
  • _t: Text fields with tokenization and analysis
  • - *
  • _i, _l, _f, _d: Numeric fields (int, long, float, double)
  • - *
  • _dt: Date/time fields
  • - *
  • _b: Boolean fields
  • + *
  • _s: String fields for exact matching + *
  • _t: Text fields with tokenization and analysis + *
  • _i, _l, _f, _d: Numeric fields (int, long, float, double) + *
  • _dt: Date/time fields + *
  • _b: Boolean fields *
- * - *

MCP Tool Integration:

- *

Search operations are exposed as MCP tools that AI clients can invoke through - * natural language requests such as "search for books by George R.R. Martin" or - * "find products under $50 in the electronics category".

- * - *

Response Format:

- *

Returns structured {@link SearchResponse} objects that encapsulate search results, - * metadata, and facet information in a format optimized for JSON serialization and - * consumption by AI clients.

+ * + *

MCP Tool Integration: + * + *

Search operations are exposed as MCP tools that AI clients can invoke through natural language + * requests such as "search for books by George R.R. Martin" or "find products under $50 in the + * electronics category". + * + *

Response Format: + * + *

Returns structured {@link SearchResponse} objects that encapsulate search results, metadata, + * and facet information in a format optimized for JSON serialization and consumption by AI clients. * * @version 0.0.1 * @since 0.0.1 - * * @see SearchResponse * @see SolrClient * @see org.springframework.ai.tool.annotation.Tool @@ -89,13 +91,12 @@ public class SearchService { /** * Constructs a new SearchService with the required SolrClient dependency. - * - *

This constructor is automatically called by Spring's dependency injection - * framework during application startup, providing the service with the necessary - * Solr client for executing search operations.

+ * + *

This constructor is automatically called by Spring's dependency injection framework during + * application startup, providing the service with the necessary Solr client for executing + * search operations. * * @param solrClient the SolrJ client instance for communicating with Solr - * * @see SolrClient */ public SearchService(SolrClient solrClient) { @@ -104,38 +105,40 @@ public SearchService(SolrClient solrClient) { /** * Converts a SolrDocumentList to a List of Maps for optimized JSON serialization. - * - *

This method transforms Solr's native document format into a structure that - * can be easily serialized to JSON and consumed by MCP clients. Each document - * becomes a flat map of field names to field values, preserving all data types.

- * - *

Conversion Process:

+ * + *

This method transforms Solr's native document format into a structure that can be easily + * serialized to JSON and consumed by MCP clients. Each document becomes a flat map of field + * names to field values, preserving all data types. + * + *

Conversion Process: + * *

    - *
  • Iterates through each SolrDocument in the list
  • - *
  • Extracts all field names and their corresponding values
  • - *
  • Creates a HashMap for each document with field-value pairs
  • - *
  • Preserves original data types (strings, numbers, dates, arrays)
  • + *
  • Iterates through each SolrDocument in the list + *
  • Extracts all field names and their corresponding values + *
  • Creates a HashMap for each document with field-value pairs + *
  • Preserves original data types (strings, numbers, dates, arrays) *
- * - *

Performance Optimization:

- *

Pre-allocates the ArrayList with the known document count to minimize - * memory allocations and improve conversion performance for large result sets.

+ * + *

Performance Optimization: + * + *

Pre-allocates the ArrayList with the known document count to minimize memory allocations + * and improve conversion performance for large result sets. * * @param documents the SolrDocumentList to convert from Solr's native format * @return a List of Maps where each Map represents a document with field names as keys - * * @see org.apache.solr.common.SolrDocument * @see org.apache.solr.common.SolrDocumentList */ private static List> getDocs(SolrDocumentList documents) { List> docs = new java.util.ArrayList<>(documents.size()); - documents.forEach(doc -> { - Map docMap = new HashMap<>(); - for (String fieldName : doc.getFieldNames()) { - docMap.put(fieldName, doc.getFieldValue(fieldName)); - } - docs.add(docMap); - }); + documents.forEach( + doc -> { + Map docMap = new HashMap<>(); + for (String fieldName : doc.getFieldNames()) { + docMap.put(fieldName, doc.getFieldValue(fieldName)); + } + docs.add(docMap); + }); return docs; } @@ -148,67 +151,78 @@ private static List> getDocs(SolrDocumentList documents) { private static Map> getFacets(QueryResponse queryResponse) { Map> facets = new HashMap<>(); if (queryResponse.getFacetFields() != null && !queryResponse.getFacetFields().isEmpty()) { - queryResponse.getFacetFields().forEach(facetField -> { - Map facetValues = new HashMap<>(); - for (FacetField.Count count : facetField.getValues()) { - facetValues.put(count.getName(), count.getCount()); - } - facets.put(facetField.getName(), facetValues); - }); + queryResponse + .getFacetFields() + .forEach( + facetField -> { + Map facetValues = new HashMap<>(); + for (FacetField.Count count : facetField.getValues()) { + facetValues.put(count.getName(), count.getCount()); + } + facets.put(facetField.getName(), facetValues); + }); } return facets; } - /** - * Searches a Solr collection with the specified parameters. - * This method is exposed as a tool for MCP clients to use. + * Searches a Solr collection with the specified parameters. This method is exposed as a tool + * for MCP clients to use. * - * @param collection The Solr collection to query - * @param query The Solr query string (q parameter). Defaults to "*:*" if not specified + * @param collection The Solr collection to query + * @param query The Solr query string (q parameter). Defaults to "*:*" if not specified * @param filterQueries List of filter queries (fq parameter) - * @param facetFields List of fields to facet on - * @param sortClauses List of sort clauses for ordering results - * @param start Starting offset for pagination - * @param rows Number of rows to return + * @param facetFields List of fields to facet on + * @param sortClauses List of sort clauses for ordering results + * @param start Starting offset for pagination + * @param rows Number of rows to return * @return A SearchResponse containing the search results and facets * @throws SolrServerException If there's an error communicating with Solr - * @throws IOException If there's an I/O error + * @throws IOException If there's an I/O error */ - @McpTool(name = "Search", - description = """ - Search specified Solr collection with query, optional filters, facets, sorting, and pagination. - Note that solr has dynamic fields where name of field in schema may end with suffixes - _s: Represents a string field, used for exact string matching. - _i: Represents an integer field. - _l: Represents a long field. - _f: Represents a float field. - _d: Represents a double field. - _dt: Represents a date field. - _b: Represents a boolean field. - _t: Often used for text fields that undergo tokenization and analysis. - One example from the books collection: - { - "id":"0553579908", - "cat":["book"], - "name":["A Clash of Kings"], - "price":[7.99], - "inStock":[true], - "author":["George R.R. Martin"], - "series_t":"A Song of Ice and Fire", - "sequence_i":2, - "genre_s":"fantasy", - "_version_":1836275819373133824, - "_root_":"0553579908" - } - """) + @McpTool( + name = "Search", + description = + """ +Search specified Solr collection with query, optional filters, facets, sorting, and pagination. +Note that solr has dynamic fields where name of field in schema may end with suffixes +_s: Represents a string field, used for exact string matching. +_i: Represents an integer field. +_l: Represents a long field. +_f: Represents a float field. +_d: Represents a double field. +_dt: Represents a date field. +_b: Represents a boolean field. +_t: Often used for text fields that undergo tokenization and analysis. +One example from the books collection: +{ + "id":"0553579908", + "cat":["book"], + "name":["A Clash of Kings"], + "price":[7.99], + "inStock":[true], + "author":["George R.R. Martin"], + "series_t":"A Song of Ice and Fire", + "sequence_i":2, + "genre_s":"fantasy", + "_version_":1836275819373133824, + "_root_":"0553579908" + } +""") public SearchResponse search( @McpToolParam(description = "Solr collection to query") String collection, - @McpToolParam(description = "Solr q parameter. If none specified defaults to \"*:*\"", required = false) String query, - @McpToolParam(description = "Solr fq parameter", required = false) List filterQueries, - @McpToolParam(description = "Solr facet fields", required = false) List facetFields, - @McpToolParam(description = "Solr sort parameter", required = false) List> sortClauses, - @McpToolParam(description = "Starting offset for pagination", required = false) Integer start, + @McpToolParam( + description = "Solr q parameter. If none specified defaults to \"*:*\"", + required = false) + String query, + @McpToolParam(description = "Solr fq parameter", required = false) + List filterQueries, + @McpToolParam(description = "Solr facet fields", required = false) + List facetFields, + @McpToolParam(description = "Solr sort parameter", required = false) + List> sortClauses, + @McpToolParam(description = "Starting offset for pagination", required = false) + Integer start, @McpToolParam(description = "Number of rows to return", required = false) Integer rows) throws SolrServerException, IOException { @@ -233,10 +247,14 @@ public SearchResponse search( // sorting if (!CollectionUtils.isEmpty(sortClauses)) { - solrQuery.setSorts(sortClauses.stream() - .map(sortClause -> new SolrQuery.SortClause(sortClause.get(SORT_ITEM), - sortClause.get(SORT_ORDER))) - .toList()); + solrQuery.setSorts( + sortClauses.stream() + .map( + sortClause -> + new SolrQuery.SortClause( + sortClause.get(SORT_ITEM), + sortClause.get(SORT_ORDER))) + .toList()); } // pagination @@ -264,9 +282,6 @@ public SearchResponse search( documents.getStart(), documents.getMaxScore(), docs, - facets - ); - + facets); } - } diff --git a/src/test/java/org/apache/solr/mcp/server/ClientHttp.java b/src/test/java/org/apache/solr/mcp/server/ClientHttp.java index 918e4d6..c0f434a 100644 --- a/src/test/java/org/apache/solr/mcp/server/ClientHttp.java +++ b/src/test/java/org/apache/solr/mcp/server/ClientHttp.java @@ -26,5 +26,4 @@ public static void main(String[] args) { var transport = HttpClientStreamableHttpTransport.builder("http://localhost:8080").build(); new SampleClient(transport).run(); } - -} \ No newline at end of file +} diff --git a/src/test/java/org/apache/solr/mcp/server/ClientStdio.java b/src/test/java/org/apache/solr/mcp/server/ClientStdio.java index 00c0235..60ab695 100644 --- a/src/test/java/org/apache/solr/mcp/server/ClientStdio.java +++ b/src/test/java/org/apache/solr/mcp/server/ClientStdio.java @@ -20,24 +20,24 @@ import io.modelcontextprotocol.client.transport.ServerParameters; import io.modelcontextprotocol.client.transport.StdioClientTransport; import io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapper; - import java.io.File; -// run after project has been built with "./gradlew build -x test and the mcp server jar is connected to a running solr" +// run after project has been built with "./gradlew build -x test and the mcp server jar is +// connected to a running solr" public class ClientStdio { public static void main(String[] args) { System.out.println(new File(".").getAbsolutePath()); - var stdioParams = ServerParameters.builder("java") - .args("-jar", - "build/libs/solr-mcp-server-0.0.1-SNAPSHOT.jar") - .build(); + var stdioParams = + ServerParameters.builder("java") + .args("-jar", "build/libs/solr-mcp-server-0.0.1-SNAPSHOT.jar") + .build(); - var transport = new StdioClientTransport(stdioParams, new JacksonMcpJsonMapper(new ObjectMapper())); + var transport = + new StdioClientTransport(stdioParams, new JacksonMcpJsonMapper(new ObjectMapper())); new SampleClient(transport).run(); } - -} \ No newline at end of file +} diff --git a/src/test/java/org/apache/solr/mcp/server/MainTest.java b/src/test/java/org/apache/solr/mcp/server/MainTest.java index c49a10b..4b5765b 100644 --- a/src/test/java/org/apache/solr/mcp/server/MainTest.java +++ b/src/test/java/org/apache/solr/mcp/server/MainTest.java @@ -26,29 +26,24 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; /** - * Application context loading test with mocked services. - * This test verifies that the Spring application context can be loaded successfully - * without requiring actual Solr connections, using mocked beans to prevent external dependencies. + * Application context loading test with mocked services. This test verifies that the Spring + * application context can be loaded successfully without requiring actual Solr connections, using + * mocked beans to prevent external dependencies. */ @SpringBootTest @ActiveProfiles("test") class MainTest { - @MockitoBean - private SearchService searchService; + @MockitoBean private SearchService searchService; - @MockitoBean - private IndexingService indexingService; + @MockitoBean private IndexingService indexingService; - @MockitoBean - private CollectionService collectionService; + @MockitoBean private CollectionService collectionService; - @MockitoBean - private SchemaService schemaService; + @MockitoBean private SchemaService schemaService; @Test void contextLoads() { // Context loading test - all services are mocked to prevent Solr API calls } - } diff --git a/src/test/java/org/apache/solr/mcp/server/McpToolRegistrationTest.java b/src/test/java/org/apache/solr/mcp/server/McpToolRegistrationTest.java index 08239f9..15da485 100644 --- a/src/test/java/org/apache/solr/mcp/server/McpToolRegistrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/McpToolRegistrationTest.java @@ -16,6 +16,12 @@ */ package org.apache.solr.mcp.server; +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.Arrays; +import java.util.List; import org.apache.solr.mcp.server.indexing.IndexingService; import org.apache.solr.mcp.server.metadata.CollectionService; import org.apache.solr.mcp.server.metadata.SchemaService; @@ -24,70 +30,68 @@ import org.springaicommunity.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpToolParam; -import java.lang.reflect.Method; -import java.lang.reflect.Parameter; -import java.util.Arrays; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; - /** - * Tests for MCP tool registration and annotation validation. - * Ensures all services expose their methods correctly as MCP tools - * with proper annotations and descriptions. + * Tests for MCP tool registration and annotation validation. Ensures all services expose their + * methods correctly as MCP tools with proper annotations and descriptions. */ class McpToolRegistrationTest { @Test void testSearchServiceHasToolAnnotation() throws NoSuchMethodException { // Get the search method from SearchService - Method searchMethod = SearchService.class.getMethod("search", - String.class, - String.class, - List.class, - List.class, - List.class, - Integer.class, - Integer.class); + Method searchMethod = + SearchService.class.getMethod( + "search", + String.class, + String.class, + List.class, + List.class, + List.class, + Integer.class, + Integer.class); // Verify it has the @McpTool annotation - assertTrue(searchMethod.isAnnotationPresent(McpTool.class), + assertTrue( + searchMethod.isAnnotationPresent(McpTool.class), "SearchService.search method should have @McpTool annotation"); // Verify the annotation properties McpTool toolAnnotation = searchMethod.getAnnotation(McpTool.class); - assertEquals("Search", toolAnnotation.name(), - "McpTool name should be 'Search'"); - assertNotNull(toolAnnotation.description(), - "McpTool description should not be null"); - assertFalse(toolAnnotation.description().isBlank(), - "McpTool description should not be blank"); + assertEquals("Search", toolAnnotation.name(), "McpTool name should be 'Search'"); + assertNotNull(toolAnnotation.description(), "McpTool description should not be null"); + assertFalse( + toolAnnotation.description().isBlank(), "McpTool description should not be blank"); } @Test void testSearchServiceToolParametersHaveAnnotations() throws NoSuchMethodException { // Get the search method - Method searchMethod = SearchService.class.getMethod("search", - String.class, - String.class, - List.class, - List.class, - List.class, - Integer.class, - Integer.class); + Method searchMethod = + SearchService.class.getMethod( + "search", + String.class, + String.class, + List.class, + List.class, + List.class, + Integer.class, + Integer.class); // Verify all parameters have @McpToolParam annotations Parameter[] parameters = searchMethod.getParameters(); assertTrue(parameters.length > 0, "Search method should have parameters"); for (Parameter param : parameters) { - assertTrue(param.isAnnotationPresent(McpToolParam.class), + assertTrue( + param.isAnnotationPresent(McpToolParam.class), "Parameter " + param.getName() + " should have @McpToolParam annotation"); McpToolParam paramAnnotation = param.getAnnotation(McpToolParam.class); - assertNotNull(paramAnnotation.description(), + assertNotNull( + paramAnnotation.description(), "Parameter " + param.getName() + " should have description"); - assertFalse(paramAnnotation.description().isBlank(), + assertFalse( + paramAnnotation.description().isBlank(), "Parameter " + param.getName() + " description should not be blank"); } } @@ -98,12 +102,12 @@ void testIndexingServiceHasToolAnnotations() { Method[] methods = IndexingService.class.getDeclaredMethods(); // Find methods with @McpTool annotation - List mcpToolMethods = Arrays.stream(methods) - .filter(m -> m.isAnnotationPresent(McpTool.class)) - .toList(); + List mcpToolMethods = + Arrays.stream(methods).filter(m -> m.isAnnotationPresent(McpTool.class)).toList(); // Verify at least one method has the annotation - assertFalse(mcpToolMethods.isEmpty(), + assertFalse( + mcpToolMethods.isEmpty(), "IndexingService should have at least one method with @McpTool annotation"); // Verify each tool has proper annotations @@ -112,7 +116,8 @@ void testIndexingServiceHasToolAnnotations() { assertNotNull(toolAnnotation.name(), "Tool name should not be null"); assertFalse(toolAnnotation.name().isBlank(), "Tool name should not be blank"); assertNotNull(toolAnnotation.description(), "Tool description should not be null"); - assertFalse(toolAnnotation.description().isBlank(), "Tool description should not be blank"); + assertFalse( + toolAnnotation.description().isBlank(), "Tool description should not be blank"); } } @@ -122,19 +127,20 @@ void testCollectionServiceHasToolAnnotations() { Method[] methods = CollectionService.class.getDeclaredMethods(); // Find methods with @McpTool annotation - List mcpToolMethods = Arrays.stream(methods) - .filter(m -> m.isAnnotationPresent(McpTool.class)) - .toList(); + List mcpToolMethods = + Arrays.stream(methods).filter(m -> m.isAnnotationPresent(McpTool.class)).toList(); // Verify at least one method has the annotation - assertFalse(mcpToolMethods.isEmpty(), + assertFalse( + mcpToolMethods.isEmpty(), "CollectionService should have at least one method with @McpTool annotation"); // Verify each tool has proper annotations for (Method method : mcpToolMethods) { McpTool toolAnnotation = method.getAnnotation(McpTool.class); assertNotNull(toolAnnotation.description(), "Tool description should not be null"); - assertFalse(toolAnnotation.description().isBlank(), "Tool description should not be blank"); + assertFalse( + toolAnnotation.description().isBlank(), "Tool description should not be blank"); } } @@ -144,19 +150,20 @@ void testSchemaServiceHasToolAnnotations() { Method[] methods = SchemaService.class.getDeclaredMethods(); // Find methods with @McpTool annotation - List mcpToolMethods = Arrays.stream(methods) - .filter(m -> m.isAnnotationPresent(McpTool.class)) - .toList(); + List mcpToolMethods = + Arrays.stream(methods).filter(m -> m.isAnnotationPresent(McpTool.class)).toList(); // Verify at least one method has the annotation - assertFalse(mcpToolMethods.isEmpty(), + assertFalse( + mcpToolMethods.isEmpty(), "SchemaService should have at least one method with @McpTool annotation"); // Verify each tool has proper annotations for (Method method : mcpToolMethods) { McpTool toolAnnotation = method.getAnnotation(McpTool.class); assertNotNull(toolAnnotation.description(), "Tool description should not be null"); - assertFalse(toolAnnotation.description().isBlank(), "Tool description should not be blank"); + assertFalse( + toolAnnotation.description().isBlank(), "Tool description should not be blank"); } } @@ -179,34 +186,41 @@ void testAllMcpToolsHaveUniqueNames() { // Verify all tool names are unique long uniqueCount = toolNames.stream().distinct().count(); - assertEquals(toolNames.size(), uniqueCount, - "All MCP tool names should be unique across all services. Found tools: " + toolNames); + assertEquals( + toolNames.size(), + uniqueCount, + "All MCP tool names should be unique across all services. Found tools: " + + toolNames); } @Test void testMcpToolParametersFollowConventions() throws NoSuchMethodException { // Get the search method - Method searchMethod = SearchService.class.getMethod("search", - String.class, - String.class, - List.class, - List.class, - List.class, - Integer.class, - Integer.class); + Method searchMethod = + SearchService.class.getMethod( + "search", + String.class, + String.class, + List.class, + List.class, + List.class, + Integer.class, + Integer.class); Parameter[] parameters = searchMethod.getParameters(); // Verify first parameter (collection) is required McpToolParam firstParam = parameters[0].getAnnotation(McpToolParam.class); - assertTrue(firstParam.required() || !firstParam.required(), + assertTrue( + firstParam.required() || !firstParam.required(), "First parameter annotation should specify required status"); // Verify optional parameters have required=false for (int i = 1; i < parameters.length; i++) { McpToolParam param = parameters[i].getAnnotation(McpToolParam.class); // Optional parameters should be marked as such in description or required flag - assertNotNull(param.description(), + assertNotNull( + param.description(), "Parameter should have description indicating if it's optional"); } } @@ -216,12 +230,13 @@ private void addToolNames(Class serviceClass, List toolNames) { Method[] methods = serviceClass.getDeclaredMethods(); Arrays.stream(methods) .filter(m -> m.isAnnotationPresent(McpTool.class)) - .forEach(m -> { - McpTool annotation = m.getAnnotation(McpTool.class); - // Use name if provided, otherwise use method name - String toolName = annotation.name().isBlank() ? m.getName() : annotation.name(); - toolNames.add(toolName); - }); + .forEach( + m -> { + McpTool annotation = m.getAnnotation(McpTool.class); + // Use name if provided, otherwise use method name + String toolName = + annotation.name().isBlank() ? m.getName() : annotation.name(); + toolNames.add(toolName); + }); } } - diff --git a/src/test/java/org/apache/solr/mcp/server/SampleClient.java b/src/test/java/org/apache/solr/mcp/server/SampleClient.java index 40045a3..6ceae3b 100644 --- a/src/test/java/org/apache/solr/mcp/server/SampleClient.java +++ b/src/test/java/org/apache/solr/mcp/server/SampleClient.java @@ -16,64 +16,65 @@ */ package org.apache.solr.mcp.server; +import static org.junit.jupiter.api.Assertions.*; + import io.modelcontextprotocol.client.McpClient; import io.modelcontextprotocol.spec.McpClientTransport; import io.modelcontextprotocol.spec.McpSchema.ListToolsResult; import io.modelcontextprotocol.spec.McpSchema.Tool; - import java.util.List; import java.util.Set; -import static org.junit.jupiter.api.Assertions.*; - /** * Sample MCP client for testing and demonstrating Solr MCP Server functionality. * - *

This test client provides a comprehensive validation suite for the Solr MCP Server, - * verifying that all expected MCP tools are properly registered and functioning as expected. - * It serves as both a testing framework and a reference implementation for MCP client integration.

+ *

This test client provides a comprehensive validation suite for the Solr MCP Server, verifying + * that all expected MCP tools are properly registered and functioning as expected. It serves as + * both a testing framework and a reference implementation for MCP client integration. + * + *

Test Coverage: * - *

Test Coverage:

*
    - *
  • Client Initialization: Verifies MCP client can connect and initialize
  • - *
  • Connection Health: Tests ping functionality and connection stability
  • - *
  • Tool Discovery: Validates all expected MCP tools are registered
  • - *
  • Tool Validation: Checks tool metadata, descriptions, and schemas
  • - *
  • Expected Tools: Verifies presence of search, indexing, and metadata tools
  • + *
  • Client Initialization: Verifies MCP client can connect and initialize + *
  • Connection Health: Tests ping functionality and connection stability + *
  • Tool Discovery: Validates all expected MCP tools are registered + *
  • Tool Validation: Checks tool metadata, descriptions, and schemas + *
  • Expected Tools: Verifies presence of search, indexing, and metadata tools *
* - *

Expected MCP Tools:

+ *

Expected MCP Tools: + * *

    - *
  • index_json_documents: JSON document indexing capability
  • - *
  • index_csv_documents: CSV document indexing capability
  • - *
  • index_xml_documents: XML document indexing capability
  • - *
  • Search: Full-text search functionality with filtering and faceting
  • - *
  • listCollections: Collection discovery and listing
  • - *
  • getCollectionStats: Collection metrics and performance data
  • - *
  • checkHealth: Health monitoring and status reporting
  • - *
  • getSchema: Schema introspection and field analysis
  • + *
  • index_json_documents: JSON document indexing capability + *
  • index_csv_documents: CSV document indexing capability + *
  • index_xml_documents: XML document indexing capability + *
  • Search: Full-text search functionality with filtering and faceting + *
  • listCollections: Collection discovery and listing + *
  • getCollectionStats: Collection metrics and performance data + *
  • checkHealth: Health monitoring and status reporting + *
  • getSchema: Schema introspection and field analysis *
* - *

Usage Example:

+ *

Usage Example: + * *

{@code
  * McpClientTransport transport = // ... initialize transport
  * SampleClient client = new SampleClient(transport);
  * client.run(); // Executes full test suite
  * }
* - *

Assertion Strategy:

- *

Uses JUnit assertions to validate expected behavior and fail fast on any - * inconsistencies. Each tool is validated for proper name, description, and schema - * configuration to ensure MCP protocol compliance.

+ *

Assertion Strategy: + * + *

Uses JUnit assertions to validate expected behavior and fail fast on any inconsistencies. Each + * tool is validated for proper name, description, and schema configuration to ensure MCP protocol + * compliance. * * @version 0.0.1 * @since 0.0.1 - * * @see McpClient * @see McpClientTransport * @see io.modelcontextprotocol.spec.McpSchema.Tool */ - public class SampleClient { private final McpClientTransport transport; @@ -91,36 +92,41 @@ public SampleClient(McpClientTransport transport) { /** * Executes the comprehensive test suite for Solr MCP Server functionality. * - *

This method performs a complete validation of the MCP server including:

+ *

This method performs a complete validation of the MCP server including: + * *

    - *
  • Client initialization and connection establishment
  • - *
  • Health check via ping operation
  • - *
  • Tool discovery and count validation
  • - *
  • Individual tool metadata validation
  • - *
  • Tool-specific description and schema verification
  • + *
  • Client initialization and connection establishment + *
  • Health check via ping operation + *
  • Tool discovery and count validation + *
  • Individual tool metadata validation + *
  • Tool-specific description and schema verification *
* - *

Test Sequence:

+ *

Test Sequence: + * *

    - *
  1. Initialize MCP client with provided transport
  2. - *
  3. Perform ping test to verify connectivity
  4. - *
  5. List all available tools and validate expected count (8 tools)
  6. - *
  7. Verify each expected tool is present in the tools list
  8. - *
  9. Validate tool metadata (name, description, schema) for each tool
  10. - *
  11. Perform tool-specific validation based on tool type
  12. + *
  13. Initialize MCP client with provided transport + *
  14. Perform ping test to verify connectivity + *
  15. List all available tools and validate expected count (8 tools) + *
  16. Verify each expected tool is present in the tools list + *
  17. Validate tool metadata (name, description, schema) for each tool + *
  18. Perform tool-specific validation based on tool type *
* * @throws RuntimeException if any test assertion fails or MCP operations encounter errors - * @throws AssertionError if expected tools are missing or tool validation fails + * @throws AssertionError if expected tools are missing or tool validation fails */ public void run() { - try (var client = McpClient.sync(this.transport) - .loggingConsumer(message -> System.out.println(">> Client Logging: " + message)) - .build()) { + try (var client = + McpClient.sync(this.transport) + .loggingConsumer( + message -> System.out.println(">> Client Logging: " + message)) + .build()) { // Assert client initialization succeeds - assertDoesNotThrow(client::initialize, "Client initialization should not throw an exception"); + assertDoesNotThrow( + client::initialize, "Client initialization should not throw an exception"); // Assert ping succeeds assertDoesNotThrow(client::ping, "Client ping should not throw an exception"); @@ -134,69 +140,92 @@ public void run() { assertEquals(8, toolsList.tools().size(), "Expected 8 tools to be available"); // Define expected tools based on the log output - Set expectedToolNames = Set.of( - "index_json_documents", - "index_csv_documents", - "getCollectionStats", - "Search", - "listCollections", - "checkHealth", - "index_xml_documents", - "getSchema" - ); + Set expectedToolNames = + Set.of( + "index_json_documents", + "index_csv_documents", + "getCollectionStats", + "Search", + "listCollections", + "checkHealth", + "index_xml_documents", + "getSchema"); // Validate each expected tool is present - List actualToolNames = toolsList.tools().stream() - .map(Tool::name) - .toList(); + List actualToolNames = toolsList.tools().stream().map(Tool::name).toList(); for (String expectedTool : expectedToolNames) { - assertTrue(actualToolNames.contains(expectedTool), + assertTrue( + actualToolNames.contains(expectedTool), "Expected tool '" + expectedTool + "' should be available"); } // Validate tool details for key tools - toolsList.tools().forEach(tool -> { - assertNotNull(tool.name(), "Tool name should not be null"); - assertNotNull(tool.description(), "Tool description should not be null"); - assertNotNull(tool.inputSchema(), "Tool input schema should not be null"); - assertFalse(tool.name().trim().isEmpty(), "Tool name should not be empty"); - assertFalse(tool.description().trim().isEmpty(), "Tool description should not be empty"); - - // Validate specific tools based on expected behavior - switch (tool.name()) { - case "index_json_documents": - assertTrue(tool.description().toLowerCase().contains("json"), - "JSON indexing tool should mention JSON in description"); - break; - case "index_csv_documents": - assertTrue(tool.description().toLowerCase().contains("csv"), - "CSV indexing tool should mention CSV in description"); - break; - case "Search": - assertTrue(tool.description().toLowerCase().contains("search"), - "Search tool should mention search in description"); - break; - case "listCollections": - assertTrue(tool.description().toLowerCase().contains("collection"), - "List collections tool should mention collections in description"); - break; - case "checkHealth": - assertTrue(tool.description().toLowerCase().contains("health"), - "Health check tool should mention health in description"); - break; - default: - // Additional tools are acceptable - break; - } - - System.out.println("Tool: " + tool.name() + ", description: " + tool.description() + ", schema: " - + tool.inputSchema()); - }); + toolsList + .tools() + .forEach( + tool -> { + assertNotNull(tool.name(), "Tool name should not be null"); + assertNotNull( + tool.description(), "Tool description should not be null"); + assertNotNull( + tool.inputSchema(), "Tool input schema should not be null"); + assertFalse( + tool.name().trim().isEmpty(), + "Tool name should not be empty"); + assertFalse( + tool.description().trim().isEmpty(), + "Tool description should not be empty"); + + // Validate specific tools based on expected behavior + switch (tool.name()) { + case "index_json_documents": + assertTrue( + tool.description().toLowerCase().contains("json"), + "JSON indexing tool should mention JSON in" + + " description"); + break; + case "index_csv_documents": + assertTrue( + tool.description().toLowerCase().contains("csv"), + "CSV indexing tool should mention CSV in" + + " description"); + break; + case "Search": + assertTrue( + tool.description().toLowerCase().contains("search"), + "Search tool should mention search in description"); + break; + case "listCollections": + assertTrue( + tool.description() + .toLowerCase() + .contains("collection"), + "List collections tool should mention collections" + + " in description"); + break; + case "checkHealth": + assertTrue( + tool.description().toLowerCase().contains("health"), + "Health check tool should mention health in" + + " description"); + break; + default: + // Additional tools are acceptable + break; + } + + System.out.println( + "Tool: " + + tool.name() + + ", description: " + + tool.description() + + ", schema: " + + tool.inputSchema()); + }); } catch (Exception e) { throw new RuntimeException("MCP client operation failed", e); } } - -} \ No newline at end of file +} diff --git a/src/test/java/org/apache/solr/mcp/server/TestcontainersConfiguration.java b/src/test/java/org/apache/solr/mcp/server/TestcontainersConfiguration.java index 046d16f..ea73984 100644 --- a/src/test/java/org/apache/solr/mcp/server/TestcontainersConfiguration.java +++ b/src/test/java/org/apache/solr/mcp/server/TestcontainersConfiguration.java @@ -34,6 +34,14 @@ SolrContainer solr() { @Bean DynamicPropertyRegistrar propertiesRegistrar(SolrContainer solr) { - return registry -> registry.add("solr.url", () -> "http://" + solr.getHost() + ":" + solr.getMappedPort(SOLR_PORT) + "/solr/"); + return registry -> + registry.add( + "solr.url", + () -> + "http://" + + solr.getHost() + + ":" + + solr.getMappedPort(SOLR_PORT) + + "/solr/"); } } diff --git a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java index e3e6081..12da2b8 100644 --- a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java +++ b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java @@ -16,6 +16,8 @@ */ package org.apache.solr.mcp.server.config; +import static org.junit.jupiter.api.Assertions.*; + import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.impl.Http2SolrClient; import org.apache.solr.mcp.server.TestcontainersConfiguration; @@ -27,20 +29,15 @@ import org.springframework.context.annotation.Import; import org.testcontainers.containers.SolrContainer; -import static org.junit.jupiter.api.Assertions.*; - @SpringBootTest @Import(TestcontainersConfiguration.class) class SolrConfigTest { - @Autowired - private SolrClient solrClient; + @Autowired private SolrClient solrClient; - @Autowired - SolrContainer solrContainer; + @Autowired SolrContainer solrContainer; - @Autowired - private SolrConfigurationProperties properties; + @Autowired private SolrConfigurationProperties properties; @Test void testSolrClientConfiguration() { @@ -48,9 +45,15 @@ void testSolrClientConfiguration() { assertNotNull(solrClient); // Verify that the SolrClient is using the correct URL - // Note: SolrConfig normalizes the URL to have trailing slash, but Http2SolrClient removes it + // Note: SolrConfig normalizes the URL to have trailing slash, but Http2SolrClient removes + // it var httpSolrClient = assertInstanceOf(Http2SolrClient.class, solrClient); - String expectedUrl = "http://" + solrContainer.getHost() + ":" + solrContainer.getMappedPort(8983) + "/solr"; + String expectedUrl = + "http://" + + solrContainer.getHost() + + ":" + + solrContainer.getMappedPort(8983) + + "/solr"; assertEquals(expectedUrl, httpSolrClient.getBaseURL()); } @@ -59,7 +62,12 @@ void testSolrConfigurationProperties() { // Verify that the properties are correctly loaded assertNotNull(properties); assertNotNull(properties.url()); - assertEquals("http://" + solrContainer.getHost() + ":" + solrContainer.getMappedPort(8983) + "/solr/", + assertEquals( + "http://" + + solrContainer.getHost() + + ":" + + solrContainer.getMappedPort(8983) + + "/solr/", properties.url()); } @@ -74,17 +82,17 @@ void testSolrConfigurationProperties() { void testUrlNormalization(String inputUrl, String expectedUrl) { // Create a test properties object SolrConfigurationProperties testProperties = new SolrConfigurationProperties(inputUrl); - + // Create SolrConfig instance SolrConfig solrConfig = new SolrConfig(); - + // Test URL normalization SolrClient client = solrConfig.solrClient(testProperties); assertNotNull(client); - + var httpClient = assertInstanceOf(Http2SolrClient.class, client); assertEquals(expectedUrl, httpClient.getBaseURL()); - + // Clean up try { client.close(); @@ -96,15 +104,16 @@ void testUrlNormalization(String inputUrl, String expectedUrl) { @Test void testUrlWithoutTrailingSlash() { // Test URL without trailing slash branch - SolrConfigurationProperties testProperties = new SolrConfigurationProperties("http://localhost:8983"); + SolrConfigurationProperties testProperties = + new SolrConfigurationProperties("http://localhost:8983"); SolrConfig solrConfig = new SolrConfig(); - + SolrClient client = solrConfig.solrClient(testProperties); Http2SolrClient httpClient = (Http2SolrClient) client; - + // Should add trailing slash and solr path assertEquals("http://localhost:8983/solr", httpClient.getBaseURL()); - + try { client.close(); } catch (Exception e) { @@ -115,15 +124,16 @@ void testUrlWithoutTrailingSlash() { @Test void testUrlWithTrailingSlashButNoSolrPath() { // Test URL with trailing slash but no solr path branch - SolrConfigurationProperties testProperties = new SolrConfigurationProperties("http://localhost:8983/"); + SolrConfigurationProperties testProperties = + new SolrConfigurationProperties("http://localhost:8983/"); SolrConfig solrConfig = new SolrConfig(); - + SolrClient client = solrConfig.solrClient(testProperties); Http2SolrClient httpClient = (Http2SolrClient) client; - + // Should add solr path to existing trailing slash assertEquals("http://localhost:8983/solr", httpClient.getBaseURL()); - + try { client.close(); } catch (Exception e) { @@ -134,15 +144,16 @@ void testUrlWithTrailingSlashButNoSolrPath() { @Test void testUrlWithSolrPathButNoTrailingSlash() { // Test URL with solr path but no trailing slash - SolrConfigurationProperties testProperties = new SolrConfigurationProperties("http://localhost:8983/solr"); + SolrConfigurationProperties testProperties = + new SolrConfigurationProperties("http://localhost:8983/solr"); SolrConfig solrConfig = new SolrConfig(); - + SolrClient client = solrConfig.solrClient(testProperties); Http2SolrClient httpClient = (Http2SolrClient) client; - + // Should add trailing slash assertEquals("http://localhost:8983/solr", httpClient.getBaseURL()); - + try { client.close(); } catch (Exception e) { @@ -153,19 +164,20 @@ void testUrlWithSolrPathButNoTrailingSlash() { @Test void testUrlAlreadyProperlyFormatted() { // Test URL that's already properly formatted - SolrConfigurationProperties testProperties = new SolrConfigurationProperties("http://localhost:8983/solr/"); + SolrConfigurationProperties testProperties = + new SolrConfigurationProperties("http://localhost:8983/solr/"); SolrConfig solrConfig = new SolrConfig(); - + SolrClient client = solrConfig.solrClient(testProperties); Http2SolrClient httpClient = (Http2SolrClient) client; - + // Should remain unchanged assertEquals("http://localhost:8983/solr", httpClient.getBaseURL()); - + try { client.close(); } catch (Exception e) { // Ignore close errors in test } } -} \ No newline at end of file +} diff --git a/src/test/java/org/apache/solr/mcp/server/indexing/CsvIndexingTest.java b/src/test/java/org/apache/solr/mcp/server/indexing/CsvIndexingTest.java index aaa541c..b22c83c 100644 --- a/src/test/java/org/apache/solr/mcp/server/indexing/CsvIndexingTest.java +++ b/src/test/java/org/apache/solr/mcp/server/indexing/CsvIndexingTest.java @@ -16,6 +16,9 @@ */ package org.apache.solr.mcp.server.indexing; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; import org.apache.solr.common.SolrInputDocument; import org.apache.solr.mcp.server.indexing.documentcreator.IndexingDocumentCreator; import org.junit.jupiter.api.Test; @@ -23,40 +26,37 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.TestPropertySource; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - /** * Test class for CSV indexing functionality in IndexingService. - * - *

This test verifies that the IndexingService can correctly parse CSV data - * and convert it into SolrInputDocument objects using the schema-less approach.

+ * + *

This test verifies that the IndexingService can correctly parse CSV data and convert it into + * SolrInputDocument objects using the schema-less approach. */ @SpringBootTest @TestPropertySource(locations = "classpath:application.properties") class CsvIndexingTest { - @Autowired - private IndexingDocumentCreator indexingDocumentCreator; + @Autowired private IndexingDocumentCreator indexingDocumentCreator; @Test void testCreateSchemalessDocumentsFromCsv() throws Exception { // Given - - String csvData = """ - id,cat,name,price,inStock,author,series_t,sequence_i,genre_s - 0553573403,book,A Game of Thrones,7.99,true,George R.R. Martin,"A Song of Ice and Fire",1,fantasy - 0553579908,book,A Clash of Kings,7.99,true,George R.R. Martin,"A Song of Ice and Fire",2,fantasy - 0553293354,book,Foundation,7.99,true,Isaac Asimov,Foundation Novels,1,scifi - """; - + + String csvData = + """ +id,cat,name,price,inStock,author,series_t,sequence_i,genre_s +0553573403,book,A Game of Thrones,7.99,true,George R.R. Martin,"A Song of Ice and Fire",1,fantasy +0553579908,book,A Clash of Kings,7.99,true,George R.R. Martin,"A Song of Ice and Fire",2,fantasy +0553293354,book,Foundation,7.99,true,Isaac Asimov,Foundation Novels,1,scifi +"""; + // When - List documents = indexingDocumentCreator.createSchemalessDocumentsFromCsv(csvData); - + List documents = + indexingDocumentCreator.createSchemalessDocumentsFromCsv(csvData); + // Then assertThat(documents).hasSize(3); - + // Verify first document SolrInputDocument firstDoc = documents.getFirst(); assertThat(firstDoc.getFieldValue("id")).isEqualTo("0553573403"); @@ -68,13 +68,13 @@ void testCreateSchemalessDocumentsFromCsv() throws Exception { assertThat(firstDoc.getFieldValue("series_t")).isEqualTo("A Song of Ice and Fire"); assertThat(firstDoc.getFieldValue("sequence_i")).isEqualTo("1"); assertThat(firstDoc.getFieldValue("genre_s")).isEqualTo("fantasy"); - + // Verify second document SolrInputDocument secondDoc = documents.get(1); assertThat(secondDoc.getFieldValue("id")).isEqualTo("0553579908"); assertThat(secondDoc.getFieldValue("name")).isEqualTo("A Clash of Kings"); assertThat(secondDoc.getFieldValue("sequence_i")).isEqualTo("2"); - + // Verify third document SolrInputDocument thirdDoc = documents.get(2); assertThat(thirdDoc.getFieldValue("id")).isEqualTo("0553293354"); @@ -82,69 +82,73 @@ void testCreateSchemalessDocumentsFromCsv() throws Exception { assertThat(thirdDoc.getFieldValue("author")).isEqualTo("Isaac Asimov"); assertThat(thirdDoc.getFieldValue("genre_s")).isEqualTo("scifi"); } - + @Test void testCreateSchemalessDocumentsFromCsvWithEmptyValues() throws Exception { // Given - - String csvData = """ - id,name,description - 1,Test Product,Some description - 2,Another Product, - 3,,Empty name - """; - + + String csvData = + """ + id,name,description + 1,Test Product,Some description + 2,Another Product, + 3,,Empty name + """; + // When - List documents = indexingDocumentCreator.createSchemalessDocumentsFromCsv(csvData); - + List documents = + indexingDocumentCreator.createSchemalessDocumentsFromCsv(csvData); + // Then assertThat(documents).hasSize(3); - + // First document should have all fields SolrInputDocument firstDoc = documents.getFirst(); assertThat(firstDoc.getFieldValue("id")).isEqualTo("1"); assertThat(firstDoc.getFieldValue("name")).isEqualTo("Test Product"); assertThat(firstDoc.getFieldValue("description")).isEqualTo("Some description"); - + // Second document should skip empty description SolrInputDocument secondDoc = documents.get(1); assertThat(secondDoc.getFieldValue("id")).isEqualTo("2"); assertThat(secondDoc.getFieldValue("name")).isEqualTo("Another Product"); assertThat(secondDoc.getFieldValue("description")).isNull(); - + // Third document should skip empty name SolrInputDocument thirdDoc = documents.get(2); assertThat(thirdDoc.getFieldValue("id")).isEqualTo("3"); assertThat(thirdDoc.getFieldValue("name")).isNull(); assertThat(thirdDoc.getFieldValue("description")).isEqualTo("Empty name"); } - + @Test void testCreateSchemalessDocumentsFromCsvWithQuotedValues() throws Exception { // Given - - String csvData = """ - id,name,description - 1,"Quoted Name","Quoted description" - 2,Regular Name,Regular description - """; - + + String csvData = + """ + id,name,description + 1,"Quoted Name","Quoted description" + 2,Regular Name,Regular description + """; + // When - List documents = indexingDocumentCreator.createSchemalessDocumentsFromCsv(csvData); - + List documents = + indexingDocumentCreator.createSchemalessDocumentsFromCsv(csvData); + // Then assertThat(documents).hasSize(2); - + // First document should have quotes removed SolrInputDocument firstDoc = documents.getFirst(); assertThat(firstDoc.getFieldValue("id")).isEqualTo("1"); assertThat(firstDoc.getFieldValue("name")).isEqualTo("Quoted Name"); assertThat(firstDoc.getFieldValue("description")).isEqualTo("Quoted description"); - + // Second document should remain unchanged SolrInputDocument secondDoc = documents.get(1); assertThat(secondDoc.getFieldValue("id")).isEqualTo("2"); assertThat(secondDoc.getFieldValue("name")).isEqualTo("Regular Name"); assertThat(secondDoc.getFieldValue("description")).isEqualTo("Regular description"); } -} \ No newline at end of file +} diff --git a/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceDirectTest.java b/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceDirectTest.java index 418bcab..d1b9819 100644 --- a/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceDirectTest.java +++ b/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceDirectTest.java @@ -16,6 +16,12 @@ */ package org.apache.solr.mcp.server.indexing; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.List; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.response.UpdateResponse; import org.apache.solr.common.SolrInputDocument; @@ -26,29 +32,23 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.util.ArrayList; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - @ExtendWith(MockitoExtension.class) class IndexingServiceDirectTest { - @Mock - private SolrClient solrClient; + @Mock private SolrClient solrClient; - @Mock - private UpdateResponse updateResponse; + @Mock private UpdateResponse updateResponse; private IndexingService indexingService; private IndexingDocumentCreator indexingDocumentCreator; + @BeforeEach void setUp() { - indexingDocumentCreator = new IndexingDocumentCreator(new XmlDocumentCreator(), - new CsvDocumentCreator(), - new JsonDocumentCreator()); + indexingDocumentCreator = + new IndexingDocumentCreator( + new XmlDocumentCreator(), + new CsvDocumentCreator(), + new JsonDocumentCreator()); indexingService = new IndexingService(solrClient, indexingDocumentCreator); } @@ -68,8 +68,7 @@ void testBatchIndexingErrorHandling() throws Exception { .thenThrow(new RuntimeException("Batch indexing failed")); // Individual document adds should succeed - when(solrClient.add(anyString(), any(SolrInputDocument.class))) - .thenReturn(updateResponse); + when(solrClient.add(anyString(), any(SolrInputDocument.class))).thenReturn(updateResponse); // Call the method under test int successCount = indexingService.indexDocuments("test_collection", documents); @@ -132,7 +131,8 @@ void testBatchIndexingPartialFailure() throws Exception { @Test void testIndexJsonDocumentsWithJsonString() throws Exception { // Test JSON string with multiple documents - String json = """ + String json = + """ [ { "id": "test001", @@ -149,7 +149,8 @@ void testIndexJsonDocumentsWithJsonString() throws Exception { // Create a spy on the indexingDocumentCreator and inject it into a new IndexingService IndexingDocumentCreator indexingDocumentCreatorSpy = spy(indexingDocumentCreator); - IndexingService indexingServiceWithSpy = new IndexingService(solrClient, indexingDocumentCreatorSpy); + IndexingService indexingServiceWithSpy = + new IndexingService(solrClient, indexingDocumentCreatorSpy); IndexingService indexingServiceSpy = spy(indexingServiceWithSpy); // Create mock documents that would be returned by createSchemalessDocuments @@ -168,7 +169,9 @@ void testIndexJsonDocumentsWithJsonString() throws Exception { mockDocuments.add(doc2); // Mock the createSchemalessDocuments method to return our mock documents - doReturn(mockDocuments).when(indexingDocumentCreatorSpy).createSchemalessDocumentsFromJson(json); + doReturn(mockDocuments) + .when(indexingDocumentCreatorSpy) + .createSchemalessDocumentsFromJson(json); // Mock the indexDocuments method that takes a collection and list of documents doReturn(2).when(indexingServiceSpy).indexDocuments(anyString(), anyList()); @@ -190,16 +193,22 @@ void testIndexJsonDocumentsWithJsonStringErrorHandling() throws Exception { // Create a spy on the indexingDocumentCreator and inject it into a new IndexingService IndexingDocumentCreator indexingDocumentCreatorSpy = spy(indexingDocumentCreator); - IndexingService indexingServiceWithSpy = new IndexingService(solrClient, indexingDocumentCreatorSpy); + IndexingService indexingServiceWithSpy = + new IndexingService(solrClient, indexingDocumentCreatorSpy); IndexingService indexingServiceSpy = spy(indexingServiceWithSpy); // Mock the createSchemalessDocuments method to throw an exception - doThrow(new DocumentProcessingException("Invalid JSON")).when(indexingDocumentCreatorSpy).createSchemalessDocumentsFromJson(invalidJson); + doThrow(new DocumentProcessingException("Invalid JSON")) + .when(indexingDocumentCreatorSpy) + .createSchemalessDocumentsFromJson(invalidJson); // Call the method under test and verify it throws an exception - DocumentProcessingException exception = assertThrows(DocumentProcessingException.class, () -> { - indexingServiceSpy.indexJsonDocuments("test_collection", invalidJson); - }); + DocumentProcessingException exception = + assertThrows( + DocumentProcessingException.class, + () -> { + indexingServiceSpy.indexJsonDocuments("test_collection", invalidJson); + }); // Verify the exception message assertTrue(exception.getMessage().contains("Invalid JSON")); diff --git a/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceTest.java b/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceTest.java index ca8eddb..0ca8b17 100644 --- a/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceTest.java +++ b/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceTest.java @@ -16,6 +16,15 @@ */ package org.apache.solr.mcp.server.indexing; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.SolrServerException; import org.apache.solr.client.solrj.request.CollectionAdminRequest; @@ -39,16 +48,6 @@ import org.springframework.context.annotation.Import; import org.testcontainers.containers.SolrContainer; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - @SpringBootTest @Import(TestcontainersConfiguration.class) class IndexingServiceTest { @@ -56,16 +55,11 @@ class IndexingServiceTest { private static boolean initialized = false; private static final String COLLECTION_NAME = "indexing_test_" + System.currentTimeMillis(); - @Autowired - private SolrContainer solrContainer; - @Autowired - private IndexingDocumentCreator indexingDocumentCreator; - @Autowired - private IndexingService indexingService; - @Autowired - private SearchService searchService; - @Autowired - private SolrClient solrClient; + @Autowired private SolrContainer solrContainer; + @Autowired private IndexingDocumentCreator indexingDocumentCreator; + @Autowired private IndexingService indexingService; + @Autowired private SearchService searchService; + @Autowired private SolrClient solrClient; @BeforeEach void setUp() throws Exception { @@ -75,27 +69,27 @@ void setUp() throws Exception { CsvDocumentCreator csvDocumentCreator = new CsvDocumentCreator(); JsonDocumentCreator jsonDocumentCreator = new JsonDocumentCreator(); - indexingDocumentCreator = new IndexingDocumentCreator(xmlDocumentCreator, - csvDocumentCreator, - jsonDocumentCreator); + indexingDocumentCreator = + new IndexingDocumentCreator( + xmlDocumentCreator, csvDocumentCreator, jsonDocumentCreator); indexingService = new IndexingService(solrClient, indexingDocumentCreator); searchService = new SearchService(solrClient); if (!initialized) { // Create collection - CollectionAdminRequest.Create createRequest = CollectionAdminRequest.createCollection( - COLLECTION_NAME, "_default", 1, 1); + CollectionAdminRequest.Create createRequest = + CollectionAdminRequest.createCollection(COLLECTION_NAME, "_default", 1, 1); createRequest.process(solrClient); initialized = true; } } - @Test void testCreateSchemalessDocumentsFromJson() throws Exception { // Test JSON string - String json = """ + String json = + """ [ { "id": "test001", @@ -112,7 +106,8 @@ void testCreateSchemalessDocumentsFromJson() throws Exception { """; // Create documents - List documents = indexingDocumentCreator.createSchemalessDocumentsFromJson(json); + List documents = + indexingDocumentCreator.createSchemalessDocumentsFromJson(json); // Verify documents were created correctly assertNotNull(documents); @@ -165,7 +160,8 @@ void testCreateSchemalessDocumentsFromJson() throws Exception { void testIndexJsonDocuments() throws Exception { // Test JSON string with multiple documents - String json = """ + String json = + """ [ { "id": "test002", @@ -192,7 +188,9 @@ void testIndexJsonDocuments() throws Exception { indexingService.indexJsonDocuments(COLLECTION_NAME, json); // Verify documents were indexed by searching for them - SearchResponse result = searchService.search(COLLECTION_NAME, "id:test002 OR id:test003", null, null, null, null, null); + SearchResponse result = + searchService.search( + COLLECTION_NAME, "id:test002 OR id:test003", null, null, null, null, null); assertNotNull(result); List> documents = result.documents(); @@ -275,7 +273,8 @@ void testIndexJsonDocuments() throws Exception { void testIndexJsonDocumentsWithNestedObjects() throws Exception { // Test JSON string with nested objects - String json = """ + String json = + """ [ { "id": "test004", @@ -296,7 +295,8 @@ void testIndexJsonDocumentsWithNestedObjects() throws Exception { indexingService.indexJsonDocuments(COLLECTION_NAME, json); // Verify documents were indexed by searching for them - SearchResponse result = searchService.search(COLLECTION_NAME, "id:test004", null, null, null, null, null); + SearchResponse result = + searchService.search(COLLECTION_NAME, "id:test004", null, null, null, null, null); assertNotNull(result); List> documents = result.documents(); @@ -344,7 +344,8 @@ void testIndexJsonDocumentsWithNestedObjects() throws Exception { void testSanitizeFieldName() throws Exception { // Test JSON string with field names that need sanitizing - String json = """ + String json = + """ [ { "id": "test005", @@ -360,7 +361,8 @@ void testSanitizeFieldName() throws Exception { indexingService.indexJsonDocuments(COLLECTION_NAME, json); // Verify documents were indexed with sanitized field names - SearchResponse result = searchService.search(COLLECTION_NAME, "id:test005", null, null, null, null, null); + SearchResponse result = + searchService.search(COLLECTION_NAME, "id:test005", null, null, null, null, null); assertNotNull(result); List> documents = result.documents(); @@ -398,7 +400,9 @@ void testSanitizeFieldName() throws Exception { assertNotNull(doc.get("multiple_underscores")); Object multipleUnderscoresValue = doc.get("multiple_underscores"); if (multipleUnderscoresValue instanceof List) { - assertEquals("Value with multiple underscores", ((List) multipleUnderscoresValue).getFirst()); + assertEquals( + "Value with multiple underscores", + ((List) multipleUnderscoresValue).getFirst()); } else { assertEquals("Value with multiple underscores", multipleUnderscoresValue); } @@ -408,7 +412,8 @@ void testSanitizeFieldName() throws Exception { void testDeeplyNestedJsonStructures() throws Exception { // Test JSON string with deeply nested objects (3+ levels) - String json = """ + String json = + """ [ { "id": "nested001", @@ -452,7 +457,8 @@ void testDeeplyNestedJsonStructures() throws Exception { indexingService.indexJsonDocuments(COLLECTION_NAME, json); // Verify documents were indexed by searching for them - SearchResponse result = searchService.search(COLLECTION_NAME, "id:nested001", null, null, null, null, null); + SearchResponse result = + searchService.search(COLLECTION_NAME, "id:nested001", null, null, null, null, null); assertNotNull(result); List> documents = result.documents(); @@ -463,15 +469,24 @@ void testDeeplyNestedJsonStructures() throws Exception { // Check that deeply nested fields were flattened with underscore prefix // Level 1 assertNotNull(doc.get("metadata_publication_publisher_name")); - assertEquals("Deep Nest Publishing", getFieldValue(doc, "metadata_publication_publisher_name")); + assertEquals( + "Deep Nest Publishing", getFieldValue(doc, "metadata_publication_publisher_name")); // Level 2 assertNotNull(doc.get("metadata_publication_publisher_location_city")); - assertEquals("Nestville", getFieldValue(doc, "metadata_publication_publisher_location_city")); + assertEquals( + "Nestville", getFieldValue(doc, "metadata_publication_publisher_location_city")); // Level 3 assertNotNull(doc.get("metadata_publication_publisher_location_coordinates_latitude")); - assertEquals(42.123, ((Number) getFieldValue(doc, "metadata_publication_publisher_location_coordinates_latitude")).doubleValue(), 0.001); + assertEquals( + 42.123, + ((Number) + getFieldValue( + doc, + "metadata_publication_publisher_location_coordinates_latitude")) + .doubleValue(), + 0.001); // Check other branches of the nested structure assertNotNull(doc.get("metadata_publication_edition_notes_condition")); @@ -493,7 +508,8 @@ private Object getFieldValue(Map doc, String fieldName) { void testSpecialCharactersInFieldNames() throws Exception { // Test JSON string with field names containing various special characters - String json = """ + String json = + """ [ { "id": "special_fields_001", @@ -529,7 +545,9 @@ void testSpecialCharactersInFieldNames() throws Exception { indexingService.indexJsonDocuments(COLLECTION_NAME, json); // Verify documents were indexed by searching for them - SearchResponse result = searchService.search(COLLECTION_NAME, "id:special_fields_001", null, null, null, null, null); + SearchResponse result = + searchService.search( + COLLECTION_NAME, "id:special_fields_001", null, null, null, null, null); assertNotNull(result); List> documents = result.documents(); @@ -574,7 +592,8 @@ void testSpecialCharactersInFieldNames() throws Exception { void testArraysOfObjects() throws Exception { // Test JSON string with arrays of objects - String json = """ + String json = + """ [ { "id": "array_objects_001", @@ -617,7 +636,9 @@ void testArraysOfObjects() throws Exception { indexingService.indexJsonDocuments(COLLECTION_NAME, json); // Verify documents were indexed by searching for them - SearchResponse result = searchService.search(COLLECTION_NAME, "id:array_objects_001", null, null, null, null, null); + SearchResponse result = + searchService.search( + COLLECTION_NAME, "id:array_objects_001", null, null, null, null, null); assertNotNull(result); List> documents = result.documents(); @@ -641,11 +662,13 @@ void testArraysOfObjects() throws Exception { // For arrays of objects, the IndexingService should flatten them with field names // that include the array name and the object field name - // We can't directly access the array elements, but we can check if the flattened fields exist + // We can't directly access the array elements, but we can check if the flattened fields + // exist // Check for flattened author fields // Note: The current implementation in IndexingService.java doesn't handle arrays of objects - // in a way that preserves the array structure. It skips object items in arrays (line 68-70). + // in a way that preserves the array structure. It skips object items in arrays (line + // 68-70). // This test is checking the current behavior, which may need improvement in the future. // Check for flattened review fields @@ -655,7 +678,8 @@ void testArraysOfObjects() throws Exception { @Test void testNonArrayJsonInput() throws Exception { // Test JSON string that is not an array but a single object - String json = """ + String json = + """ { "id": "single_object_001", "title": "Single Object Document", @@ -665,7 +689,8 @@ void testNonArrayJsonInput() throws Exception { """; // Create documents - List documents = indexingDocumentCreator.createSchemalessDocumentsFromJson(json); + List documents = + indexingDocumentCreator.createSchemalessDocumentsFromJson(json); // Verify no documents were created since input is not an array assertNotNull(documents); @@ -675,7 +700,8 @@ void testNonArrayJsonInput() throws Exception { @Test void testConvertJsonValueTypes() throws Exception { // Test JSON with different value types - String json = """ + String json = + """ [ { "id": "value_types_001", @@ -689,7 +715,8 @@ void testConvertJsonValueTypes() throws Exception { """; // Create documents - List documents = indexingDocumentCreator.createSchemalessDocumentsFromJson(json); + List documents = + indexingDocumentCreator.createSchemalessDocumentsFromJson(json); // Verify documents were created correctly assertNotNull(documents); @@ -710,7 +737,8 @@ void testConvertJsonValueTypes() throws Exception { void testDirectSanitizeFieldName() throws Exception { // Test sanitizing field names directly // Create a document with field names that need sanitizing - String json = """ + String json = + """ [ { "id": "field_names_001", @@ -726,7 +754,8 @@ void testDirectSanitizeFieldName() throws Exception { """; // Create documents - List documents = indexingDocumentCreator.createSchemalessDocumentsFromJson(json); + List documents = + indexingDocumentCreator.createSchemalessDocumentsFromJson(json); // Verify documents were created correctly assertNotNull(documents); @@ -746,16 +775,13 @@ void testDirectSanitizeFieldName() throws Exception { } } - @Nested @ExtendWith(MockitoExtension.class) class UnitTests { - @Mock - private SolrClient solrClient; + @Mock private SolrClient solrClient; - @Mock - private IndexingDocumentCreator indexingDocumentCreator; + @Mock private IndexingDocumentCreator indexingDocumentCreator; private IndexingService indexingService; @@ -785,14 +811,20 @@ void indexJsonDocuments_WithValidJson_ShouldIndexDocuments() throws Exception { } @Test - void indexJsonDocuments_WhenDocumentCreatorThrowsException_ShouldPropagateException() throws Exception { + void indexJsonDocuments_WhenDocumentCreatorThrowsException_ShouldPropagateException() + throws Exception { String invalidJson = "not valid json"; when(indexingDocumentCreator.createSchemalessDocumentsFromJson(invalidJson)) - .thenThrow(new org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException("Invalid JSON")); - - assertThrows(org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException.class, () -> { - indexingService.indexJsonDocuments("test_collection", invalidJson); - }); + .thenThrow( + new org.apache.solr.mcp.server.indexing.documentcreator + .DocumentProcessingException("Invalid JSON")); + + assertThrows( + org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException + .class, + () -> { + indexingService.indexJsonDocuments("test_collection", invalidJson); + }); verify(solrClient, never()).add(anyString(), any(Collection.class)); verify(solrClient, never()).commit(anyString()); } @@ -813,14 +845,20 @@ void indexCsvDocuments_WithValidCsv_ShouldIndexDocuments() throws Exception { } @Test - void indexCsvDocuments_WhenDocumentCreatorThrowsException_ShouldPropagateException() throws Exception { + void indexCsvDocuments_WhenDocumentCreatorThrowsException_ShouldPropagateException() + throws Exception { String invalidCsv = "malformed csv data"; when(indexingDocumentCreator.createSchemalessDocumentsFromCsv(invalidCsv)) - .thenThrow(new org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException("Invalid CSV")); - - assertThrows(org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException.class, () -> { - indexingService.indexCsvDocuments("test_collection", invalidCsv); - }); + .thenThrow( + new org.apache.solr.mcp.server.indexing.documentcreator + .DocumentProcessingException("Invalid CSV")); + + assertThrows( + org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException + .class, + () -> { + indexingService.indexCsvDocuments("test_collection", invalidCsv); + }); verify(solrClient, never()).add(anyString(), any(Collection.class)); verify(solrClient, never()).commit(anyString()); } @@ -841,14 +879,20 @@ void indexXmlDocuments_WithValidXml_ShouldIndexDocuments() throws Exception { } @Test - void indexXmlDocuments_WhenParserConfigurationFails_ShouldPropagateException() throws Exception { + void indexXmlDocuments_WhenParserConfigurationFails_ShouldPropagateException() + throws Exception { String xml = "xml"; when(indexingDocumentCreator.createSchemalessDocumentsFromXml(xml)) - .thenThrow(new org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException("Parser error")); - - assertThrows(org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException.class, () -> { - indexingService.indexXmlDocuments("test_collection", xml); - }); + .thenThrow( + new org.apache.solr.mcp.server.indexing.documentcreator + .DocumentProcessingException("Parser error")); + + assertThrows( + org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException + .class, + () -> { + indexingService.indexXmlDocuments("test_collection", xml); + }); verify(solrClient, never()).add(anyString(), any(Collection.class)); verify(solrClient, never()).commit(anyString()); } @@ -857,11 +901,16 @@ void indexXmlDocuments_WhenParserConfigurationFails_ShouldPropagateException() t void indexXmlDocuments_WhenSaxExceptionOccurs_ShouldPropagateException() throws Exception { String xml = ""; when(indexingDocumentCreator.createSchemalessDocumentsFromXml(xml)) - .thenThrow(new org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException("SAX parsing error")); - - assertThrows(org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException.class, () -> { - indexingService.indexXmlDocuments("test_collection", xml); - }); + .thenThrow( + new org.apache.solr.mcp.server.indexing.documentcreator + .DocumentProcessingException("SAX parsing error")); + + assertThrows( + org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException + .class, + () -> { + indexingService.indexXmlDocuments("test_collection", xml); + }); verify(solrClient, never()).add(anyString(), any(Collection.class)); verify(solrClient, never()).commit(anyString()); } @@ -911,7 +960,8 @@ void indexDocuments_WhenBatchFails_ShouldRetryIndividually() throws Exception { } @Test - void indexDocuments_WhenSomeIndividualDocumentsFail_ShouldIndexSuccessfulOnes() throws Exception { + void indexDocuments_WhenSomeIndividualDocumentsFail_ShouldIndexSuccessfulOnes() + throws Exception { List docs = createMockDocuments(3); when(solrClient.add(eq("test_collection"), any(List.class))) @@ -950,9 +1000,11 @@ void indexDocuments_WhenCommitFails_ShouldPropagateException() throws Exception when(solrClient.add(eq("test_collection"), any(Collection.class))).thenReturn(null); when(solrClient.commit("test_collection")).thenThrow(new IOException("Commit failed")); - assertThrows(IOException.class, () -> { - indexingService.indexDocuments("test_collection", docs); - }); + assertThrows( + IOException.class, + () -> { + indexingService.indexDocuments("test_collection", docs); + }); verify(solrClient).add(eq("test_collection"), any(Collection.class)); verify(solrClient).commit("test_collection"); } @@ -967,14 +1019,16 @@ void indexDocuments_ShouldBatchCorrectly() throws Exception { assertEquals(1000, result); - ArgumentCaptor> captor = ArgumentCaptor.forClass(Collection.class); + ArgumentCaptor> captor = + ArgumentCaptor.forClass(Collection.class); verify(solrClient).add(eq("test_collection"), captor.capture()); assertEquals(1000, captor.getValue().size()); verify(solrClient).commit("test_collection"); } @Test - void indexJsonDocuments_WhenSolrClientThrowsException_ShouldPropagateException() throws Exception { + void indexJsonDocuments_WhenSolrClientThrowsException_ShouldPropagateException() + throws Exception { String json = "[{\"id\":\"1\"}]"; List mockDocs = createMockDocuments(1); when(indexingDocumentCreator.createSchemalessDocumentsFromJson(json)).thenReturn(mockDocs); @@ -991,7 +1045,8 @@ void indexJsonDocuments_WhenSolrClientThrowsException_ShouldPropagateException() } @Test - void indexCsvDocuments_WhenSolrClientThrowsIOException_ShouldPropagateException() throws Exception { + void indexCsvDocuments_WhenSolrClientThrowsIOException_ShouldPropagateException() + throws Exception { String csv = "id,title\n1,Test"; List mockDocs = createMockDocuments(1); when(indexingDocumentCreator.createSchemalessDocumentsFromCsv(csv)).thenReturn(mockDocs); diff --git a/src/test/java/org/apache/solr/mcp/server/indexing/XmlIndexingTest.java b/src/test/java/org/apache/solr/mcp/server/indexing/XmlIndexingTest.java index c43f2f9..a8a6b63 100644 --- a/src/test/java/org/apache/solr/mcp/server/indexing/XmlIndexingTest.java +++ b/src/test/java/org/apache/solr/mcp/server/indexing/XmlIndexingTest.java @@ -16,6 +16,10 @@ */ package org.apache.solr.mcp.server.indexing; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; import org.apache.solr.common.SolrInputDocument; import org.apache.solr.mcp.server.indexing.documentcreator.IndexingDocumentCreator; import org.junit.jupiter.api.Test; @@ -23,29 +27,24 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.TestPropertySource; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - /** * Test class for XML indexing functionality in IndexingService. * - *

This test verifies that the IndexingService can correctly parse XML data - * and convert it into SolrInputDocument objects using the schema-less approach.

+ *

This test verifies that the IndexingService can correctly parse XML data and convert it into + * SolrInputDocument objects using the schema-less approach. */ @SpringBootTest @TestPropertySource(locations = "classpath:application.properties") class XmlIndexingTest { - @Autowired - private IndexingDocumentCreator indexingDocumentCreator; + @Autowired private IndexingDocumentCreator indexingDocumentCreator; @Test void testCreateSchemalessDocumentsFromXmlSingleDocument() throws Exception { // Given - String xmlData = """ + String xmlData = + """ A Game of Thrones @@ -59,7 +58,8 @@ void testCreateSchemalessDocumentsFromXmlSingleDocument() throws Exception { """; // When - List documents = indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlData); + List documents = + indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlData); // Then assertThat(documents).hasSize(1); @@ -78,7 +78,8 @@ void testCreateSchemalessDocumentsFromXmlSingleDocument() throws Exception { void testCreateSchemalessDocumentsFromXmlMultipleDocuments() throws Exception { // Given - String xmlData = """ + String xmlData = + """ A Game of Thrones @@ -99,7 +100,8 @@ void testCreateSchemalessDocumentsFromXmlMultipleDocuments() throws Exception { """; // When - List documents = indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlData); + List documents = + indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlData); // Then assertThat(documents).hasSize(3); @@ -130,7 +132,8 @@ void testCreateSchemalessDocumentsFromXmlMultipleDocuments() throws Exception { void testCreateSchemalessDocumentsFromXmlWithAttributes() throws Exception { // Given - String xmlData = """ + String xmlData = + """ Smartphone 599.99 @@ -139,7 +142,8 @@ void testCreateSchemalessDocumentsFromXmlWithAttributes() throws Exception { """; // When - List documents = indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlData); + List documents = + indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlData); // Then assertThat(documents).hasSize(1); @@ -152,14 +156,16 @@ void testCreateSchemalessDocumentsFromXmlWithAttributes() throws Exception { assertThat(doc.getFieldValue("product_price_currency_attr")).isEqualTo("USD"); assertThat(doc.getFieldValue("product_name")).isEqualTo("Smartphone"); assertThat(doc.getFieldValue("product_price")).isEqualTo("599.99"); - assertThat(doc.getFieldValue("product_description")).isEqualTo("Latest smartphone with advanced features"); + assertThat(doc.getFieldValue("product_description")) + .isEqualTo("Latest smartphone with advanced features"); } @Test void testCreateSchemalessDocumentsFromXmlWithEmptyValues() throws Exception { // Given - String xmlData = """ + String xmlData = + """ Product One @@ -175,7 +181,8 @@ void testCreateSchemalessDocumentsFromXmlWithEmptyValues() throws Exception { """; // When - List documents = indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlData); + List documents = + indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlData); // Then assertThat(documents).hasSize(2); @@ -184,13 +191,15 @@ void testCreateSchemalessDocumentsFromXmlWithEmptyValues() throws Exception { SolrInputDocument firstDoc = documents.getFirst(); assertThat(firstDoc.getFieldValue("id_attr")).isEqualTo("1"); assertThat(firstDoc.getFieldValue("item_name")).isEqualTo("Product One"); - assertThat(firstDoc.getFieldValue("item_description")).isNull(); // Empty element should not be indexed + assertThat(firstDoc.getFieldValue("item_description")) + .isNull(); // Empty element should not be indexed assertThat(firstDoc.getFieldValue("item_price")).isEqualTo("19.99"); // Second document should skip empty name SolrInputDocument secondDoc = documents.get(1); assertThat(secondDoc.getFieldValue("id_attr")).isEqualTo("2"); - assertThat(secondDoc.getFieldValue("item_name")).isNull(); // Empty element should not be indexed + assertThat(secondDoc.getFieldValue("item_name")) + .isNull(); // Empty element should not be indexed assertThat(secondDoc.getFieldValue("item_description")).isEqualTo("Product with no name"); assertThat(secondDoc.getFieldValue("item_price")).isEqualTo("29.99"); } @@ -199,7 +208,8 @@ void testCreateSchemalessDocumentsFromXmlWithEmptyValues() throws Exception { void testCreateSchemalessDocumentsFromXmlWithRepeatedElements() throws Exception { // Given - String xmlData = """ + String xmlData = + """ Programming Book John Doe @@ -216,7 +226,8 @@ void testCreateSchemalessDocumentsFromXmlWithRepeatedElements() throws Exception """; // When - List documents = indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlData); + List documents = + indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlData); // Then assertThat(documents).hasSize(1); @@ -236,11 +247,12 @@ void testCreateSchemalessDocumentsFromXmlWithRepeatedElements() throws Exception void testCreateSchemalessDocumentsFromXmlMixedContent() throws Exception { // Given - String xmlData = """ + String xmlData = + """

Mixed Content Example - This is some text content with + This is some text content with emphasized text and more content here. @@ -249,7 +261,8 @@ void testCreateSchemalessDocumentsFromXmlMixedContent() throws Exception { """; // When - List documents = indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlData); + List documents = + indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlData); // Then assertThat(documents).hasSize(1); @@ -267,7 +280,8 @@ void testCreateSchemalessDocumentsFromXmlMixedContent() throws Exception { void testCreateSchemalessDocumentsFromXmlWithMalformedXml() { // Given - String malformedXml = """ + String malformedXml = + """ Incomplete Book <author>John Doe</author> @@ -275,7 +289,10 @@ void testCreateSchemalessDocumentsFromXmlWithMalformedXml() { """; // When/Then - assertThatThrownBy(() -> indexingDocumentCreator.createSchemalessDocumentsFromXml(malformedXml)) + assertThatThrownBy( + () -> + indexingDocumentCreator.createSchemalessDocumentsFromXml( + malformedXml)) .isInstanceOf(RuntimeException.class); } @@ -283,7 +300,8 @@ void testCreateSchemalessDocumentsFromXmlWithMalformedXml() { void testCreateSchemalessDocumentsFromXmlWithInvalidCharacters() { // Given - String invalidXml = """ + String invalidXml = + """ <book> <title>Book with invalid character: \u0000 John Doe @@ -291,7 +309,8 @@ void testCreateSchemalessDocumentsFromXmlWithInvalidCharacters() { """; // When/Then - assertThatThrownBy(() -> indexingDocumentCreator.createSchemalessDocumentsFromXml(invalidXml)) + assertThatThrownBy( + () -> indexingDocumentCreator.createSchemalessDocumentsFromXml(invalidXml)) .isInstanceOf(RuntimeException.class); } @@ -299,7 +318,8 @@ void testCreateSchemalessDocumentsFromXmlWithInvalidCharacters() { void testCreateSchemalessDocumentsFromXmlWithDoctype() { // Given - String xmlWithDoctype = """ + String xmlWithDoctype = + """ @@ -313,7 +333,10 @@ void testCreateSchemalessDocumentsFromXmlWithDoctype() { """; // When/Then - Should fail due to XXE protection - assertThatThrownBy(() -> indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlWithDoctype)) + assertThatThrownBy( + () -> + indexingDocumentCreator.createSchemalessDocumentsFromXml( + xmlWithDoctype)) .isInstanceOf(RuntimeException.class); } @@ -321,7 +344,8 @@ void testCreateSchemalessDocumentsFromXmlWithDoctype() { void testCreateSchemalessDocumentsFromXmlWithExternalEntity() { // Given - String xmlWithExternalEntity = """ + String xmlWithExternalEntity = + """ @@ -333,7 +357,10 @@ void testCreateSchemalessDocumentsFromXmlWithExternalEntity() { """; // When/Then - Should fail due to XXE protection - assertThatThrownBy(() -> indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlWithExternalEntity)) + assertThatThrownBy( + () -> + indexingDocumentCreator.createSchemalessDocumentsFromXml( + xmlWithExternalEntity)) .isInstanceOf(RuntimeException.class); } @@ -362,7 +389,8 @@ void testCreateSchemalessDocumentsFromXmlWithWhitespaceOnlyInput() { // Given // When/Then - assertThatThrownBy(() -> indexingDocumentCreator.createSchemalessDocumentsFromXml(" \n\t ")) + assertThatThrownBy( + () -> indexingDocumentCreator.createSchemalessDocumentsFromXml(" \n\t ")) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("XML input cannot be null or empty"); } @@ -376,7 +404,8 @@ void testCreateSchemalessDocumentsFromXmlWithLargeDocument() { largeXml.append(""); // Add enough data to exceed the 10MB limit - String bookTemplate = """ + String bookTemplate = + """ %s %s @@ -391,7 +420,10 @@ void testCreateSchemalessDocumentsFromXmlWithLargeDocument() { largeXml.append(""); // When/Then - assertThatThrownBy(() -> indexingDocumentCreator.createSchemalessDocumentsFromXml(largeXml.toString())) + assertThatThrownBy( + () -> + indexingDocumentCreator.createSchemalessDocumentsFromXml( + largeXml.toString())) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("XML document too large"); } @@ -400,7 +432,8 @@ void testCreateSchemalessDocumentsFromXmlWithLargeDocument() { void testCreateSchemalessDocumentsFromXmlWithComplexNestedStructure() throws Exception { // Given - String complexXml = """ + String complexXml = + """
Smartphone @@ -428,7 +461,8 @@ void testCreateSchemalessDocumentsFromXmlWithComplexNestedStructure() throws Exc """; // When - List documents = indexingDocumentCreator.createSchemalessDocumentsFromXml(complexXml); + List documents = + indexingDocumentCreator.createSchemalessDocumentsFromXml(complexXml); // Then assertThat(documents).hasSize(1); @@ -441,17 +475,24 @@ void testCreateSchemalessDocumentsFromXmlWithComplexNestedStructure() throws Exc // Verify nested structure flattening assertThat(doc.getFieldValue("product_details_name_lang_attr")).isNotNull(); - assertThat(doc.getFieldValue("product_details_specifications_screen_size_attr")).isEqualTo("6.1"); - assertThat(doc.getFieldValue("product_details_specifications_screen_type_attr")).isEqualTo("OLED"); - assertThat(doc.getFieldValue("product_details_specifications_screen")).isEqualTo("Full HD+"); + assertThat(doc.getFieldValue("product_details_specifications_screen_size_attr")) + .isEqualTo("6.1"); + assertThat(doc.getFieldValue("product_details_specifications_screen_type_attr")) + .isEqualTo("OLED"); + assertThat(doc.getFieldValue("product_details_specifications_screen")) + .isEqualTo("Full HD+"); // Verify multiple similar elements - assertThat(doc.getFieldValue("product_details_specifications_camera_type_attr")).isNotNull(); - assertThat(doc.getFieldValue("product_details_specifications_camera_resolution_attr")).isNotNull(); + assertThat(doc.getFieldValue("product_details_specifications_camera_type_attr")) + .isNotNull(); + assertThat(doc.getFieldValue("product_details_specifications_camera_resolution_attr")) + .isNotNull(); // Verify deeply nested elements - assertThat(doc.getFieldValue("product_details_specifications_storage_internal")).isEqualTo("128GB"); - assertThat(doc.getFieldValue("product_details_specifications_storage_expandable")).isEqualTo("Yes"); + assertThat(doc.getFieldValue("product_details_specifications_storage_internal")) + .isEqualTo("128GB"); + assertThat(doc.getFieldValue("product_details_specifications_storage_expandable")) + .isEqualTo("Yes"); // Verify pricing and availability assertThat(doc.getFieldValue("product_pricing_currency_attr")).isEqualTo("USD"); @@ -464,7 +505,8 @@ void testCreateSchemalessDocumentsFromXmlWithComplexNestedStructure() throws Exc void testFieldNameSanitization() throws Exception { // Given - String xmlWithSpecialChars = """ + String xmlWithSpecialChars = + """ Test Product 99.99 @@ -476,7 +518,8 @@ void testFieldNameSanitization() throws Exception { """; // When - List documents = indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlWithSpecialChars); + List documents = + indexingDocumentCreator.createSchemalessDocumentsFromXml(xmlWithSpecialChars); // Then assertThat(documents).hasSize(1); @@ -488,8 +531,9 @@ void testFieldNameSanitization() throws Exception { assertThat(doc.getFieldValue("product_data_product_name")).isEqualTo("Test Product"); assertThat(doc.getFieldValue("product_data_price_usd")).isEqualTo("99.99"); assertThat(doc.getFieldValue("product_data_category_type")).isEqualTo("electronics"); - assertThat(doc.getFieldValue("product_data_field_with_multiple_underscores")).isEqualTo("value"); + assertThat(doc.getFieldValue("product_data_field_with_multiple_underscores")) + .isEqualTo("value"); assertThat(doc.getFieldValue("product_data_field_with_dashes")).isEqualTo("dashed value"); assertThat(doc.getFieldValue("product_data_uppercase_field")).isEqualTo("uppercase value"); } -} \ No newline at end of file +} diff --git a/src/test/java/org/apache/solr/mcp/server/metadata/CollectionServiceIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/metadata/CollectionServiceIntegrationTest.java index a12be4f..5f9261c 100644 --- a/src/test/java/org/apache/solr/mcp/server/metadata/CollectionServiceIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/metadata/CollectionServiceIntegrationTest.java @@ -16,6 +16,9 @@ */ package org.apache.solr.mcp.server.metadata; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.request.CollectionAdminRequest; import org.apache.solr.mcp.server.TestcontainersConfiguration; @@ -25,19 +28,13 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; - @SpringBootTest @Import(TestcontainersConfiguration.class) class CollectionServiceIntegrationTest { private static final String TEST_COLLECTION = "test_collection"; - @Autowired - private CollectionService collectionService; - @Autowired - private SolrClient solrClient; + @Autowired private CollectionService collectionService; + @Autowired private SolrClient solrClient; private static boolean initialized = false; @BeforeEach @@ -46,7 +43,8 @@ void setupCollection() throws Exception { if (!initialized) { // Create a test collection using the container's connection details // Create a collection for testing - CollectionAdminRequest.Create createRequest = CollectionAdminRequest.createCollection(TEST_COLLECTION, "_default", 1, 1); + CollectionAdminRequest.Create createRequest = + CollectionAdminRequest.createCollection(TEST_COLLECTION, "_default", 1, 1); createRequest.process(solrClient); // Verify collection was created successfully @@ -71,11 +69,17 @@ void testListCollections() { assertFalse(collections.isEmpty(), "Collections list should not be empty"); // Check if the test collection exists (either as exact name or as shard) - boolean testCollectionExists = collections.contains(TEST_COLLECTION) || - collections.stream().anyMatch(col -> col.startsWith(TEST_COLLECTION + "_shard")); - assertTrue(testCollectionExists, - "Collections should contain the test collection: " + TEST_COLLECTION + - " (found: " + collections + ")"); + boolean testCollectionExists = + collections.contains(TEST_COLLECTION) + || collections.stream() + .anyMatch(col -> col.startsWith(TEST_COLLECTION + "_shard")); + assertTrue( + testCollectionExists, + "Collections should contain the test collection: " + + TEST_COLLECTION + + " (found: " + + collections + + ")"); // Verify collection names are not null or empty for (String collection : collections) { @@ -84,18 +88,20 @@ void testListCollections() { } // Verify expected collection characteristics - assertEquals(collections.size(), collections.stream().distinct().count(), + assertEquals( + collections.size(), + collections.stream().distinct().count(), "Collection names should be unique"); // Verify that collections follow expected naming patterns for (String collection : collections) { // Collection names should either be simple names or shard names - assertTrue(collection.matches("^[a-zA-Z0-9_]+(_shard\\d+_replica_n\\d+)?$"), + assertTrue( + collection.matches("^[a-zA-Z0-9_]+(_shard\\d+_replica_n\\d+)?$"), "Collection name should follow expected pattern: " + collection); } } - @Test void testGetCollectionStats() throws Exception { // Test getting collection stats @@ -124,24 +130,26 @@ void testGetCollectionStats() throws Exception { // Verify timestamp is recent (within last 10 seconds) long currentTime = System.currentTimeMillis(); long timestampTime = metrics.timestamp().getTime(); - assertTrue(currentTime - timestampTime < 10000, + assertTrue( + currentTime - timestampTime < 10000, "Timestamp should be recent (within 10 seconds)"); // Verify optional stats (cache and handler stats may be null, which is acceptable) if (metrics.cacheStats() != null) { CacheStats cacheStats = metrics.cacheStats(); // Verify at least one cache type exists if cache stats are present - assertTrue(cacheStats.queryResultCache() != null || - cacheStats.documentCache() != null || - cacheStats.filterCache() != null, + assertTrue( + cacheStats.queryResultCache() != null + || cacheStats.documentCache() != null + || cacheStats.filterCache() != null, "At least one cache type should be present if cache stats exist"); } if (metrics.handlerStats() != null) { HandlerStats handlerStats = metrics.handlerStats(); // Verify at least one handler type exists if handler stats are present - assertTrue(handlerStats.selectHandler() != null || - handlerStats.updateHandler() != null, + assertTrue( + handlerStats.selectHandler() != null || handlerStats.updateHandler() != null, "At least one handler type should be present if handler stats exist"); } } @@ -161,7 +169,8 @@ void testCheckHealthHealthy() { // Verify response time assertNotNull(status.responseTime(), "Response time should not be null"); assertTrue(status.responseTime() >= 0, "Response time should be non-negative"); - assertTrue(status.responseTime() < 30000, "Response time should be reasonable (< 30 seconds)"); + assertTrue( + status.responseTime() < 30000, "Response time should be reasonable (< 30 seconds)"); // Verify document count assertNotNull(status.totalDocuments(), "Total documents should not be null"); @@ -171,7 +180,8 @@ void testCheckHealthHealthy() { assertNotNull(status.lastChecked(), "Last checked timestamp should not be null"); long currentTime = System.currentTimeMillis(); long lastCheckedTime = status.lastChecked().getTime(); - assertTrue(currentTime - lastCheckedTime < 5000, + assertTrue( + currentTime - lastCheckedTime < 5000, "Last checked timestamp should be very recent (within 5 seconds)"); // Verify no error message for healthy collection @@ -180,7 +190,8 @@ void testCheckHealthHealthy() { // Verify string representation contains meaningful information String statusString = status.toString(); if (statusString != null) { - assertTrue(statusString.contains("healthy") || statusString.contains("true"), + assertTrue( + statusString.contains("healthy") || statusString.contains("true"), "Status string should indicate healthy state"); } } @@ -202,29 +213,38 @@ void testCheckHealthUnhealthy() { assertNotNull(status.lastChecked(), "Last checked timestamp should not be null"); long currentTime = System.currentTimeMillis(); long lastCheckedTime = status.lastChecked().getTime(); - assertTrue(currentTime - lastCheckedTime < 5000, + assertTrue( + currentTime - lastCheckedTime < 5000, "Last checked timestamp should be very recent (within 5 seconds)"); // Verify error message - assertNotNull(status.errorMessage(), "Error message should not be null for unhealthy collection"); - assertFalse(status.errorMessage().trim().isEmpty(), + assertNotNull( + status.errorMessage(), "Error message should not be null for unhealthy collection"); + assertFalse( + status.errorMessage().trim().isEmpty(), "Error message should not be empty for unhealthy collection"); // Verify that performance metrics are null for unhealthy collection assertNull(status.responseTime(), "Response time should be null for unhealthy collection"); - assertNull(status.totalDocuments(), "Total documents should be null for unhealthy collection"); + assertNull( + status.totalDocuments(), "Total documents should be null for unhealthy collection"); // Verify error message contains meaningful information String errorMessage = status.errorMessage().toLowerCase(); - assertTrue(errorMessage.contains("collection") || errorMessage.contains("not found") || - errorMessage.contains("error") || errorMessage.contains("fail"), + assertTrue( + errorMessage.contains("collection") + || errorMessage.contains("not found") + || errorMessage.contains("error") + || errorMessage.contains("fail"), "Error message should contain meaningful error information"); // Verify string representation indicates unhealthy state String statusString = status.toString(); if (statusString != null) { - assertTrue(statusString.contains("false") || statusString.contains("unhealthy") || - statusString.contains("error"), + assertTrue( + statusString.contains("false") + || statusString.contains("unhealthy") + || statusString.contains("error"), "Status string should indicate unhealthy state"); } } @@ -232,22 +252,25 @@ void testCheckHealthUnhealthy() { @Test void testCollectionNameExtraction() { // Test collection name extraction functionality - assertEquals(TEST_COLLECTION, + assertEquals( + TEST_COLLECTION, collectionService.extractCollectionName(TEST_COLLECTION), "Regular collection name should be returned as-is"); - assertEquals("films", + assertEquals( + "films", collectionService.extractCollectionName("films_shard1_replica_n1"), "Shard name should be extracted to base collection name"); - assertEquals("products", + assertEquals( + "products", collectionService.extractCollectionName("products_shard2_replica_n3"), "Complex shard name should be extracted correctly"); - assertNull(collectionService.extractCollectionName(null), - "Null input should return null"); + assertNull(collectionService.extractCollectionName(null), "Null input should return null"); - assertEquals("", + assertEquals( + "", collectionService.extractCollectionName(""), "Empty string should return empty string"); } diff --git a/src/test/java/org/apache/solr/mcp/server/metadata/CollectionServiceTest.java b/src/test/java/org/apache/solr/mcp/server/metadata/CollectionServiceTest.java index b29781f..02fccd0 100644 --- a/src/test/java/org/apache/solr/mcp/server/metadata/CollectionServiceTest.java +++ b/src/test/java/org/apache/solr/mcp/server/metadata/CollectionServiceTest.java @@ -16,6 +16,16 @@ */ package org.apache.solr.mcp.server.metadata; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.SolrRequest; import org.apache.solr.client.solrj.SolrServerException; @@ -31,34 +41,18 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.io.IOException; -import java.lang.reflect.Method; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; - @ExtendWith(MockitoExtension.class) class CollectionServiceTest { - @Mock - private SolrClient solrClient; + @Mock private SolrClient solrClient; - @Mock - private CloudSolrClient cloudSolrClient; + @Mock private CloudSolrClient cloudSolrClient; - @Mock - private QueryResponse queryResponse; + @Mock private QueryResponse queryResponse; - @Mock - private LukeResponse lukeResponse; + @Mock private LukeResponse lukeResponse; - @Mock - private SolrPingResponse pingResponse; + @Mock private SolrPingResponse pingResponse; private CollectionService collectionService; @@ -115,7 +109,8 @@ void extractCollectionName_WithShardName_ShouldExtractCollectionName() { @Test void extractCollectionName_WithMultipleShards_ShouldExtractCorrectly() { // Given & When & Then - assertEquals("products", collectionService.extractCollectionName("products_shard2_replica_n3")); + assertEquals( + "products", collectionService.extractCollectionName("products_shard2_replica_n3")); assertEquals("users", collectionService.extractCollectionName("users_shard5_replica_n10")); } @@ -150,7 +145,8 @@ void extractCollectionName_WithEmptyString_ShouldReturnEmptyString() { } @Test - void extractCollectionName_WithCollectionNameContainingUnderscore_ShouldOnlyExtractBeforeShard() { + void + extractCollectionName_WithCollectionNameContainingUnderscore_ShouldOnlyExtractBeforeShard() { // Given - collection name itself contains underscore String complexName = "my_complex_collection_shard1_replica_n1"; @@ -179,7 +175,10 @@ void extractCollectionName_WithShardInMiddleOfName_ShouldExtractCorrectly() { String result = collectionService.extractCollectionName(name); // Then - assertEquals("resharding_tasks", result, "Should not extract when '_shard' is not followed by number"); + assertEquals( + "resharding_tasks", + result, + "Should not extract when '_shard' is not followed by number"); } @Test @@ -296,8 +295,7 @@ void checkHealth_WithSlowResponse_ShouldCaptureResponseTime() throws Exception { @Test void checkHealth_IOException() throws Exception { - when(solrClient.ping("error_collection")) - .thenThrow(new IOException("Network error")); + when(solrClient.ping("error_collection")).thenThrow(new IOException("Network error")); SolrHealthStatus result = collectionService.checkHealth("error_collection"); @@ -383,8 +381,10 @@ void getCollectionStats_NotFound() { CollectionService spyService = spy(collectionService); doReturn(Collections.emptyList()).when(spyService).listCollections(); - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, - () -> spyService.getCollectionStats("non_existent")); + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> spyService.getCollectionStats("non_existent")); assertTrue(exception.getMessage().contains("Collection not found: non_existent")); } @@ -395,7 +395,8 @@ void validateCollectionExists() throws Exception { List collections = Arrays.asList("collection1", "films_shard1_replica_n1"); doReturn(collections).when(spyService).listCollections(); - Method method = CollectionService.class.getDeclaredMethod("validateCollectionExists", String.class); + Method method = + CollectionService.class.getDeclaredMethod("validateCollectionExists", String.class); method.setAccessible(true); assertTrue((boolean) method.invoke(spyService, "collection1")); @@ -408,7 +409,8 @@ void validateCollectionExists_WithException() throws Exception { CollectionService spyService = spy(collectionService); doReturn(Collections.emptyList()).when(spyService).listCollections(); - Method method = CollectionService.class.getDeclaredMethod("validateCollectionExists", String.class); + Method method = + CollectionService.class.getDeclaredMethod("validateCollectionExists", String.class); method.setAccessible(true); assertFalse((boolean) method.invoke(spyService, "any_collection")); @@ -467,8 +469,7 @@ void getCacheMetrics_IOException() throws Exception { CollectionService spyService = spy(collectionService); doReturn(Arrays.asList("test_collection")).when(spyService).listCollections(); - when(solrClient.request(any(SolrRequest.class))) - .thenThrow(new IOException("IO Error")); + when(solrClient.request(any(SolrRequest.class))).thenThrow(new IOException("IO Error")); CacheStats result = spyService.getCacheMetrics("test_collection"); @@ -505,7 +506,8 @@ void getCacheMetrics_WithShardName() throws Exception { @Test void extractCacheStats() throws Exception { NamedList mbeans = createMockCacheData(); - Method method = CollectionService.class.getDeclaredMethod("extractCacheStats", NamedList.class); + Method method = + CollectionService.class.getDeclaredMethod("extractCacheStats", NamedList.class); method.setAccessible(true); CacheStats result = (CacheStats) method.invoke(collectionService, mbeans); @@ -518,7 +520,8 @@ void extractCacheStats() throws Exception { @Test void extractCacheStats_AllCacheTypes() throws Exception { NamedList mbeans = createCompleteMockCacheData(); - Method method = CollectionService.class.getDeclaredMethod("extractCacheStats", NamedList.class); + Method method = + CollectionService.class.getDeclaredMethod("extractCacheStats", NamedList.class); method.setAccessible(true); CacheStats result = (CacheStats) method.invoke(collectionService, mbeans); @@ -533,7 +536,8 @@ void extractCacheStats_NullCacheCategory() throws Exception { NamedList mbeans = new NamedList<>(); mbeans.add("CACHE", null); - Method method = CollectionService.class.getDeclaredMethod("extractCacheStats", NamedList.class); + Method method = + CollectionService.class.getDeclaredMethod("extractCacheStats", NamedList.class); method.setAccessible(true); CacheStats result = (CacheStats) method.invoke(collectionService, mbeans); @@ -546,18 +550,16 @@ void extractCacheStats_NullCacheCategory() throws Exception { @Test void isCacheStatsEmpty() throws Exception { - Method method = CollectionService.class.getDeclaredMethod("isCacheStatsEmpty", CacheStats.class); + Method method = + CollectionService.class.getDeclaredMethod("isCacheStatsEmpty", CacheStats.class); method.setAccessible(true); CacheStats emptyStats = new CacheStats(null, null, null); assertTrue((boolean) method.invoke(collectionService, emptyStats)); assertTrue((boolean) method.invoke(collectionService, (CacheStats) null)); - CacheStats nonEmptyStats = new CacheStats( - new CacheInfo(100L, null, null, null, null, null), - null, - null - ); + CacheStats nonEmptyStats = + new CacheStats(new CacheInfo(100L, null, null, null, null, null), null, null); assertFalse((boolean) method.invoke(collectionService, nonEmptyStats)); } @@ -613,8 +615,7 @@ void getHandlerMetrics_IOException() throws Exception { CollectionService spyService = spy(collectionService); doReturn(Arrays.asList("test_collection")).when(spyService).listCollections(); - when(solrClient.request(any(SolrRequest.class))) - .thenThrow(new IOException("IO Error")); + when(solrClient.request(any(SolrRequest.class))).thenThrow(new IOException("IO Error")); HandlerStats result = spyService.getHandlerMetrics("test_collection"); @@ -651,7 +652,8 @@ void getHandlerMetrics_WithShardName() throws Exception { @Test void extractHandlerStats() throws Exception { NamedList mbeans = createMockHandlerData(); - Method method = CollectionService.class.getDeclaredMethod("extractHandlerStats", NamedList.class); + Method method = + CollectionService.class.getDeclaredMethod("extractHandlerStats", NamedList.class); method.setAccessible(true); HandlerStats result = (HandlerStats) method.invoke(collectionService, mbeans); @@ -663,7 +665,8 @@ void extractHandlerStats() throws Exception { @Test void extractHandlerStats_BothHandlers() throws Exception { NamedList mbeans = createCompleteHandlerData(); - Method method = CollectionService.class.getDeclaredMethod("extractHandlerStats", NamedList.class); + Method method = + CollectionService.class.getDeclaredMethod("extractHandlerStats", NamedList.class); method.setAccessible(true); HandlerStats result = (HandlerStats) method.invoke(collectionService, mbeans); @@ -679,7 +682,8 @@ void extractHandlerStats_NullHandlerCategory() throws Exception { NamedList mbeans = new NamedList<>(); mbeans.add("QUERYHANDLER", null); - Method method = CollectionService.class.getDeclaredMethod("extractHandlerStats", NamedList.class); + Method method = + CollectionService.class.getDeclaredMethod("extractHandlerStats", NamedList.class); method.setAccessible(true); HandlerStats result = (HandlerStats) method.invoke(collectionService, mbeans); @@ -691,17 +695,17 @@ void extractHandlerStats_NullHandlerCategory() throws Exception { @Test void isHandlerStatsEmpty() throws Exception { - Method method = CollectionService.class.getDeclaredMethod("isHandlerStatsEmpty", HandlerStats.class); + Method method = + CollectionService.class.getDeclaredMethod( + "isHandlerStatsEmpty", HandlerStats.class); method.setAccessible(true); HandlerStats emptyStats = new HandlerStats(null, null); assertTrue((boolean) method.invoke(collectionService, emptyStats)); assertTrue((boolean) method.invoke(collectionService, (HandlerStats) null)); - HandlerStats nonEmptyStats = new HandlerStats( - new HandlerInfo(100L, null, null, null, null, null), - null - ); + HandlerStats nonEmptyStats = + new HandlerStats(new HandlerInfo(100L, null, null, null, null, null), null); assertFalse((boolean) method.invoke(collectionService, nonEmptyStats)); } @@ -743,7 +747,8 @@ void listCollections_CloudClient_NullCollections() throws Exception { @Test void listCollections_CloudClient_Error() throws Exception { CloudSolrClient cloudClient = mock(CloudSolrClient.class); - when(cloudClient.request(any(), any())).thenThrow(new SolrServerException("Connection error")); + when(cloudClient.request(any(), any())) + .thenThrow(new SolrServerException("Connection error")); CollectionService service = new CollectionService(cloudClient); List result = service.listCollections(); @@ -904,4 +909,4 @@ private NamedList createCompleteHandlerData() { return mbeans; } -} \ No newline at end of file +} diff --git a/src/test/java/org/apache/solr/mcp/server/metadata/CollectionUtilsTest.java b/src/test/java/org/apache/solr/mcp/server/metadata/CollectionUtilsTest.java index 558f9dc..1e0ba34 100644 --- a/src/test/java/org/apache/solr/mcp/server/metadata/CollectionUtilsTest.java +++ b/src/test/java/org/apache/solr/mcp/server/metadata/CollectionUtilsTest.java @@ -16,18 +16,17 @@ */ package org.apache.solr.mcp.server.metadata; -import org.apache.solr.common.util.NamedList; -import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import java.math.BigDecimal; import java.math.BigInteger; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; +import org.apache.solr.common.util.NamedList; +import org.junit.jupiter.api.Test; /** - * Comprehensive test suite for the Utils utility class. - * Tests all public methods and edge cases for type-safe value extraction from Solr NamedList objects. + * Comprehensive test suite for the Utils utility class. Tests all public methods and edge cases for + * type-safe value extraction from Solr NamedList objects. */ class CollectionUtilsTest { @@ -309,4 +308,4 @@ void testGetInteger_withZeroValue() { assertEquals(0, CollectionUtils.getInteger(namedList, "zeroKey")); } -} \ No newline at end of file +} diff --git a/src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceIntegrationTest.java index 3b61514..e49e3c7 100644 --- a/src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceIntegrationTest.java @@ -16,6 +16,8 @@ */ package org.apache.solr.mcp.server.metadata; +import static org.junit.jupiter.api.Assertions.*; + import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.request.CollectionAdminRequest; import org.apache.solr.client.solrj.response.schema.SchemaRepresentation; @@ -26,30 +28,27 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; -import static org.junit.jupiter.api.Assertions.*; - /** - * Integration test suite for SchemaService using real Solr containers. - * Tests actual schema retrieval functionality against a live Solr instance. + * Integration test suite for SchemaService using real Solr containers. Tests actual schema + * retrieval functionality against a live Solr instance. */ @SpringBootTest @Import(TestcontainersConfiguration.class) class SchemaServiceIntegrationTest { - @Autowired - private SchemaService schemaService; + @Autowired private SchemaService schemaService; - @Autowired - private SolrClient solrClient; + @Autowired private SolrClient solrClient; private static final String TEST_COLLECTION = "schema_test_collection"; private static boolean initialized = false; @BeforeEach void setupCollection() throws Exception { - // Create a collection for testing + // Create a collection for testing if (!initialized) { - CollectionAdminRequest.Create createRequest = CollectionAdminRequest.createCollection(TEST_COLLECTION, "_default", 1, 1); + CollectionAdminRequest.Create createRequest = + CollectionAdminRequest.createCollection(TEST_COLLECTION, "_default", 1, 1); createRequest.process(solrClient); initialized = true; } @@ -64,44 +63,57 @@ void testGetSchema_ValidCollection() throws Exception { assertNotNull(schema, "Schema should not be null"); assertNotNull(schema.getFields(), "Schema fields should not be null"); assertNotNull(schema.getFieldTypes(), "Schema field types should not be null"); - + // Verify basic schema properties assertFalse(schema.getFields().isEmpty(), "Schema should have at least some fields"); - assertFalse(schema.getFieldTypes().isEmpty(), "Schema should have at least some field types"); - + assertFalse( + schema.getFieldTypes().isEmpty(), "Schema should have at least some field types"); + // Check for common default fields in Solr - boolean hasIdField = schema.getFields().stream() - .anyMatch(field -> "id".equals(field.get("name"))); + boolean hasIdField = + schema.getFields().stream().anyMatch(field -> "id".equals(field.get("name"))); assertTrue(hasIdField, "Schema should have an 'id' field"); - + // Check for common field types - boolean hasStringType = schema.getFieldTypes().stream() - .anyMatch(fieldType -> "string".equals(fieldType.getAttributes().get("name"))); + boolean hasStringType = + schema.getFieldTypes().stream() + .anyMatch( + fieldType -> + "string".equals(fieldType.getAttributes().get("name"))); assertTrue(hasStringType, "Schema should have a 'string' field type"); } @Test void testGetSchema_InvalidCollection() { // When/Then - assertThrows(Exception.class, () -> { - schemaService.getSchema("non_existent_collection_12345"); - }, "Getting schema for non-existent collection should throw exception"); + assertThrows( + Exception.class, + () -> { + schemaService.getSchema("non_existent_collection_12345"); + }, + "Getting schema for non-existent collection should throw exception"); } @Test void testGetSchema_NullCollection() { // When/Then - assertThrows(Exception.class, () -> { - schemaService.getSchema(null); - }, "Getting schema with null collection should throw exception"); + assertThrows( + Exception.class, + () -> { + schemaService.getSchema(null); + }, + "Getting schema with null collection should throw exception"); } @Test void testGetSchema_EmptyCollection() { // When/Then - assertThrows(Exception.class, () -> { - schemaService.getSchema(""); - }, "Getting schema with empty collection should throw exception"); + assertThrows( + Exception.class, + () -> { + schemaService.getSchema(""); + }, + "Getting schema with empty collection should throw exception"); } @Test @@ -113,17 +125,25 @@ void testGetSchema_ValidatesSchemaContent() throws Exception { assertNotNull(schema.getName(), "Schema should have a name"); // Check that we can access field details - schema.getFields().forEach(field -> { - assertNotNull(field.get("name"), "Field should have a name"); - assertNotNull(field.get("type"), "Field should have a type"); - // indexed and stored can be null (defaults to true in many cases) - }); + schema.getFields() + .forEach( + field -> { + assertNotNull(field.get("name"), "Field should have a name"); + assertNotNull(field.get("type"), "Field should have a type"); + // indexed and stored can be null (defaults to true in many cases) + }); // Check that we can access field type details - schema.getFieldTypes().forEach(fieldType -> { - assertNotNull(fieldType.getAttributes().get("name"), "Field type should have a name"); - assertNotNull(fieldType.getAttributes().get("class"), "Field type should have a class"); - }); + schema.getFieldTypes() + .forEach( + fieldType -> { + assertNotNull( + fieldType.getAttributes().get("name"), + "Field type should have a name"); + assertNotNull( + fieldType.getAttributes().get("class"), + "Field type should have a class"); + }); } @Test @@ -133,17 +153,20 @@ void testGetSchema_ChecksDynamicFields() throws Exception { // Then - verify dynamic fields are accessible assertNotNull(schema.getDynamicFields(), "Dynamic fields should not be null"); - + // Most Solr schemas have some dynamic fields by default assertTrue(schema.getDynamicFields().size() >= 0, "Dynamic fields should be a valid list"); - + // Check for common dynamic field patterns - boolean hasStringDynamicField = schema.getDynamicFields().stream() - .anyMatch(dynField -> { - String name = (String) dynField.get("name"); - return name != null && (name.contains("*_s") || name.contains("*_str")); - }); - + boolean hasStringDynamicField = + schema.getDynamicFields().stream() + .anyMatch( + dynField -> { + String name = (String) dynField.get("name"); + return name != null + && (name.contains("*_s") || name.contains("*_str")); + }); + assertTrue(hasStringDynamicField, "Schema should have string dynamic fields"); } @@ -154,7 +177,7 @@ void testGetSchema_ChecksCopyFields() throws Exception { // Then - verify copy fields are accessible assertNotNull(schema.getCopyFields(), "Copy fields should not be null"); - + // Copy fields list can be empty, that's valid assertTrue(schema.getCopyFields().size() >= 0, "Copy fields should be a valid list"); } @@ -171,4 +194,4 @@ void testGetSchema_ReturnsUniqueKey() throws Exception { assertNotNull(schema.getUniqueKey(), "Unique key should be accessible"); } } -} \ No newline at end of file +} diff --git a/src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceTest.java b/src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceTest.java index bcee158..803e94e 100644 --- a/src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceTest.java +++ b/src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceTest.java @@ -16,6 +16,12 @@ */ package org.apache.solr.mcp.server.metadata; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.io.IOException; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.SolrServerException; import org.apache.solr.client.solrj.request.schema.SchemaRequest; @@ -27,28 +33,18 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.io.IOException; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.when; - /** - * Comprehensive test suite for the SchemaService class. - * Tests schema retrieval functionality with various scenarios including success and error cases. + * Comprehensive test suite for the SchemaService class. Tests schema retrieval functionality with + * various scenarios including success and error cases. */ @ExtendWith(MockitoExtension.class) class SchemaServiceTest { - @Mock - private SolrClient solrClient; + @Mock private SolrClient solrClient; - @Mock - private SchemaResponse schemaResponse; + @Mock private SchemaResponse schemaResponse; - @Mock - private SchemaRepresentation schemaRepresentation; + @Mock private SchemaRepresentation schemaRepresentation; private SchemaService schemaService; @@ -61,7 +57,7 @@ void setUp() { void testSchemaService_InstantiatesCorrectly() { // Given/When SchemaService service = new SchemaService(solrClient); - + // Then assertNotNull(service, "SchemaService should be instantiated correctly"); } @@ -70,63 +66,74 @@ void testSchemaService_InstantiatesCorrectly() { void testGetSchema_CollectionNotFound() throws Exception { // Given final String nonExistentCollection = "non_existent_collection"; - + // When SolrClient throws an exception for non-existent collection when(solrClient.request(any(SchemaRequest.class), eq(nonExistentCollection))) - .thenThrow(new SolrServerException("Collection not found: " + nonExistentCollection)); + .thenThrow( + new SolrServerException("Collection not found: " + nonExistentCollection)); // Then - assertThrows(Exception.class, () -> { - schemaService.getSchema(nonExistentCollection); - }); + assertThrows( + Exception.class, + () -> { + schemaService.getSchema(nonExistentCollection); + }); } @Test void testGetSchema_SolrServerException() throws Exception { // Given final String collectionName = "test_collection"; - + // When SolrClient throws a SolrServerException when(solrClient.request(any(SchemaRequest.class), eq(collectionName))) .thenThrow(new SolrServerException("Solr server error")); // Then - assertThrows(Exception.class, () -> { - schemaService.getSchema(collectionName); - }); + assertThrows( + Exception.class, + () -> { + schemaService.getSchema(collectionName); + }); } @Test void testGetSchema_IOException() throws Exception { // Given final String collectionName = "test_collection"; - + // When SolrClient throws an IOException when(solrClient.request(any(SchemaRequest.class), eq(collectionName))) .thenThrow(new IOException("Network connection error")); // Then - assertThrows(Exception.class, () -> { - schemaService.getSchema(collectionName); - }); + assertThrows( + Exception.class, + () -> { + schemaService.getSchema(collectionName); + }); } @Test void testGetSchema_WithNullCollection() { // Given a null collection name // Then should throw an exception (NullPointerException or IllegalArgumentException) - assertThrows(Exception.class, () -> { - schemaService.getSchema(null); - }); + assertThrows( + Exception.class, + () -> { + schemaService.getSchema(null); + }); } @Test void testGetSchema_WithEmptyCollection() { // Given an empty collection name // Then should throw an exception - assertThrows(Exception.class, () -> { - schemaService.getSchema(""); - }); + assertThrows( + Exception.class, + () -> { + schemaService.getSchema(""); + }); } @Test @@ -139,8 +146,9 @@ void testConstructor() { @Test void testConstructor_WithNullClient() { // Test constructor with null client - assertDoesNotThrow(() -> { - new SchemaService(null); - }); + assertDoesNotThrow( + () -> { + new SchemaService(null); + }); } -} \ No newline at end of file +} diff --git a/src/test/java/org/apache/solr/mcp/server/search/SearchServiceDirectTest.java b/src/test/java/org/apache/solr/mcp/server/search/SearchServiceDirectTest.java index 263f630..304bc52 100644 --- a/src/test/java/org/apache/solr/mcp/server/search/SearchServiceDirectTest.java +++ b/src/test/java/org/apache/solr/mcp/server/search/SearchServiceDirectTest.java @@ -16,6 +16,15 @@ */ package org.apache.solr.mcp.server.search; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.SolrQuery; import org.apache.solr.client.solrj.SolrServerException; @@ -29,24 +38,12 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.when; - @ExtendWith(MockitoExtension.class) class SearchServiceDirectTest { - @Mock - private SolrClient solrClient; + @Mock private SolrClient solrClient; - @Mock - private QueryResponse queryResponse; + @Mock private QueryResponse queryResponse; private SearchService searchService; @@ -147,7 +144,8 @@ void testSearchWithEmptyResults() throws SolrServerException, IOException { when(solrClient.query(eq("books"), any(SolrQuery.class))).thenReturn(queryResponse); // Test - SearchResponse result = searchService.search("books", "nonexistent_query", null, null, null, null, null); + SearchResponse result = + searchService.search("books", "nonexistent_query", null, null, null, null, null); // Verify assertNotNull(result); @@ -180,7 +178,8 @@ void testSearchWithEmptyFacets() throws SolrServerException, IOException { when(solrClient.query(eq("books"), any(SolrQuery.class))).thenReturn(queryResponse); // Test with facet fields requested but none returned - SearchResponse result = searchService.search("books", null, null, List.of("genre_s"), null, null, null); + SearchResponse result = + searchService.search("books", null, null, List.of("genre_s"), null, null, null); // Verify assertNotNull(result); @@ -215,7 +214,8 @@ void testSearchWithEmptyFacetValues() throws SolrServerException, IOException { when(solrClient.query(eq("books"), any(SolrQuery.class))).thenReturn(queryResponse); // Test - SearchResponse result = searchService.search("books", null, null, List.of("genre_s"), null, null, null); + SearchResponse result = + searchService.search("books", null, null, List.of("genre_s"), null, null, null); // Verify assertNotNull(result); @@ -229,12 +229,14 @@ void testSearchWithSolrError() { // Setup mock to throw exception try { when(solrClient.query(eq("books"), any(SolrQuery.class))) - .thenThrow(new SolrServerException("Simulated Solr server error")); + .thenThrow(new SolrServerException("Simulated Solr server error")); // Test - assertThrows(SolrServerException.class, () -> { - searchService.search("books", null, null, null, null, null, null); - }); + assertThrows( + SolrServerException.class, + () -> { + searchService.search("books", null, null, null, null, null, null); + }); } catch (Exception e) { fail("Test setup failed: " + e.getMessage()); } @@ -270,12 +272,11 @@ void testSearchWithAllParameters() throws SolrServerException, IOException { // Test with all parameters List filterQueries = List.of("price:[10 TO 15]"); List facetFields2 = List.of("genre_s", "author"); - List> sortClauses = List.of( - Map.of("item", "price", "order", "desc") - ); + List> sortClauses = List.of(Map.of("item", "price", "order", "desc")); - SearchResponse result = searchService.search( - "books", "mystery", filterQueries, facetFields2, sortClauses, 5, 10); + SearchResponse result = + searchService.search( + "books", "mystery", filterQueries, facetFields2, sortClauses, 5, 10); // Verify assertNotNull(result); diff --git a/src/test/java/org/apache/solr/mcp/server/search/SearchServiceTest.java b/src/test/java/org/apache/solr/mcp/server/search/SearchServiceTest.java index ea1f238..fc1ed07 100644 --- a/src/test/java/org/apache/solr/mcp/server/search/SearchServiceTest.java +++ b/src/test/java/org/apache/solr/mcp/server/search/SearchServiceTest.java @@ -16,6 +16,17 @@ */ package org.apache.solr.mcp.server.search; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.OptionalDouble; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.SolrQuery; import org.apache.solr.client.solrj.SolrServerException; @@ -32,21 +43,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.OptionalDouble; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -/** - * Combined tests for SearchService: integration + unit (mocked SolrClient) in one class. - */ +/** Combined tests for SearchService: integration + unit (mocked SolrClient) in one class. */ @SpringBootTest @Import(TestcontainersConfiguration.class) class SearchServiceTest { @@ -54,23 +51,21 @@ class SearchServiceTest { // ===== Integration test context ===== private static final String COLLECTION_NAME = "search_test_" + System.currentTimeMillis(); - @Autowired - private SearchService searchService; - @Autowired - private IndexingService indexingService; - @Autowired - private SolrClient solrClient; + @Autowired private SearchService searchService; + @Autowired private IndexingService indexingService; + @Autowired private SolrClient solrClient; private static boolean initialized = false; @BeforeEach void setUp() throws Exception { if (!initialized) { - CollectionAdminRequest.Create createRequest = CollectionAdminRequest.createCollection( - COLLECTION_NAME, "_default", 1, 1); + CollectionAdminRequest.Create createRequest = + CollectionAdminRequest.createCollection(COLLECTION_NAME, "_default", 1, 1); createRequest.process(solrClient); - String sampleData = """ + String sampleData = + """ [ { "id": "book001", @@ -185,7 +180,8 @@ void setUp() throws Exception { @Test void testBasicSearch() throws SolrServerException, IOException { - SearchResponse result = searchService.search(COLLECTION_NAME, null, null, null, null, null, null); + SearchResponse result = + searchService.search(COLLECTION_NAME, null, null, null, null, null, null); assertNotNull(result); List> documents = result.documents(); assertFalse(documents.isEmpty()); @@ -194,7 +190,9 @@ void testBasicSearch() throws SolrServerException, IOException { @Test void testSearchWithQuery() throws SolrServerException, IOException { - SearchResponse result = searchService.search(COLLECTION_NAME, "name:\"Game of Thrones\"", null, null, null, null, null); + SearchResponse result = + searchService.search( + COLLECTION_NAME, "name:\"Game of Thrones\"", null, null, null, null, null); assertNotNull(result); List> documents = result.documents(); assertEquals(1, documents.size()); @@ -204,8 +202,15 @@ void testSearchWithQuery() throws SolrServerException, IOException { @Test void testSearchReturnsAuthor() throws Exception { - SearchResponse result = searchService.search( - COLLECTION_NAME, "author_ss:\"George R.R. Martin\"", null, null, null, null, null); + SearchResponse result = + searchService.search( + COLLECTION_NAME, + "author_ss:\"George R.R. Martin\"", + null, + null, + null, + null, + null); assertNotNull(result); List> documents = result.documents(); assertEquals(3, documents.size()); @@ -215,8 +220,9 @@ void testSearchReturnsAuthor() throws Exception { @Test void testSearchWithFacets() throws Exception { - SearchResponse result = searchService.search( - COLLECTION_NAME, null, null, List.of("genre_s"), null, null, null); + SearchResponse result = + searchService.search( + COLLECTION_NAME, null, null, List.of("genre_s"), null, null, null); assertNotNull(result); Map> facets = result.facets(); assertNotNull(facets); @@ -225,23 +231,24 @@ void testSearchWithFacets() throws Exception { @Test void testSearchWithPrice() throws Exception { - SearchResponse result = searchService.search( - COLLECTION_NAME, null, null, null, null, null, null); + SearchResponse result = + searchService.search(COLLECTION_NAME, null, null, null, null, null, null); assertNotNull(result); List> documents = result.documents(); assertFalse(documents.isEmpty()); Map book = documents.getFirst(); - double currentPrice = ((List) book.get("price")).isEmpty() ? 0.0 : ((Number) ((List) book.get("price")).getFirst()).doubleValue(); + double currentPrice = + ((List) book.get("price")).isEmpty() + ? 0.0 + : ((Number) ((List) book.get("price")).getFirst()).doubleValue(); assertTrue(currentPrice > 0); } @Test void testSortByPriceAscending() throws Exception { - List> sortClauses = List.of( - Map.of("item", "price", "order", "asc") - ); - SearchResponse result = searchService.search( - COLLECTION_NAME, null, null, null, sortClauses, null, null); + List> sortClauses = List.of(Map.of("item", "price", "order", "asc")); + SearchResponse result = + searchService.search(COLLECTION_NAME, null, null, null, sortClauses, null, null); assertNotNull(result); List> documents = result.documents(); assertFalse(documents.isEmpty()); @@ -250,18 +257,18 @@ void testSortByPriceAscending() throws Exception { OptionalDouble priceOpt = extractPrice(book); if (priceOpt.isEmpty()) continue; double currentPrice = priceOpt.getAsDouble(); - assertTrue(currentPrice >= previousPrice, "Books should be sorted by price in ascending order"); + assertTrue( + currentPrice >= previousPrice, + "Books should be sorted by price in ascending order"); previousPrice = currentPrice; } } @Test void testSortByPriceDescending() throws Exception { - List> sortClauses = List.of( - Map.of("item", "price", "order", "desc") - ); - SearchResponse result = searchService.search( - COLLECTION_NAME, null, null, null, sortClauses, null, null); + List> sortClauses = List.of(Map.of("item", "price", "order", "desc")); + SearchResponse result = + searchService.search(COLLECTION_NAME, null, null, null, sortClauses, null, null); assertNotNull(result); List> documents = result.documents(); assertFalse(documents.isEmpty()); @@ -270,26 +277,30 @@ void testSortByPriceDescending() throws Exception { OptionalDouble priceOpt = extractPrice(book); if (priceOpt.isEmpty()) continue; double currentPrice = priceOpt.getAsDouble(); - assertTrue(currentPrice <= previousPrice, "Books should be sorted by price in descending order"); + assertTrue( + currentPrice <= previousPrice, + "Books should be sorted by price in descending order"); previousPrice = currentPrice; } } @Test void testSortBySequence() throws Exception { - List> sortClauses = List.of( - Map.of("item", "sequence_i", "order", "asc") - ); + List> sortClauses = + List.of(Map.of("item", "sequence_i", "order", "asc")); List filterQueries = List.of("series_s:\"A Song of Ice and Fire\""); - SearchResponse result = searchService.search( - COLLECTION_NAME, null, filterQueries, null, sortClauses, null, null); + SearchResponse result = + searchService.search( + COLLECTION_NAME, null, filterQueries, null, sortClauses, null, null); assertNotNull(result); List> documents = result.documents(); assertFalse(documents.isEmpty()); int previousSequence = 0; for (Map book : documents) { int currentSequence = ((Number) book.get("sequence_i")).intValue(); - assertTrue(currentSequence >= previousSequence, "Books should be sorted by sequence_i in ascending order"); + assertTrue( + currentSequence >= previousSequence, + "Books should be sorted by sequence_i in ascending order"); previousSequence = currentSequence; } } @@ -297,8 +308,8 @@ void testSortBySequence() throws Exception { @Test void testFilterByGenre() throws Exception { List filterQueries = List.of("genre_s:fantasy"); - SearchResponse result = searchService.search( - COLLECTION_NAME, null, filterQueries, null, null, null, null); + SearchResponse result = + searchService.search(COLLECTION_NAME, null, filterQueries, null, null, null, null); assertNotNull(result); List> documents = result.documents(); assertFalse(documents.isEmpty()); @@ -311,8 +322,8 @@ void testFilterByGenre() throws Exception { @Test void testFilterByPriceRange() throws Exception { List filterQueries = List.of("price:[6.0 TO 7.0]"); - SearchResponse result = searchService.search( - COLLECTION_NAME, null, filterQueries, null, null, null, null); + SearchResponse result = + searchService.search(COLLECTION_NAME, null, filterQueries, null, null, null, null); assertNotNull(result); List> documents = result.documents(); assertFalse(documents.isEmpty()); @@ -321,18 +332,19 @@ void testFilterByPriceRange() throws Exception { OptionalDouble priceOpt = extractPrice(book); if (priceOpt.isEmpty()) continue; double price = priceOpt.getAsDouble(); - assertTrue(price >= 6.0 && price <= 7.0, "All books should have price between 6.0 and 7.0"); + assertTrue( + price >= 6.0 && price <= 7.0, + "All books should have price between 6.0 and 7.0"); } } @Test void testCombinedSortingAndFiltering() throws Exception { - List> sortClauses = List.of( - Map.of("item", "price", "order", "desc") - ); + List> sortClauses = List.of(Map.of("item", "price", "order", "desc")); List filterQueries = List.of("genre_s:fantasy"); - SearchResponse result = searchService.search( - COLLECTION_NAME, null, filterQueries, null, sortClauses, null, null); + SearchResponse result = + searchService.search( + COLLECTION_NAME, null, filterQueries, null, sortClauses, null, null); assertNotNull(result); List> documents = result.documents(); assertFalse(documents.isEmpty()); @@ -355,26 +367,28 @@ void testCombinedSortingAndFiltering() throws Exception { } else { continue; } - assertTrue(currentPrice <= previousPrice, "Books should be sorted by price in descending order"); + assertTrue( + currentPrice <= previousPrice, + "Books should be sorted by price in descending order"); previousPrice = currentPrice; } } @Test void testPagination() throws Exception { - SearchResponse allResults = searchService.search( - COLLECTION_NAME, null, null, null, null, null, null); + SearchResponse allResults = + searchService.search(COLLECTION_NAME, null, null, null, null, null, null); assertNotNull(allResults); long totalDocuments = allResults.numFound(); assertTrue(totalDocuments > 0, "Should have at least some documents"); - SearchResponse firstPage = searchService.search( - COLLECTION_NAME, null, null, null, null, 0, 2); + SearchResponse firstPage = + searchService.search(COLLECTION_NAME, null, null, null, null, 0, 2); assertNotNull(firstPage); assertEquals(0, firstPage.start(), "Start offset should be 0"); assertEquals(totalDocuments, firstPage.numFound(), "Total count should match"); assertEquals(2, firstPage.documents().size(), "Should return exactly 2 documents"); - SearchResponse secondPage = searchService.search( - COLLECTION_NAME, null, null, null, null, 2, 2); + SearchResponse secondPage = + searchService.search(COLLECTION_NAME, null, null, null, null, 2, 2); assertNotNull(secondPage); assertEquals(2, secondPage.start(), "Start offset should be 2"); assertEquals(totalDocuments, secondPage.numFound(), "Total count should match"); @@ -382,26 +396,30 @@ void testPagination() throws Exception { List firstPageIds = getDocumentIds(firstPage.documents()); List secondPageIds = getDocumentIds(secondPage.documents()); for (String id : firstPageIds) { - assertFalse(secondPageIds.contains(id), "Second page should not contain documents from first page"); + assertFalse( + secondPageIds.contains(id), + "Second page should not contain documents from first page"); } } @Test void testSpecialCharactersInQuery() throws Exception { - String specialJson = """ - [ - { - "id": "special001", - "title": "Book with special characters: & + - ! ( ) { } [ ] ^ \\" ~ * ? : \\\\ /", - "author_ss": ["Special Author (with parentheses)"], - "description": "This is a test document with special characters: & + - ! ( ) { } [ ] ^ \\" ~ * ? : \\\\ /" - } - ] - """; + String specialJson = + """ +[ + { + "id": "special001", + "title": "Book with special characters: & + - ! ( ) { } [ ] ^ \\" ~ * ? : \\\\ /", + "author_ss": ["Special Author (with parentheses)"], + "description": "This is a test document with special characters: & + - ! ( ) { } [ ] ^ \\" ~ * ? : \\\\ /" + } +] +"""; indexingService.indexJsonDocuments(COLLECTION_NAME, specialJson); solrClient.commit(COLLECTION_NAME); String query = "id:special001"; - SearchResponse result = searchService.search(COLLECTION_NAME, query, null, null, null, null, null); + SearchResponse result = + searchService.search(COLLECTION_NAME, query, null, null, null, null, null); assertNotNull(result); assertEquals(1, result.numFound(), "Should find exactly one document"); query = "author_ss:\"Special Author \\(" + "with parentheses\\)\""; // escape parentheses @@ -429,13 +447,16 @@ void unit_search_WithNullQuery_ShouldDefaultToMatchAll() throws Exception { SolrDocumentList mockDocuments = createMockDocumentList(); when(mockResponse.getResults()).thenReturn(mockDocuments); when(mockResponse.getFacetFields()).thenReturn(null); - when(mockClient.query(eq("test_collection"), any(SolrQuery.class))).thenAnswer(invocation -> { - SolrQuery q = invocation.getArgument(1); - assertEquals("*:*", q.getQuery()); - return mockResponse; - }); + when(mockClient.query(eq("test_collection"), any(SolrQuery.class))) + .thenAnswer( + invocation -> { + SolrQuery q = invocation.getArgument(1); + assertEquals("*:*", q.getQuery()); + return mockResponse; + }); SearchService localService = new SearchService(mockClient); - SearchResponse result = localService.search("test_collection", null, null, null, null, null, null); + SearchResponse result = + localService.search("test_collection", null, null, null, null, null, null); assertNotNull(result); } @@ -447,13 +468,16 @@ void unit_search_WithCustomQuery_ShouldUseProvidedQuery() throws Exception { SolrDocumentList mockDocuments = createMockDocumentList(); when(mockResponse.getResults()).thenReturn(mockDocuments); when(mockResponse.getFacetFields()).thenReturn(null); - when(mockClient.query(eq("test_collection"), any(SolrQuery.class))).thenAnswer(invocation -> { - SolrQuery q = invocation.getArgument(1); - assertEquals(customQuery, q.getQuery()); - return mockResponse; - }); + when(mockClient.query(eq("test_collection"), any(SolrQuery.class))) + .thenAnswer( + invocation -> { + SolrQuery q = invocation.getArgument(1); + assertEquals(customQuery, q.getQuery()); + return mockResponse; + }); SearchService localService = new SearchService(mockClient); - SearchResponse result = localService.search("test_collection", customQuery, null, null, null, null, null); + SearchResponse result = + localService.search("test_collection", customQuery, null, null, null, null, null); assertNotNull(result); } @@ -465,13 +489,16 @@ void unit_search_WithFilterQueries_ShouldApplyFilters() throws Exception { SolrDocumentList mockDocuments = createMockDocumentList(); when(mockResponse.getResults()).thenReturn(mockDocuments); when(mockResponse.getFacetFields()).thenReturn(null); - when(mockClient.query(eq("test_collection"), any(SolrQuery.class))).thenAnswer(invocation -> { - SolrQuery q = invocation.getArgument(1); - assertArrayEquals(filterQueries.toArray(), q.getFilterQueries()); - return mockResponse; - }); + when(mockClient.query(eq("test_collection"), any(SolrQuery.class))) + .thenAnswer( + invocation -> { + SolrQuery q = invocation.getArgument(1); + assertArrayEquals(filterQueries.toArray(), q.getFilterQueries()); + return mockResponse; + }); SearchService localService = new SearchService(mockClient); - SearchResponse result = localService.search("test_collection", null, filterQueries, null, null, null, null); + SearchResponse result = + localService.search("test_collection", null, filterQueries, null, null, null, null); assertNotNull(result); } @@ -483,9 +510,11 @@ void unit_search_WithFacetFields_ShouldEnableFaceting() throws Exception { SolrDocumentList mockDocuments = createMockDocumentList(); when(mockResponse.getResults()).thenReturn(mockDocuments); when(mockResponse.getFacetFields()).thenReturn(createMockFacetFields()); - when(mockClient.query(eq("test_collection"), any(SolrQuery.class))).thenAnswer(invocation -> mockResponse); + when(mockClient.query(eq("test_collection"), any(SolrQuery.class))) + .thenAnswer(invocation -> mockResponse); SearchService localService = new SearchService(mockClient); - SearchResponse result = localService.search("test_collection", null, null, facetFields, null, null, null); + SearchResponse result = + localService.search("test_collection", null, null, facetFields, null, null, null); assertNotNull(result); assertNotNull(result.facets()); } @@ -494,16 +523,18 @@ void unit_search_WithFacetFields_ShouldEnableFaceting() throws Exception { void unit_search_WithSortClauses_ShouldApplySorting() throws Exception { SolrClient mockClient = mock(SolrClient.class); QueryResponse mockResponse = mock(QueryResponse.class); - List> sortClauses = List.of( - Map.of("item", "price", "order", "asc"), - Map.of("item", "name", "order", "desc") - ); + List> sortClauses = + List.of( + Map.of("item", "price", "order", "asc"), + Map.of("item", "name", "order", "desc")); SolrDocumentList mockDocuments = createMockDocumentList(); when(mockResponse.getResults()).thenReturn(mockDocuments); when(mockResponse.getFacetFields()).thenReturn(null); - when(mockClient.query(eq("test_collection"), any(SolrQuery.class))).thenAnswer(invocation -> mockResponse); + when(mockClient.query(eq("test_collection"), any(SolrQuery.class))) + .thenAnswer(invocation -> mockResponse); SearchService localService = new SearchService(mockClient); - SearchResponse result = localService.search("test_collection", null, null, null, sortClauses, null, null); + SearchResponse result = + localService.search("test_collection", null, null, null, sortClauses, null, null); assertNotNull(result); } @@ -516,14 +547,17 @@ void unit_search_WithPagination_ShouldApplyStartAndRows() throws Exception { SolrDocumentList mockDocuments = createMockDocumentList(); when(mockResponse.getResults()).thenReturn(mockDocuments); when(mockResponse.getFacetFields()).thenReturn(null); - when(mockClient.query(eq("test_collection"), any(SolrQuery.class))).thenAnswer(invocation -> { - SolrQuery q = invocation.getArgument(1); - assertEquals(start, q.getStart()); - assertEquals(rows, q.getRows()); - return mockResponse; - }); + when(mockClient.query(eq("test_collection"), any(SolrQuery.class))) + .thenAnswer( + invocation -> { + SolrQuery q = invocation.getArgument(1); + assertEquals(start, q.getStart()); + assertEquals(rows, q.getRows()); + return mockResponse; + }); SearchService localService = new SearchService(mockClient); - SearchResponse result = localService.search("test_collection", null, null, null, null, start, rows); + SearchResponse result = + localService.search("test_collection", null, null, null, null, start, rows); assertNotNull(result); } @@ -540,17 +574,27 @@ void unit_search_WithAllParameters_ShouldCombineAllOptions() throws Exception { SolrDocumentList mockDocuments = createMockDocumentList(); when(mockResponse.getResults()).thenReturn(mockDocuments); when(mockResponse.getFacetFields()).thenReturn(createMockFacetFields()); - when(mockClient.query(eq("test_collection"), any(SolrQuery.class))).thenAnswer(invocation -> { - SolrQuery captured = invocation.getArgument(1); - assertEquals(query, captured.getQuery()); - assertArrayEquals(filterQueries.toArray(), captured.getFilterQueries()); - assertNotNull(captured.getFacetFields()); - assertEquals(start, captured.getStart()); - assertEquals(rows, captured.getRows()); - return mockResponse; - }); + when(mockClient.query(eq("test_collection"), any(SolrQuery.class))) + .thenAnswer( + invocation -> { + SolrQuery captured = invocation.getArgument(1); + assertEquals(query, captured.getQuery()); + assertArrayEquals(filterQueries.toArray(), captured.getFilterQueries()); + assertNotNull(captured.getFacetFields()); + assertEquals(start, captured.getStart()); + assertEquals(rows, captured.getRows()); + return mockResponse; + }); SearchService localService = new SearchService(mockClient); - SearchResponse result = localService.search("test_collection", query, filterQueries, facetFields, sortClauses, start, rows); + SearchResponse result = + localService.search( + "test_collection", + query, + filterQueries, + facetFields, + sortClauses, + start, + rows); assertNotNull(result); } @@ -560,8 +604,9 @@ void unit_search_WhenSolrThrowsException_ShouldPropagateException() throws Excep when(mockClient.query(eq("test_collection"), any(SolrQuery.class))) .thenThrow(new SolrServerException("Connection error")); SearchService localService = new SearchService(mockClient); - assertThrows(SolrServerException.class, () -> - localService.search("test_collection", null, null, null, null, null, null)); + assertThrows( + SolrServerException.class, + () -> localService.search("test_collection", null, null, null, null, null, null)); } @Test @@ -570,8 +615,9 @@ void unit_search_WhenIOException_ShouldPropagateException() throws Exception { when(mockClient.query(eq("test_collection"), any(SolrQuery.class))) .thenThrow(new IOException("Network error")); SearchService localService = new SearchService(mockClient); - assertThrows(IOException.class, () -> - localService.search("test_collection", null, null, null, null, null, null)); + assertThrows( + IOException.class, + () -> localService.search("test_collection", null, null, null, null, null, null)); } @Test @@ -583,9 +629,12 @@ void unit_search_WithEmptyResults_ShouldReturnEmptyDocumentList() throws Excepti emptyDocuments.setStart(0); when(mockResponse.getResults()).thenReturn(emptyDocuments); when(mockResponse.getFacetFields()).thenReturn(null); - when(mockClient.query(eq("test_collection"), any(SolrQuery.class))).thenReturn(mockResponse); + when(mockClient.query(eq("test_collection"), any(SolrQuery.class))) + .thenReturn(mockResponse); SearchService localService = new SearchService(mockClient); - SearchResponse result = localService.search("test_collection", "nonexistent:value", null, null, null, null, null); + SearchResponse result = + localService.search( + "test_collection", "nonexistent:value", null, null, null, null, null); assertNotNull(result); assertEquals(0, result.numFound()); assertTrue(result.documents().isEmpty()); @@ -598,13 +647,16 @@ void unit_search_WithNullFilterQueries_ShouldNotApplyFilters() throws Exception SolrDocumentList mockDocuments = createMockDocumentList(); when(mockResponse.getResults()).thenReturn(mockDocuments); when(mockResponse.getFacetFields()).thenReturn(null); - when(mockClient.query(eq("test_collection"), any(SolrQuery.class))).thenAnswer(invocation -> { - SolrQuery q = invocation.getArgument(1); - assertNull(q.getFilterQueries()); - return mockResponse; - }); + when(mockClient.query(eq("test_collection"), any(SolrQuery.class))) + .thenAnswer( + invocation -> { + SolrQuery q = invocation.getArgument(1); + assertNull(q.getFilterQueries()); + return mockResponse; + }); SearchService localService = new SearchService(mockClient); - SearchResponse result = localService.search("test_collection", null, null, null, null, null, null); + SearchResponse result = + localService.search("test_collection", null, null, null, null, null, null); assertNotNull(result); } @@ -615,13 +667,16 @@ void unit_search_WithEmptyFacetFields_ShouldNotEnableFaceting() throws Exception SolrDocumentList mockDocuments = createMockDocumentList(); when(mockResponse.getResults()).thenReturn(mockDocuments); when(mockResponse.getFacetFields()).thenReturn(null); - when(mockClient.query(eq("test_collection"), any(SolrQuery.class))).thenAnswer(invocation -> { - SolrQuery q = invocation.getArgument(1); - assertNull(q.getFacetFields()); - return mockResponse; - }); + when(mockClient.query(eq("test_collection"), any(SolrQuery.class))) + .thenAnswer( + invocation -> { + SolrQuery q = invocation.getArgument(1); + assertNull(q.getFacetFields()); + return mockResponse; + }); SearchService localService = new SearchService(mockClient); - SearchResponse result = localService.search("test_collection", null, null, List.of(), null, null, null); + SearchResponse result = + localService.search("test_collection", null, null, List.of(), null, null, null); assertNotNull(result); } @@ -632,9 +687,12 @@ void unit_searchResponse_ShouldContainAllFields() throws Exception { SolrDocumentList mockDocuments = createMockDocumentListWithData(); when(mockResponse.getResults()).thenReturn(mockDocuments); when(mockResponse.getFacetFields()).thenReturn(createMockFacetFields()); - when(mockClient.query(eq("test_collection"), any(SolrQuery.class))).thenReturn(mockResponse); + when(mockClient.query(eq("test_collection"), any(SolrQuery.class))) + .thenReturn(mockResponse); SearchService localService = new SearchService(mockClient); - SearchResponse result = localService.search("test_collection", null, null, List.of("genre_s"), null, null, null); + SearchResponse result = + localService.search( + "test_collection", null, null, List.of("genre_s"), null, null, null); assertNotNull(result); assertEquals(2, result.numFound()); assertEquals(0, result.start()); From b7fa39311c2a5daad4584eb590365e49862f8226 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Sun, 26 Oct 2025 20:18:37 -0400 Subject: [PATCH 4/7] Add Docker support with Jib and GitHub Actions CI/CD # Conflicts: # build.gradle.kts # gradle/libs.versions.toml --- .github/workflows/build-and-publish.yml | 309 +++++++++++++++++++++++ .github/workflows/claude-code-review.yml | 38 --- .github/workflows/claude.yml | 36 --- README.md | 273 +++++++++++++++++++- build.gradle.kts | 127 ++++++++++ gradle/libs.versions.toml | 2 + 6 files changed, 704 insertions(+), 81 deletions(-) create mode 100644 .github/workflows/build-and-publish.yml delete mode 100644 .github/workflows/claude-code-review.yml delete mode 100644 .github/workflows/claude.yml diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml new file mode 100644 index 0000000..c9e805b --- /dev/null +++ b/.github/workflows/build-and-publish.yml @@ -0,0 +1,309 @@ +# GitHub Actions Workflow: Build and Publish +# =========================================== +# +# This workflow builds the Solr MCP Server project and publishes Docker images +# to both GitHub Container Registry (GHCR) and Docker Hub. +# +# Workflow Triggers: +# ------------------ +# 1. Push to 'main' branch - Builds, tests, and publishes Docker images +# 2. Version tags (v*) - Builds and publishes release images with version tags +# 3. Pull requests to 'main' - Only builds and tests (no publishing) +# 4. Manual trigger via workflow_dispatch +# +# Jobs: +# ----- +# 1. build: Compiles the JAR, runs tests, and uploads artifacts +# 2. publish-docker: Publishes multi-platform Docker images using Jib +# +# Published Images: +# ---------------- +# - GitHub Container Registry: ghcr.io/OWNER/solr-mcp-server:TAG +# - Docker Hub: DOCKERHUB_USERNAME/solr-mcp-server:TAG +# +# Image Tagging Strategy: +# ---------------------- +# - Main branch: VERSION-SHORT_SHA (e.g., 0.0.1-SNAPSHOT-a1b2c3d) + latest +# - Version tags: VERSION (e.g., 1.0.0) + latest +# +# Required Secrets (for Docker Hub): +# ---------------------------------- +# - DOCKERHUB_USERNAME: Your Docker Hub username +# - DOCKERHUB_TOKEN: Docker Hub access token (https://hub.docker.com/settings/security) +# +# Note: GitHub Container Registry uses GITHUB_TOKEN automatically (no setup needed) + +name: Build and Publish + +on: + push: + branches: + - main + tags: + - 'v*' # Trigger on version tags like v1.0.0, v2.1.3, etc. + pull_request: + branches: + - main + workflow_dispatch: # Allow manual workflow runs from GitHub UI + +env: + JAVA_VERSION: '25' + JAVA_DISTRIBUTION: 'temurin' + +jobs: + # ============================================================================ + # Job 1: Build JAR + # ============================================================================ + # This job compiles the project, runs tests, and generates build artifacts. + # It runs on all triggers (push, PR, tags, manual). + # + # Outputs: + # - Spring Boot JAR with all dependencies (fat JAR) + # - Plain JAR without dependencies + # - JUnit test results + # - JaCoCo code coverage reports + # ============================================================================ + build: + name: Build JAR + runs-on: ubuntu-latest + + steps: + # Checkout the repository code + - name: Checkout code + uses: actions/checkout@v4 + + # Set up Java Development Kit + # Uses Temurin (Eclipse Adoptium) distribution of OpenJDK 25 + # Gradle cache is enabled to speed up subsequent builds + - name: Set up JDK ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: ${{ env.JAVA_DISTRIBUTION }} + cache: 'gradle' + + # Make the Gradle wrapper executable + # Required on Unix-based systems (Linux, macOS) + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + # Build the project with Gradle + # This runs: compilation, tests, spotless formatting, error-prone checks, + # JaCoCo coverage, and creates the JAR files + - name: Build with Gradle + run: ./gradlew build + + # Upload the compiled JAR files as workflow artifacts + # These can be downloaded from the GitHub Actions UI + # Artifacts are retained for 7 days + - name: Upload JAR artifact + uses: actions/upload-artifact@v4 + with: + name: solr-mcp-server-jar + path: build/libs/solr-mcp-server-*.jar + retention-days: 7 + + # Upload JUnit test results + # if: always() ensures this runs even if the build fails + # This allows viewing test results for failed builds + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: build/test-results/ + retention-days: 7 + + # Upload JaCoCo code coverage report + # if: always() ensures this runs even if tests fail + # Coverage reports help identify untested code paths + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: build/reports/jacoco/ + retention-days: 7 + + # ============================================================================ + # Job 2: Publish Docker Images + # ============================================================================ + # This job builds multi-platform Docker images using Jib and publishes them + # to GitHub Container Registry (GHCR) and Docker Hub. + # + # This job: + # - Only runs after 'build' job succeeds (needs: build) + # - Skips for pull requests (only runs on push to main and tags) + # - Uses Jib to build without requiring Docker daemon + # - Supports multi-platform: linux/amd64 and linux/arm64 + # - Publishes to both GHCR (always) and Docker Hub (if secrets configured) + # + # Security Note: + # - Secrets are passed to Jib CLI arguments for authentication + # - This is required for registry authentication and is handled securely + # - GitHub Actions masks secret values in logs automatically + # ============================================================================ + publish-docker: + name: Publish Docker Images + runs-on: ubuntu-latest + needs: build # Wait for build job to complete successfully + if: github.event_name != 'pull_request' # Skip for PRs + + # Grant permissions for GHCR publishing + # contents:read - Read repository contents + # packages:write - Publish to GitHub Container Registry + permissions: + contents: read + packages: write + + steps: + # Checkout the repository code + - name: Checkout code + uses: actions/checkout@v4 + + # Set up Java for running Jib + # Jib doesn't require Docker but needs Java to run + - name: Set up JDK ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: ${{ env.JAVA_DISTRIBUTION }} + cache: 'gradle' + + # Make Gradle wrapper executable + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + # Extract version and determine image tags + # Outputs: + # - version: Project version from build.gradle.kts + # - tags: Comma-separated list of Docker tags to apply + # - is_release: Whether this is a release build (from version tag) + - name: Extract metadata + id: meta + run: | + # Get version from build.gradle.kts + VERSION=$(grep '^version = ' build.gradle.kts | sed 's/version = "\(.*\)"/\1/') + echo "version=$VERSION" >> $GITHUB_OUTPUT + + # Determine image tags based on trigger type + if [[ "${{ github.ref }}" == refs/tags/v* ]]; then + # For version tags (e.g., v1.0.0), use semantic version + TAG_VERSION=${GITHUB_REF#refs/tags/v} + echo "tags=$TAG_VERSION,latest" >> $GITHUB_OUTPUT + echo "is_release=true" >> $GITHUB_OUTPUT + else + # For main branch, append short commit SHA for traceability + SHORT_SHA=$(echo ${{ github.sha }} | cut -c1-7) + echo "tags=$VERSION-$SHORT_SHA,latest" >> $GITHUB_OUTPUT + echo "is_release=false" >> $GITHUB_OUTPUT + fi + + # Authenticate to GitHub Container Registry + # Uses built-in GITHUB_TOKEN (no configuration needed) + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Authenticate to Docker Hub + # Requires DOCKERHUB_USERNAME and DOCKERHUB_TOKEN secrets + # This step will fail silently if secrets are not configured + # Create a Docker Hub access token, then add two GitHub Actions secrets named `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN`. + # + # Steps (web UI) + # - Create Docker Hub token: + # - Visit `https://hub.docker.com` + # - Account → Settings → Security → New Access Token + # - Copy the generated token (you can’t view it again). + # - Add secrets to the repository: + # - In GitHub, open the repo → `Settings` → `Secrets and variables` → `Actions` → `New repository secret` + # - Add secret `DOCKERHUB_USERNAME` with your Docker Hub username. + # - Add secret `DOCKERHUB_TOKEN` with the token from Docker Hub. + # + # Optional + # - To make secrets available to multiple repos, add them at the organization level: Org → `Settings` → `Secrets and variables` → `Actions`. + # - You can also add environment-level secrets if you use GitHub Environments. + # + # CLI example (GitHub CLI) + # ```bash + # gh secret set DOCKERHUB_USERNAME --body "your-docker-username" + # gh secret set DOCKERHUB_TOKEN --body "your-docker-access-token" + # ``` + # + # Note: `GITHUB_TOKEN` is provided automatically for GHCR; do not store it manually. + # - name: Log in to Docker Hub + # uses: docker/login-action@v3 + # with: + # username: ${{ secrets.DOCKERHUB_USERNAME }} + # password: ${{ secrets.DOCKERHUB_TOKEN }} + + # Convert repository owner to lowercase + # Required because container registry names must be lowercase + # Example: "Apache" -> "apache" + - name: Determine repository owner (lowercase) + id: repo + run: | + echo "owner_lc=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT + + # Build and publish images to GitHub Container Registry + # Uses Jib Gradle plugin to build multi-platform images + # Jib creates optimized, layered images without Docker daemon + # Each tag is built and pushed separately + - name: Build and publish to GitHub Container Registry + run: | + TAGS="${{ steps.meta.outputs.tags }}" + IFS=',' read -ra TAG_ARRAY <<< "$TAGS" + + # Build and push each tag to GHCR + # Jib automatically handles multi-platform builds (amd64, arm64) + for TAG in "${TAG_ARRAY[@]}"; do + echo "Building and pushing ghcr.io/${{ steps.repo.outputs.owner_lc }}/solr-mcp-server:$TAG" + ./gradlew jib \ + -Djib.to.image=ghcr.io/${{ steps.repo.outputs.owner_lc }}/solr-mcp-server:$TAG \ + -Djib.to.auth.username=${{ github.actor }} \ + -Djib.to.auth.password=${{ secrets.GITHUB_TOKEN }} + done + + # Build and publish images to Docker Hub + # Only runs if Docker Hub secrets are configured + # Gracefully skips if secrets are not available + - name: Build and publish to Docker Hub + if: secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' + run: | + TAGS="${{ steps.meta.outputs.tags }}" + IFS=',' read -ra TAG_ARRAY <<< "$TAGS" + + # Build and push each tag to Docker Hub + for TAG in "${TAG_ARRAY[@]}"; do + echo "Building and pushing ${{ secrets.DOCKERHUB_USERNAME }}/solr-mcp-server:$TAG" + ./gradlew jib \ + -Djib.to.image=${{ secrets.DOCKERHUB_USERNAME }}/solr-mcp-server:$TAG \ + -Djib.to.auth.username=${{ secrets.DOCKERHUB_USERNAME }} \ + -Djib.to.auth.password=${{ secrets.DOCKERHUB_TOKEN }} + done + + # Create a summary of published images + # Displayed in the GitHub Actions workflow summary page + # Makes it easy to see which images were published and their tags + - name: Summary + run: | + echo "### Docker Images Published :rocket:" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "#### GitHub Container Registry" >> $GITHUB_STEP_SUMMARY + TAGS="${{ steps.meta.outputs.tags }}" + IFS=',' read -ra TAG_ARRAY <<< "$TAGS" + for TAG in "${TAG_ARRAY[@]}"; do + echo "- \`ghcr.io/${{ steps.repo.outputs.owner_lc }}/solr-mcp-server:$TAG\`" >> $GITHUB_STEP_SUMMARY + done + + # Only show Docker Hub section if secrets are configured + if [[ "${{ secrets.DOCKERHUB_USERNAME }}" != "" ]]; then + echo "" >> $GITHUB_STEP_SUMMARY + echo "#### Docker Hub" >> $GITHUB_STEP_SUMMARY + for TAG in "${TAG_ARRAY[@]}"; do + echo "- \`${{ secrets.DOCKERHUB_USERNAME }}/solr-mcp-server:$TAG\`" >> $GITHUB_STEP_SUMMARY + done + fi \ No newline at end of file diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index 4f75338..0000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Claude Auto Review - -on: - pull_request: - types: [opened, synchronize] - -jobs: - auto-review: - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - id-token: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Automatic PR Review - uses: anthropics/claude-code-action@beta - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - timeout_minutes: "60" - direct_prompt: | - Please review this pull request and provide comprehensive feedback. - - Focus on: - - Code quality and best practices - - Potential bugs or issues - - Performance considerations - - Security implications - - Test coverage - - Documentation updates if needed - - Provide constructive feedback with specific suggestions for improvement. - Use inline comments to highlight specific areas of concern. - # allowed_tools: "mcp__github__create_pending_pull_request_review,mcp__github__add_pull_request_review_comment_to_pending_review,mcp__github__submit_pending_pull_request_review,mcp__github__get_pull_request_diff" \ No newline at end of file diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index 71b68ac..0000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Claude PR Assistant - -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - issues: - types: [opened, assigned] - pull_request_review: - types: [submitted] - -jobs: - claude-code-action: - if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && contains(github.event.issue.body, '@claude')) - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude PR Action - uses: anthropics/claude-code-action@beta - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - timeout_minutes: "60" \ No newline at end of file diff --git a/README.md b/README.md index 78188a3..457b243 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,18 @@ +[![Project Status: Incubating](https://img.shields.io/badge/status-incubating-yellow.svg)](https://github.com/apache/solr-mcp) + # Solr MCP Server -A Spring AI Model Context Protocol (MCP) server that provides tools for interacting with Apache Solr. This server enables AI assistants like Claude to search, index, and manage Solr collections through the MCP protocol. +A Spring AI Model Context Protocol (MCP) server that provides tools for interacting with Apache Solr. This server +enables AI assistants like Claude to search, index, and manage Solr collections through the MCP protocol. ## Overview -This project provides a set of tools that allow AI assistants to interact with Apache Solr, a powerful open-source search platform. By implementing the Spring AI MCP protocol, these tools can be used by any MCP-compatible client, including Claude Desktop. The project uses SolrJ, the official Java client for Solr, to communicate with Solr instances. +This project provides a set of tools that allow AI assistants to interact with Apache Solr, a powerful open-source +search platform. By implementing the Spring AI MCP protocol, these tools can be used by any MCP-compatible client, +including Claude Desktop. The project uses SolrJ, the official Java client for Solr, to communicate with Solr instances. The server provides the following capabilities: + - Search Solr collections with advanced query options - Index documents into Solr collections - Manage and monitor Solr collections @@ -43,6 +49,7 @@ docker-compose up -d ``` This will start a Solr instance in SolrCloud mode with ZooKeeper and create two sample collections: + - `books` - A collection with sample book data - `films` - A collection with sample film data @@ -67,6 +74,127 @@ The build produces two JAR files in `build/libs/`: - `solr-mcp-server-0.0.1-SNAPSHOT.jar` - Executable JAR with all dependencies (fat JAR) - `solr-mcp-server-0.0.1-SNAPSHOT-plain.jar` - Plain JAR without dependencies +### 4. Building Docker Images (Optional) + +This project uses [Jib](https://github.com/GoogleContainerTools/jib) to build optimized Docker images without requiring +Docker installed. Jib creates layered images for faster rebuilds and smaller image sizes. + +#### Option 1: Build to Docker Daemon (Recommended) + +Build directly to your local Docker daemon (requires Docker installed): + +```bash +./gradlew jibDockerBuild +``` + +This creates a local Docker image: `solr-mcp-server:0.0.1-SNAPSHOT` + +Verify the image: + +```bash +docker images | grep solr-mcp-server +``` + +#### Option 2: Build to Tar File (No Docker Required) + +Build to a tar file without Docker installed: + +```bash +./gradlew jibBuildTar +``` + +This creates `build/jib-image.tar`. Load it into Docker: + +```bash +docker load < build/jib-image.tar +``` + +#### Option 3: Push to Docker Hub + +Authenticate with Docker Hub and push: + +```bash +# Login to Docker Hub +docker login + +# Build and push +./gradlew jib -Djib.to.image=YOUR_DOCKERHUB_USERNAME/solr-mcp-server:0.0.1-SNAPSHOT +``` + +#### Option 4: Push to GitHub Container Registry + +Authenticate with GitHub Container Registry and push: + +```bash +# Create a Personal Access Token (classic) with write:packages scope at: +# https://github.com/settings/tokens + +# Login to GitHub Container Registry +export GITHUB_TOKEN=YOUR_GITHUB_TOKEN +echo $GITHUB_TOKEN | docker login ghcr.io -u YOUR_GITHUB_USERNAME --password-stdin + +# Build and push +./gradlew jib -Djib.to.image=ghcr.io/YOUR_GITHUB_USERNAME/solr-mcp-server:0.0.1-SNAPSHOT +``` + +#### Multi-Platform Support + +The Docker images are built with multi-platform support for: + +- `linux/amd64` (Intel/AMD 64-bit) +- `linux/arm64` (Apple Silicon M1/M2/M3) + +#### Automated Builds with GitHub Actions + +This project includes a GitHub Actions workflow that automatically builds and publishes Docker images to both GitHub +Container Registry and Docker Hub. + +**Triggers:** + +- Push to `main` branch - Builds and publishes images tagged with `version-SHA` and `latest` +- Version tags (e.g., `v1.0.0`) - Builds and publishes images tagged with the version number and `latest` +- Pull requests - Builds and tests only (no publishing) + +**Published Images:** + +- GitHub Container Registry: `ghcr.io/OWNER/solr-mcp-server:TAG` +- Docker Hub: `DOCKERHUB_USERNAME/solr-mcp-server:TAG` + +**Setup for Docker Hub Publishing:** + +To enable Docker Hub publishing, configure these repository secrets: + +1. Go to your GitHub repository Settings > Secrets and variables > Actions +2. Add the following secrets: + - `DOCKERHUB_USERNAME`: Your Docker Hub username + - `DOCKERHUB_TOKEN`: Docker Hub access token (create at https://hub.docker.com/settings/security) + +**Note:** GitHub Container Registry publishing works automatically using the `GITHUB_TOKEN` provided by GitHub Actions. + +#### Running the Docker Container + +Run the container with STDIO mode: + +```bash +docker run -i --rm solr-mcp-server:0.0.1-SNAPSHOT +``` + +Or with custom Solr URL: + +```bash +docker run -i --rm \ + -e SOLR_URL=http://your-solr-host:8983/solr/ \ + solr-mcp-server:0.0.1-SNAPSHOT +``` + +**Note for Linux users:** If you need to connect to Solr running on the host machine, add the `--add-host` flag: + +```bash +docker run -i --rm \ + --add-host=host.docker.internal:host-gateway \ + solr-mcp-server:0.0.1-SNAPSHOT +``` + ## Project Structure The codebase follows a clean, modular architecture organized by functionality: @@ -108,7 +236,7 @@ src/main/java/org/apache/solr/mcp/server/ - **Configuration**: Spring Boot configuration using properties files - `application.properties` - Default configuration - `application-stdio.properties` - STDIO transport profile - - `application-http.properties` - HTTP transport profile + - `application-http.properties` - HTTP transport profile - **Document Creators**: Strategy pattern implementation for parsing different document formats - Automatically sanitizes field names to comply with Solr schema requirements @@ -194,7 +322,9 @@ Parameters: ## Adding to Claude Desktop -To add this MCP server to Claude Desktop: +You can add this MCP server to Claude Desktop using either the JAR file or Docker container. + +### Option 1: Using JAR File 1. Build the project as a standalone JAR: @@ -220,13 +350,118 @@ To add this MCP server to Claude Desktop: "PROFILES": "stdio" } } - } + } } ``` **Note:** Replace `/absolute/path/to/solr-mcp-server` with the actual path to your project directory. -### 4. Restart Claude Desktop & Invoke +### Option 2: Using Docker Container + +1. Build the Docker image: + +```bash +./gradlew jibDockerBuild +``` + +2. In Claude Desktop, go to Settings > Developer > Edit Config + +3. Add the following configuration to your MCP settings: + +```json +{ + "mcpServers": { + "solr-search-mcp": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "solr-mcp-server:0.0.1-SNAPSHOT" + ], + "env": { + "SOLR_URL": "http://localhost:8983/solr/" + } + } + } +} +``` + +**Note for macOS/Windows users:** Docker Desktop automatically provides `host.docker.internal` for accessing services on +the host machine. The container is pre-configured to use this. + +**Note for Linux users:** You need to add the `--add-host` flag to enable communication with services running on the +host: + +```json +{ + "mcpServers": { + "solr-search-mcp": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "--add-host=host.docker.internal:host-gateway", + "solr-mcp-server:0.0.1-SNAPSHOT" + ], + "env": { + "SOLR_URL": "http://host.docker.internal:8983/solr/" + } + } + } +} +``` + +### Using a Public Docker Image + +If you've pushed the image to Docker Hub or GitHub Container Registry, you can use it directly: + +#### Docker Hub + +```json +{ + "mcpServers": { + "solr-search-mcp": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "YOUR_DOCKERHUB_USERNAME/solr-mcp-server:0.0.1-SNAPSHOT" + ], + "env": { + "SOLR_URL": "http://localhost:8983/solr/" + } + } + } +} +``` + +#### GitHub Container Registry + +```json +{ + "mcpServers": { + "solr-search-mcp": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "ghcr.io/YOUR_GITHUB_USERNAME/solr-mcp-server:0.0.1-SNAPSHOT" + ], + "env": { + "SOLR_URL": "http://localhost:8983/solr/" + } + } + } +} +``` + +### Restart Claude Desktop & Invoke + +After configuring, restart Claude Desktop to load the MCP server. ![claude-stdio.png](images/claude-stdio.png) @@ -440,12 +675,36 @@ controls: If you encounter issues: -1. Ensure Solr is running and accessible. By default, the server connects to http://localhost:8983/solr/, but you can set the `SOLR_URL` environment variable to point to a different Solr instance. +1. Ensure Solr is running and accessible. By default, the server connects to http://localhost:8983/solr/, but you can + set the `SOLR_URL` environment variable to point to a different Solr instance. 2. Check the logs for any error messages 3. Verify that the collections exist using the Solr Admin UI 4. If using HTTP mode, ensure the server is running on the expected port (default: 8080) 5. For STDIO mode with Claude Desktop, verify the JAR path is absolute and correct in the configuration +## FAQ + +### Why use Jib instead of Spring Boot Buildpacks? + +This project uses [Jib](https://github.com/GoogleContainerTools/jib) for building Docker images instead of Spring Boot +Buildpacks for a critical compatibility reason: + +**STDIO Mode Compatibility**: Docker images built with Spring Boot Buildpacks were outputting logs and diagnostic +information to stdout, which interfered with the MCP protocol's STDIO transport. The MCP protocol requires a clean +stdout channel for protocol messages - any extraneous output causes connection errors and prevents the server from +working properly with MCP clients like Claude Desktop. + +Jib provides additional benefits: + +- **Clean stdout**: Jib-built images don't pollute stdout with build information or runtime logs +- **No Docker daemon required**: Jib can build images without Docker installed +- **Faster builds**: Layered image building with better caching +- **Smaller images**: More efficient layer organization +- **Multi-platform support**: Easy cross-platform image building for amd64 and arm64 + +If you're building an MCP server with Docker support, ensure your containerization approach maintains a clean stdout +channel when running in STDIO mode. + ## License This project is licensed under the Apache License 2.0. diff --git a/build.gradle.kts b/build.gradle.kts index 363308b..3c37af4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,6 +7,7 @@ plugins { jacoco alias(libs.plugins.errorprone) alias(libs.plugins.spotless) + alias(libs.plugins.jib) } group = "org.apache.solr" @@ -99,3 +100,129 @@ spotless { ktlint() } } + +// Jib Plugin Configuration +// ========================= +// Jib is a Gradle plugin that builds optimized Docker images without requiring Docker installed. +// It creates layered images for faster rebuilds and smaller image sizes. +// +// Key features: +// - Multi-platform support (amd64 and arm64) +// - No Docker daemon required +// - Reproducible builds +// - Optimized layering for faster deployments +// +// Building Images: +// ---------------- +// 1. Build to Docker daemon (requires Docker installed): +// ./gradlew jibDockerBuild +// Creates image: solr-mcp-server:0.0.1-SNAPSHOT +// +// 2. Build to local tar file (no Docker required): +// ./gradlew jibBuildTar +// Creates: build/jib-image.tar +// Load with: docker load < build/jib-image.tar +// +// 3. Push to Docker Hub (requires authentication): +// docker login +// ./gradlew jib -Djib.to.image=dockerhub-username/solr-mcp-server:0.0.1-SNAPSHOT +// +// 4. Push to GitHub Container Registry (requires authentication): +// echo $GITHUB_TOKEN | docker login ghcr.io -u GITHUB_USERNAME --password-stdin +// ./gradlew jib -Djib.to.image=ghcr.io/github-username/solr-mcp-server:0.0.1-SNAPSHOT +// +// Authentication: +// --------------- +// For Docker Hub: +// docker login +// +// For GitHub Container Registry: +// Create a Personal Access Token (classic) with write:packages scope at: +// https://github.com/settings/tokens +// Then authenticate: +// echo YOUR_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin +// +// Alternative: Set credentials in ~/.gradle/gradle.properties: +// jib.to.auth.username=YOUR_USERNAME +// jib.to.auth.password=YOUR_TOKEN_OR_PASSWORD +// +// Environment Variables: +// ---------------------- +// The container is pre-configured with: +// - SPRING_DOCKER_COMPOSE_ENABLED=false (Docker Compose disabled in container) +// - SOLR_URL=http://host.docker.internal:8983/solr/ (default Solr connection) +// +// These can be overridden at runtime: +// docker run -e SOLR_URL=http://custom-solr:8983/solr/ solr-mcp-server:0.0.1-SNAPSHOT +jib { + from { + // Use Eclipse Temurin JRE 25 as the base image + // Temurin is the open-source build of OpenJDK from Adoptium + image = "eclipse-temurin:25-jre" + + // Multi-platform support for both AMD64 and ARM64 architectures + // This allows the image to run on x86_64 machines and Apple Silicon (M1/M2/M3) + platforms { + platform { + architecture = "amd64" + os = "linux" + } + platform { + architecture = "arm64" + os = "linux" + } + } + } + + to { + // Default image name (can be overridden with -Djib.to.image=...) + // Format: repository/image-name:tag + image = "solr-mcp-server:$version" + + // Tags to apply to the image + // The version tag is applied by default, plus "latest" tag + tags = setOf("latest") + } + + container { + // Container environment variables + // These are baked into the image but can be overridden at runtime + environment = + mapOf( + // Disable Spring Boot Docker Compose support when running in container + "SPRING_DOCKER_COMPOSE_ENABLED" to "false", + // Default Solr URL using host.docker.internal to reach host machine + // On Linux, use --add-host=host.docker.internal:host-gateway + "SOLR_URL" to "http://host.docker.internal:8983/solr/", + ) + + // JVM flags for containerized environments + // These optimize the JVM for running in containers + jvmFlags = + listOf( + // Use container-aware memory settings + "-XX:+UseContainerSupport", + // Set max RAM percentage (default 75%) + "-XX:MaxRAMPercentage=75.0", + ) + + // Main class to run (auto-detected from Spring Boot plugin) + // mainClass is automatically set by Spring Boot Gradle plugin + + // Port exposures (for documentation purposes) + // The application doesn't expose ports by default (STDIO mode) + // If running in HTTP mode, the port would be 8080 + ports = listOf("8080") + + // Labels for image metadata + labels.set( + mapOf( + "org.opencontainers.image.title" to "Solr MCP Server", + "org.opencontainers.image.description" to "Spring AI MCP Server for Apache Solr", + "org.opencontainers.image.version" to version.toString(), + "org.opencontainers.image.vendor" to "Apache Software Foundation", + "org.opencontainers.image.licenses" to "Apache-2.0", + ), + ) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9de2429..f53361d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ spring-boot = "3.5.6" spring-dependency-management = "1.1.7" errorprone-plugin = "4.2.0" +jib = "3.4.5" spotless = "7.0.2" # Main dependencies @@ -81,4 +82,5 @@ errorprone = [ spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" } spring-dependency-management = { id = "io.spring.dependency-management", version.ref = "spring-dependency-management" } errorprone = { id = "net.ltgt.errorprone", version.ref = "errorprone-plugin" } +jib = { id = "com.google.cloud.tools.jib", version.ref = "jib" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } \ No newline at end of file From 1cb62fba6c0716d4677606b0dae0b49fc4d1a26e Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Wed, 29 Oct 2025 10:56:52 -0400 Subject: [PATCH 5/7] Update the repo name pattern. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 457b243..813ebe6 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,8 @@ The server supports two transport modes: ### 1. Clone the repository ```bash -git clone https://github.com/yourusername/solr-mcp-server.git -cd solr-mcp-server +git clone https://github.com/yourusername/solr-mcp.git +cd solr-mcp ``` ### 2. Start Solr using Docker Compose From e841c4c857c5f7fa3294a9e44860280e2014bae9 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Sun, 26 Oct 2025 20:18:37 -0400 Subject: [PATCH 6/7] Add Docker support with Jib and GitHub Actions CI/CD # Conflicts: # build.gradle.kts # gradle/libs.versions.toml --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f53361d..7e48e57 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ spring-boot = "3.5.6" spring-dependency-management = "1.1.7" errorprone-plugin = "4.2.0" -jib = "3.4.5" +jib = "3.4.7" spotless = "7.0.2" # Main dependencies From 14343b9724ad9ad4abc9d00ffbd6d740ceb323aa Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Thu, 30 Oct 2025 19:58:17 -0400 Subject: [PATCH 7/7] test: add Docker integration tests for MCP server under both STDIO and HTTP modes --- .github/workflows/build-and-publish.yml | 15 ++ .github/workflows/build.yml | 15 ++ build.gradle.kts | 151 ++++++++++- compose.yaml | 15 ++ gradle/libs.versions.toml | 7 +- init-solr.sh | 15 ++ settings.gradle.kts | 17 ++ .../apache/solr/mcp/server/ClientStdio.java | 33 ++- .../DockerImageHttpIntegrationTest.java | 248 ++++++++++++++++++ .../DockerImageStdioIntegrationTest.java | 191 ++++++++++++++ 10 files changed, 690 insertions(+), 17 deletions(-) create mode 100644 src/test/java/org/apache/solr/mcp/server/DockerImageHttpIntegrationTest.java create mode 100644 src/test/java/org/apache/solr/mcp/server/DockerImageStdioIntegrationTest.java diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index c9e805b..80a27ee 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -1,3 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + # GitHub Actions Workflow: Build and Publish # =========================================== # diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f02281a..281bd62 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,3 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + name: SonarQube on: push: diff --git a/build.gradle.kts b/build.gradle.kts index 3c37af4..56b39db 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,20 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import net.ltgt.gradle.errorprone.errorprone plugins { @@ -59,9 +76,38 @@ dependencyManagement { } } +// Configures Spring Boot plugin to generate build metadata at build time +// This creates META-INF/build-info.properties containing: +// - build.artifact: The artifact name (e.g., "solr-mcp-server") +// - build.group: The group ID (e.g., "org.apache.solr") +// - build.name: The project name +// - build.version: The version (e.g., "0.0.1-SNAPSHOT") +// - build.time: The timestamp when the build was executed +// +// When it executes: +// - bootBuildInfo task runs before processResources during any build +// - Triggered by: ./gradlew build, bootJar, test, classes, etc. +// - The generated file is included in the JAR's classpath +// - Tests can access it via: getResourceAsStream("/META-INF/build-info.properties") +// +// Use cases: +// - Runtime version introspection via Spring Boot Actuator +// - Dynamic JAR path resolution in tests (e.g., ClientStdio.java) +// - Application metadata exposure through /actuator/info endpoint +springBoot { + buildInfo() +} + tasks.withType { - useJUnitPlatform() - finalizedBy(tasks.jacocoTestReport) + useJUnitPlatform { + // Only exclude docker integration tests from regular test runs, not from dockerIntegrationTest + if (name != "dockerIntegrationTest") { + excludeTags("docker-integration") + } + } + if (name != "dockerIntegrationTest") { + finalizedBy(tasks.jacocoTestReport) + } } tasks.jacocoTestReport { @@ -71,6 +117,19 @@ tasks.jacocoTestReport { html.required.set(true) csv.required.set(false) } + // Exclude docker integration tests from coverage + classDirectories.setFrom( + files( + classDirectories.files.map { + fileTree(it) { + exclude( + "**/DockerImageStdioIntegrationTest*.class", + "**/DockerImageHttpIntegrationTest*.class", + ) + } + }, + ), + ) } tasks.withType().configureEach { @@ -101,6 +160,87 @@ spotless { } } +// Docker Integration Test Task +// ============================= +// This task runs integration tests for the Docker image produced by Jib. +// It is separate from the regular test task and must be explicitly invoked. +// +// Usage: +// ./gradlew dockerIntegrationTest +// +// Prerequisites: +// - Docker must be installed and running +// - The task will automatically build the Docker image using jibDockerBuild +// +// The task: +// - Checks if Docker is available +// - Builds the Docker image using Jib (if Docker is available) +// - Runs tests tagged with "docker-integration" +// - Uses the same test configuration as regular tests +// +// Notes: +// - If Docker is not available, the task will fail with a helpful error message +// - The test will verify the Docker image starts correctly and remains stable +// - Tests run in isolation from regular unit tests +tasks.register("dockerIntegrationTest") { + description = "Runs integration tests for the Docker image" + group = "verification" + + // Always run this task, don't use Gradle's up-to-date checking + // Docker images can change without Gradle knowing + outputs.upToDateWhen { false } + + // Check if Docker is available + val dockerAvailable = + try { + val process = ProcessBuilder("docker", "info").start() + process.waitFor() == 0 + } catch (e: Exception) { + false + } + + if (!dockerAvailable) { + doFirst { + throw GradleException( + "Docker is not available. Please ensure Docker is installed and running.", + ) + } + } + + // Depend on building the Docker image first (only if Docker is available) + if (dockerAvailable) { + dependsOn(tasks.jibDockerBuild) + } + + // Configure test task to only run docker integration tests + useJUnitPlatform { + includeTags("docker-integration") + } + + // Use the same test classpath and configuration as regular tests + testClassesDirs = sourceSets["test"].output.classesDirs + classpath = sourceSets["test"].runtimeClasspath + + // Ensure this doesn't trigger the regular test task or jacocoTestReport + mustRunAfter(tasks.test) + + // Set longer timeout for Docker tests + systemProperty("junit.jupiter.execution.timeout.default", "5m") + + // Output test results + testLogging { + events("passed", "skipped", "failed", "standardOut", "standardError") + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + showStandardStreams = true + } + + // Generate separate test report in a different directory + reports { + html.outputLocation.set(layout.buildDirectory.dir("reports/dockerIntegrationTest")) + junitXml.outputLocation.set(layout.buildDirectory.dir("test-results/dockerIntegrationTest")) + } +} + // Jib Plugin Configuration // ========================= // Jib is a Gradle plugin that builds optimized Docker images without requiring Docker installed. @@ -191,9 +331,6 @@ jib { mapOf( // Disable Spring Boot Docker Compose support when running in container "SPRING_DOCKER_COMPOSE_ENABLED" to "false", - // Default Solr URL using host.docker.internal to reach host machine - // On Linux, use --add-host=host.docker.internal:host-gateway - "SOLR_URL" to "http://host.docker.internal:8983/solr/", ) // JVM flags for containerized environments @@ -206,8 +343,8 @@ jib { "-XX:MaxRAMPercentage=75.0", ) - // Main class to run (auto-detected from Spring Boot plugin) - // mainClass is automatically set by Spring Boot Gradle plugin + // Explicitly set main class to avoid ASM scanning issues with newer Java versions + mainClass = "org.apache.solr.mcp.server.Main" // Port exposures (for documentation purposes) // The application doesn't expose ports by default (STDIO mode) diff --git a/compose.yaml b/compose.yaml index 4238b85..96f9eb6 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,3 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + services: solr: image: solr:9-slim diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7e48e57..81ca101 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ spring-boot = "3.5.6" spring-dependency-management = "1.1.7" errorprone-plugin = "4.2.0" -jib = "3.4.7" +jib = "3.4.4" spotless = "7.0.2" # Main dependencies @@ -21,6 +21,7 @@ jetty = "10.0.22" # Test dependencies testcontainers = "1.21.3" +awaitility = "4.2.2" [libraries] # Spring @@ -52,6 +53,7 @@ nullaway = { module = "com.uber.nullaway:nullaway", version.ref = "nullaway" } testcontainers-junit-jupiter = { module = "org.testcontainers:junit-jupiter" } testcontainers-solr = { module = "org.testcontainers:solr", version.ref = "testcontainers" } junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } +awaitility = { module = "org.awaitility:awaitility", version.ref = "awaitility" } [bundles] spring-ai-mcp = [ @@ -70,7 +72,8 @@ test = [ "spring-ai-spring-boot-testcontainers", "testcontainers-junit-jupiter", "testcontainers-solr", - "spring-ai-starter-mcp-client" + "spring-ai-starter-mcp-client", + "awaitility" ] errorprone = [ diff --git a/init-solr.sh b/init-solr.sh index 4ae13f6..2c015a8 100755 --- a/init-solr.sh +++ b/init-solr.sh @@ -1,4 +1,19 @@ #!/bin/bash +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + set -e # Ensure mydata directory exists diff --git a/settings.gradle.kts b/settings.gradle.kts index aa6a022..881dbc5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1 +1,18 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + rootProject.name = "solr-mcp-server" diff --git a/src/test/java/org/apache/solr/mcp/server/ClientStdio.java b/src/test/java/org/apache/solr/mcp/server/ClientStdio.java index 60ab695..066a5e5 100644 --- a/src/test/java/org/apache/solr/mcp/server/ClientStdio.java +++ b/src/test/java/org/apache/solr/mcp/server/ClientStdio.java @@ -20,24 +20,41 @@ import io.modelcontextprotocol.client.transport.ServerParameters; import io.modelcontextprotocol.client.transport.StdioClientTransport; import io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapper; -import java.io.File; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; // run after project has been built with "./gradlew build -x test and the mcp server jar is // connected to a running solr" public class ClientStdio { - public static void main(String[] args) { + public static void main(String[] args) throws IOException { + // Read build info generated by Spring Boot + Properties buildInfo = new Properties(); + try (InputStream input = + ClientStdio.class.getResourceAsStream("/META-INF/build-info.properties")) { + if (input == null) { + throw new IllegalStateException( + "build-info.properties not found. Run './gradlew build' first."); + } + buildInfo.load(input); + } - System.out.println(new File(".").getAbsolutePath()); + String jarName = + String.format( + "build/libs/%s-%s.jar", + buildInfo.getProperty("build.artifact"), + buildInfo.getProperty("build.version")); - var stdioParams = - ServerParameters.builder("java") - .args("-jar", "build/libs/solr-mcp-server-0.0.1-SNAPSHOT.jar") - .build(); + var stdioParams = ServerParameters + .builder("java") + .args("-jar", jarName) + .build(); var transport = new StdioClientTransport(stdioParams, new JacksonMcpJsonMapper(new ObjectMapper())); new SampleClient(transport).run(); } -} +} \ No newline at end of file diff --git a/src/test/java/org/apache/solr/mcp/server/DockerImageHttpIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/DockerImageHttpIntegrationTest.java new file mode 100644 index 0000000..05bd0ed --- /dev/null +++ b/src/test/java/org/apache/solr/mcp/server/DockerImageHttpIntegrationTest.java @@ -0,0 +1,248 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.mcp.server; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.SolrContainer; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Integration test for the Docker image produced by Jib running in HTTP mode (streamable HTTP). + * + *

This test verifies that the Docker image built by Jib: + * + *

    + *
  • Starts successfully without errors in HTTP mode + *
  • Runs the Spring Boot MCP server application correctly + *
  • Exposes HTTP endpoint on port 8080 + *
  • Responds to HTTP requests + *
  • Can connect to an external Solr instance + *
+ * + *

Prerequisites: Before running this test, you must build the Docker image: + * + *

{@code
+ * ./gradlew jibDockerBuild
+ * }
+ * + *

This will create the image: {@code solr-mcp-server:0.0.1-SNAPSHOT} + * + *

Test Architecture: + * + *

    + *
  1. Creates a shared Docker network for inter-container communication + *
  2. Starts a Solr container on the network + *
  3. Starts the MCP server Docker image in HTTP mode with connection to Solr + *
  4. Verifies the container starts and HTTP endpoint is accessible + *
  5. Validates HTTP responses and container health + *
+ * + *

Note: This test is tagged with "docker-integration" and is designed to run + * separately from regular unit tests using the {@code dockerIntegrationTest} Gradle task. + */ +@Testcontainers +@Tag("docker-integration") +class DockerImageHttpIntegrationTest { + + private static final Logger log = + LoggerFactory.getLogger(DockerImageHttpIntegrationTest.class); + + // Docker image name and tag from build.gradle.kts + private static final String DOCKER_IMAGE = "solr-mcp-server:0.0.1-SNAPSHOT"; + private static final String SOLR_IMAGE = "solr:9.9-slim"; + private static final int HTTP_PORT = 8080; + + // Network for container communication + private static final Network network = Network.newNetwork(); + + // Solr container for backend + // Note: This field is used implicitly through the @Container annotation. + // Testcontainers JUnit extension automatically: + // 1. Starts this container before tests run + // 2. Makes it accessible via network alias "solr" at http://solr:8983/solr/ + // 3. Stops and cleans up the container after tests complete + @Container + private static final SolrContainer solrContainer = + new SolrContainer(DockerImageName.parse(SOLR_IMAGE)) + .withNetwork(network) + .withNetworkAliases("solr") + .withLogConsumer(new Slf4jLogConsumer(log).withPrefix("SOLR")); + + // MCP Server container (the image we're testing) + // Note: In HTTP mode, the application exposes a web server on port 8080 + @Container + private static final GenericContainer mcpServerContainer = + new GenericContainer<>(DockerImageName.parse(DOCKER_IMAGE)) + .withNetwork(network) + .withEnv("SOLR_URL", "http://solr:8983/solr/") + .withEnv("SPRING_DOCKER_COMPOSE_ENABLED", "false") + .withEnv("PROFILES", "http") + .withExposedPorts(HTTP_PORT) + .withLogConsumer(new Slf4jLogConsumer(log).withPrefix("MCP-SERVER-HTTP")) + // Wait for HTTP endpoint to be ready + .waitingFor( + Wait.forHttp("/actuator/health") + .forPort(HTTP_PORT) + .withStartupTimeout(Duration.ofSeconds(60))); + + private static HttpClient httpClient; + private static String baseUrl; + + @BeforeAll + static void setup() { + log.info("Solr container started. Internal URL: http://solr:8983/solr/"); + log.info("MCP Server container started in HTTP mode"); + + // Initialize HTTP client + httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); + + // Get the mapped port for accessing the container from the host + Integer mappedPort = mcpServerContainer.getMappedPort(HTTP_PORT); + baseUrl = "http://localhost:" + mappedPort; + + log.info("MCP Server HTTP endpoint available at: {}", baseUrl); + } + + @Test + void testSolrContainerIsRunning() { + // Verify Solr container started successfully + // This is essential because MCP server depends on Solr being available + assertTrue(solrContainer.isRunning(), "Solr container should be running"); + + log.info("Solr container is running and available at http://solr:8983/solr/"); + } + + @Test + void testContainerStartsAndRemainsStable() { + // Verify initial startup + assertTrue(mcpServerContainer.isRunning(), "Container should start successfully"); + + // Monitor container stability over 10 seconds to ensure it doesn't crash + await().atMost(10, TimeUnit.SECONDS) + .pollInterval(1, TimeUnit.SECONDS) + .pollDelay(Duration.ZERO) + .untilAsserted(() -> assertTrue(mcpServerContainer.isRunning())); + + log.info("Container started successfully and remained stable for 10 seconds"); + } + + @Test + void testNoErrorsInLogs() { + String logs = mcpServerContainer.getLogs(); + + // Check for critical error patterns + assertFalse( + logs.contains("Exception in thread \"main\""), + "Logs should not contain main thread exceptions"); + + assertFalse( + logs.contains("Application run failed"), + "Logs should not contain application failure messages"); + + assertFalse( + logs.contains("ERROR") && logs.contains("Failed to start"), + "Logs should not contain startup failure errors"); + + assertFalse( + logs.contains("fatal error") || logs.contains("JVM crash"), + "Logs should not contain JVM crash messages"); + + log.info("No critical errors found in container logs"); + } + + @Test + void testHttpEndpointResponds() throws IOException, InterruptedException { + // Test that the HTTP endpoint is accessible + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/actuator/health")) + .timeout(Duration.ofSeconds(10)) + .GET() + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + assertEquals(200, response.statusCode(), "Health endpoint should return 200 OK"); + assertTrue( + response.body().contains("UP") || response.body().contains("\"status\""), + "Health endpoint should return UP status or status field"); + + log.info("HTTP endpoint responded successfully with status: {}", response.statusCode()); + } + + @Test + void testSolrConnectivity() { + // Verify environment variables are working and Solr is accessible + String logs = mcpServerContainer.getLogs(); + + assertFalse( + logs.contains("Connection refused"), + "Logs should not contain connection refused errors"); + + assertFalse( + logs.contains("UnknownHostException"), + "Logs should not contain unknown host exceptions"); + + log.info("Container can connect to Solr without errors"); + } + + @Test + void testHttpModeConfiguration() { + String logs = mcpServerContainer.getLogs(); + + // Verify HTTP mode is active by checking for typical Spring Boot web server logs + assertTrue( + logs.contains("Tomcat started on port") || logs.contains("Netty started on port"), + "Logs should indicate web server started on a port"); + + log.info("HTTP mode configuration verified"); + } + + @Test + void testPortExposure() { + // Verify the port is exposed and mapped + Integer mappedPort = mcpServerContainer.getMappedPort(HTTP_PORT); + assertNotNull(mappedPort, "HTTP port should be exposed and mapped"); + assertTrue(mappedPort > 0, "Mapped port should be a valid port number"); + + log.info("Port {} is properly exposed and mapped to {}", HTTP_PORT, mappedPort); + } +} diff --git a/src/test/java/org/apache/solr/mcp/server/DockerImageStdioIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/DockerImageStdioIntegrationTest.java new file mode 100644 index 0000000..a8cb4ca --- /dev/null +++ b/src/test/java/org/apache/solr/mcp/server/DockerImageStdioIntegrationTest.java @@ -0,0 +1,191 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.mcp.server; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.SolrContainer; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Integration test for the Docker image produced by Jib running in STDIO mode. + * + *

This test verifies that the Docker image built by Jib: + * + *

    + *
  • Starts successfully without errors in STDIO mode + *
  • Runs the Spring Boot MCP server application correctly + *
  • Doesn't crash during initial startup period + *
  • Can connect to an external Solr instance + *
+ * + *

Prerequisites: Before running this test, you must build the Docker image: + * + *

{@code
+ * ./gradlew jibDockerBuild
+ * }
+ * + *

This will create the image: {@code solr-mcp-server:0.0.1-SNAPSHOT} + * + *

Test Architecture: + * + *

    + *
  1. Creates a shared Docker network for inter-container communication + *
  2. Starts a Solr container on the network + *
  3. Starts the MCP server Docker image in STDIO mode with connection to Solr + *
  4. Verifies the container starts and remains stable + *
  5. Validates container health over time + *
+ * + *

Note: This test is tagged with "docker-integration" and is designed to run + * separately from regular unit tests using the {@code dockerIntegrationTest} Gradle task. + */ +@Testcontainers +@Tag("docker-integration") +class DockerImageStdioIntegrationTest { + + private static final Logger log = + LoggerFactory.getLogger(DockerImageStdioIntegrationTest.class); + + // Docker image name and tag from build.gradle.kts + private static final String DOCKER_IMAGE = "solr-mcp-server:0.0.1-SNAPSHOT"; + private static final String SOLR_IMAGE = "solr:9.9-slim"; + + // Network for container communication + private static final Network network = Network.newNetwork(); + + // Solr container for backend + // Note: This field is used implicitly through the @Container annotation. + // Testcontainers JUnit extension automatically: + // 1. Starts this container before tests run + // 2. Makes it accessible via network alias "solr" at http://solr:8983/solr/ + // 3. Stops and cleans up the container after tests complete + @Container + private static final SolrContainer solrContainer = + new SolrContainer(DockerImageName.parse(SOLR_IMAGE)) + .withNetwork(network) + .withNetworkAliases("solr") + .withLogConsumer(new Slf4jLogConsumer(log).withPrefix("SOLR")); + + // MCP Server container (the image we're testing) + // Note: In STDIO mode, the application doesn't produce logs to stdout that we can wait for, + // so we use a simple startup delay and then verify the container is running + @Container + private static final GenericContainer mcpServerContainer = + new GenericContainer<>(DockerImageName.parse(DOCKER_IMAGE)) + .withNetwork(network) + .withEnv("SOLR_URL", "http://solr:8983/solr/") + .withEnv("SPRING_DOCKER_COMPOSE_ENABLED", "false") + .withLogConsumer(new Slf4jLogConsumer(log).withPrefix("MCP-SERVER")) + // Give the application time to start (STDIO mode doesn't produce logs to wait + // for) + .withStartupTimeout(Duration.ofSeconds(60)); + + @BeforeAll + static void setup() throws InterruptedException { + log.info("Solr container started. Internal URL: http://solr:8983/solr/"); + log.info("MCP Server container starting. Waiting for initialization..."); + + // Give the MCP server a few seconds to initialize + // In STDIO mode, the app runs but doesn't produce logs we can monitor + Thread.sleep(5000); + + log.info("Initialization wait complete. Beginning tests."); + } + + @Test + void testSolrContainerIsRunning() { + // Verify Solr container started successfully + // This is essential because MCP server depends on Solr being available + assertTrue(solrContainer.isRunning(), "Solr container should be running"); + + log.info("Solr container is running and available at http://solr:8983/solr/"); + } + + @Test + void testContainerStartsAndRemainsStable() { + // Verify initial startup + assertTrue(mcpServerContainer.isRunning(), "Container should start successfully"); + + // Monitor container stability over 10 seconds to ensure it doesn't crash + await().atMost(10, TimeUnit.SECONDS) + .pollInterval(1, TimeUnit.SECONDS) + .pollDelay(Duration.ZERO) + .untilAsserted(() -> assertTrue(mcpServerContainer.isRunning())); + + log.info("Container started successfully and remained stable for 10 seconds"); + } + + @Test + void testNoErrorsInLogs() { + String logs = mcpServerContainer.getLogs(); + + // Check for critical error patterns + assertFalse( + logs.contains("Exception in thread \"main\""), + "Logs should not contain main thread exceptions"); + + assertFalse( + logs.contains("Application run failed"), + "Logs should not contain application failure messages"); + + assertFalse( + logs.contains("ERROR") && logs.contains("Failed to start"), + "Logs should not contain startup failure errors"); + + assertFalse( + logs.contains("fatal error") || logs.contains("JVM crash"), + "Logs should not contain JVM crash messages"); + + assertFalse( + logs.contains("exec format error"), + "Logs should not contain platform compatibility errors"); + + log.info("No critical errors found in container logs"); + } + + @Test + void testSolrConnectivity() { + // Verify environment variables are working and Solr is accessible + String logs = mcpServerContainer.getLogs(); + + assertFalse( + logs.contains("Connection refused"), + "Logs should not contain connection refused errors"); + + assertFalse( + logs.contains("UnknownHostException"), + "Logs should not contain unknown host exceptions"); + + log.info("Container can connect to Solr without errors"); + } +}