diff --git a/AGENTS.md b/AGENTS.md index 7d7ce524..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 3.5.14, Spring AI 1.1.7 +- **Framework:** Spring Boot 4.1.1, Spring AI 2.0.1 - **License:** Apache 2.0 ## Common Commands @@ -120,14 +120,25 @@ 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.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 +(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. 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 @@ -222,6 +233,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.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: + +- **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* @@ -355,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 6a846c98..0c244e0a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -91,6 +91,28 @@ 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). +// 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")) +} + // Maven Publishing Configuration // ============================== // This configuration enables publishing the project artifacts to Maven repositories. @@ -143,28 +165,41 @@ repositories { dependencies { - developmentOnly(libs.bundles.spring.boot.dev) + 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") + } - 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 +214,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 +361,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 +520,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 +535,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..ef3fc9db 100644 --- a/compose.yaml +++ b/compose.yaml @@ -35,27 +35,44 @@ 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 + # - Prometheus: Metrics storage (the default Grafana datasource) + # - Pyroscope: Continuous profiling + # - 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. 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: Prometheus (default), Loki, Tempo, Pyroscope. 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: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. + - "${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: + # Anonymous access for local development, read-only unless overridden. + GF_AUTH_ANONYMOUS_ENABLED: "true" + GF_AUTH_ANONYMOUS_ORG_ROLE: "${GF_ANON_ROLE:-Viewer}" volumes: data: diff --git a/dev-docs/Observability.md b/dev-docs/Observability.md new file mode 100644 index 00000000..d406e0a8 --- /dev/null +++ b/dev-docs/Observability.md @@ -0,0 +1,325 @@ +# 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. + +> **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) +- [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 | 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. + +## 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 | 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 +- **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, 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 + +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 │──┼─────────────────────┼─▶│ Prometheus │ │ - Explore │ │ +│ │ (actuator) │ │ │ └────────────┘ │ - Alerts │ │ +│ └───────────────┘ │ │ └─────────────┘ │ +│ ┌───────────────┐ │ │ ┌────────────┐ │ +│ │ Logs │──┼─────────────────────┼─▶│ Loki │ │ +│ │ (logback) │ │ │ └────────────┘ │ +│ └───────────────┘ │ │ │ +└─────────────────────┘ └───────────────────────────────────┘ +``` + +## Accessing Telemetry Data + +### Grafana Dashboard + +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: +- **Prometheus** - For metrics (the default datasource) +- **Tempo** - For distributed traces +- **Loki** - For logs +- **Pyroscope** - For continuous profiling + +### 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 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 +- 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 **Prometheus** 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` | `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 +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/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 473ff9ff..717b496a 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.1" 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.1" solr = "10.0.0" commons-csv = "1.14.1" -jspecify = "1.0.0" -mcp-server-security = "0.0.6" +mcp-server-security = "0.1.14" + +# 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/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(); 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/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/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..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 @@ -16,7 +16,10 @@ */ 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.assertFalse; +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 +52,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 +73,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..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 @@ -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) @@ -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: 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..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 @@ -16,7 +16,11 @@ */ 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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; import java.util.ArrayList; @@ -48,7 +52,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 +60,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;