Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 47 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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*
Expand Down Expand Up @@ -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`.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
100 changes: 89 additions & 11 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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<org.cyclonedx.gradle.CycloneDxTask>("cyclonedxBom") {
setOutputName("application.cdx")
includeConfigs.set(listOf("productionRuntimeClasspath"))
}

// Maven Publishing Configuration
// ==============================
// This configuration enables publishing the project artifacts to Maven repositories.
Expand Down Expand Up @@ -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)
Expand All @@ -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")
Expand Down Expand Up @@ -304,6 +361,20 @@ tasks.named<JavaCompile>("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)
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
57 changes: 37 additions & 20 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading