From 7b4d4aee9c5ad23b4b891cd90cf1ddf230bcf606 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Sat, 13 Jun 2026 20:11:00 -0400 Subject: [PATCH 1/7] feat: upgrade to Spring Boot 4.1.0 and Spring AI 2.0.0 GA Migrate the Solr MCP server from Spring Boot 3.5.x / Spring AI 1.1.x to Spring Boot 4.1.0 and Spring AI 2.0.0 GA. Highlights: - Jackson 3: tools.jackson.databind replaces com.fasterxml.jackson.databind (annotations stay in com.fasterxml.jackson.annotation); the MCP SDK uses the mcp-json-jackson3 module. JacksonException replaces IOException/ JsonProcessingException; JsonNode.properties()/asString() replace fields()/asText(). - MCP annotations: package moved from org.springaicommunity.mcp.annotation to org.springframework.ai.mcp.annotation (now part of Spring AI core). - Modular starters: spring-boot-starter-web -> -webmvc + -json; explicit spring-ai-autoconfigure-mcp-server-common (no longer pulled transitively after Spring AI 2.0.0-M7); modular test starters (actuator / opentelemetry / webmvc). - Observability: spring-boot-starter-opentelemetry (SB4 idiomatic) for traces, metrics, and OTLP log export, replacing the manual OTel BOM + micrometer-tracing-bridge-otel approach; spring-boot-starter-aspectj for @Observed; OpenTelemetry logback appender installed programmatically via InstallOpenTelemetryAppender; management.opentelemetry.* OTLP properties. - Testcontainers 2.x module/package renames. - JSpecify is built into Spring Boot 4, so the explicit dependency is dropped. - GraalVM native: AOT NullAway exclusions for generated sources, JUnit-platform build-time init, DefaultMetaProvider + Solr API model reflection hints, and per-profile AOT documentation; dependency-resolution alignment for opentelemetry-proto and opentelemetry-api-incubator against the Spring Boot 4.1.0 BOM. Both Spring Boot 4.1.0 and Spring AI 2.0.0 are GA on Maven Central, so no milestone repositories are required. Scope is limited to the Spring Boot 4 / Spring AI 2.0 upgrade and its required changes; unrelated CI, Solr-version-matrix, and Solr 10 metrics changes are intentionally excluded. Signed-off-by: adityamparikh Co-authored-by: Claude Opus 4.8 (1M context) --- AGENTS.md | 24 +- build.gradle.kts | 73 +++- compose.yaml | 46 +-- dev-docs/Observability.md | 314 ++++++++++++++++++ dev-docs/graalvm-native-image.md | 39 ++- gradle/libs.versions.toml | 41 ++- .../server/collection/CollectionService.java | 14 +- .../config/InstallOpenTelemetryAppender.java | 56 ++++ .../mcp/server/config/JsonResponseParser.java | 12 +- .../solr/mcp/server/config/SolrConfig.java | 2 +- .../mcp/server/config/SolrNativeHints.java | 32 +- .../mcp/server/indexing/IndexingService.java | 8 +- .../documentcreator/JsonDocumentCreator.java | 10 +- .../solr/mcp/server/schema/SchemaService.java | 12 +- .../solr/mcp/server/search/SearchService.java | 8 +- .../solr/mcp/server/util/JsonUtils.java | 6 +- .../resources/application-http.properties | 6 +- src/main/resources/application.properties | 2 +- .../apache/solr/mcp/server/ClientStdio.java | 6 +- .../server/McpClientIntegrationTestBase.java | 7 +- .../server/McpClientStdioIntegrationTest.java | 6 +- .../mcp/server/McpToolRegistrationTest.java | 10 +- .../CollectionServiceIntegrationTest.java | 6 +- .../collection/CollectionServiceTest.java | 17 +- .../mcp/server/config/SolrConfigAuthTest.java | 2 +- .../SolrConfigUrlNormalizationTest.java | 6 +- ...kerImageMcpClientStdioIntegrationTest.java | 6 +- .../IndexingServiceIntegrationTest.java | 9 +- .../observability/DistributedTracingTest.java | 4 +- .../InMemoryTracingTestConfiguration.java | 37 +++ .../server/observability/LgtmAssertions.java | 4 +- .../OtlpExportIntegrationTest.java | 4 +- .../mcp/server/schema/SchemaServiceTest.java | 2 +- .../search/SearchServiceIntegrationTest.java | 9 +- .../mcp/server/search/SearchServiceTest.java | 8 +- 35 files changed, 695 insertions(+), 153 deletions(-) create mode 100644 dev-docs/Observability.md create mode 100644 src/main/java/org/apache/solr/mcp/server/config/InstallOpenTelemetryAppender.java create mode 100644 src/test/java/org/apache/solr/mcp/server/observability/InMemoryTracingTestConfiguration.java diff --git a/AGENTS.md b/AGENTS.md index 7d7ce524..86b83f00 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Solr MCP Server is a Spring AI Model Context Protocol (MCP) server that enables - **Status:** Apache incubating project (v0.0.2-SNAPSHOT) - **Java:** 25+ (centralized in build.gradle.kts) -- **Framework:** Spring Boot 3.5.14, Spring AI 1.1.7 +- **Framework:** Spring Boot 4.1.0, Spring AI 2.0.0 - **License:** Apache 2.0 ## Common Commands @@ -222,6 +222,28 @@ buildpacks (`bootBuildImage -Pnative`). Key configuration: - **CI:** Separate `native.yml` workflow; native failures do not block JVM-path merges. - **Spec:** [dev-docs/graalvm-native-image.md](dev-docs/graalvm-native-image.md) +### Spring Boot 4 Notes + +This branch targets Spring Boot 4.1.0 and Spring AI 2.0.0 +([release announcement](https://spring.io/blog/2026/06/12/spring-ai-2-0-0-GA-available-now)). +Key differences from the main (SB 3.x) branch: + +- **Jackson 3:** `tools.jackson.databind` replaces `com.fasterxml.jackson.databind`. Annotations + remain in `com.fasterxml.jackson.annotation`. +- **MCP Annotations:** Package moved from `org.springaicommunity.mcp.annotation` to + `org.springframework.ai.mcp.annotation` in Spring AI 2.0. +- **Testcontainers 2.x:** Module names changed (e.g., `testcontainers-junit-jupiter`, `testcontainers-solr`). +- **JSpecify:** Built into Spring Boot 4 — no separate dependency needed. +- **`spring-boot-starter-aop` removed:** Replaced by `spring-boot-starter-aspectj` for + `@Observed` annotation support. +- **Observability:** Uses `spring-boot-starter-opentelemetry` (SB4 idiomatic) for traces, + metrics, and log export via OTLP. The old `micrometer-tracing-bridge-otel` + manual OTel BOM + approach from SB 3.x is no longer needed. +- **MCP SDK:** Uses `io.modelcontextprotocol.sdk:mcp:2.0.0` with Jackson 3 module + (`mcp-json-jackson3`). +- **Span naming:** `@Observed` spans use `ClassName#methodName` (PascalCase) instead of + SB3's `class-name#method-name` (kebab-case). + ## Release LICENSE / NOTICE ASF policy requires distinct LICENSE/NOTICE for the *source* form and the *binary* diff --git a/build.gradle.kts b/build.gradle.kts index 6a846c98..5a4635fe 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -143,28 +143,36 @@ repositories { dependencies { - developmentOnly(libs.bundles.spring.boot.dev) + developmentOnly(libs.spring.boot.docker.compose) + developmentOnly(libs.spring.ai.spring.boot.docker.compose) { + exclude(group = "org.springframework.boot", module = "spring-boot-starter-mongodb") + } - implementation(libs.spring.boot.starter.web) + implementation(libs.spring.boot.starter.webmvc) + implementation(libs.spring.boot.starter.json) implementation(libs.spring.boot.starter.actuator) - implementation(libs.spring.boot.starter.aop) implementation(libs.spring.ai.starter.mcp.server.webmvc) + // Spring AI 2.0.0-M7 marked the common autoconfigure module as optional in the + // webmvc starter POM (#6088), so it is no longer pulled transitively even though + // the webmvc autoconfig classes still reference McpServerStdioDisabledCondition + // and other types from it. + implementation(libs.spring.ai.autoconfigure.mcp.server.common) implementation(libs.solr.solrj) implementation(libs.commons.csv) - // JSpecify for nullability annotations - implementation(libs.jspecify) - - implementation(platform("io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom:2.11.0")) - implementation("io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter") - implementation(libs.micrometer.tracing.bridge.otel) - - implementation("io.micrometer:micrometer-registry-prometheus") // Security implementation(libs.mcp.server.security) implementation(libs.spring.boot.starter.security) implementation(libs.spring.boot.starter.oauth2.resource.server) + // Observability: Spring Boot 4 idiomatic OpenTelemetry support + // spring-boot-starter-opentelemetry provides traces, metrics, and log export via OTLP + // spring-boot-starter-aspectj enables @Observed annotation support (replaces starter-aop in SB4) + implementation(libs.spring.boot.starter.opentelemetry) + implementation(libs.spring.boot.starter.aspectj) + implementation(libs.opentelemetry.logback.appender) + runtimeOnly(libs.micrometer.registry.otlp) + // Error Prone and NullAway for null safety analysis errorprone(libs.errorprone.core) errorprone(libs.nullaway) @@ -179,6 +187,28 @@ dependencyManagement { } } +// Force opentelemetry-proto to a version compiled with protobuf 3.x +// This resolves NoSuchMethodError with protobuf 4.x +// See: https://github.com/micrometer-metrics/micrometer/issues/5658 +configurations.all { + resolutionStrategy.eachDependency { + if (requested.group == "io.opentelemetry.proto" && requested.name == "opentelemetry-proto") { + useVersion("1.3.2-alpha") + because("Version 1.8.0-alpha has protobuf 4.x incompatibility causing NoSuchMethodError") + } + // Align the OpenTelemetry incubator API with the stable API version managed by + // the Spring Boot 4.1.0 BOM (opentelemetry-api:1.62.0). The logback-appender + // (opentelemetry-instrumentation 2.21.0-alpha) transitively pins + // opentelemetry-api-incubator to 1.55.0-alpha, which lacks + // DeclarativeConfigProperties.get(String) used by SB4's OpenTelemetrySdk + // autoconfiguration — causing a NoSuchMethodError at context startup. + if (requested.group == "io.opentelemetry" && requested.name == "opentelemetry-api-incubator") { + useVersion("1.62.0-alpha") + because("Must match Spring Boot 4.1.0-managed opentelemetry-api:1.62.0") + } + } +} + // 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") @@ -304,6 +334,20 @@ tasks.named("compileTestJava") { options.errorprone.disable("NullAway") } +// Disable Error Prone / NullAway for AOT-generated sources. The GraalVM native +// plugin registers compileAotJava and compileAotTestJava tasks that compile +// Spring Boot AOT-generated bean definitions. These generated sources contain +// patterns (e.g., args.get(0)) that NullAway flags as nullable, but they are +// correct code produced by the Spring AOT engine and cannot be modified. +tasks.matching { it.name == "compileAotJava" || it.name == "compileAotTestJava" }.configureEach { + if (this is JavaCompile) { + options.errorprone { + disableAllChecks.set(true) + disable("NullAway") + } + } +} + tasks.build { dependsOn(tasks.spotlessApply) } @@ -449,6 +493,8 @@ 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" platforms { platform { @@ -462,7 +508,12 @@ jib { } } to { + // Default image name (can be overridden with -Djib.to.image=...) + // Format: repository/image-name:tag image = "solr-mcp:$version" + + // Tags to apply to the image + // The version tag is applied by default, plus "latest" tag tags = setOf("latest") } container { diff --git a/compose.yaml b/compose.yaml index 75c59201..dd8d577c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -35,27 +35,33 @@ services: environment: ZOO_4LW_COMMANDS_WHITELIST: "mntr,conf,ruok" - # ============================================================================= - # LGTM Stack - Grafana observability backend (Loki, Grafana, Tempo, Mimir) - # ============================================================================= - # This all-in-one container provides: - # - Loki: Log aggregation (LogQL queries) - # - Grafana: Visualization at http://localhost:3000 (no auth required) - # - Tempo: Distributed tracing (TraceQL queries) - # - Mimir: Prometheus-compatible metrics storage - # - OpenTelemetry Collector: Receives OTLP data on ports 4317 (gRPC) and 4318 (HTTP) - # - # Spring Boot auto-configures OTLP endpoints when this container is running. + # ============================================================================= + # OpenTelemetry LGTM Stack (HTTP mode only) + # ============================================================================= + # Provides a complete observability stack for local development: + # - Grafana: Visualization dashboards (http://localhost:3000) + # - Loki: Log aggregation + # - Tempo: Distributed tracing + # - Mimir: Metrics storage (Prometheus-compatible) + # - OpenTelemetry Collector: Receives OTLP data on ports 4317 (gRPC) and 4318 (HTTP) + # + # Usage: + # docker compose up -d lgtm # Start only the observability stack + # docker compose up -d # Start everything including Solr + # + # Access Grafana at http://localhost:3000 (no authentication required) + # Pre-configured datasources for Loki, Tempo, and Mimir are available. lgtm: - image: grafana/otel-lgtm:latest - ports: - - "3000:3000" # Grafana UI - - "4317:4317" # OTLP gRPC receiver - - "4318:4318" # OTLP HTTP receiver - networks: [ search ] - labels: - # Prevent Spring Boot auto-configuration from trying to manage this service - org.springframework.boot.ignore: "true" + image: grafana/otel-lgtm:latest + ports: + - "3000:3000" # Grafana UI + - "4317:4317" # OTLP gRPC receiver + - "4318:4318" # OTLP HTTP receiver + networks: [ search ] + environment: + # Disable authentication for local development + GF_AUTH_ANONYMOUS_ENABLED: "true" + GF_AUTH_ANONYMOUS_ORG_ROLE: "Admin" volumes: data: diff --git a/dev-docs/Observability.md b/dev-docs/Observability.md new file mode 100644 index 00000000..6f778fa3 --- /dev/null +++ b/dev-docs/Observability.md @@ -0,0 +1,314 @@ +# Observability Guide for Solr MCP Server + +This guide covers setting up observability (metrics, traces, and logs) for the Solr MCP Server running in HTTP mode using OpenTelemetry. + +## Table of Contents + +- [Overview](#overview) +- [The LGTM Stack](#the-lgtm-stack) +- [Quick Start](#quick-start) +- [Architecture](#architecture) +- [Accessing Telemetry Data](#accessing-telemetry-data) + - [Grafana Dashboard](#grafana-dashboard) + - [Viewing Traces](#viewing-traces) + - [Viewing Logs](#viewing-logs) + - [Viewing Metrics](#viewing-metrics) +- [Configuration](#configuration) + - [Environment Variables](#environment-variables) + - [Sampling Configuration](#sampling-configuration) + - [Custom OTLP Endpoints](#custom-otlp-endpoints) +- [Production Considerations](#production-considerations) +- [Troubleshooting](#troubleshooting) + +## Overview + +The Solr MCP Server integrates with OpenTelemetry to provide comprehensive observability in HTTP mode: + +| Signal | Description | Backend | +|--------|-------------|---------| +| **Traces** | Distributed tracing for request flows | Tempo | +| **Metrics** | Application and JVM metrics | Mimir (Prometheus-compatible) | +| **Logs** | Structured log export with trace correlation | Loki | + +**Note:** Observability is only available in HTTP mode. STDIO mode disables telemetry to prevent stdout pollution that would interfere with MCP protocol communication. + +## The LGTM Stack + +The project uses the **Grafana LGTM stack** (`grafana/otel-lgtm`) - an all-in-one Docker image that provides a complete observability backend for local development. LGTM stands for: + +| Component | Purpose | Port | +|-----------|---------|------| +| **L**oki | Log aggregation and querying | Internal | +| **G**rafana | Visualization, dashboards, and exploration | 3000 | +| **T**empo | Distributed tracing backend | Internal | +| **M**imir | Prometheus-compatible metrics storage | Internal | + +The image also includes an **OpenTelemetry Collector** that receives telemetry data via OTLP protocol: +- **Port 4317**: OTLP gRPC receiver +- **Port 4318**: OTLP HTTP receiver (used by Spring Boot) + +This single container replaces what would otherwise require deploying and configuring multiple services separately, making it ideal for local development and testing. + +## Quick Start + +Thanks to the `spring-boot-docker-compose` dependency, **Docker containers are automatically started** when you run the application locally. Simply run: + +```bash +# Run the MCP server in HTTP mode - Docker containers start automatically! +PROFILES=http ./gradlew bootRun +``` + +Spring Boot detects the `compose.yaml` file and automatically: +1. Starts the `lgtm` container (Grafana, Loki, Tempo, Mimir) +2. Starts the `solr` and `zoo` containers +3. Configures OTLP endpoints to point to the running containers +4. Waits for containers to be healthy before accepting requests + +Once running, open Grafana at **http://localhost:3000** to explore your telemetry data. + +**Note:** To start containers manually (e.g., for debugging), use: +```bash +docker compose up -d lgtm solr +``` + +## Architecture + +``` +┌─────────────────────┐ OTLP/HTTP ┌─────────────────────────────────┐ +│ Solr MCP Server │─────────────────────│ OpenTelemetry Collector │ +│ (HTTP mode) │ :4318 │ (grafana/otel-lgtm) │ +│ │ │ │ +│ ┌───────────────┐ │ │ ┌─────────┐ ┌─────────────┐ │ +│ │ Traces │──┼─────────────────────┼─▶│ Tempo │ │ Grafana │ │ +│ │ (auto-instr.) │ │ │ └─────────┘ │ :3000 │ │ +│ └───────────────┘ │ │ │ │ │ +│ ┌───────────────┐ │ │ ┌─────────┐ │ - Dashboards│ │ +│ │ Metrics │──┼─────────────────────┼─▶│ Mimir │ │ - Explore │ │ +│ │ (actuator) │ │ │ └─────────┘ │ - Alerts │ │ +│ └───────────────┘ │ │ └─────────────┘ │ +│ ┌───────────────┐ │ │ ┌─────────┐ │ +│ │ Logs │──┼─────────────────────┼─▶│ Loki │ │ +│ │ (logback) │ │ │ └─────────┘ │ +│ └───────────────┘ │ │ │ +└─────────────────────┘ └─────────────────────────────────┘ +``` + +## Accessing Telemetry Data + +### Grafana Dashboard + +Access Grafana at **http://localhost:3000** (no login required in development mode). + +The LGTM stack comes with pre-configured datasources: +- **Tempo** - For distributed traces +- **Loki** - For logs +- **Mimir** - For metrics (Prometheus-compatible) + +### Viewing Traces + +Grafana's **Drilldown** feature provides an integrated view for exploring traces, metrics, and logs all in one place. + +1. Open Grafana: http://localhost:3000 +2. Go to **Drilldown** > **Traces** in the sidebar +3. Select **Tempo** as the datasource +4. Filter traces by: + - Service name: `solr-mcp-server` + - Span name (e.g., `http post /mcp`) + - Duration + - URL path + +The trace view shows the complete request flow with timing breakdown for each span: + +![Distributed Tracing in Grafana](images/grafana-traces.png) + +In this example, you can see: +- The root span `http post /mcp` taking 223.98ms total +- Security filter chain spans for authentication/authorization +- The `SearchService#search` span (177.01ms) created by the `@Observed` annotation on the service method +- Nested security filter spans for the secured request + +**Navigating Between Signals:** + +The Drilldown sidebar provides quick access to related telemetry: +- **Metrics** - View application and JVM metrics (request rates, latencies, memory usage) +- **Logs** - View correlated logs with the same trace ID +- **Traces** - The current distributed trace view +- **Profiles** - CPU and memory profiling data (if configured) + +This unified view makes it easy to investigate issues by correlating traces with their associated logs and metrics. + +**Example TraceQL query:** +``` +{resource.service.name="solr-mcp-server"} +``` + +### Viewing Logs + +1. Open Grafana: http://localhost:3000 +2. Go to **Explore** +3. Select **Loki** as the datasource +4. Query logs using LogQL: + +**Example queries:** +```logql +# All logs from the MCP server +{service_name="solr-mcp-server"} + +# Error logs only +{service_name="solr-mcp-server"} |= "ERROR" + +# Logs with specific trace ID +{service_name="solr-mcp-server"} | json | trace_id="" +``` + +### Viewing Metrics + +1. Open Grafana: http://localhost:3000 +2. Go to **Explore** +3. Select **Mimir** as the datasource +4. Query metrics using PromQL: + +**Example queries:** +```promql +# HTTP request rate +rate(http_server_requests_seconds_count{application="solr-mcp-server"}[5m]) + +# Request latency (p99) +histogram_quantile(0.99, rate(http_server_requests_seconds_bucket{application="solr-mcp-server"}[5m])) + +# JVM memory usage +jvm_memory_used_bytes{application="solr-mcp-server"} + +# Active threads +jvm_threads_live_threads{application="solr-mcp-server"} +``` + +## Configuration + +### Environment Variables + +For production deployments without Docker Compose, set these environment variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `OTEL_SAMPLING_PROBABILITY` | `1.0` | Trace sampling rate (0.0-1.0) | +| `OTEL_METRICS_URL` | (auto-configured) | OTLP metrics endpoint | +| `OTEL_TRACES_URL` | (auto-configured) | OTLP traces endpoint | +| `OTEL_LOGS_URL` | (auto-configured) | OTLP logs endpoint | + +Example production configuration: +```bash +export OTEL_SAMPLING_PROBABILITY=0.1 +export OTEL_METRICS_URL=https://otel-collector.prod.example.com/v1/metrics +export OTEL_TRACES_URL=https://otel-collector.prod.example.com/v1/traces +export OTEL_LOGS_URL=https://otel-collector.prod.example.com/v1/logs +``` + +### Sampling Configuration + +For production, reduce sampling to manage costs and storage: + +```bash +# Sample 10% of traces +export OTEL_SAMPLING_PROBABILITY=0.1 +``` + +Or in `application-http.properties`: +```properties +management.tracing.sampling.probability=0.1 +``` + +### Custom OTLP Endpoints + +To send telemetry to a different backend (e.g., Jaeger, Datadog, New Relic): + +```bash +# Example: Send traces to Jaeger +export OTEL_TRACES_URL=http://jaeger:4318/v1/traces + +# Example: Send metrics to Prometheus remote write endpoint +export OTEL_METRICS_URL=http://prometheus:9090/api/v1/otlp/v1/metrics +``` + +## Production Considerations + +### 1. Use Secure Endpoints + +```properties +# Use HTTPS for production OTLP endpoints +management.otlp.metrics.export.url=https://otel-collector.prod.example.com/v1/metrics +management.opentelemetry.tracing.export.otlp.endpoint=https://otel-collector.prod.example.com/v1/traces +management.opentelemetry.logging.export.otlp.endpoint=https://otel-collector.prod.example.com/v1/logs +``` + +### 2. Add Authentication Headers + +If your OTLP collector requires authentication, configure headers in your OpenTelemetry configuration. + +### 3. Resource Attributes + +Add deployment-specific attributes for better filtering: + +```properties +spring.application.name=solr-mcp-server-prod +``` + +## Troubleshooting + +### No Data in Grafana + +1. **Check the LGTM container is running:** + ```bash + docker compose ps lgtm + ``` + +2. **Verify OTLP endpoints are reachable:** + ```bash + curl -v http://localhost:4318/v1/traces + ``` + +3. **Check application logs for OTLP errors:** + ```bash + ./gradlew bootRun 2>&1 | grep -i otel + ``` + +### Traces Not Appearing + +1. Ensure you're running in HTTP mode (`PROFILES=http`) +2. Check sampling probability is > 0 +3. Verify the trace endpoint URL is correct + +### Logs Not Appearing + +1. Check that logback-spring.xml is being loaded +2. Verify the OTEL appender is installed (check startup logs) +3. Ensure log level is INFO or lower + +### Metrics Not Appearing + +1. Verify actuator endpoints are exposed: + ```bash + curl http://localhost:8080/actuator/metrics + ``` +2. Check the metrics endpoint URL is correct + +### High Memory Usage + +If the LGTM container uses too much memory: +```yaml +# compose.yaml +lgtm: + image: grafana/otel-lgtm:latest + deploy: + resources: + limits: + memory: 2G +``` + +## References + +- [Spring Boot OpenTelemetry](https://docs.spring.io/spring-boot/reference/actuator/tracing.html) +- [OpenTelemetry Documentation](https://opentelemetry.io/docs/) +- [Grafana LGTM Stack](https://grafana.com/blog/2024/03/13/an-opentelemetry-backend-in-a-docker-image-introducing-grafana/otel-lgtm/) +- [LogQL Query Language](https://grafana.com/docs/loki/latest/logql/) +- [TraceQL Query Language](https://grafana.com/docs/tempo/latest/traceql/) diff --git a/dev-docs/graalvm-native-image.md b/dev-docs/graalvm-native-image.md index 5051f84f..7097d7e9 100644 --- a/dev-docs/graalvm-native-image.md +++ b/dev-docs/graalvm-native-image.md @@ -184,12 +184,14 @@ container and value types. ## OpenTelemetry build-time initialization -The OTel instrumentation BOM is pinned at **2.11.0**, which ships **no** -native-image reachability metadata. The OTel logback appender's -`LoggingEventMapper` holds static `AttributeKey` fields (via -`InternalAttributeKeyImpl`) that land in the image heap, and GraalVM requires -their types to be initialized at build time. Hence the four -`--initialize-at-build-time` entries in `nativeImageBuildArgs`: +Spring Boot 4 provides idiomatic OpenTelemetry via +`spring-boot-starter-opentelemetry` (traces, metrics, and OTLP log export); the +OTel logback appender (`opentelemetry-logback-appender-1.0`, `2.21.0-alpha`) is +declared separately in the version catalog. The appender ships **no** +native-image reachability metadata, and its `LoggingEventMapper` holds static +`AttributeKey` fields (via `InternalAttributeKeyImpl`) that land in the image +heap — GraalVM requires their types to be initialized at build time. Hence the +four `--initialize-at-build-time` entries in `nativeImageBuildArgs`: - `io.opentelemetry.api` — `InternalAttributeKeyImpl`, `AttributeType` - `io.opentelemetry.context` — context propagation @@ -200,13 +202,17 @@ their types to be initialized at build time. Hence the four proxy classes that cannot be build-time initialized; including it breaks the build. -**Why not just bump OTel?** The version catalog declares `2.26.1`, which *does* -ship native metadata, but bumping fails at AOT time: 2.26.1 expects -`io.opentelemetry.common.ComponentLoader`, absent from the OTel SDK version -managed by Spring Boot 3.5.x. The bump is deferred until Spring Boot's managed -OTel SDK and the instrumentation BOM line up. The OTLP exporter is only wired in -the `http` profile, so the `stdio` native image never exercises its reflection -surface anyway. +**OTel dependency alignment.** Spring Boot 4.1.0 manages the OpenTelemetry SDK +(`opentelemetry-api:1.62.0`) through the starter, but the logback appender +(`opentelemetry-instrumentation 2.21.0-alpha`) transitively pins +`opentelemetry-api-incubator` to `1.55.0-alpha`, which lacks +`DeclarativeConfigProperties.get(String)` used by SB4's `OpenTelemetrySdk` +autoconfiguration — a `NoSuchMethodError` at context startup. A +`resolutionStrategy` in `build.gradle.kts` forces `opentelemetry-api-incubator` +to `1.62.0-alpha` to match, and pins `opentelemetry-proto` to `1.3.2-alpha` +(the `1.8.0-alpha` line is incompatible with protobuf 3.x). The OTLP exporter is +only wired in the `http` profile, so the `stdio` native image never exercises +its reflection surface anyway. The **native test binary** needs a few extra entries beyond the shared args (see the `named("test")` block): `io.opentelemetry.sdk` (a build-time @@ -289,9 +295,10 @@ JVM-only and fast. ## Known limitations and follow-ups - **NOT CURRENTLY SHIPPING.** Right now we don't as a project yet use the native code (or any code) to ship Docker based image. -- **OTel BOM bump blocked.** Stuck on 2.11.0 (no native metadata, worked around - with build-time init) until Spring Boot's managed OTel SDK aligns with the - 2.26.x instrumentation BOM. Revisit on Spring Boot upgrades. +- **OTel appender lacks native metadata.** The `opentelemetry-logback-appender-1.0` + still ships no native-image reachability metadata, so the build-time-init + workaround above remains necessary. Revisit if a future OTel instrumentation + release ships native metadata. - **Native compile is resource-hungry.** Expect ~4–8 GB RAM per compile; ensure CI runners and dev boxes have headroom. - **Paketo builder download.** First `bootBuildImage` run pulls a ~1 GB builder; diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 473ff9ff..d4c55646 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,7 @@ # [versions] # Build plugins -spring-boot = "3.5.14" +spring-boot = "4.1.0" spring-dependency-management = "1.1.7" errorprone-plugin = "5.1.0" jib = "3.5.3" @@ -25,35 +25,41 @@ graalvm-native = "0.10.6" cyclonedx-plugin = "2.4.1" # Main dependencies -spring-ai = "1.1.7" +spring-ai = "2.0.0" solr = "10.0.0" commons-csv = "1.14.1" -jspecify = "1.0.0" mcp-server-security = "0.0.6" +# OpenTelemetry +opentelemetry-logback-appender = "2.21.0-alpha" + # Error Prone and analysis tools errorprone-core = "2.48.0" nullaway = "0.13.1" - # Test dependencies -testcontainers = "1.21.3" +testcontainers = "2.0.2" awaitility = "4.3.0" opentelemetry-instrumentation-bom = "2.26.1" [libraries] # Spring -spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web" } +spring-boot-starter-webmvc = { module = "org.springframework.boot:spring-boot-starter-webmvc" } +spring-boot-starter-json = { module = "org.springframework.boot:spring-boot-starter-json" } spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator" } -spring-boot-starter-aop = { module = "org.springframework.boot:spring-boot-starter-aop" } spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security" } spring-boot-starter-oauth2-resource-server = { module = "org.springframework.boot:spring-boot-starter-oauth2-resource-server" } spring-boot-docker-compose = { module = "org.springframework.boot:spring-boot-docker-compose" } spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test" } +spring-boot-starter-actuator-test = { module = "org.springframework.boot:spring-boot-starter-actuator-test" } +spring-boot-starter-opentelemetry-test = { module = "org.springframework.boot:spring-boot-starter-opentelemetry-test" } +spring-boot-starter-webmvc-test = { module = "org.springframework.boot:spring-boot-starter-webmvc-test" } spring-boot-testcontainers = { module = "org.springframework.boot:spring-boot-testcontainers" } # Spring AI spring-ai-starter-mcp-server-webmvc = { module = "org.springframework.ai:spring-ai-starter-mcp-server-webmvc" } +spring-ai-autoconfigure-mcp-server-common = { module = "org.springframework.ai:spring-ai-autoconfigure-mcp-server-common" } spring-ai-starter-mcp-client = { module = "org.springframework.ai:spring-ai-starter-mcp-client" } +spring-ai-autoconfigure-mcp-client-common = { module = "org.springframework.ai:spring-ai-autoconfigure-mcp-client-common" } spring-ai-spring-boot-docker-compose = { module = "org.springframework.ai:spring-ai-spring-boot-docker-compose" } spring-ai-spring-boot-testcontainers = { module = "org.springframework.ai:spring-ai-spring-boot-testcontainers" } @@ -66,8 +72,13 @@ solr-solrj = { module = "org.apache.solr:solr-solrj", version.ref = "solr" } # Apache Commons commons-csv = { module = "org.apache.commons:commons-csv", version.ref = "commons-csv" } -# Null safety -jspecify = { module = "org.jspecify:jspecify", version.ref = "jspecify" } +# OpenTelemetry (HTTP mode only) +spring-boot-starter-opentelemetry = { module = "org.springframework.boot:spring-boot-starter-opentelemetry" } +opentelemetry-logback-appender = { module = "io.opentelemetry.instrumentation:opentelemetry-logback-appender-1.0", version.ref = "opentelemetry-logback-appender" } +micrometer-registry-otlp = { module = "io.micrometer:micrometer-registry-otlp" } + +# AspectJ (required for @Observed annotation support) +spring-boot-starter-aspectj = { module = "org.springframework.boot:spring-boot-starter-aspectj" } # Error Prone errorprone-core = { module = "com.google.errorprone:error_prone_core", version.ref = "errorprone-core" } @@ -77,9 +88,9 @@ nullaway = { module = "com.uber.nullaway:nullaway", version.ref = "nullaway" } micrometer-tracing-bridge-otel = { module = "io.micrometer:micrometer-tracing-bridge-otel" } # Test dependencies -testcontainers-junit-jupiter = { module = "org.testcontainers:junit-jupiter" } -testcontainers-solr = { module = "org.testcontainers:solr", version.ref = "testcontainers" } -testcontainers-grafana = { module = "org.testcontainers:grafana", version.ref = "testcontainers" } +testcontainers-junit-jupiter = { module = "org.testcontainers:testcontainers-junit-jupiter", version.ref = "testcontainers" } +testcontainers-solr = { module = "org.testcontainers:testcontainers-solr", version.ref = "testcontainers" } +testcontainers-grafana = { module = "org.testcontainers:testcontainers-grafana", version.ref = "testcontainers" } junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } awaitility = { module = "org.awaitility:awaitility", version.ref = "awaitility" } opentelemetry-sdk-testing = { module = "io.opentelemetry:opentelemetry-sdk-testing" } @@ -91,7 +102,7 @@ jetty-util = { module = "org.eclipse.jetty:jetty-util" } [bundles] spring-ai-mcp = [ - "spring-boot-starter-web", + "spring-boot-starter-webmvc", "spring-ai-starter-mcp-server-webmvc" ] @@ -102,12 +113,16 @@ spring-boot-dev = [ test = [ "spring-boot-starter-test", + "spring-boot-starter-actuator-test", + "spring-boot-starter-opentelemetry-test", + "spring-boot-starter-webmvc-test", "spring-boot-testcontainers", "spring-ai-spring-boot-testcontainers", "testcontainers-junit-jupiter", "testcontainers-solr", "testcontainers-grafana", "spring-ai-starter-mcp-client", + "spring-ai-autoconfigure-mcp-client-common", "awaitility", "opentelemetry-sdk-testing", "micrometer-tracing-test", diff --git a/src/main/java/org/apache/solr/mcp/server/collection/CollectionService.java b/src/main/java/org/apache/solr/mcp/server/collection/CollectionService.java index 011d278e..e20e643f 100644 --- a/src/main/java/org/apache/solr/mcp/server/collection/CollectionService.java +++ b/src/main/java/org/apache/solr/mcp/server/collection/CollectionService.java @@ -21,7 +21,6 @@ import static org.apache.solr.mcp.server.collection.CollectionUtils.getLong; import static org.apache.solr.mcp.server.util.JsonUtils.toJson; -import com.fasterxml.jackson.databind.ObjectMapper; import io.micrometer.observation.annotation.Observed; import io.modelcontextprotocol.spec.McpSchema.CompleteRequest; import java.io.IOException; @@ -45,14 +44,15 @@ import org.apache.solr.mcp.server.config.SolrConfigurationProperties; import org.apache.solr.mcp.server.util.PromptNames; import org.jspecify.annotations.Nullable; -import org.springaicommunity.mcp.annotation.McpArg; -import org.springaicommunity.mcp.annotation.McpComplete; -import org.springaicommunity.mcp.annotation.McpPrompt; -import org.springaicommunity.mcp.annotation.McpResource; -import org.springaicommunity.mcp.annotation.McpTool; -import org.springaicommunity.mcp.annotation.McpToolParam; +import org.springframework.ai.mcp.annotation.McpArg; +import org.springframework.ai.mcp.annotation.McpComplete; +import org.springframework.ai.mcp.annotation.McpPrompt; +import org.springframework.ai.mcp.annotation.McpResource; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.McpToolParam; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; +import tools.jackson.databind.ObjectMapper; /** * Spring Service providing comprehensive Solr collection management and diff --git a/src/main/java/org/apache/solr/mcp/server/config/InstallOpenTelemetryAppender.java b/src/main/java/org/apache/solr/mcp/server/config/InstallOpenTelemetryAppender.java new file mode 100644 index 00000000..981524c1 --- /dev/null +++ b/src/main/java/org/apache/solr/mcp/server/config/InstallOpenTelemetryAppender.java @@ -0,0 +1,56 @@ +/* + * 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.config; + +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.instrumentation.logback.appender.v1_0.OpenTelemetryAppender; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +/** + * Installs the application's {@link OpenTelemetry} instance into the Logback + * {@link OpenTelemetryAppender} so that log records are exported over OTLP. + * + *

+ * Spring Boot 4 does not bundle the OpenTelemetry Logback appender, and the + * appender needs programmatic access to an {@link OpenTelemetry} instance at + * runtime — it cannot obtain one from the Spring context on its own. This + * {@link InitializingBean} performs that one-time wiring once the + * {@link OpenTelemetry} bean is available. + * + *

+ * Active only in the {@code http} profile, where observability export is + * enabled; the {@code stdio} transport keeps stdout clean for the MCP JSON-RPC + * stream and does not export logs. Log records emitted before this bean + * initializes are not exported via OTLP. + */ +@Component +@Profile("http") +class InstallOpenTelemetryAppender implements InitializingBean { + + private final OpenTelemetry openTelemetry; + + InstallOpenTelemetryAppender(OpenTelemetry openTelemetry) { + this.openTelemetry = openTelemetry; + } + + @Override + public void afterPropertiesSet() { + OpenTelemetryAppender.install(this.openTelemetry); + } +} diff --git a/src/main/java/org/apache/solr/mcp/server/config/JsonResponseParser.java b/src/main/java/org/apache/solr/mcp/server/config/JsonResponseParser.java index 545d5e87..9d3f8f07 100644 --- a/src/main/java/org/apache/solr/mcp/server/config/JsonResponseParser.java +++ b/src/main/java/org/apache/solr/mcp/server/config/JsonResponseParser.java @@ -16,9 +16,6 @@ */ package org.apache.solr.mcp.server.config; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.Collection; @@ -31,6 +28,9 @@ import org.apache.solr.common.util.SimpleOrderedMap; import org.jspecify.annotations.Nullable; import org.springframework.http.MediaType; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; /** * SolrJ {@link ResponseParser} that requests JSON wire format ({@code wt=json}) @@ -92,14 +92,14 @@ public Collection getContentTypes() { public NamedList processResponse(InputStream body, String encoding) { try { return toNamedList(mapper.readTree(body)); - } catch (IOException e) { + } catch (JacksonException e) { throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Failed to parse Solr JSON response", e); } } private SimpleOrderedMap toNamedList(JsonNode objectNode) { SimpleOrderedMap result = new SimpleOrderedMap<>(); - objectNode.fields().forEachRemaining(entry -> result.add(entry.getKey(), convertValue(entry.getValue()))); + objectNode.properties().forEach(entry -> result.add(entry.getKey(), convertValue(entry.getValue()))); return result; } @@ -191,7 +191,7 @@ private SolrDocumentList toSolrDocumentList(JsonNode node) { private SolrDocument toSolrDocument(JsonNode node) { SolrDocument doc = new SolrDocument(); - node.fields().forEachRemaining(entry -> { + node.properties().forEach(entry -> { JsonNode val = entry.getValue(); if (val.isArray()) { // Multi-valued field — always a plain list, never a flat NamedList 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 dceb10c4..b684cc05 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,7 +16,6 @@ */ package org.apache.solr.mcp.server.config; -import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.util.concurrent.TimeUnit; import org.apache.solr.client.solrj.SolrClient; @@ -26,6 +25,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.util.StringUtils; +import tools.jackson.databind.ObjectMapper; /** * Spring Configuration class for Apache Solr client setup and connection diff --git a/src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java b/src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java index 2d1cedf8..ea1d8eb8 100644 --- a/src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java +++ b/src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java @@ -42,7 +42,7 @@ * {@code HttpJdkSolrClient}), avoiding the JavaBin/XML codec paths that * historically drive most SolrJ native-image issues. The hints below cover the * narrow remaining surface: response containers and the {@code NamedList} admin - * shape returned by the mbeans path. + * shape returned by the Metrics API path. * *

* This class is registered unconditionally — on the JVM path it is a no-op @@ -95,6 +95,22 @@ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) hints.reflection().registerType(FacetField.class, categories); hints.reflection().registerType(FacetField.Count.class, categories); + // SolrJ / Solr API model types used by CoreAdminResponse.getCoreStatus() + // which deserializes via Jackson reflection. + for (String solrApiType : List.of("org.apache.solr.client.api.model.SolrJerseyResponse", + "org.apache.solr.client.api.model.SolrJerseyResponse$ResponseHeader", + "org.apache.solr.client.api.model.ErrorInfo", "org.apache.solr.client.api.model.CoreStatusResponse", + "org.apache.solr.client.api.model.CoreStatusResponse$SingleCoreData", + "org.apache.solr.client.api.model.CoreStatusResponse$CloudDetails", + "org.apache.solr.client.api.model.CoreStatusResponse$IndexDetails")) { + hints.reflection().registerTypeIfPresent(classLoader, solrApiType, categories); + } + + // Spring AI MCP annotation internals — MetaUtils reflectively + // instantiates DefaultMetaProvider via its no-arg constructor. + hints.reflection().registerTypeIfPresent(classLoader, + "org.springframework.ai.mcp.annotation.context.DefaultMetaProvider", categories); + // SolrJ schema request types (needed for Jackson's convertValue in native // image when add-field-types deserializes analyzer trees) hints.reflection().registerType(org.apache.solr.client.solrj.request.schema.AnalyzerDefinition.class, @@ -115,9 +131,17 @@ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) hints.reflection().registerTypeIfPresent(classLoader, className, categories); } - // Spring AI MCP reflectively instantiates DefaultMetaProvider via its - // no-arg constructor in MetaUtils.getMeta() when building resource - // specifications. AOT does not generate this hint automatically. + // SolrJ EnvUtils loads these properties files in its static + // initializer; without them getResourceAsStream returns null + // and the throws NullPointerException. + hints.resources().registerPattern("EnvToSyspropMappings.properties"); + hints.resources().registerPattern("DeprecatedSystemPropertyMappings.properties"); + + // Older springaicommunity location of DefaultMetaProvider (Spring AI 1.x + // transitive). Spring AI 2.x relocated this to + // org.springframework.ai.mcp.annotation.context.DefaultMetaProvider, + // which is registered above. Kept defensively via registerTypeIfPresent + // so this is a no-op when the older class isn't on the classpath. hints.reflection().registerTypeIfPresent(classLoader, "org.springaicommunity.mcp.context.DefaultMetaProvider", MemberCategory.INVOKE_DECLARED_CONSTRUCTORS); 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 34674852..147449b9 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 @@ -29,10 +29,10 @@ import org.apache.solr.mcp.server.indexing.documentcreator.IndexingDocumentCreator; import org.apache.solr.mcp.server.util.PromptNames; import org.apache.solr.mcp.server.util.PromptText; -import org.springaicommunity.mcp.annotation.McpArg; -import org.springaicommunity.mcp.annotation.McpPrompt; -import org.springaicommunity.mcp.annotation.McpTool; -import org.springaicommunity.mcp.annotation.McpToolParam; +import org.springframework.ai.mcp.annotation.McpArg; +import org.springframework.ai.mcp.annotation.McpPrompt; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.McpToolParam; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; import org.xml.sax.SAXException; 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 605e5204..f6279903 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 @@ -16,9 +16,6 @@ */ package org.apache.solr.mcp.server.indexing.documentcreator; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; @@ -26,6 +23,9 @@ import java.util.Set; import org.apache.solr.common.SolrInputDocument; import org.springframework.stereotype.Component; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; /** * Utility class for processing JSON documents and converting them to @@ -146,7 +146,7 @@ public List create(String json) throws DocumentProcessingExce } else { throw new DocumentProcessingException("JSON input must be an object or an array of objects"); } - } catch (IOException e) { + } catch (JacksonException e) { throw new DocumentProcessingException("Failed to parse JSON document", e); } @@ -275,6 +275,6 @@ private Object convertJsonValue(JsonNode value) { return value.asDouble(); if (value.isInt()) return value.asInt(); - return value.asText(); + return value.asString(); } } diff --git a/src/main/java/org/apache/solr/mcp/server/schema/SchemaService.java b/src/main/java/org/apache/solr/mcp/server/schema/SchemaService.java index 3f3bb96a..c75c64f3 100644 --- a/src/main/java/org/apache/solr/mcp/server/schema/SchemaService.java +++ b/src/main/java/org/apache/solr/mcp/server/schema/SchemaService.java @@ -19,7 +19,6 @@ import static org.apache.solr.mcp.server.util.JsonUtils.toJson; import static org.apache.solr.mcp.server.util.PromptText.optionalCodeBlock; -import com.fasterxml.jackson.databind.ObjectMapper; import io.micrometer.observation.annotation.Observed; import java.io.IOException; import java.util.ArrayList; @@ -33,13 +32,14 @@ import org.apache.solr.client.solrj.request.schema.SchemaRequest; import org.apache.solr.client.solrj.response.schema.SchemaRepresentation; import org.apache.solr.mcp.server.util.PromptNames; -import org.springaicommunity.mcp.annotation.McpArg; -import org.springaicommunity.mcp.annotation.McpPrompt; -import org.springaicommunity.mcp.annotation.McpResource; -import org.springaicommunity.mcp.annotation.McpTool; -import org.springaicommunity.mcp.annotation.McpToolParam; +import org.springframework.ai.mcp.annotation.McpArg; +import org.springframework.ai.mcp.annotation.McpPrompt; +import org.springframework.ai.mcp.annotation.McpResource; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.McpToolParam; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; +import tools.jackson.databind.ObjectMapper; /** * Spring Service providing schema introspection and management capabilities for 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 cff51681..c63ad8c6 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 @@ -34,10 +34,10 @@ import org.apache.solr.common.params.FacetParams; import org.apache.solr.mcp.server.util.PromptNames; import org.jspecify.annotations.Nullable; -import org.springaicommunity.mcp.annotation.McpArg; -import org.springaicommunity.mcp.annotation.McpPrompt; -import org.springaicommunity.mcp.annotation.McpTool; -import org.springaicommunity.mcp.annotation.McpToolParam; +import org.springframework.ai.mcp.annotation.McpArg; +import org.springframework.ai.mcp.annotation.McpPrompt; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.McpToolParam; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; import org.springframework.util.CollectionUtils; diff --git a/src/main/java/org/apache/solr/mcp/server/util/JsonUtils.java b/src/main/java/org/apache/solr/mcp/server/util/JsonUtils.java index 6ecc3bc1..fe67de74 100644 --- a/src/main/java/org/apache/solr/mcp/server/util/JsonUtils.java +++ b/src/main/java/org/apache/solr/mcp/server/util/JsonUtils.java @@ -16,8 +16,8 @@ */ package org.apache.solr.mcp.server.util; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; /** * Utility class for JSON serialization operations. @@ -51,7 +51,7 @@ private JsonUtils() { public static String toJson(ObjectMapper objectMapper, Object obj) { try { return objectMapper.writeValueAsString(obj); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { return "{\"error\": \"Failed to serialize response\"}"; } } diff --git a/src/main/resources/application-http.properties b/src/main/resources/application-http.properties index 77578a3c..0af0d401 100644 --- a/src/main/resources/application-http.properties +++ b/src/main/resources/application-http.properties @@ -61,5 +61,7 @@ management.observations.annotations.enabled=true # Tracing Configuration # Set to 1.0 for 100% sampling in development, lower in production (e.g., 0.1) management.tracing.sampling.probability=${OTEL_SAMPLING_PROBABILITY:1.0} -otel.exporter.otlp.endpoint=${OTEL_TRACES_URL:http://localhost:4317} -otel.exporter.otlp.protocol=grpc +management.otlp.metrics.export.url=${OTEL_METRICS_URL:http://localhost:4318/v1/metrics} +management.opentelemetry.tracing.export.otlp.endpoint=${OTEL_TRACES_URL:http://localhost:4318/v1/traces} +management.opentelemetry.logging.export.otlp.endpoint=${OTEL_LOGS_URL:http://localhost:4318/v1/logs} +spring.application.name=solr-mcp-server diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index c2038f5c..02abf341 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -21,5 +21,5 @@ spring.ai.mcp.server.name=${spring.application.name} spring.ai.mcp.server.version=1.0.0 # Solr configuration solr.url=${SOLR_URL:http://localhost:8983/solr/} -# Enable virtual threads for improved concurrency +# Virtual threads spring.threads.virtual.enabled=true 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 ae6a231b..1b08eaed 100644 --- a/src/test/java/org/apache/solr/mcp/server/ClientStdio.java +++ b/src/test/java/org/apache/solr/mcp/server/ClientStdio.java @@ -16,10 +16,10 @@ */ package org.apache.solr.mcp.server; -import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.client.transport.ServerParameters; import io.modelcontextprotocol.client.transport.StdioClientTransport; -import io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapper; +import io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapper; +import tools.jackson.databind.json.JsonMapper; // run after project has been built with "./gradlew build -x test and the mcp server jar is // connected to a running solr" @@ -31,7 +31,7 @@ static void main() { var stdioParams = ServerParameters.builder("java").args("-jar", jarName).build(); - var transport = new StdioClientTransport(stdioParams, new JacksonMcpJsonMapper(new ObjectMapper())); + var transport = new StdioClientTransport(stdioParams, new JacksonMcpJsonMapper(JsonMapper.builder().build())); new SampleClient(transport).run(); } diff --git a/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java b/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java index d3540822..0a66c6e9 100644 --- a/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java +++ b/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java @@ -18,8 +18,6 @@ import static org.junit.jupiter.api.Assertions.*; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.client.McpSyncClient; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import io.modelcontextprotocol.spec.McpSchema.CallToolResult; @@ -48,6 +46,9 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.TestMethodOrder; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; /** * Base class for MCP client integration tests. Exercises the full @@ -70,7 +71,7 @@ public abstract class McpClientIntegrationTestBase { */ protected static final int SHOWS_DOC_COUNT = 61; - protected static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + protected static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder().build(); protected McpSyncClient mcpClient; diff --git a/src/test/java/org/apache/solr/mcp/server/McpClientStdioIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/McpClientStdioIntegrationTest.java index c6120a72..4a0586bf 100644 --- a/src/test/java/org/apache/solr/mcp/server/McpClientStdioIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/McpClientStdioIntegrationTest.java @@ -16,17 +16,17 @@ */ package org.apache.solr.mcp.server; -import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.client.McpClient; import io.modelcontextprotocol.client.McpSyncClient; import io.modelcontextprotocol.client.transport.ServerParameters; import io.modelcontextprotocol.client.transport.StdioClientTransport; -import io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapper; +import io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapper; import org.junit.jupiter.api.Tag; import org.testcontainers.containers.SolrContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; import org.testcontainers.utility.DockerImageName; +import tools.jackson.databind.json.JsonMapper; /** * MCP client integration test running against the server in STDIO mode. Spawns @@ -49,7 +49,7 @@ protected McpSyncClient createClient() { var params = ServerParameters.builder("java").args("-jar", jarPath).addEnvVar("SOLR_URL", solrUrl) .addEnvVar("SPRING_DOCKER_COMPOSE_ENABLED", "false").build(); - var transport = new StdioClientTransport(params, new JacksonMcpJsonMapper(new ObjectMapper())); + var transport = new StdioClientTransport(params, new JacksonMcpJsonMapper(JsonMapper.builder().build())); return McpClient.sync(transport).build(); } 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 3813675f..56ea2661 100644 --- a/src/test/java/org/apache/solr/mcp/server/McpToolRegistrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/McpToolRegistrationTest.java @@ -30,11 +30,11 @@ import org.apache.solr.mcp.server.search.SearchService; import org.apache.solr.mcp.server.util.PromptNames; import org.junit.jupiter.api.Test; -import org.springaicommunity.mcp.annotation.McpComplete; -import org.springaicommunity.mcp.annotation.McpPrompt; -import org.springaicommunity.mcp.annotation.McpResource; -import org.springaicommunity.mcp.annotation.McpTool; -import org.springaicommunity.mcp.annotation.McpToolParam; +import org.springframework.ai.mcp.annotation.McpComplete; +import org.springframework.ai.mcp.annotation.McpPrompt; +import org.springframework.ai.mcp.annotation.McpResource; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.McpToolParam; import org.springframework.security.access.prepost.PreAuthorize; /** diff --git a/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceIntegrationTest.java index 6560af9d..98d2b075 100644 --- a/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceIntegrationTest.java @@ -22,7 +22,6 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -import com.fasterxml.jackson.databind.ObjectMapper; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -41,6 +40,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; import org.testcontainers.junit.jupiter.Testcontainers; +import tools.jackson.databind.json.JsonMapper; @SpringBootTest @Import(TestcontainersConfiguration.class) @@ -65,7 +65,7 @@ class CollectionServiceIntegrationTest { private SearchService searchService; @Autowired - private ObjectMapper objectMapper; + private JsonMapper jsonMapper; @BeforeAll void setupCollectionWithData() throws Exception { @@ -84,7 +84,7 @@ void setupCollectionWithData() throws Exception { doc.put("count_i", i); docs.add(doc); } - String json = objectMapper.writeValueAsString(docs); + String json = jsonMapper.writeValueAsString(docs); indexingService.indexJsonDocuments(TEST_COLLECTION, json); log.debug("Indexed {} documents via IndexingService", DOC_COUNT); diff --git a/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceTest.java b/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceTest.java index d0772ec9..ffdcbac1 100644 --- a/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceTest.java +++ b/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceTest.java @@ -21,7 +21,6 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; -import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.spec.McpSchema.CompleteRequest; import java.io.IOException; import java.lang.reflect.Method; @@ -43,6 +42,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import tools.jackson.databind.json.JsonMapper; @ExtendWith(MockitoExtension.class) @DisabledInNativeImage @@ -62,7 +62,7 @@ class CollectionServiceTest { private CollectionService collectionService; - private final ObjectMapper objectMapper = new ObjectMapper(); + private final JsonMapper objectMapper = JsonMapper.shared(); @BeforeEach void setUp() { @@ -939,13 +939,12 @@ void completeCollection_WithEmptyPrefix_ReturnsAllSorted() throws Exception { } @Test - void completeCollection_WithNullValue_ReturnsAllSorted() throws Exception { - CollectionService spyService = spy(collectionService); - doReturn(Arrays.asList("zeta", "alpha")).when(spyService).listCollections(); - - List result = spyService.completeCollection(new CompleteRequest.CompleteArgument("collection", null)); - - assertEquals(List.of("alpha", "zeta"), result); + void completeCollection_NullValueRejectedAtSdkBoundary() { + // The MCP SDK (io.modelcontextprotocol >= 0.16) validates that + // CompleteArgument.value is non-null at construction time, so the + // CollectionService#completeCollection null-value branch is unreachable + // from a real MCP client. Document the SDK contract here. + assertThrows(IllegalArgumentException.class, () -> new CompleteRequest.CompleteArgument("collection", null)); } @Test diff --git a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigAuthTest.java b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigAuthTest.java index 71104552..7ee64c99 100644 --- a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigAuthTest.java +++ b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigAuthTest.java @@ -21,7 +21,6 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; -import com.fasterxml.jackson.databind.ObjectMapper; import java.lang.reflect.Field; import java.nio.charset.StandardCharsets; import java.util.Base64; @@ -34,6 +33,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.json.JsonTest; import org.springframework.util.ReflectionUtils; +import tools.jackson.databind.ObjectMapper; /** * Unit tests for the optional HTTP Basic Authentication wiring performed by diff --git a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigUrlNormalizationTest.java b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigUrlNormalizationTest.java index 4bcd222b..ae9ee034 100644 --- a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigUrlNormalizationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigUrlNormalizationTest.java @@ -18,19 +18,19 @@ import static org.junit.jupiter.api.Assertions.*; -import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.impl.HttpJdkSolrClient; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.json.JsonTest; +import tools.jackson.databind.json.JsonMapper; @JsonTest class SolrConfigUrlNormalizationTest { @Autowired - private ObjectMapper objectMapper; + private JsonMapper jsonMapper; @ParameterizedTest @CsvSource({"http://localhost:8983, http://localhost:8983/solr", @@ -42,7 +42,7 @@ void testUrlNormalization(String inputUrl, String expectedUrl) throws Exception SolrConfigurationProperties testProperties = new SolrConfigurationProperties(inputUrl, null, null); SolrConfig solrConfig = new SolrConfig(); - try (SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser(objectMapper))) { + try (SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser(jsonMapper))) { assertNotNull(client); var httpClient = assertInstanceOf(HttpJdkSolrClient.class, client); diff --git a/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageMcpClientStdioIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageMcpClientStdioIntegrationTest.java index 6e4a009d..574037cf 100644 --- a/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageMcpClientStdioIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageMcpClientStdioIntegrationTest.java @@ -16,12 +16,11 @@ */ package org.apache.solr.mcp.server.containerization; -import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.client.McpClient; import io.modelcontextprotocol.client.McpSyncClient; import io.modelcontextprotocol.client.transport.ServerParameters; import io.modelcontextprotocol.client.transport.StdioClientTransport; -import io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapper; +import io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapper; import org.apache.solr.mcp.server.BuildInfoReader; import org.apache.solr.mcp.server.McpClientIntegrationTestBase; import org.junit.jupiter.api.Tag; @@ -29,6 +28,7 @@ import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; import org.testcontainers.utility.DockerImageName; +import tools.jackson.databind.json.JsonMapper; /** * End-to-end MCP STDIO test against the Paketo Docker image built by @@ -67,7 +67,7 @@ protected McpSyncClient createClient() { "-e", "SPRING_DOCKER_COMPOSE_ENABLED=false", DOCKER_IMAGE) .build(); - var transport = new StdioClientTransport(params, new JacksonMcpJsonMapper(new ObjectMapper())); + var transport = new StdioClientTransport(params, new JacksonMcpJsonMapper(JsonMapper.builder().build())); return McpClient.sync(transport).build(); } diff --git a/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceIntegrationTest.java index e4f75169..050df48b 100644 --- a/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceIntegrationTest.java @@ -16,7 +16,9 @@ */ package org.apache.solr.mcp.server.indexing; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; import java.util.Map; @@ -49,9 +51,8 @@ @Testcontainers(disabledWithoutDocker = true) class IndexingServiceIntegrationTest { - private static boolean initialized = false; - private static final String COLLECTION_NAME = "indexing_test_" + System.currentTimeMillis(); + private static boolean initialized = false; @Autowired private SolrContainer solrContainer; @Autowired @@ -71,7 +72,7 @@ void setUp() throws Exception { XmlDocumentCreator xmlDocumentCreator = new XmlDocumentCreator(); CsvDocumentCreator csvDocumentCreator = new CsvDocumentCreator(); JsonDocumentCreator jsonDocumentCreator = new JsonDocumentCreator( - new com.fasterxml.jackson.databind.ObjectMapper()); + tools.jackson.databind.json.JsonMapper.builder().build()); indexingDocumentCreator = new IndexingDocumentCreator(xmlDocumentCreator, csvDocumentCreator, jsonDocumentCreator); diff --git a/src/test/java/org/apache/solr/mcp/server/observability/DistributedTracingTest.java b/src/test/java/org/apache/solr/mcp/server/observability/DistributedTracingTest.java index 04d93425..23c0efcd 100644 --- a/src/test/java/org/apache/solr/mcp/server/observability/DistributedTracingTest.java +++ b/src/test/java/org/apache/solr/mcp/server/observability/DistributedTracingTest.java @@ -109,8 +109,8 @@ void shouldCreateSpanForSearchServiceMethod() { await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { var spans = tracer.getSpans(); assertThat(spans).as("Should have created at least one span").isNotEmpty(); - assertThat(spans).as("Should have span for search-service#search method") - .anyMatch(span -> span.getName().equals("search-service#search")); + assertThat(spans).as("Should have span for SearchService#search method") + .anyMatch(span -> span.getName().equals("SearchService#search")); }); } diff --git a/src/test/java/org/apache/solr/mcp/server/observability/InMemoryTracingTestConfiguration.java b/src/test/java/org/apache/solr/mcp/server/observability/InMemoryTracingTestConfiguration.java new file mode 100644 index 00000000..56af46fb --- /dev/null +++ b/src/test/java/org/apache/solr/mcp/server/observability/InMemoryTracingTestConfiguration.java @@ -0,0 +1,37 @@ +/* + * 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.observability; + +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +/** + * Minimal test configuration that provides InMemorySpanExporter bean. + *

+ * Spring Boot's opentelemetry-test starter requires this to be explicitly + * configured. + */ +@TestConfiguration +public class InMemoryTracingTestConfiguration { + + @Bean + public InMemorySpanExporter inMemorySpanExporter() { + return InMemorySpanExporter.create(); + } + +} diff --git a/src/test/java/org/apache/solr/mcp/server/observability/LgtmAssertions.java b/src/test/java/org/apache/solr/mcp/server/observability/LgtmAssertions.java index f8e8df47..395e9b17 100644 --- a/src/test/java/org/apache/solr/mcp/server/observability/LgtmAssertions.java +++ b/src/test/java/org/apache/solr/mcp/server/observability/LgtmAssertions.java @@ -16,8 +16,6 @@ */ package org.apache.solr.mcp.server.observability; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.Optional; @@ -25,6 +23,8 @@ import org.slf4j.LoggerFactory; import org.springframework.web.client.RestClient; import org.testcontainers.grafana.LgtmStackContainer; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; /** * Helper class to query LGTM stack backends (Tempo, Prometheus, Loki). diff --git a/src/test/java/org/apache/solr/mcp/server/observability/OtlpExportIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/observability/OtlpExportIntegrationTest.java index 2c83aae4..8e514bea 100644 --- a/src/test/java/org/apache/solr/mcp/server/observability/OtlpExportIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/observability/OtlpExportIntegrationTest.java @@ -19,8 +19,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import java.util.concurrent.TimeUnit; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.request.CollectionAdminRequest; @@ -39,6 +37,8 @@ import org.testcontainers.grafana.LgtmStackContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; /** * Integration test verifying that observability signals (traces, metrics, logs) diff --git a/src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceTest.java b/src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceTest.java index 0ca900e6..02b15fc0 100644 --- a/src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceTest.java +++ b/src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceTest.java @@ -22,7 +22,6 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.util.List; import java.util.Map; @@ -41,6 +40,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import tools.jackson.databind.ObjectMapper; /** * Comprehensive test suite for the SchemaService class. Tests schema retrieval diff --git a/src/test/java/org/apache/solr/mcp/server/search/SearchServiceIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/search/SearchServiceIntegrationTest.java index bb3c842c..af1d22ab 100644 --- a/src/test/java/org/apache/solr/mcp/server/search/SearchServiceIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/search/SearchServiceIntegrationTest.java @@ -16,7 +16,10 @@ */ package org.apache.solr.mcp.server.search; -import static org.junit.jupiter.api.Assertions.*; +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; import java.io.IOException; import java.util.ArrayList; @@ -48,7 +51,7 @@ class SearchServiceIntegrationTest { private static final String COLLECTION_NAME = "search_test_" + System.currentTimeMillis(); - + private static boolean initialized = false; @Autowired private SearchService searchService; @Autowired @@ -56,8 +59,6 @@ class SearchServiceIntegrationTest { @Autowired private SolrClient solrClient; - private static boolean initialized = false; - @BeforeEach void setUp() throws Exception { if (!initialized) { 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 798348b0..548f2ae4 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,7 +16,13 @@ */ package org.apache.solr.mcp.server.search; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; From d184b1527e5f1defb3f7a0498ed1d45ed3a0de9c Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Sun, 28 Jun 2026 10:58:55 -0400 Subject: [PATCH 2/7] fix(build): scope cyclonedxBom to productionRuntimeClasspath; migrate JSON test to Jackson 3 Resolving PR #23 (sb4 -> main) onto current main surfaced two build breaks that the conflict resolution had to fix: 1. SBOM completeness gate failed. `generateBinaryLicense` compares the CycloneDX SBOM against the shipped `productionRuntimeClasspath`, but `cyclonedxBom` was never configured for it. Spring Boot's `CycloneDxPluginAction` only auto-configures the cyclonedx plugin version it recognizes (3.x for Spring Boot 4.1.0); we stay pinned to 2.4.1 because cyclonedx 3.x fails at configuration time on Gradle 9.4.1 (variant-mutation conflict on `:cyclonedxDirectBom`). With 2.4.1 unrecognized, the task fell back to plugin defaults: it wrote `build/reports/bom.json` (not `application.cdx.json`) and scanned the wrong configuration set (stale Jackson 2, none of the Spring Boot 4 modular jars), so the gate reported ~30 bundled deps "absent from the SBOM". Configure the task explicitly: `outputName = "application.cdx"` and `includeConfigs = [productionRuntimeClasspath]`. 2. `JsonResponseParserContentTypesTest` (added on main via #90) imported the Jackson 2 `com.fasterxml.jackson.databind.ObjectMapper`, but SB4 migrated `JsonResponseParser` to Jackson 3. Use `tools.jackson.databind.json.JsonMapper` (the project's Jackson 3 mapper-construction pattern) instead. Update the AGENTS.md SBOM Architecture note to match the explicit wiring. `./gradlew build` is green. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: adityamparikh --- AGENTS.md | 25 +++++++++++++------ build.gradle.kts | 20 +++++++++++++++ .../JsonResponseParserContentTypesTest.java | 4 +-- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 86b83f00..cb057496 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,14 +120,23 @@ Configuration files: `application-stdio.properties`, `application-http.propertie ### SBOM Architecture -CycloneDX SBOM generation is wired by applying the `org.cyclonedx.bom` plugin -(version 2.4.1, matching what Spring Initializr ships for Spring Boot 3.5.14). -Spring Boot's `CycloneDxPluginAction` auto-configures `cyclonedxBom` and makes -the bootJar embed the result at `META-INF/sbom/application.cdx.json`; the -actuator serves it at `/actuator/sbom/application` in the `http` profile -(enabled via `application-http.properties`). Both the Jib JVM image and the -Paketo native images package the bootJar contents, so every distribution -artifact ships the SBOM without per-image wiring. +CycloneDX SBOM generation is wired by applying the `org.cyclonedx.bom` plugin. +It stays pinned to **2.4.1** even on Spring Boot 4.1.0 (Spring Initializr ships +3.x for SB4) because cyclonedx 3.x fails at *configuration* time on Gradle 9.4.1 +— a variant-mutation conflict on `:cyclonedxDirectBom`. Spring Boot's +`CycloneDxPluginAction` only auto-configures the plugin version it recognizes +(3.x), so with 2.4.1 unrecognized it leaves `cyclonedxBom` at plugin defaults: +it would write `build/reports/bom.json` and scan the wrong configuration set +(stale Jackson 2, no Spring Boot 4 modular jars). `build.gradle.kts` therefore +configures the task explicitly — `outputName = "application.cdx"` and +`includeConfigs = [productionRuntimeClasspath]` — so the SBOM lands at +`build/reports/application.cdx.json` and describes exactly the shipped fat-jar +classpath (matching `generateBinaryLicense`'s completeness gate). The bootJar +embeds the result at `META-INF/sbom/application.cdx.json`; the actuator serves +it at `/actuator/sbom/application` in the `http` profile (enabled via +`application-http.properties`). Both the Jib JVM image and the Paketo native +images package the bootJar contents, so every distribution artifact ships the +SBOM without per-image wiring. ### Logging Architecture diff --git a/build.gradle.kts b/build.gradle.kts index 5a4635fe..5fd0f1b8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -91,6 +91,26 @@ java { // the bootJar — bundling the base files here too would duplicate META-INF/LICENSE. // See https://www.apache.org/legal/release-policy.html#licensing-documentation +// CycloneDX SBOM scope and output name +// ==================================== +// Spring Boot's `CycloneDxPluginAction` only auto-configures `cyclonedxBom` for the +// cyclonedx plugin version it recognizes (3.x ships with Spring Boot 4.1.0). We pin +// `org.cyclonedx.bom` to 2.4.1 because cyclonedx 3.x fails at *configuration* time on +// Gradle 9.4.1 (a variant-mutation conflict on `:cyclonedxDirectBom`). With 2.4.1 +// unrecognized, Spring Boot does not adjust the task, so it falls back to plugin defaults: +// it writes `build/reports/bom.json` (not `application.cdx.json`) and scans cyclonedx's +// default configuration set rather than the shipped classpath — yielding an SBOM with the +// wrong components (stale Jackson 2, none of the Spring Boot 4 modular jars). Configure +// both explicitly so the SBOM lands where the license-notice plugin and the actuator +// endpoint expect it, and describes exactly what ships: +// - `outputName = "application.cdx"` -> build/reports/application.cdx.json +// - `includeConfigs = [productionRuntimeClasspath]` -> only the fat-jar classpath, +// matching `generateBinaryLicense`'s completeness gate (shippedCoordinates). +tasks.named("cyclonedxBom") { + setOutputName("application.cdx") + includeConfigs.set(listOf("productionRuntimeClasspath")) +} + // Maven Publishing Configuration // ============================== // This configuration enables publishing the project artifacts to Maven repositories. diff --git a/src/test/java/org/apache/solr/mcp/server/config/JsonResponseParserContentTypesTest.java b/src/test/java/org/apache/solr/mcp/server/config/JsonResponseParserContentTypesTest.java index 81181d37..16b3ebde 100644 --- a/src/test/java/org/apache/solr/mcp/server/config/JsonResponseParserContentTypesTest.java +++ b/src/test/java/org/apache/solr/mcp/server/config/JsonResponseParserContentTypesTest.java @@ -18,10 +18,10 @@ import static org.junit.jupiter.api.Assertions.assertTrue; -import com.fasterxml.jackson.databind.ObjectMapper; import java.util.Collection; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; +import tools.jackson.databind.json.JsonMapper; /** * Unit tests verifying the Content-Types accepted by @@ -39,7 +39,7 @@ class JsonResponseParserContentTypesTest { @Test void advertisesJsonAndTextPlain() { - JsonResponseParser parser = new JsonResponseParser(new ObjectMapper()); + JsonResponseParser parser = new JsonResponseParser(JsonMapper.builder().build()); Collection contentTypes = parser.getContentTypes(); From fbf8f638f3c38efc15431f257810b9e16e57c25b Mon Sep 17 00:00:00 2001 From: Aditya Parikh Date: Sun, 2 Aug 2026 09:58:01 -0400 Subject: [PATCH 3/7] fix(compose): stop exposing anonymous-admin Grafana to the network The lgtm service published Grafana on "3000:3000" (all interfaces) while setting GF_AUTH_ANONYMOUS_ORG_ROLE=Admin, so any host that could reach the developer's machine had unauthenticated admin access to the observability stack - no credentials, no prompt. Verified against the running container: - Old config: anonymous POST /api/folders -> HTTP 200, folder created. - New config: same request -> HTTP 403 "folders:create" denied. - Grafana answers on 127.0.0.1:3000 but the LAN address refuses the connection. Defaults are now loopback-only and read-only. Both are overridable for the cases that genuinely need them, so the previous behaviour stays available but has to be asked for: GF_ANON_ROLE=Admin docker compose up -d lgtm # editable dashboards GRAFANA_BIND=0.0.0.0 docker compose up -d lgtm # expose on a trusted LAN The OTLP receivers (4317/4318) are bound the same way via OTLP_BIND. The application posts telemetry to http://localhost:4318 by default, so the documented workflow is unaffected. Reported by a CodeRabbit review of #23. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Aditya Parikh --- compose.yaml | 22 ++++++++++++++++------ dev-docs/Observability.md | 5 ++++- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/compose.yaml b/compose.yaml index dd8d577c..8c0608a2 100644 --- a/compose.yaml +++ b/compose.yaml @@ -49,19 +49,29 @@ services: # docker compose up -d lgtm # Start only the observability stack # docker compose up -d # Start everything including Solr # - # Access Grafana at http://localhost:3000 (no authentication required) + # Access Grafana at http://localhost:3000. Anonymous access is enabled but + # read-only (Viewer), and the UI is published on the loopback interface only, + # so it is not reachable from other hosts on your network. + # + # To grant anonymous Admin (e.g. to edit dashboards), opt in explicitly: + # GF_ANON_ROLE=Admin docker compose up -d lgtm + # To expose the UI beyond loopback on a trusted network, set the bind address: + # GRAFANA_BIND=0.0.0.0 docker compose up -d lgtm + # # Pre-configured datasources for Loki, Tempo, and Mimir are available. lgtm: image: grafana/otel-lgtm:latest ports: - - "3000:3000" # Grafana UI - - "4317:4317" # OTLP gRPC receiver - - "4318:4318" # OTLP HTTP receiver + # Loopback-only by default: anonymous Grafana on 0.0.0.0 would hand the + # dashboards to anyone who can reach this machine. + - "${GRAFANA_BIND:-127.0.0.1}:3000:3000" # Grafana UI + - "${OTLP_BIND:-127.0.0.1}:4317:4317" # OTLP gRPC receiver + - "${OTLP_BIND:-127.0.0.1}:4318:4318" # OTLP HTTP receiver networks: [ search ] environment: - # Disable authentication for local development + # Anonymous access for local development, read-only unless overridden. GF_AUTH_ANONYMOUS_ENABLED: "true" - GF_AUTH_ANONYMOUS_ORG_ROLE: "Admin" + GF_AUTH_ANONYMOUS_ORG_ROLE: "${GF_ANON_ROLE:-Viewer}" volumes: data: diff --git a/dev-docs/Observability.md b/dev-docs/Observability.md index 6f778fa3..6718e936 100644 --- a/dev-docs/Observability.md +++ b/dev-docs/Observability.md @@ -97,7 +97,10 @@ docker compose up -d lgtm solr ### Grafana Dashboard -Access Grafana at **http://localhost:3000** (no login required in development mode). +Access Grafana at **http://localhost:3000** (no login required in development +mode). Anonymous access is read-only and the UI is published on the loopback +interface only, so it is not reachable from other machines. To edit dashboards +anonymously, start the stack with `GF_ANON_ROLE=Admin docker compose up -d lgtm`. The LGTM stack comes with pre-configured datasources: - **Tempo** - For distributed traces From 48488ceff2cd8ff47d6e6080f6f0b6e80fe7bc62 Mon Sep 17 00:00:00 2001 From: Aditya Parikh Date: Sun, 2 Aug 2026 10:07:35 -0400 Subject: [PATCH 4/7] docs(compose): pin otel-lgtm and name Prometheus, not Mimir The lgtm service tracked grafana/otel-lgtm:latest, so a rebuild could pull a different observability stack without any change to this repo. Pin 0.30.0 (current release as of 2026-07-31). Verified against the pinned image rather than assumed: - Provisioned Grafana datasources are Loki, Prometheus (default), Pyroscope and Tempo. There is no Mimir datasource, and the image ships a prometheus binary and prometheus.yaml with no mimir equivalent. - OTLP ingestion works end to end on this tag: POST /v1/metrics returned 200 and Prometheus then answered probe_counter_total{service_name= "solr-mcp-probe"} = 7. So the docs were sending readers to a datasource that does not exist - "Select Mimir as the datasource" could not be followed. Corrected across compose.yaml and dev-docs/Observability.md, and added Pyroscope, which the stack provides but the docs never mentioned. The architecture diagram was redrawn (Prometheus is wider than Mimir) and all rows re-aligned to 81 columns. The LGTM table keeps its "M is for Mimir" row - that is what the acronym stands for - but now says the image actually ships Prometheus. Reported by a CodeRabbit review of #23. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Aditya Parikh --- compose.yaml | 7 +-- dev-docs/Observability.md | 45 ++++++++++--------- .../OtlpExportIntegrationTest.java | 3 +- 3 files changed, 28 insertions(+), 27 deletions(-) diff --git a/compose.yaml b/compose.yaml index 8c0608a2..ef3fc9db 100644 --- a/compose.yaml +++ b/compose.yaml @@ -42,7 +42,8 @@ services: # - Grafana: Visualization dashboards (http://localhost:3000) # - Loki: Log aggregation # - Tempo: Distributed tracing - # - Mimir: Metrics storage (Prometheus-compatible) + # - Prometheus: Metrics storage (the default Grafana datasource) + # - Pyroscope: Continuous profiling # - OpenTelemetry Collector: Receives OTLP data on ports 4317 (gRPC) and 4318 (HTTP) # # Usage: @@ -58,9 +59,9 @@ services: # To expose the UI beyond loopback on a trusted network, set the bind address: # GRAFANA_BIND=0.0.0.0 docker compose up -d lgtm # - # Pre-configured datasources for Loki, Tempo, and Mimir are available. + # Pre-configured datasources: Prometheus (default), Loki, Tempo, Pyroscope. lgtm: - image: grafana/otel-lgtm:latest + image: grafana/otel-lgtm:0.30.0 ports: # Loopback-only by default: anonymous Grafana on 0.0.0.0 would hand the # dashboards to anyone who can reach this machine. diff --git a/dev-docs/Observability.md b/dev-docs/Observability.md index 6718e936..0d246f3a 100644 --- a/dev-docs/Observability.md +++ b/dev-docs/Observability.md @@ -27,7 +27,7 @@ The Solr MCP Server integrates with OpenTelemetry to provide comprehensive obser | Signal | Description | Backend | |--------|-------------|---------| | **Traces** | Distributed tracing for request flows | Tempo | -| **Metrics** | Application and JVM metrics | Mimir (Prometheus-compatible) | +| **Metrics** | Application and JVM metrics | Prometheus | | **Logs** | Structured log export with trace correlation | Loki | **Note:** Observability is only available in HTTP mode. STDIO mode disables telemetry to prevent stdout pollution that would interfere with MCP protocol communication. @@ -41,7 +41,7 @@ The project uses the **Grafana LGTM stack** (`grafana/otel-lgtm`) - an all-in-on | **L**oki | Log aggregation and querying | Internal | | **G**rafana | Visualization, dashboards, and exploration | 3000 | | **T**empo | Distributed tracing backend | Internal | -| **M**imir | Prometheus-compatible metrics storage | Internal | +| **M**imir | Metrics storage — note the image actually ships **Prometheus**, which is what Grafana is wired to | Internal | The image also includes an **OpenTelemetry Collector** that receives telemetry data via OTLP protocol: - **Port 4317**: OTLP gRPC receiver @@ -59,7 +59,7 @@ PROFILES=http ./gradlew bootRun ``` Spring Boot detects the `compose.yaml` file and automatically: -1. Starts the `lgtm` container (Grafana, Loki, Tempo, Mimir) +1. Starts the `lgtm` container (Grafana, Loki, Tempo, Prometheus, Pyroscope) 2. Starts the `solr` and `zoo` containers 3. Configures OTLP endpoints to point to the running containers 4. Waits for containers to be healthy before accepting requests @@ -74,23 +74,23 @@ docker compose up -d lgtm solr ## Architecture ``` -┌─────────────────────┐ OTLP/HTTP ┌─────────────────────────────────┐ -│ Solr MCP Server │─────────────────────│ OpenTelemetry Collector │ -│ (HTTP mode) │ :4318 │ (grafana/otel-lgtm) │ -│ │ │ │ -│ ┌───────────────┐ │ │ ┌─────────┐ ┌─────────────┐ │ -│ │ Traces │──┼─────────────────────┼─▶│ Tempo │ │ Grafana │ │ -│ │ (auto-instr.) │ │ │ └─────────┘ │ :3000 │ │ -│ └───────────────┘ │ │ │ │ │ -│ ┌───────────────┐ │ │ ┌─────────┐ │ - Dashboards│ │ -│ │ Metrics │──┼─────────────────────┼─▶│ Mimir │ │ - Explore │ │ -│ │ (actuator) │ │ │ └─────────┘ │ - Alerts │ │ -│ └───────────────┘ │ │ └─────────────┘ │ -│ ┌───────────────┐ │ │ ┌─────────┐ │ -│ │ Logs │──┼─────────────────────┼─▶│ Loki │ │ -│ │ (logback) │ │ │ └─────────┘ │ -│ └───────────────┘ │ │ │ -└─────────────────────┘ └─────────────────────────────────┘ +┌─────────────────────┐ OTLP/HTTP ┌───────────────────────────────────┐ +│ Solr MCP Server │─────────────────────│ OpenTelemetry Collector │ +│ (HTTP mode) │ :4318 │ (grafana/otel-lgtm) │ +│ │ │ │ +│ ┌───────────────┐ │ │ ┌────────────┐ ┌─────────────┐ │ +│ │ Traces │──┼─────────────────────┼─▶│ Tempo │ │ Grafana │ │ +│ │ (auto-instr.) │ │ │ └────────────┘ │ :3000 │ │ +│ └───────────────┘ │ │ │ │ │ +│ ┌───────────────┐ │ │ ┌────────────┐ │ - Dashboards│ │ +│ │ Metrics │──┼─────────────────────┼─▶│ Prometheus │ │ - Explore │ │ +│ │ (actuator) │ │ │ └────────────┘ │ - Alerts │ │ +│ └───────────────┘ │ │ └─────────────┘ │ +│ ┌───────────────┐ │ │ ┌────────────┐ │ +│ │ Logs │──┼─────────────────────┼─▶│ Loki │ │ +│ │ (logback) │ │ │ └────────────┘ │ +│ └───────────────┘ │ │ │ +└─────────────────────┘ └───────────────────────────────────┘ ``` ## Accessing Telemetry Data @@ -103,9 +103,10 @@ interface only, so it is not reachable from other machines. To edit dashboards anonymously, start the stack with `GF_ANON_ROLE=Admin docker compose up -d lgtm`. The LGTM stack comes with pre-configured datasources: +- **Prometheus** - For metrics (the default datasource) - **Tempo** - For distributed traces - **Loki** - For logs -- **Mimir** - For metrics (Prometheus-compatible) +- **Pyroscope** - For continuous profiling ### Viewing Traces @@ -168,7 +169,7 @@ This unified view makes it easy to investigate issues by correlating traces with 1. Open Grafana: http://localhost:3000 2. Go to **Explore** -3. Select **Mimir** as the datasource +3. Select **Prometheus** as the datasource 4. Query metrics using PromQL: **Example queries:** diff --git a/src/test/java/org/apache/solr/mcp/server/observability/OtlpExportIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/observability/OtlpExportIntegrationTest.java index 8e514bea..906caf70 100644 --- a/src/test/java/org/apache/solr/mcp/server/observability/OtlpExportIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/observability/OtlpExportIntegrationTest.java @@ -47,8 +47,7 @@ *

* This test uses Spring Boot 3.5's {@code @ServiceConnection} with * {@code LgtmStackContainer} to integrate with the Grafana LGTM stack (Loki for - * logs, Grafana for visualization, Tempo for traces, Mimir/Prometheus for - * metrics). + * logs, Grafana for visualization, Tempo for traces, Prometheus for metrics). * *

* What this test verifies: From 39d16aae326910122545069371e57ef8f29117d7 Mon Sep 17 00:00:00 2001 From: Aditya Parikh Date: Wed, 19 Aug 2026 07:42:06 -0400 Subject: [PATCH 5/7] fix(test): repair tests that the rebase onto main broke Rebasing the Spring Boot 4 / Spring AI 2.0 branch onto current main applied cleanly at the textual level but left three test-side breaks that only compiling and running the suite surfaces. 1. `SearchServiceIntegrationTest` and `IndexingServiceIntegrationTest` failed to compile. Main still uses the wildcard `import static org.junit.jupiter.api.Assertions.*`, which this branch had expanded to explicit imports. Git merged the explicit list, so the assertions main added since the fork point (#166 `assertThrows`, #165 `assertFalse`) had no import. Add the two missing static imports rather than reintroducing the wildcard. 2. `SolrNativeHintsTest.registersDefaultMetaProviderConstructorHint` (added on main via #172) asserts a hint for `org.springaicommunity.mcp.context.DefaultMetaProvider`. Spring AI 2.x relocated that class to `org.springframework.ai.mcp.annotation.context.DefaultMetaProvider`, which the Registrar already registers. Because `registerTypeIfPresent` silently no-ops for a class that is absent from the classpath, the legacy coordinate registers nothing here and the assertion failed. Point the test at the coordinate that Spring AI 2.x actually ships; the legacy registration stays in the Registrar defensively. `./gradlew build` is green on JDK 25: 372 tests, 0 failures, 0 errors, 7 skipped. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Aditya Parikh --- .../apache/solr/mcp/server/config/SolrNativeHintsTest.java | 7 ++++++- .../server/indexing/IndexingServiceIntegrationTest.java | 1 + .../mcp/server/search/SearchServiceIntegrationTest.java | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/test/java/org/apache/solr/mcp/server/config/SolrNativeHintsTest.java b/src/test/java/org/apache/solr/mcp/server/config/SolrNativeHintsTest.java index 3e12d098..e5515e57 100644 --- a/src/test/java/org/apache/solr/mcp/server/config/SolrNativeHintsTest.java +++ b/src/test/java/org/apache/solr/mcp/server/config/SolrNativeHintsTest.java @@ -50,8 +50,13 @@ void registersDefaultMetaProviderConstructorHint() { // Spring AI MCP instantiates DefaultMetaProvider reflectively in // MetaUtils.getMeta(); without this hint every Spring context refresh // fails in native image with "Required no-arg constructor not found". + // Spring AI 2.x moved the class out of the springaicommunity package and + // into Spring AI core, so this pins the current coordinate — the legacy + // org.springaicommunity.mcp.context name is still registered defensively + // in the Registrar, but registerTypeIfPresent no-ops when, as here, the + // class is absent from the classpath. assertTrue(RuntimeHintsPredicates.reflection() - .onType(TypeReference.of("org.springaicommunity.mcp.context.DefaultMetaProvider")) + .onType(TypeReference.of("org.springframework.ai.mcp.annotation.context.DefaultMetaProvider")) .withMemberCategory(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS).test(hints)); } diff --git a/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceIntegrationTest.java index 050df48b..67c524d0 100644 --- a/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceIntegrationTest.java @@ -17,6 +17,7 @@ package org.apache.solr.mcp.server.indexing; 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; diff --git a/src/test/java/org/apache/solr/mcp/server/search/SearchServiceIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/search/SearchServiceIntegrationTest.java index af1d22ab..a4fb4f28 100644 --- a/src/test/java/org/apache/solr/mcp/server/search/SearchServiceIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/search/SearchServiceIntegrationTest.java @@ -19,6 +19,7 @@ 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.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; From 9c9bdb0f46d0a08d4f9242e3bb89d0eea63c578f Mon Sep 17 00:00:00 2001 From: Aditya Parikh Date: Wed, 19 Aug 2026 10:51:49 -0400 Subject: [PATCH 6/7] chore(deps): bump mcp-server-security 0.0.6 -> 0.1.14 for the Boot 4 line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.0.6 is built against the Spring Boot 3 line: its POM declares spring-core 6.2.9 and spring-security-{web,config,core,oauth2-*} 6.5.2, and it predates mcp-core 2.x entirely. This branch ships Spring Framework 7.0.8 and Spring Security 7.1.0 via the Spring Boot 4.1.0 BOM, so the 0.0.6 bytecode was running against a Security major it never saw. 0.1.14 is the current release of the same library on the Boot 4 line: spring-core 7.0.8, spring-security-* 7.1.0, mcp-core 2.0.0 — matching exactly what Spring Boot 4.1.0 and Spring AI 2.0.0 put on the classpath. The upgrade is a version-catalog change only; McpServerOAuth2Configurer's API in HttpSecurityConfiguration is unchanged. Note on verification: no automated test in this repo exercises McpServerOAuth2Configurer on any version. It is wired only when OAUTH2_ISSUER_URI is set (HttpSecurityConfiguration#secured), and DockerImageHttpIntegrationTest deliberately runs without an issuer since it has no IdP. That coverage gap is pre-existing and worth closing separately. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Aditya Parikh --- 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 d4c55646..01100bde 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,7 +28,7 @@ cyclonedx-plugin = "2.4.1" spring-ai = "2.0.0" solr = "10.0.0" commons-csv = "1.14.1" -mcp-server-security = "0.0.6" +mcp-server-security = "0.1.14" # OpenTelemetry opentelemetry-logback-appender = "2.21.0-alpha" From 6364bd32d2acfa1c4437ce67b81b9d08f6b3d6fa Mon Sep 17 00:00:00 2001 From: Aditya Parikh Date: Thu, 20 Aug 2026 11:37:47 -0400 Subject: [PATCH 7/7] fix: correct observability docs and bump to Boot 4.1.1 / Spring AI 2.0.1 Addresses review findings on this PR. Versions: 4.1.0 -> 4.1.1 and 2.0.0 -> 2.0.1, both verified as current against maven-metadata.xml rather than a search index. The PR described 4.1.0/2.0.0 as "the latest GA releases", which was true when written. Observability docs were the substantive problem. This PR moves OTLP export from gRPC on 4317 to HTTP on 4318 with a separate full URL per signal, which silently redefines OTEL_TRACES_URL: it used to be a base endpoint and is now a complete path ending in /v1/traces. docs/observability.md -- the user-facing guide README links to -- still documented the removed otel.exporter.otlp.* properties and told readers to export a :4317 base URL. Following it after this merged would not error; traces would just stop arriving. - docs/observability.md: real property names, corrected production example, a variable table, and an explicit upgrade warning about the changed semantics - dev-docs/Observability.md: added here, so it now states its relationship to the user-facing guide instead of silently competing with it; defaults filled in for the three OTLP variables - README.md: names all four OTEL variables - AGENTS.md: version references, and the OTEL variables added to Key Configuration Also removed a broken image reference in dev-docs/Observability.md (images/grafana-traces.png does not exist in the repo); the prose that follows already describes the trace breakdown in full. build.gradle.kts: documented why spring-boot-starter-mongodb is excluded from the Spring AI docker-compose module, and linked the CycloneDX 2.4.1 pin to #186 so it gets revisited rather than becoming permanent. Verified: ./gradlew build green, 372 tests / 0 failures (matching main's count), and nativeTest green at 227 passing / 0 failed -- unchanged by the version bump. Signed-off-by: Aditya Parikh --- AGENTS.md | 15 +++++++++++---- README.md | 2 +- build.gradle.kts | 7 +++++++ dev-docs/Observability.md | 23 +++++++++++++++-------- docs/observability.md | 37 ++++++++++++++++++++++++++++++------- gradle/libs.versions.toml | 4 ++-- 6 files changed, 66 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cb057496..2f5f0be7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Solr MCP Server is a Spring AI Model Context Protocol (MCP) server that enables - **Status:** Apache incubating project (v0.0.2-SNAPSHOT) - **Java:** 25+ (centralized in build.gradle.kts) -- **Framework:** Spring Boot 4.1.0, Spring AI 2.0.0 +- **Framework:** Spring Boot 4.1.1, Spring AI 2.0.1 - **License:** Apache 2.0 ## Common Commands @@ -121,7 +121,7 @@ Configuration files: `application-stdio.properties`, `application-http.propertie ### SBOM Architecture CycloneDX SBOM generation is wired by applying the `org.cyclonedx.bom` plugin. -It stays pinned to **2.4.1** even on Spring Boot 4.1.0 (Spring Initializr ships +It stays pinned to **2.4.1** even on Spring Boot 4.1.1 (Spring Initializr ships 3.x for SB4) because cyclonedx 3.x fails at *configuration* time on Gradle 9.4.1 — a variant-mutation conflict on `:cyclonedxDirectBom`. Spring Boot's `CycloneDxPluginAction` only auto-configures the plugin version it recognizes @@ -136,7 +136,9 @@ embeds the result at `META-INF/sbom/application.cdx.json`; the actuator serves it at `/actuator/sbom/application` in the `http` profile (enabled via `application-http.properties`). Both the Jib JVM image and the Paketo native images package the bootJar contents, so every distribution artifact ships the -SBOM without per-image wiring. +SBOM without per-image wiring. Dropping the pin and the manual task configuration +once cyclonedx 3.x configures cleanly is tracked in +[#186](https://github.com/apache/solr-mcp/issues/186). ### Logging Architecture @@ -233,7 +235,7 @@ buildpacks (`bootBuildImage -Pnative`). Key configuration: ### Spring Boot 4 Notes -This branch targets Spring Boot 4.1.0 and Spring AI 2.0.0 +This branch targets Spring Boot 4.1.1 and Spring AI 2.0.1 ([release announcement](https://spring.io/blog/2026/06/12/spring-ai-2-0-0-GA-available-now)). Key differences from the main (SB 3.x) branch: @@ -386,6 +388,11 @@ Environment variables: - `SOLR_URL`: Solr URL (default: `http://localhost:8983/solr/`) - `PROFILES`: Transport mode (`stdio` or `http`) - `OAUTH2_ISSUER_URI`: OAuth2 issuer URL (HTTP mode only) +- `OTEL_SAMPLING_PROBABILITY`: trace sampling rate (default `1.0`) +- `OTEL_TRACES_URL` / `OTEL_METRICS_URL` / `OTEL_LOGS_URL`: OTLP/HTTP endpoints + (default `http://localhost:4318/v1/{traces,metrics,logs}`). Each is a complete + signal path. On SB 3.x a single `OTEL_TRACES_URL` was a *base* gRPC endpoint on + port 4317 — a value carried over from there stops exporting silently. Dependencies managed in `gradle/libs.versions.toml`. diff --git a/README.md b/README.md index dc1b5a47..031626e3 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ The server reads configuration from environment variables. The essentials: | `SOLR_URL` | Solr base URL | `http://localhost:8983/solr/` | | `PROFILES` | Transport mode: `stdio` (default, for Claude Desktop) or `http` (remote / multi-client) | `stdio` | -Running in **HTTP mode** — OAuth2, CORS, and the `HTTP_SECURITY_ENABLED` toggle (secured by default) — is covered in the [security docs](docs/security/). Tracing and metrics env vars (`OTEL_SAMPLING_PROBABILITY`, `OTEL_TRACES_URL`) are covered in [Observability](docs/observability.md). +Running in **HTTP mode** — OAuth2, CORS, and the `HTTP_SECURITY_ENABLED` toggle (secured by default) — is covered in the [security docs](docs/security/). Tracing, metrics and log-export env vars (`OTEL_SAMPLING_PROBABILITY`, `OTEL_TRACES_URL`, `OTEL_METRICS_URL`, `OTEL_LOGS_URL`) are covered in [Observability](docs/observability.md). ## Documentation diff --git a/build.gradle.kts b/build.gradle.kts index 5fd0f1b8..0c244e0a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -106,6 +106,8 @@ java { // - `outputName = "application.cdx"` -> build/reports/application.cdx.json // - `includeConfigs = [productionRuntimeClasspath]` -> only the fat-jar classpath, // matching `generateBinaryLicense`'s completeness gate (shippedCoordinates). +// Both the pin and this block should be dropped once cyclonedx 3.x configures cleanly — +// tracked in https://github.com/apache/solr-mcp/issues/186. tasks.named("cyclonedxBom") { setOutputName("application.cdx") includeConfigs.set(listOf("productionRuntimeClasspath")) @@ -164,6 +166,11 @@ repositories { dependencies { developmentOnly(libs.spring.boot.docker.compose) + // Spring AI's docker-compose module declares starters for every vector store it can + // detect, so it drags in spring-boot-starter-mongodb transitively. That starter's + // autoconfiguration then tries to build a Mongo client at startup even though this + // application has no Mongo. Excluded rather than tolerated: it is developmentOnly, so + // the failure would surface as a confusing local `bootRun` error and never in CI. developmentOnly(libs.spring.ai.spring.boot.docker.compose) { exclude(group = "org.springframework.boot", module = "spring-boot-starter-mongodb") } diff --git a/dev-docs/Observability.md b/dev-docs/Observability.md index 0d246f3a..d406e0a8 100644 --- a/dev-docs/Observability.md +++ b/dev-docs/Observability.md @@ -2,6 +2,11 @@ This guide covers setting up observability (metrics, traces, and logs) for the Solr MCP Server running in HTTP mode using OpenTelemetry. +> **Looking for the short version?** [docs/observability.md](../docs/observability.md) is the +> user-facing guide: start the stack, run the server, read the dashboards, and the environment +> variables you need in production. This document is the developer companion — exporter +> architecture, the Logback OTLP appender wiring, and how the pieces fit together. + ## Table of Contents - [Overview](#overview) @@ -121,11 +126,8 @@ Grafana's **Drilldown** feature provides an integrated view for exploring traces - Duration - URL path -The trace view shows the complete request flow with timing breakdown for each span: - -![Distributed Tracing in Grafana](images/grafana-traces.png) - -In this example, you can see: +The trace view shows the complete request flow with a timing breakdown for each +span. A representative `/mcp` search request looks like this: - The root span `http post /mcp` taking 223.98ms total - Security filter chain spans for authentication/authorization - The `SearchService#search` span (177.01ms) created by the `@Observed` annotation on the service method @@ -196,9 +198,14 @@ For production deployments without Docker Compose, set these environment variabl | Variable | Default | Description | |----------|---------|-------------| | `OTEL_SAMPLING_PROBABILITY` | `1.0` | Trace sampling rate (0.0-1.0) | -| `OTEL_METRICS_URL` | (auto-configured) | OTLP metrics endpoint | -| `OTEL_TRACES_URL` | (auto-configured) | OTLP traces endpoint | -| `OTEL_LOGS_URL` | (auto-configured) | OTLP logs endpoint | +| `OTEL_METRICS_URL` | `http://localhost:4318/v1/metrics` | OTLP/HTTP metrics endpoint | +| `OTEL_TRACES_URL` | `http://localhost:4318/v1/traces` | OTLP/HTTP traces endpoint | +| `OTEL_LOGS_URL` | `http://localhost:4318/v1/logs` | OTLP/HTTP logs endpoint | + +Each URL is a complete signal path, not a base address. `OTEL_TRACES_URL` previously meant a +base endpoint on the gRPC port (`http://collector:4317`) and shared that endpoint with metrics +and logs — a value carried over from before this change stops exporting silently rather than +failing loudly. Example production configuration: ```bash diff --git a/docs/observability.md b/docs/observability.md index b3c17671..63ed437b 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -29,8 +29,8 @@ This starts: | Service | URL | Purpose | |---------|-----|---------| | Grafana | http://localhost:3000 | Dashboards and exploration (no auth required) | -| OTLP gRPC | localhost:4317 | Trace/metric/log ingestion (gRPC) | -| OTLP HTTP | localhost:4318 | Trace/metric/log ingestion (HTTP) | +| OTLP HTTP | localhost:4318 | Trace/metric/log ingestion — **the port this server exports to** | +| OTLP gRPC | localhost:4317 | Also accepted by the collector; not used by this server | ### Run the Server with Observability ### @@ -42,10 +42,15 @@ The server auto-configures OTLP export when the LGTM stack is running. Default c ```properties management.tracing.sampling.probability=1.0 # 100% sampling (dev) -otel.exporter.otlp.endpoint=http://localhost:4317 -otel.exporter.otlp.protocol=grpc +management.opentelemetry.tracing.export.otlp.endpoint=${OTEL_TRACES_URL:http://localhost:4318/v1/traces} +management.otlp.metrics.export.url=${OTEL_METRICS_URL:http://localhost:4318/v1/metrics} +management.opentelemetry.logging.export.otlp.endpoint=${OTEL_LOGS_URL:http://localhost:4318/v1/logs} ``` +Export goes over **OTLP/HTTP on port 4318**, with a separate full URL per signal. +Each endpoint is a complete path ending in `/v1/traces`, `/v1/metrics` or +`/v1/logs` — not a base address. + *** ## Grafana ## @@ -102,10 +107,28 @@ curl http://localhost:8080/actuator/loggers # Logger levels ## Production Configuration ## -For production, reduce the sampling rate and configure the OTLP endpoint for your collector: +For production, reduce the sampling rate and point each signal at your collector: ```bash -export OTEL_SAMPLING_PROBABILITY=0.1 # 10% sampling -export OTEL_TRACES_URL=https://otel-collector.example.com:4317 +export OTEL_SAMPLING_PROBABILITY=0.1 # 10% sampling +export OTEL_TRACES_URL=https://otel-collector.example.com/v1/traces +export OTEL_METRICS_URL=https://otel-collector.example.com/v1/metrics +export OTEL_LOGS_URL=https://otel-collector.example.com/v1/logs PROFILES=http java -jar build/libs/solr-mcp-1.0.0-SNAPSHOT.jar ``` + +| Variable | Default | Purpose | +|----------|---------|---------| +| `OTEL_SAMPLING_PROBABILITY` | `1.0` | Fraction of traces sampled | +| `OTEL_TRACES_URL` | `http://localhost:4318/v1/traces` | OTLP/HTTP traces endpoint | +| `OTEL_METRICS_URL` | `http://localhost:4318/v1/metrics` | OTLP/HTTP metrics endpoint | +| `OTEL_LOGS_URL` | `http://localhost:4318/v1/logs` | OTLP/HTTP logs endpoint | + +> **Upgrading from a pre-Spring-Boot-4 release?** `OTEL_TRACES_URL` changed meaning. +> It used to be a *base* endpoint on the gRPC port (`http://collector:4317`); it is now +> the *complete* traces URL on the HTTP port (`http://collector:4318/v1/traces`). A value +> carried over unchanged will not error — traces simply stop arriving. `OTEL_METRICS_URL` +> and `OTEL_LOGS_URL` are new; previously all three signals shared one endpoint. + +For the exporter architecture and how the Logback OTLP appender is wired, see +[dev-docs/Observability.md](../dev-docs/Observability.md). diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 01100bde..717b496a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,7 @@ # [versions] # Build plugins -spring-boot = "4.1.0" +spring-boot = "4.1.1" spring-dependency-management = "1.1.7" errorprone-plugin = "5.1.0" jib = "3.5.3" @@ -25,7 +25,7 @@ graalvm-native = "0.10.6" cyclonedx-plugin = "2.4.1" # Main dependencies -spring-ai = "2.0.0" +spring-ai = "2.0.1" solr = "10.0.0" commons-csv = "1.14.1" mcp-server-security = "0.1.14"