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
71 changes: 62 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,40 @@ 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,
version **3.4.1**. Spring Boot 4.1.1's `CyclonedxPluginAction` recognises 3.x and
auto-configures the `cyclonedxBom` task (type `org.cyclonedx.gradle.CyclonedxAggregateTask`):
it writes `build/reports/cyclonedx/application.cdx.json`, embeds that in the bootJar at
`META-INF/sbom/application.cdx.json`, and sets the `Sbom-Format` / `Sbom-Location`
manifest headers the actuator needs. The actuator then 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.

What the build still configures by hand is **scope**. cyclonedx 3.x splits the work
across two tasks: `cyclonedxDirectBom` (`CyclonedxDirectTask`) resolves the dependency
graph and owns `includeConfigs`, while `cyclonedxBom` only aggregates its output. Left
at defaults the direct task scans every configuration, which adds ~100 test/build-only
components (JUnit, AssertJ, ByteBuddy, docker-java, JaCoCo, Error Prone, NullAway) that
are not in the fat jar. `build.gradle.kts` therefore sets
`includeConfigs = [productionRuntimeClasspath]` **on `cyclonedxDirectBom`**, giving a
141-component SBOM that matches `generateBinaryLicense`'s completeness gate exactly.

> **Do not drop that scoping.** Nothing in the build would catch it: the LICENSE appendix
> filters to shipped coordinates, and the completeness gate only fails on *missing*
> entries, never extra ones. The over-broad SBOM would ship silently and register as
> false-positive CVEs in scanners that read it as a manifest of the artifact's contents.

Historical note: this used to pin cyclonedx to **2.4.1**, because 3.x once failed at
configuration time on Gradle 9.4.1 (a variant-mutation conflict on `:cyclonedxDirectBom`).
That is fixed as of 3.4.1. The pin had a cost that was not obvious: because Spring Boot's
action bails on an unrecognised plugin version, and jar-embedding is part of that same
action, **the pinned build shipped no SBOM at all** — no `META-INF/sbom/` entry and no
`Sbom-*` manifest headers in the bootJar, hence nothing for `/actuator/sbom/application`
to serve. `./gradlew cyclonedxBom` still produced a report under `build/reports/`, which
is why this went unnoticed. Resolved along with
[#186](https://github.com/apache/solr-mcp/issues/186).

### Logging Architecture

Expand Down Expand Up @@ -222,6 +248,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 +403,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
112 changes: 101 additions & 11 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,34 @@ 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
// ==============
// What we no longer configure: *output*. Spring Boot 4.1.1's `CyclonedxPluginAction`
// auto-configures the `cyclonedxBom` task (type `org.cyclonedx.gradle.CyclonedxAggregateTask`)
// for cyclonedx 3.x -- it sets the output to `build/reports/cyclonedx/application.cdx.json`
// and makes the bootJar embed it at `META-INF/sbom/application.cdx.json`. The
// `org.apache.solr.mcp.license-notice` plugin reads the SBOM from that same path.
//
// What still needs configuring: *scope*. cyclonedx 3.x splits the work in two --
// `cyclonedxDirectBom` (`CyclonedxDirectTask`) resolves the dependency graph and owns
// `includeConfigs`; `cyclonedxBom` (`CyclonedxAggregateTask`) only aggregates its output.
// Left at defaults, the direct task scans every configuration, which puts JUnit, AssertJ,
// ByteBuddy, docker-java, JaCoCo, Error Prone and NullAway into the SBOM -- ~100 components
// that are not in the fat jar. That is invisible to our checks (the LICENSE appendix filters
// to shipped coordinates, and the completeness gate only fails on *missing* entries), but the
// SBOM ships at `META-INF/sbom/application.cdx.json` and is served from
// `/actuator/sbom/application`, where scanners read it as a claim about the artifact's
// contents -- test-only entries there become false-positive CVEs against a release.
// So scope the direct task to the shipped classpath, matching `generateBinaryLicense`.
//
// Historical note: this used to pin cyclonedx to 2.4.1 and set `outputName` by hand as well,
// because 3.x failed at configuration time on Gradle 9.4.1 (a variant-mutation conflict on
// `:cyclonedxDirectBom`). That is fixed as of 3.4.1, so the pin and the output-name wiring
// are gone -- see https://github.com/apache/solr-mcp/issues/186.
tasks.named<org.cyclonedx.gradle.CyclonedxDirectTask>("cyclonedxDirectBom") {
includeConfigs.set(listOf("productionRuntimeClasspath"))
}

// Maven Publishing Configuration
// ==============================
// This configuration enables publishing the project artifacts to Maven repositories.
Expand Down Expand Up @@ -143,28 +171,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 @@ -175,10 +216,38 @@ dependencies {

dependencyManagement {
imports {
// Declared before spring-ai-bom: the dependency-management plugin uses
// Maven "first declaration wins" semantics. spring-ai-bom does not manage
// the MCP SDK at all -- Spring AI 2.0.1 depends on mcp 2.0.0 directly --
// so this BOM is what lifts the whole SDK to 2.0.1 as one coherent set
// rather than pinning mcp-core and leaving mcp-json-jackson3 behind.
mavenBom("io.modelcontextprotocol.sdk:mcp-bom:${libs.versions.mcp.sdk.get()}")
mavenBom("org.springframework.ai:spring-ai-bom:${libs.versions.spring.ai.get()}")
}
}

// 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 +373,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 +532,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 +547,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
2 changes: 1 addition & 1 deletion buildSrc/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ dependencies {
// here (mirroring how the junit dep below is pinned) since buildSrc does not read the
// root project's version catalog. Latest release as of writing; bump in lockstep with
// the plugin's upstream releases.
implementation("org.nosphere.apache.rat:org.nosphere.apache.rat.gradle.plugin:0.8.1")
implementation("org.nosphere.apache.rat:org.nosphere.apache.rat.gradle.plugin:0.8.2")

// Only used by the task unit tests under src/test (the main code needs no extra deps;
// the Gradle API is provided by the kotlin-dsl plugin).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ val generateBinaryLicense =
group = "documentation"
dependsOn("cyclonedxBom")
baseLicense.set(licenseFile)
sbom.set(layout.buildDirectory.file("reports/application.cdx.json"))
sbom.set(layout.buildDirectory.file("reports/cyclonedx/application.cdx.json"))
bundledCoordinates.set(shippedCoordinates)
outputFile.set(layout.buildDirectory.file("generated/license/LICENSE"))
}
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