This is an automated email from the ASF dual-hosted git repository.

epugh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr-mcp.git


The following commit(s) were added to refs/heads/main by this push:
     new 56c16f6  Prune out ai docs, but keep details on graalvm. (#156)
56c16f6 is described below

commit 56c16f6ba25bd9d208758e1566e4739dff4ba9d0
Author: Eric Pugh <[email protected]>
AuthorDate: Fri Jun 19 12:15:43 2026 -0400

    Prune out ai docs, but keep details on graalvm. (#156)
---
 .github/workflows/native.yml                       |    1 -
 AGENTS.md                                          |    4 +-
 README.md                                          |    2 +-
 build.gradle.kts                                   |   12 +-
 dev-docs/DEPLOYMENT.md                             |    2 +-
 dev-docs/graalvm-native-image.md                   |  304 ++++++
 docs/specs/graalvm-native-image.md                 |  382 --------
 .../plans/2026-05-17-schema-modification.md        | 1011 --------------------
 .../plans/2026-06-05-sbom-generation.md            |  673 -------------
 .../specs/2026-05-17-schema-modification-design.md |  400 --------
 .../specs/2026-06-05-sbom-generation-design.md     |  160 ----
 scripts/benchmark-native.sh                        |    1 +
 12 files changed, 315 insertions(+), 2637 deletions(-)

diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml
index 0d28c6f..6ab5aa3 100644
--- a/.github/workflows/native.yml
+++ b/.github/workflows/native.yml
@@ -38,7 +38,6 @@ on:
         paths:
             - 'build.gradle.kts'
             - 'gradle/libs.versions.toml'
-            - 'docs/specs/graalvm-native-image.md'
             - 'scripts/benchmark-native.sh'
             - '.github/workflows/native.yml'
             - 'src/main/java/**/*NativeHints*.java'
diff --git a/AGENTS.md b/AGENTS.md
index e301bad..2d08918 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -129,8 +129,6 @@ actuator serves it at `/actuator/sbom/application` in the 
`http` profile
 Paketo native images package the bootJar contents, so every distribution
 artifact ships the SBOM without per-image wiring.
 
-Spec: 
[docs/superpowers/specs/2026-06-05-sbom-generation-design.md](docs/superpowers/specs/2026-06-05-sbom-generation-design.md)
-
 ### Logging Architecture
 
 The STDIO transport uses stdout for JSON-RPC messages, so any stray stdout 
output
@@ -222,7 +220,7 @@ buildpacks (`bootBuildImage -Pnative`). Key configuration:
   Paketo native = `solr-mcp:<version>-native-stdio` /
   `solr-mcp:<version>-native-http` (with corresponding `:latest-native-*` 
tags).
 - **CI:** Separate `native.yml` workflow; native failures do not block 
JVM-path merges.
-- **Spec:** 
[docs/specs/graalvm-native-image.md](docs/specs/graalvm-native-image.md)
+- **Spec:** 
[dev-docs/graalvm-native-image.md](dev-docs/graalvm-native-image.md)
 
 ## Release LICENSE / NOTICE
 
diff --git a/README.md b/README.md
index 80607b6..a6234f9 100644
--- a/README.md
+++ b/README.md
@@ -158,7 +158,7 @@ Running in **HTTP mode** — OAuth2, CORS, and the 
`HTTP_SECURITY_ENABLED` toggl
 **Developing it**
 - [Development guide](dev-docs/DEVELOPMENT.md) — build, run, test, IDE, native 
image, SBOM · [Architecture](dev-docs/ARCHITECTURE.md)
 - [Deployment](dev-docs/DEPLOYMENT.md) — Docker images, the three-image 
matrix, registries, Kubernetes · [Troubleshooting](dev-docs/TROUBLESHOOTING.md)
-- [GraalVM native image spec](docs/specs/graalvm-native-image.md) · 
[Contributing](CONTRIBUTING.md)
+- [GraalVM native image spec](dev-docs/graalvm-native-image.md) · 
[Contributing](CONTRIBUTING.md)
 
 > **Container images:** published images are not yet available on a public 
 > registry. The Docker examples in the client guides use a **locally built** 
 > image — build it with `./gradlew jibDockerBuild` (produces 
 > `solr-mcp:latest`). See [Building Docker 
 > images](dev-docs/DEPLOYMENT.md#docker-images-with-jib).
 
diff --git a/build.gradle.kts b/build.gradle.kts
index c9052fb..f1df742 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -503,11 +503,13 @@ 
tasks.named<org.springframework.boot.gradle.tasks.bundling.BootBuildImage>("boot
 // `nativeTest` tasks and triggers Spring Boot's bootBuildImage to use the
 // Paketo native-image buildpack.
 //
-// AOT runs with the stdio profile only. The http profile sets
-// spring.main.web-application-type=servlet, which Spring AOT bakes in at
-// build time — activating both profiles produces a binary that always starts
-// Tomcat regardless of runtime PROFILES, breaking STDIO. The native image is
-// therefore STDIO-only.
+// AOT runs with a single pinned profile ($nativeProfile, default stdio). The
+// http profile sets spring.main.web-application-type=servlet, which Spring AOT
+// bakes in at build time — activating both profiles produces a binary that
+// always starts Tomcat regardless of runtime PROFILES, breaking STDIO. There 
is
+// no way to defer the choice to runtime in a native image, so each binary is
+// pinned to one transport and we build one native image per profile
+// (solr-mcp:<v>-native-stdio and solr-mcp:<v>-native-http).
 if (nativeBuild) {
     
extensions.configure<org.graalvm.buildtools.gradle.dsl.GraalVMExtension>("graalvmNative")
 {
         binaries {
diff --git a/dev-docs/DEPLOYMENT.md b/dev-docs/DEPLOYMENT.md
index 93d44ee..629cb04 100644
--- a/dev-docs/DEPLOYMENT.md
+++ b/dev-docs/DEPLOYMENT.md
@@ -130,7 +130,7 @@ docker run -p 8080:8080 --rm \
 }
 ```
 
-See [docs/specs/graalvm-native-image.md](../docs/specs/graalvm-native-image.md)
+See [graalvm-native-image.md](graalvm-native-image.md)
 for the native image design and known risks.
 
 ## Running Docker Containers
diff --git a/dev-docs/graalvm-native-image.md b/dev-docs/graalvm-native-image.md
new file mode 100644
index 0000000..0436b66
--- /dev/null
+++ b/dev-docs/graalvm-native-image.md
@@ -0,0 +1,304 @@
+# GraalVM Native Image
+
+How the project builds, tests, and ships GraalVM native images, and the
+hard-won knowledge needed to keep them working. This is the deep-dive
+reference; for the short version (commands, the image matrix) see the
+**GraalVM Native Image** and **Image × Mode matrix** sections of `AGENTS.md`.
+
+## What exists today
+
+Native image is **opt-in**, gated entirely behind the `-Pnative` Gradle
+property. Without it, nothing native-related is applied and the build is a
+plain JVM build.
+
+The project ships three Docker artifacts; two of them are native:
+
+| Image                          | Toolchain | Transport |
+|--------------------------------|-----------|-----------|
+| `solr-mcp:<v>`                 | Jib       | stdio + http (runtime 
`PROFILES`) |
+| `solr-mcp:<v>-native-stdio`    | Paketo    | stdio only |
+| `solr-mcp:<v>-native-http`     | Paketo    | http only  |
+
+The single most important fact about the native path: **a native binary is
+AOT-pinned to exactly one Spring profile**, so we build one native image per
+transport rather than one dual-mode image. Everything below follows from that.
+
+## Why native at all
+
+The motivating use case is local STDIO: an MCP client (e.g. Claude Desktop)
+launches the container on demand, once per session. There the JVM's costs
+dominate — cold-start warm-up on every session, a large idle RSS for a Spring
+Boot + Spring AI + SolrJ process, and a hundreds-of-MB image carrying a JRE
+layer. A native image trades build-time complexity for sub-second startup,
+much lower RSS, and a smaller self-contained image. Spring AI 1.1's first-class
+AOT support is what makes this tractable here.
+
+The JVM image is **not** going away — it remains the default and the only 
multi-arch-from-one-build artifact. Native is an alternative, not a replacement.
+
+## Build wiring
+
+All native configuration lives in `build.gradle.kts` and is guarded by two
+properties:
+
+- **`-Pnative`** (`val nativeBuild = project.hasProperty("native")`) — the
+  master switch. Only when set is the `org.graalvm.buildtools.native` plugin
+  applied, the `graalvmNative { … }` block configured, and the `processAot`
+  profile pin installed. `nativeCompile`, `nativeTest`, and the native variant
+  of `bootBuildImage` all require it.
+- **`-Pprofile=stdio|http`** (`nativeProfile`, default `stdio`) — selects the
+  Spring profile baked in at AOT time, and therefore which transport the
+  resulting binary serves. Validated early: an invalid value fails the
+  configuration phase.
+
+The GraalVM image arguments are defined once and shared by both the local
+`graalvmNative` builds and the Paketo `bootBuildImage` builds, so the two paths
+can never drift:
+
+```kotlin
+val nativeImageBuildArgs = listOf(
+    "--no-fallback",
+    "-H:+ReportExceptionStackTraces",
+    "--initialize-at-build-time=io.opentelemetry.api",
+    "--initialize-at-build-time=io.opentelemetry.context",
+    "--initialize-at-build-time=io.opentelemetry.instrumentation.api",
+    "--initialize-at-build-time=io.opentelemetry.instrumentation.logback",
+)
+```
+
+`bootBuildImage` passes these through to the buildpack via
+`BP_NATIVE_IMAGE_BUILD_ARGUMENTS`, names the image 
`solr-mcp:<v>-native-<profile>`,
+and pins the runtime profile (`SPRING_PROFILES_ACTIVE` / 
`BPE_DEFAULT_PROFILES`).
+Compilation happens **inside a Linux Paketo builder container**, so a macOS or
+Windows host still produces a working Linux binary — no cross-compilation
+toolchain to manage. The cost is a large (~1 GB) one-time builder download.
+
+For local work without Docker, the plugin also registers `nativeCompile` 
(host-OS
+binary) and `nativeTest` (run the test suite as a native image). Both need a
+**GraalVM JDK 25** on `JAVA_HOME`/`PATH` (e.g. `sdk install java 25-graalce`);
+the native build tools plugin reads it from the environment rather than via
+Gradle toolchain auto-detection.
+
+## The AOT-per-profile constraint (why two native images)
+
+Spring AOT runs **once at build time** with a fixed set of active profiles and
+freezes the resulting bean graph — including `spring.main.web-application-type`
+— into the binary. The `http` profile sets that to `servlet`; `stdio` leaves it
+`none`. If you activate both profiles during AOT, `http` wins, `servlet` is
+baked in, and the binary **always starts Tomcat regardless of the runtime
+`PROFILES` value**. That breaks STDIO at the protocol level (Tomcat logging and
+startup noise corrupt the JSON-RPC stream).
+
+There is no way to defer this decision to runtime in a native image. So we pin
+it: `processAot` is configured to run with 
`--spring.profiles.active=$nativeProfile`,
+and we produce a separate binary per profile. The `stdio` binary excludes the
+web servlet beans; the `http` binary includes them.
+
+```kotlin
+tasks.named<JavaExec>("processAot") {
+    args("--spring.profiles.active=$nativeProfile")
+}
+```
+
+This is also why the JVM Jib image can be dual-mode while the native images
+cannot: the JVM image makes the `web-application-type` decision at *runtime*
+from the `PROFILES` env var; the native image already made it at *build time*.
+
+## Why Jib for JVM, Paketo for native
+
+- **Jib (JVM image).** Plain `java -jar` entrypoint, no launcher script, so
+  stdout stays clean for MCP STDIO. Builds multi-arch (amd64 + arm64) from a
+  single invocation without a Docker daemon. One image serves both transports.
+- **Paketo `bootBuildImage` (native images).** Solves the cross-OS compile
+  problem and runs the compiled binary directly as PID 1, so its stdout is
+  clean too.
+- **Why not Paketo for the JVM image:** Paketo's `libjvm` helpers (memory
+  calculator, NMT, ca-certificates) write ~6 status lines to stdout *before*
+  the JVM starts, which breaks MCP STDIO. Filed upstream as
+  
[paketo-buildpacks/libjvm#482](https://github.com/paketo-buildpacks/libjvm/issues/482).
+  The native images don't hit this because there's no JVM launcher in front of
+  the binary. Multi-arch for native is handled in CI via a GitHub Actions
+  matrix rather than from one build.
+
+## Reflection and resource hints
+
+Spring AOT generates most hints automatically, but a few types are invisible to
+it and must be registered by hand. They live in
+`SolrConfig`'s sibling `SolrNativeHints.java`, a `@Configuration` annotated
+`@ImportRuntimeHints(...)`. Registering hints through a `RuntimeHintsRegistrar`
+(rather than scattering `@RegisterReflection` annotations) keeps the whole
+reflective surface in one reviewable place. The class is registered
+unconditionally; on the JVM path it is simply a no-op.
+
+### Wire format choice underpins the small hint surface
+
+`SolrConfig` deliberately builds the client to **avoid SolrJ's JavaBin codec**,
+which uses deep reflection that would otherwise demand extensive native
+metadata:
+
+- Responses use the **JSON** parser (`JsonResponseParser` on
+  `HttpJdkSolrClient`), not JavaBin.
+- Updates use `XMLRequestWriter`, not the default `JavaBinRequestWriter`.
+
+Because the JavaBin path is never taken, the hints reduce to a narrow set of
+container and value types.
+
+### What's registered, and why
+
+- **SolrJ response/value types** — `QueryResponse`, `UpdateResponse`,
+  `NamedList`, `SimpleOrderedMap`, `SolrDocument`, `SolrDocumentList`,
+  `SolrInputDocument`, `SolrInputField`, `FacetField`, `FacetField.Count`.
+  These are the JSON response containers and indexing inputs reflected over at
+  runtime.
+- **SolrJ schema request types** — `AnalyzerDefinition`, `FieldTypeDefinition`.
+  Needed for Jackson's `convertValue` when `add-field-types` deserializes
+  analyzer trees in native image.
+- **SolrJ schema response type** — `SchemaRepresentation`, returned by the
+  `get-schema` MCP tool. Without its hints, Spring AI's JSON in native image
+  silently drops the `fields`/`fieldTypes`/`dynamicFields`/`copyFields` arrays 
—
+  a quiet correctness bug, not a crash.
+- **MCP tool response records** — `CollectionCreationResult`, 
`SolrHealthStatus`,
+  `SolrMetrics`, `IndexStats`, `FieldStats`, `QueryStats`, `CacheStats`,
+  `CacheInfo`, `HandlerStats`, `HandlerInfo`, `SearchResponse`,
+  `SchemaUpdateResult`. These are package-private records the MCP framework
+  dispatches via generic `Object`, so AOT can't see them. They're registered by
+  name with `registerTypeIfPresent`.
+- **`logback.xml` resource.** Registered as a resource pattern so logback's
+  early (pre-Spring) initialization finds it and installs the 
`NopStatusListener`.
+  Without it, logback falls through to `BasicConfigurator` and writes status
+  lines to stdout, corrupting STDIO framing. (See the Logging Architecture
+  section of `AGENTS.md`.)
+
+### Adding a hint when a native run fails
+
+1. Reproduce with `./gradlew nativeTest -Pnative` (or run the native binary and
+   trigger the failing path). GraalVM reports the missing reflective/resource
+   element.
+2. Add a targeted registration in `SolrNativeHints.Registrar` — a single
+   `registerType(...)` with `INVOKE_DECLARED_CONSTRUCTORS`,
+   `INVOKE_DECLARED_METHODS`, `DECLARED_FIELDS`, or a `registerPattern(...)` 
for
+   a resource. Prefer this over the tracing agent so the rule is explicit and
+   reviewed.
+3. Only fall back to the native-image agent (`-agentlib:native-image-agent`) if
+   static analysis of the failure is too noisy; commit any generated metadata
+   under `src/main/resources/META-INF/native-image/`.
+
+## 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`:
+
+- `io.opentelemetry.api` — `InternalAttributeKeyImpl`, `AttributeType`
+- `io.opentelemetry.context` — context propagation
+- `io.opentelemetry.instrumentation.api` — `MapBackedCache`
+- `io.opentelemetry.instrumentation.logback` — the logback appender
+
+**Do not add `io.opentelemetry.instrumentation.spring`.** It contains CGLIB
+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.
+
+The **native test binary** needs a few extra entries beyond the shared args
+(see the `named("test")` block): `io.opentelemetry.sdk` (a build-time
+`ServiceLoader` provider), `--initialize-at-run-time` for
+`AndroidFriendlyRandomHolder` (it seeds a `java.util.Random` in `<clinit>`,
+which GraalVM forbids in the image heap), and the JUnit Platform 
launcher/engine
+packages (the native JUnit launcher embeds the test plan in the image heap).
+
+## Security and profiles under native
+
+Security stays globally available so the JVM `http` profile is unaffected — the
+`@SpringBootApplication` on `Main` is **never** modified. Instead, the `stdio`
+profile keeps security out of the AOT graph through configuration that already
+exists for the JVM build:
+
+- `application-stdio.properties` excludes `SecurityAutoConfiguration` and
+  `ManagementWebSecurityAutoConfiguration` via `spring.autoconfigure.exclude`.
+- `HttpSecurityConfiguration` and `MethodSecurityConfiguration` are
+  `@Profile("http")`.
+
+Because `processAot` runs with the pinned profile, the `stdio` native binary
+captures the bean graph *with* those exclusions, and the security/OAuth2 
classes
+never enter its closed world.
+
+## Building and running
+
+```bash
+# Local native binary (host OS; needs GraalVM JDK 25)
+./gradlew nativeCompile -Pnative
+
+# Native Docker images (compiled inside a Paketo Linux builder; any host OS)
+./gradlew bootBuildImage -Pnative                      # 
solr-mcp:<v>-native-stdio
+./gradlew bootBuildImage -Pnative -Pprofile=http       # 
solr-mcp:<v>-native-http
+
+# Run
+docker run -i --rm \
+    -e SOLR_URL=http://host.docker.internal:8983/solr/ 
solr-mcp:latest-native-stdio
+docker run -p 8080:8080 --rm -e PROFILES=http \
+    -e SOLR_URL=http://host.docker.internal:8983/solr/ 
solr-mcp:latest-native-http
+```
+
+## Testing
+
+- **`./gradlew nativeTest -Pnative`** runs the integration test suite compiled
+  as a native image — the truest proof that the closed world is complete. It is
+  deliberately **not** part of `./gradlew build` (a native compile per run is
+  slow); it runs in its own CI job.
+- **`./gradlew dockerIntegrationTest -Pnative`** builds the native-stdio image
+  and re-runs the STDIO black-box scenarios against it;
+  `-Pnative -Pprofile=http` does the same for the native-http image and its 
HTTP
+  scenarios. The native-stdio image skips the HTTP test (no servlet beans in 
its
+  closed world) and vice versa. See the Image × Mode test-coverage table in
+  `AGENTS.md`.
+
+Note that Mockito-based unit tests are `@DisabledInNativeImage` — ByteBuddy
+proxies don't survive GraalVM's closed-world assumption.
+
+## Benchmarking
+
+`scripts/benchmark-native.sh` (Linux/CI) builds the JVM and native images and
+compares them on the metrics that matter for the STDIO use case: image size on
+disk, cold-start time, idle RSS after startup, and RSS after one `search` call.
+Each measurement is the median of several runs; results are written to
+`docs/specs/benchmark-results.md` (a generated artifact, not checked in).
+
+The native image is considered a win when **all** hold: startup ≤ 25% of JVM,
+idle RSS ≤ 50% of JVM, image size ≤ 60% of JVM. If a threshold misses, keep the
+numbers and the opt-in flag and document the gap rather than blocking.
+
+## CI
+
+A separate `native.yml` workflow exercises the native path on PRs that touch
+native-related files (this doc, `build.gradle.kts`, 
`gradle/libs.versions.toml`,
+`scripts/benchmark-native.sh`, the workflow itself). It runs
+`dockerIntegrationTest` over a `[stdio, http]` matrix so both native variants 
are
+covered, and provides multi-arch images via a build matrix. **Native failures 
do
+not block JVM-path merges** — the default PR build (`./gradlew build`) stays
+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.
+- **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;
+  CI caching mitigates it.
+- **`mcp-server-security`.** Small, non-Spring-official library. If it ever
+  grows an eager `@Configuration` that loads outside `@Profile("http")`, it
+  could pull security classes into the STDIO AOT graph — watch for it when
+  upgrading.
+- **Profile-Guided Optimization (PGO)** and publishing native images to a 
public
+  registry from CI are not done yet.
diff --git a/docs/specs/graalvm-native-image.md 
b/docs/specs/graalvm-native-image.md
deleted file mode 100644
index d46aaba..0000000
--- a/docs/specs/graalvm-native-image.md
+++ /dev/null
@@ -1,382 +0,0 @@
-# Spec: GraalVM Native Image Support (Opt-In, bootBuildImage, STDIO)
-
-Status: **Partially superseded** — this spec describes the original plan
-(Jib for JVM images, `bootBuildImage` for native images, STDIO profile only
-for native). The current state has evolved:
-
-- Jib has been **dropped**. Both JVM and native images are now built with
-  `bootBuildImage` (Paketo Cloud Native Buildpacks).
-- Native AOT now processes both `stdio` and `http` profiles, so the native
-  image serves both modes via the runtime `PROFILES` env var.
-- The JVM Paketo image suffers from stdout pollution (libjvm helpers) and is
-  therefore HTTP-only; the native image is the recommended STDIO artifact.
-
-See `CLAUDE.md` (Image × Mode matrix) and `README.md` (Building Docker images)
-for the current commands and tradeoffs. The historical content below is kept
-for context on the original design decisions.
-
-Owner: TBD
-Target branch: `claude/graalvm-native-image-support-u1RqL`
-Related: [Spring AI 1.1 blog 
post](https://spring.io/blog/2025/05/20/your-first-spring-ai-1)
-
-## 1. Motivation
-
-The Docker image produced by Jib currently ships the app on 
`eclipse-temurin:25-jre`.
-For the **local STDIO use case** (Claude Desktop launching the container on 
demand
-per session), JVM cold start and memory overhead are the main pain points:
-
-- Each new session pays the JVM warm-up cost.
-- Idle memory of a Spring Boot + Spring AI + SolrJ process is substantial even 
before
-  it does any work.
-- The image is hundreds of MB.
-
-A GraalVM native image trades build-time complexity for:
-
-- Sub-second startup.
-- Significantly lower RSS.
-- A smaller, self-contained image (no JRE layer).
-
-Spring AI 1.1 added first-class AOT/native support, which makes this tractable
-for this project.
-
-## 2. Goals
-
-1. Add an **opt-in** native image build path, triggered by a Gradle property
-   (`-Pnative`), that produces a Docker image via `bootBuildImage`.
-2. Keep the default build (JVM mode) unchanged.
-3. Prove correctness: the existing test suite passes under `nativeTest`.
-4. Prove the win: a reproducible benchmark script measures startup time,
-   resident memory, and image size for JVM vs native, and the results are
-   recorded in this spec.
-5. Target transport: **STDIO profile only** for the initial cut. HTTP mode is
-   out of scope for v1 but not precluded.
-
-## 3. Non-Goals
-
-- Replacing the JVM image. Both flavors ship.
-- Native image support for the HTTP profile (OAuth2, actuator, Prometheus
-  registry). These often need extra reachability metadata; deferred to a
-  follow-up.
-- JMH-style throughput benchmarks. Startup / RSS / disk only.
-
-## 4. High-Level Approach
-
-1. Add the **GraalVM Native Build Tools** plugin
-   (`org.graalvm.buildtools.native`) alongside the existing Spring Boot plugin.
-   Spring Boot's AOT tasks (`processAot`, `processTestAot`) are picked up
-   automatically.
-2. For native Docker images, `bootBuildImage` is used with
-   `BP_NATIVE_IMAGE=true`. This compiles the native binary inside a Paketo
-   builder container, so it works on any host OS/architecture — no
-   cross-compilation needed.
-3. Jib remains for JVM images (proven stdout-clean for STDIO).
-4. For local native builds/tests (without Docker), `nativeCompile` and
-   `nativeTest` are still available but produce host-OS binaries.
-5. `processAot` runs with `--spring.profiles.active=stdio` under `-Pnative`
-   so the correct bean graph (with security exclusions) is captured.
-6. Native tests (`nativeTest`) run in a separate CI job, not as part of
-   `./gradlew build`, because they are slow (image compile per run).
-
-### 4.1 Why the Jib / bootBuildImage split
-
-- **Jib for JVM images:** Proven clean stdout, multi-arch support
-  (amd64/arm64), and no Docker daemon needed for registry push. This is the
-  default image used by MCP STDIO clients.
-- **`bootBuildImage` for native images:** Compiles inside a Linux Paketo
-  builder container, solving the cross-OS problem (macOS hosts produce Linux
-  binaries). The native binary IS the entrypoint — no buildpack launcher sits
-  in between — so stdout is clean.
-- The original concern about `bootBuildImage` stdout pollution applies to the
-  **JVM buildpack launcher**, not to native images where the compiled binary
-  runs directly as PID 1.
-
-## 5. Gradle Changes
-
-### 5.1 Plugin
-
-`build.gradle.kts` plugins block:
-```kotlin
-alias(libs.plugins.graalvm.native)       // new, version-catalogued
-```
-
-`gradle/libs.versions.toml`:
-```toml
-[versions]
-graalvm-native = "0.10.6"   # latest at time of writing; verify
-
-[plugins]
-graalvm-native = { id = "org.graalvm.buildtools.native", version.ref = 
"graalvm-native" }
-```
-
-### 5.2 Toolchain & Build Args
-
-`nativeCompile` requires a GraalVM JDK on `PATH` or `JAVA_HOME`. The plugin
-reads the location from the environment (toolchain detection is disabled by
-default in the native build tools plugin). CI sets this up via
-`graalvm/setup-graalvm@v1`; locally, use SDKMAN (`sdk install java
-25.0.2-graalce`) or download from https://www.graalvm.org.
-
-```kotlin
-graalvmNative {
-    binaries {
-        named("main") {
-            imageName.set("solr-mcp")
-            buildArgs.addAll(
-                "--no-fallback",
-                "-H:+ReportExceptionStackTraces",
-                // OTel 2.11.0 lacks native metadata — see §6.2
-                "--initialize-at-build-time=io.opentelemetry.api",
-                "--initialize-at-build-time=io.opentelemetry.context",
-                
"--initialize-at-build-time=io.opentelemetry.instrumentation.api",
-                
"--initialize-at-build-time=io.opentelemetry.instrumentation.logback",
-            )
-        }
-        named("test") {
-            buildArgs.addAll(
-                "--no-fallback",
-                "--initialize-at-build-time=io.opentelemetry.api",
-                "--initialize-at-build-time=io.opentelemetry.context",
-                
"--initialize-at-build-time=io.opentelemetry.instrumentation.api",
-                
"--initialize-at-build-time=io.opentelemetry.instrumentation.logback",
-            )
-        }
-    }
-}
-```
-
-### 5.3 Opt-in flag
-
-```kotlin
-val nativeBuild = project.hasProperty("native")
-```
-
-The `-Pnative` flag is only needed for:
-- `nativeCompile` — triggers `processAot` with the STDIO profile.
-- `dockerIntegrationTest` — selects the `-native` image tag suffix.
-
-`bootBuildImage` is always configured for native builds (it passes
-`BP_NATIVE_IMAGE=true` unconditionally). No `-Pnative` flag is required
-to run it.
-
-### 5.4 bootBuildImage config for native
-
-```kotlin
-tasks.named<BootBuildImage>("bootBuildImage") {
-    imageName.set("solr-mcp:${version}-native")
-    tags.set(listOf("solr-mcp:latest-native"))
-    environment.set(mapOf(
-        "BP_NATIVE_IMAGE" to "true",
-        "BP_NATIVE_IMAGE_BUILD_ARGUMENTS" to 
nativeImageBuildArgs.joinToString(" "),
-        "BP_JVM_VERSION" to "25",
-    ))
-}
-```
-
-### 5.5 Native tests
-
-No extra config needed beyond the plugin — `./gradlew nativeTest` is provided
-by `org.graalvm.buildtools.native`. We do **not** wire it into `./gradlew 
build`.
-It is invoked explicitly in a dedicated CI job.
-
-Docker integration tests (`@Tag("docker-integration")`) should gain a
-native counterpart that builds the `-native` image and re-runs the STDIO
-integration scenario against it. This is the end-to-end proof.
-
-## 6. Reflection / Resource Hints
-
-### 6.1 SolrJ (JSON wire format only)
-
-The client is constructed in `SolrConfig.java` as:
-
-```java
-new HttpJdkSolrClient.Builder(url)
-    .withResponseParser(jsonResponseParser)   // JSON, not JavaBin
-    .build();
-```
-
-This means the **JavaBin codec path is not taken**, which is the SolrJ
-surface most frequently cited as native-hostile. The XML response parser
-is similarly out of scope.
-
-`SolrNativeHints.java` registers reflection hints for the narrow set of
-types actually used: `QueryResponse`, `UpdateResponse`, `NamedList`,
-`SolrDocument`, and `SolrDocumentList`.
-
-### 6.2 OpenTelemetry / Micrometer tracing
-
-The OpenTelemetry Spring Boot Starter officially supports native image
-in newer versions. However, the project currently pins
-`opentelemetry-instrumentation-bom:2.11.0`, which does **not** ship
-native-image reachability metadata. The version catalog declares `2.26.1`
-but bumping introduces an OTel SDK incompatibility with Spring Boot
-3.5.x (`io.opentelemetry.common.ComponentLoader` not found), so the
-bump is deferred until the OTel SDK and Spring Boot BOMs are aligned.
-
-**Build-time initialization workaround:** The OTel logback appender's
-`LoggingEventMapper` holds static `AttributeKey` fields (via
-`InternalAttributeKeyImpl`) that end up in the image heap. GraalVM
-requires their types to be build-time initialized. The `graalvmNative`
-block adds targeted `--initialize-at-build-time` for four OTel packages:
-- `io.opentelemetry.api` — `InternalAttributeKeyImpl`, `AttributeType`
-- `io.opentelemetry.context` — context propagation
-- `io.opentelemetry.instrumentation.api` — `MapBackedCache`
-- `io.opentelemetry.instrumentation.logback` — the logback appender
-
-**Important:** `io.opentelemetry.instrumentation.spring` must NOT be
-included — it contains CGLIB proxy classes that cannot be build-time
-initialized.
-
-The OTLP/gRPC exporter is only wired in the HTTP profile, so the STDIO
-native image does not exercise its reflection surface.
-
-### 6.3 Security / OAuth2 on classpath under STDIO
-
-Current state (already good):
-- `application-stdio.properties` excludes `SecurityAutoConfiguration` and
-  `ManagementWebSecurityAutoConfiguration` via `spring.autoconfigure.exclude`.
-- `HttpSecurityConfiguration.java` is annotated `@Profile("http")`.
-- `MethodSecurityConfiguration.java` is `@Profile("http")`.
-
-**AOT mitigation:** The `processAot` Gradle task is configured (under
-`-Pnative` only) to pass `--spring.profiles.active=stdio`, so the STDIO
-property exclusions are active during AOT hint generation.
-
-**Important:** The `@SpringBootApplication` annotation on `Main` is
-**not** modified. Security autoconfiguration remains globally available
-so the HTTP profile continues to work without any special handling.
-
-### 6.4 Hints workflow
-
-1. First pass: build and run `nativeTest` with `-Pnative`. Fix each
-   reflection/resource failure by adding a targeted hint via a
-   `RuntimeHintsRegistrar` in a `@Configuration` class registered with
-   `@ImportRuntimeHints`. Preferred over annotation-scattering because
-   the rules are centralized and reviewable.
-2. Only fall back to the agent (`-agentlib:native-image-agent`) if
-   static analysis of the failures is too noisy. Agent output goes to
-   `src/main/resources/META-INF/native-image/org.apache.solr/solr-mcp/`
-   and is committed.
-
-## 7. Profile / Application Config
-
-- Native v1 targets the STDIO profile. The native image's default profile
-  is set via `SPRING_PROFILES_ACTIVE=stdio` env var in the
-  `bootBuildImage` environment map.
-- The `processAot` task also runs with `--spring.profiles.active=stdio`
-  so the correct bean graph (with security exclusions) is captured.
-- Actuator, Prometheus registry, Security starters: kept on the classpath.
-  They do not interfere with STDIO AOT processing because the STDIO
-  profile excludes security autoconfig and the HTTP-only `@Configuration`
-  classes are not loaded.
-
-## 8. Benchmark Plan
-
-### 8.1 Script
-
-`scripts/benchmark-native.sh` (new). Requirements:
-- Runs on Linux (CI or Linux dev box).
-- Builds both images:
-  - `./gradlew jibDockerBuild` → `solr-mcp:<v>` (JVM)
-  - `./gradlew bootBuildImage` → `solr-mcp:<v>-native`
-- For each image, measures:
-  - **Image size on disk** via `docker image inspect <img> --format 
'{{.Size}}'`.
-  - **Startup time**: time from `docker run` until the container prints its
-    MCP "server ready" signal on stdout (STDIO mode). If no such signal
-    exists, add one in `Main` behind a `solr.mcp.startup.log=true` flag that
-    is enabled for benchmarks only.
-  - **Memory after startup**: after server-ready, sample
-    `docker stats --no-stream --format '{{.MemUsage}}'` N times over 5 seconds,
-    record the minimum (steady-state idle RSS).
-  - **Memory after one search**: drive one MCP `search` call via the existing
-    client harness, then resample.
-- Each measurement is the median of 5 runs.
-- Output a markdown table to stdout and write 
`docs/specs/benchmark-results.md`.
-
-### 8.2 Results table (to be filled in after implementation)
-
-| Metric                          | JVM (`:<v>`) | Native (`:<v>-native`) | 
Delta |
-|---------------------------------|--------------|------------------------|-------|
-| Image size (MB)                 | TBD          | TBD                    | 
TBD   |
-| Cold start (ms)                 | TBD          | TBD                    | 
TBD   |
-| Idle RSS after start (MB)       | TBD          | TBD                    | 
TBD   |
-| RSS after first search (MB)     | TBD          | TBD                    | 
TBD   |
-| `nativeTest` wall-clock         | n/a          | TBD                    | 
n/a   |
-
-### 8.3 Acceptance thresholds
-
-The native image is considered a win for the STDIO use case if **all** hold:
-- Startup ≤ 25% of JVM startup.
-- Idle RSS ≤ 50% of JVM idle RSS.
-- Image size ≤ 60% of JVM image size.
-
-If any threshold fails, capture the numbers anyway and keep the native
-path as opt-in behind the same flag; document the gap.
-
-## 9. CI
-
-Add a separate GitHub Actions workflow (or job in the existing one)
-`native.yml`:
-- Triggers: `workflow_dispatch` and on PRs touching this spec, the native
-  config, or `gradle/libs.versions.toml`.
-- Steps:
-  1. Set up GraalVM JDK 25 (via `graalvm/setup-graalvm@v1`).
-  2. `./gradlew nativeTest`
-  3. `./gradlew bootBuildImage`
-  4. `./gradlew dockerIntegrationTest -Pnative` (native-mode variant of the
-     STDIO integration test).
-  5. `scripts/benchmark-native.sh` — upload results table as a job artifact.
-
-The default PR build (`./gradlew build`) remains JVM-only and fast.
-
-## 10. Rollout
-
-1. Land Gradle plumbing behind `-Pnative` with no native-specific hints.
-2. Iterate on hints until `nativeTest` is green.
-3. Add `dockerIntegrationTest -Pnative` path and the STDIO integration test
-   variant.
-4. Land `scripts/benchmark-native.sh` and fill in section 8.2.
-5. Update root README with a "Native image (experimental)" section pointing
-   at this spec, plus the one-liner build command.
-6. Tag the native image as `:latest-native` so users can opt in on pull:
-   `docker pull solr-mcp:latest-native`.
-
-## 11. Risks & Open Questions
-
-- **SolrJ native compatibility.** *Downgraded from medium to low-medium.*
-  The project already uses `JsonResponseParser` on `HttpJdkSolrClient`,
-  avoiding the JavaBin/XML reflective surface. Residual risk is
-  `ServiceLoader` metadata and a narrow set of response bean fields;
-  handle via a local `RuntimeHintsRegistrar`. See §6.1.
-- **OpenTelemetry starter.** *Managed via build-time init workaround.*
-  The OTel instrumentation BOM 2.11.0 does not ship native metadata.
-  Bumping to 2.26.1 (declared in the version catalog) fails at AOT time
-  because 2.26.1 requires `io.opentelemetry.common.ComponentLoader` which
-  is not present in the OTel SDK version managed by Spring Boot 3.5.x.
-  The workaround is `--initialize-at-build-time` for four targeted OTel
-  packages (see §6.2). The OTLP/gRPC exporter is only wired in the HTTP
-  profile, so the STDIO native image does not exercise its reflection
-  surface. **Follow-up:** align OTel BOM + SDK versions when Spring Boot
-  upgrades its managed OTel dependency.
-- **Spring Security + OAuth2 resource server.** *Downgraded.* Already
-  excluded via `spring.autoconfigure.exclude` in
-  `application-stdio.properties` and all security config classes carry
-  `@Profile("http")`. The AOT concern is addressed by pinning
-  `spring.profiles.active=stdio` on the `processAot` Gradle task so the
-  exclusions are applied before hint generation. The `@SpringBootApplication`
-  annotation is **not** modified — security autoconfiguration remains
-  globally available for the HTTP profile.
-- **AOT profile correctness.** AOT runs once at build time with one
-  profile active. Building the native image with the wrong (or no)
-  profile means the wrong bean graph is captured. V1 commits to STDIO
-  only; HTTP native is an explicit follow-up.
-- **Paketo builder download size.** `bootBuildImage` downloads a large
-  Paketo builder on first run (~1 GB). CI caching mitigates this.
-- **Build time & memory.** `nativeCompile` is RAM-hungry (commonly 4–8 GB).
-  Ensure CI runners have headroom.
-- **`mcp-server-security` library.** Small, non-Spring-official. Verify
-  it has no eager `@Configuration` classes that load outside `@Profile("http")`
-  that would force its classes into the STDIO AOT graph.
-  image.
-- HTTP profile native image (Actuator, Prometheus, OAuth2).
-- Profile-Guided Optimization (PGO) builds.
-- Publishing the native image from CI to GHCR / Docker Hub.
diff --git a/docs/superpowers/plans/2026-05-17-schema-modification.md 
b/docs/superpowers/plans/2026-05-17-schema-modification.md
deleted file mode 100644
index 6b12b24..0000000
--- a/docs/superpowers/plans/2026-05-17-schema-modification.md
+++ /dev/null
@@ -1,1011 +0,0 @@
-# Schema Modification Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use 
superpowers:subagent-driven-development (recommended) or 
superpowers:executing-plans to implement this plan task-by-task. Steps use 
checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Add two MCP tools — `add-fields` and `add-field-types` — to the Solr 
MCP server so AI assistants can extend a collection's schema through the MCP 
protocol, partially closing 
[apache/solr-mcp#30](https://github.com/apache/solr-mcp/issues/30).
-
-**Architecture:** Two new methods on the existing `SchemaService` use SolrJ's 
`SchemaRequest.MultiUpdate` to batch additive schema changes. Inputs are 
`List<Map<String, Object>>` matching Solr's Schema API JSON shape (no 
transformation layer). `addFieldTypes` includes a small manual conversion from 
flat input maps to SolrJ's typed `FieldTypeDefinition` (since SolrJ splits 
`name`/`class` into an attributes map and pulls analyzers into typed 
sub-objects).
-
-**Tech Stack:** Java 25, Spring Boot 3.5, Spring AI MCP 1.1.4, SolrJ 10.0, 
JUnit 5.12, Mockito, Testcontainers, Gradle.
-
-**Reference spec:** 
[`docs/superpowers/specs/2026-05-17-schema-modification-design.md`](../specs/2026-05-17-schema-modification-design.md)
-
----
-
-## File Structure
-
-**Create:**
-- `src/main/java/org/apache/solr/mcp/server/schema/SchemaUpdateResult.java` — 
MCP tool response record
-
-**Modify:**
-- `src/main/java/org/apache/solr/mcp/server/schema/SchemaService.java` — add 2 
`@McpTool` methods + 2 private helpers
-- `src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java` — 
register `SchemaUpdateResult` for reflection
-- `src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceTest.java` — 
extend with unit tests for new methods
-- 
`src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceIntegrationTest.java`
 — extend with integration tests for new methods
-- `src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java` 
— append ordered tests 16–18 exercising new MCP tools
-- `README.md` — document the two new MCP tools
-- `CLAUDE.md` — update SchemaService description
-
-**Carries over from `docs-restructure` branch:**
-- `docs/superpowers/specs/2026-05-17-schema-modification-design.md` — design 
spec
-- `docs/superpowers/plans/2026-05-17-schema-modification.md` — this plan
-
----
-
-## Task 1: Branch setup and initial spec/plan commit
-
-**Files:**
-- Sync: local `main` with `upstream/main`
-- Create branch: `schema-modification` off updated `main`
-- Carry over (unstaged): 
`docs/superpowers/specs/2026-05-17-schema-modification-design.md`, 
`docs/superpowers/plans/2026-05-17-schema-modification.md`
-
-- [ ] **Step 1: Confirm with user before touching origin**
-
-The next step does `git push origin main` after a `git reset --hard 
upstream/main` on local `main`. That's a remote-affecting operation. Ask the 
user to confirm before proceeding.
-
-- [ ] **Step 2: Verify spec and plan files are present and unstaged on current 
branch**
-
-```bash
-git status -- docs/superpowers/specs/2026-05-17-schema-modification-design.md 
docs/superpowers/plans/2026-05-17-schema-modification.md
-```
-
-Expected: both files appear as `??` (untracked). They must NOT be committed on 
`docs-restructure`. If they are committed there, stop and resolve before 
continuing.
-
-- [ ] **Step 3: Sync local main with upstream main**
-
-```bash
-git fetch upstream
-git checkout main
-git reset --hard upstream/main
-git push origin main
-```
-
-Expected: `origin/main` is now identical to `upstream/main`. Working tree 
still contains the untracked spec + plan files (they survive branch switches 
because they're untracked).
-
-- [ ] **Step 4: Create the feature branch**
-
-```bash
-git checkout -b schema-modification
-```
-
-Expected: on branch `schema-modification`, untracked files still present.
-
-- [ ] **Step 5: Commit spec and plan**
-
-```bash
-git add docs/superpowers/specs/2026-05-17-schema-modification-design.md \
-        docs/superpowers/plans/2026-05-17-schema-modification.md
-git commit -s -m "$(cat <<'EOF'
-docs: add design spec and implementation plan for schema modification
-
-Spec and plan for adding add-fields and add-field-types MCP tools per
-issue #30. See docs/superpowers/specs/ and docs/superpowers/plans/.
-
-Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
-EOF
-)"
-```
-
-Expected: commit succeeds. `git log -1 --stat` shows both files added.
-
----
-
-## Task 2: Create `SchemaUpdateResult` record
-
-**Files:**
-- Create: 
`src/main/java/org/apache/solr/mcp/server/schema/SchemaUpdateResult.java`
-
-No tests for the record itself (Java records are trivial); it'll be exercised 
by every test in subsequent tasks.
-
-- [ ] **Step 1: Create the record file**
-
-```java
-/*
- * 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.schema;
-
-import java.util.Date;
-import java.util.List;
-
-/**
- * Result of an additive schema update (add-fields or add-field-types).
- *
- * <p>{@code success} is always {@code true} on return — failures throw and 
never produce a
- * result. {@code addedNames} echoes the {@code name} field from each input 
definition in
- * input order, useful for confirming what was sent.
- */
-public record SchemaUpdateResult(String collection, boolean success, 
List<String> addedNames, Date timestamp) {
-}
-```
-
-- [ ] **Step 2: Verify compilation**
-
-```bash
-./gradlew compileJava
-```
-
-Expected: BUILD SUCCESSFUL.
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add src/main/java/org/apache/solr/mcp/server/schema/SchemaUpdateResult.java
-git commit -s -m "$(cat <<'EOF'
-feat(schema): add SchemaUpdateResult record for schema modification tools
-
-Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
-EOF
-)"
-```
-
----
-
-## Task 3: Implement `addFields` (TDD)
-
-**Files:**
-- Modify: `src/main/java/org/apache/solr/mcp/server/schema/SchemaService.java`
-- Modify: 
`src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceTest.java`
-
-Existing imports in `SchemaServiceTest` already include `SchemaRequest` and 
the mock infrastructure. Use the same `MockitoExtension` setup.
-
-- [ ] **Step 1: Add the failing tests to `SchemaServiceTest`**
-
-Append after the existing tests (before the closing brace). Add the necessary 
imports at the top: `org.apache.solr.client.solrj.SolrRequest`, 
`org.apache.solr.client.solrj.request.schema.SchemaRequest.AddField`, 
`org.apache.solr.client.solrj.request.schema.SchemaRequest.MultiUpdate`, 
`org.apache.solr.client.solrj.request.schema.SchemaRequest.Update`, 
`org.apache.solr.common.util.NamedList`, `org.mockito.ArgumentCaptor`, 
`java.util.List`, `java.util.Map`.
-
-```java
-@Test
-void addFields_blankCollection_throws() {
-    assertThrows(IllegalArgumentException.class,
-            () -> schemaService.addFields(null, List.of(Map.of("name", "x", 
"type", "string"))));
-    assertThrows(IllegalArgumentException.class,
-            () -> schemaService.addFields("", List.of(Map.of("name", "x", 
"type", "string"))));
-    assertThrows(IllegalArgumentException.class,
-            () -> schemaService.addFields("   ", List.of(Map.of("name", "x", 
"type", "string"))));
-}
-
-@Test
-void addFields_emptyList_throws() {
-    assertThrows(IllegalArgumentException.class, () -> 
schemaService.addFields("col", null));
-    assertThrows(IllegalArgumentException.class, () -> 
schemaService.addFields("col", List.of()));
-}
-
-@Test
-void addFields_happyPath_buildsMultiUpdateAndEchoesNames() throws Exception {
-    List<Map<String, Object>> fields = List.of(
-            Map.of("name", "title", "type", "text_general", "stored", true, 
"indexed", true),
-            Map.of("name", "platform", "type", "string", "stored", true, 
"indexed", true, "docValues", true));
-
-    when(solrClient.request(any(SolrRequest.class), eq("col"))).thenReturn(new 
NamedList<>());
-
-    SchemaUpdateResult result = schemaService.addFields("col", fields);
-
-    assertTrue(result.success());
-    assertEquals(List.of("title", "platform"), result.addedNames());
-    assertEquals("col", result.collection());
-    assertNotNull(result.timestamp());
-
-    ArgumentCaptor<SolrRequest> captor = 
ArgumentCaptor.forClass(SolrRequest.class);
-    verify(solrClient).request(captor.capture(), eq("col"));
-    assertInstanceOf(MultiUpdate.class, captor.getValue());
-}
-
-@Test
-void addFields_solrThrows_propagates() throws Exception {
-    when(solrClient.request(any(SolrRequest.class), eq("col")))
-            .thenThrow(new SolrServerException("simulated"));
-
-    assertThrows(SolrServerException.class,
-            () -> schemaService.addFields("col", List.of(Map.of("name", "x", 
"type", "string"))));
-}
-```
-
-Also add `import static org.mockito.Mockito.verify;` to the existing static 
imports.
-
-- [ ] **Step 2: Run tests, expect failure**
-
-```bash
-./gradlew test --tests SchemaServiceTest -i
-```
-
-Expected: 4 new tests FAIL with compilation errors (no `addFields` method on 
`SchemaService`).
-
-- [ ] **Step 3: Implement `addFields` in `SchemaService.java`**
-
-Add the imports (top of file): `java.util.ArrayList`, `java.util.Date`, 
`java.util.List`, `java.util.Map`, 
`org.apache.solr.client.solrj.request.schema.SchemaRequest.Update`.
-
-Add the method at the end of the class (before the closing brace). Also add a 
private validation helper.
-
-```java
-@PreAuthorize("isAuthenticated()")
-@McpTool(name = "add-fields", description = "Add one or more fields to a Solr 
collection schema. "
-        + "Call get-schema first to inspect existing field configuration 
before adding. "
-        + "Each field map follows the Solr Schema API add-field shape: 
required keys "
-        + "'name' and 'type', plus optional 'stored', 'indexed', 'docValues', "
-        + "'multiValued', 'required', 'omitNorms', etc. "
-        + "Example: 
{\"name\":\"platform\",\"type\":\"string\",\"stored\":true,\"indexed\":true,\"docValues\":true}.
 "
-        + "Use 'strings' (not 'string') for multi-valued string fields. "
-        + "Note: this only adds new fields; existing fields cannot be 
modified. "
-        + "Commands run in input order; if one fails mid-batch, prior commands 
remain applied "
-        + "(use get-schema to inspect on failure).")
-public SchemaUpdateResult addFields(
-        @McpToolParam(description = "Solr collection name") String collection,
-        @McpToolParam(description = "List of field definitions (Solr add-field 
JSON shape)")
-                List<Map<String, Object>> fields)
-        throws SolrServerException, IOException {
-    requireCollection(collection);
-    requireNonEmpty(fields, "fields");
-
-    List<String> names = new ArrayList<>(fields.size());
-    List<SchemaRequest.Update> updates = new ArrayList<>(fields.size());
-    for (Map<String, Object> field : fields) {
-        names.add(String.valueOf(field.get("name")));
-        updates.add(new SchemaRequest.AddField(field));
-    }
-
-    new SchemaRequest.MultiUpdate(updates).process(solrClient, collection);
-    return new SchemaUpdateResult(collection, true, names, new Date());
-}
-
-private static void requireCollection(String collection) {
-    if (collection == null || collection.isBlank()) {
-        throw new IllegalArgumentException("Collection name must not be 
blank");
-    }
-}
-
-private static void requireNonEmpty(List<?> list, String name) {
-    if (list == null || list.isEmpty()) {
-        throw new IllegalArgumentException(name + " must not be empty");
-    }
-}
-```
-
-Add to existing imports: `org.springaicommunity.mcp.annotation.McpToolParam`, 
`java.io.IOException`, `org.apache.solr.client.solrj.SolrServerException`, 
`org.apache.solr.client.solrj.request.schema.SchemaRequest`.
-
-- [ ] **Step 4: Run tests, expect pass**
-
-```bash
-./gradlew test --tests SchemaServiceTest -i
-```
-
-Expected: all SchemaServiceTest tests PASS, including the 4 new ones.
-
-- [ ] **Step 5: Apply Spotless and commit**
-
-```bash
-./gradlew spotlessApply
-git add src/main/java/org/apache/solr/mcp/server/schema/SchemaService.java \
-        src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceTest.java
-git commit -s -m "$(cat <<'EOF'
-feat(schema): add add-fields MCP tool for additive schema modification
-
-Closes part of #30. Adds one or more fields atomically per call via
-SolrJ's SchemaRequest.MultiUpdate. Input is List<Map<String, Object>>
-matching the Solr Schema API add-field JSON shape; validation is
-limited to collection name and non-empty list (Solr returns clear
-errors for malformed field defs).
-
-Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
-EOF
-)"
-```
-
----
-
-## Task 4: Implement `addFieldTypes` + `toFieldTypeDefinition` (TDD)
-
-**Files:**
-- Modify: `src/main/java/org/apache/solr/mcp/server/schema/SchemaService.java`
-- Modify: 
`src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceTest.java`
-
-- [ ] **Step 1: Add the failing tests to `SchemaServiceTest`**
-
-Add the import: 
`org.apache.solr.client.solrj.request.schema.AnalyzerDefinition`, 
`org.apache.solr.client.solrj.request.schema.FieldTypeDefinition`, 
`org.apache.solr.client.solrj.request.schema.SchemaRequest.AddFieldType`.
-
-Append after the addFields tests:
-
-```java
-@Test
-void addFieldTypes_blankCollection_throws() {
-    assertThrows(IllegalArgumentException.class,
-            () -> schemaService.addFieldTypes(null,
-                    List.of(Map.of("name", "x", "class", "solr.StrField"))));
-    assertThrows(IllegalArgumentException.class,
-            () -> schemaService.addFieldTypes("",
-                    List.of(Map.of("name", "x", "class", "solr.StrField"))));
-}
-
-@Test
-void addFieldTypes_emptyList_throws() {
-    assertThrows(IllegalArgumentException.class, () -> 
schemaService.addFieldTypes("col", null));
-    assertThrows(IllegalArgumentException.class, () -> 
schemaService.addFieldTypes("col", List.of()));
-}
-
-@Test
-void addFieldTypes_happyPathWithAnalyzer_buildsCorrectFieldTypeDefinition() 
throws Exception {
-    // Use a real ObjectMapper so convertValue actually works; rebuild service 
with it.
-    ObjectMapper realMapper = new ObjectMapper();
-    SchemaService service = new SchemaService(solrClient, realMapper);
-
-    List<Map<String, Object>> types = List.of(Map.of(
-            "name", "text_lowercase",
-            "class", "solr.TextField",
-            "analyzer", Map.of(
-                    "tokenizer", Map.of("class", 
"solr.KeywordTokenizerFactory"),
-                    "filters", List.of(Map.of("class", 
"solr.LowerCaseFilterFactory")))));
-
-    when(solrClient.request(any(SolrRequest.class), eq("col"))).thenReturn(new 
NamedList<>());
-
-    SchemaUpdateResult result = service.addFieldTypes("col", types);
-
-    assertTrue(result.success());
-    assertEquals(List.of("text_lowercase"), result.addedNames());
-
-    ArgumentCaptor<SolrRequest> captor = 
ArgumentCaptor.forClass(SolrRequest.class);
-    verify(solrClient).request(captor.capture(), eq("col"));
-    assertInstanceOf(MultiUpdate.class, captor.getValue());
-}
-
-@Test
-void addFieldTypes_separateAnalyzers_buildsCorrectFieldTypeDefinition() throws 
Exception {
-    ObjectMapper realMapper = new ObjectMapper();
-    SchemaService service = new SchemaService(solrClient, realMapper);
-
-    List<Map<String, Object>> types = List.of(Map.of(
-            "name", "text_autocomplete",
-            "class", "solr.TextField",
-            "indexAnalyzer", Map.of(
-                    "tokenizer", Map.of("class", 
"solr.KeywordTokenizerFactory"),
-                    "filters", List.of(
-                            Map.of("class", "solr.LowerCaseFilterFactory"),
-                            Map.of("class", "solr.EdgeNGramFilterFactory", 
"minGramSize", 2, "maxGramSize", 20))),
-            "queryAnalyzer", Map.of(
-                    "tokenizer", Map.of("class", 
"solr.KeywordTokenizerFactory"),
-                    "filters", List.of(Map.of("class", 
"solr.LowerCaseFilterFactory")))));
-
-    when(solrClient.request(any(SolrRequest.class), eq("col"))).thenReturn(new 
NamedList<>());
-
-    SchemaUpdateResult result = service.addFieldTypes("col", types);
-
-    assertTrue(result.success());
-    assertEquals(List.of("text_autocomplete"), result.addedNames());
-}
-
-@Test
-void addFieldTypes_denseVectorField_noAnalyzer() throws Exception {
-    ObjectMapper realMapper = new ObjectMapper();
-    SchemaService service = new SchemaService(solrClient, realMapper);
-
-    List<Map<String, Object>> types = List.of(Map.of(
-            "name", "openai_embedding",
-            "class", "solr.DenseVectorField",
-            "vectorDimension", 1536,
-            "similarityFunction", "cosine",
-            "knnAlgorithm", "hnsw"));
-
-    when(solrClient.request(any(SolrRequest.class), eq("col"))).thenReturn(new 
NamedList<>());
-
-    SchemaUpdateResult result = service.addFieldTypes("col", types);
-
-    assertTrue(result.success());
-    assertEquals(List.of("openai_embedding"), result.addedNames());
-}
-
-@Test
-void addFieldTypes_solrThrows_propagates() throws Exception {
-    ObjectMapper realMapper = new ObjectMapper();
-    SchemaService service = new SchemaService(solrClient, realMapper);
-
-    when(solrClient.request(any(SolrRequest.class), eq("col")))
-            .thenThrow(new SolrServerException("simulated"));
-
-    assertThrows(SolrServerException.class,
-            () -> service.addFieldTypes("col",
-                    List.of(Map.of("name", "x", "class", "solr.StrField"))));
-}
-```
-
-Note: tests use a real `ObjectMapper` rather than the `@Mock` one because 
`toFieldTypeDefinition` calls `convertValue(...)` which must actually work. The 
class-level `@Mock ObjectMapper` stays — only the field-type tests construct a 
real one.
-
-- [ ] **Step 2: Run tests, expect failure**
-
-```bash
-./gradlew test --tests SchemaServiceTest -i
-```
-
-Expected: 5 new tests FAIL with compilation errors (no `addFieldTypes` method).
-
-- [ ] **Step 3: Implement `addFieldTypes` + `toFieldTypeDefinition` in 
`SchemaService.java`**
-
-Add imports: `java.util.LinkedHashMap`, 
`org.apache.solr.client.solrj.request.schema.AnalyzerDefinition`, 
`org.apache.solr.client.solrj.request.schema.FieldTypeDefinition`.
-
-Append after `addFields`:
-
-```java
-@PreAuthorize("isAuthenticated()")
-@McpTool(name = "add-field-types", description = "Add one or more field types 
to a Solr collection schema. "
-        + "Call get-schema first to inspect existing field types before 
adding. "
-        + "Each map follows the Solr Schema API add-field-type shape: required 
keys "
-        + "'name' and 'class', optional 'analyzer' (or 
'indexAnalyzer'+'queryAnalyzer'), "
-        + "and class-specific attributes. "
-        + "Common recipes: "
-        + "(1) case-insensitive exact match: class=solr.TextField with 
analyzer "
-        + "{tokenizer:{class:solr.KeywordTokenizerFactory}, 
filters:[{class:solr.LowerCaseFilterFactory}]}; "
-        + "(2) dense vector for semantic search: class=solr.DenseVectorField 
with "
-        + "vectorDimension, similarityFunction (cosine/dot_product/euclidean), 
and knnAlgorithm=hnsw; "
-        + "(3) autocomplete: class=solr.TextField with separate indexAnalyzer 
using EdgeNGramFilterFactory "
-        + "and queryAnalyzer without it. "
-        + "After adding a type, use add-fields to create fields of that type. "
-        + "Commands run in input order; partial application possible on 
failure.")
-public SchemaUpdateResult addFieldTypes(
-        @McpToolParam(description = "Solr collection name") String collection,
-        @McpToolParam(description = "List of field type definitions (Solr 
add-field-type JSON shape)")
-                List<Map<String, Object>> fieldTypes)
-        throws SolrServerException, IOException {
-    requireCollection(collection);
-    requireNonEmpty(fieldTypes, "fieldTypes");
-
-    List<String> names = new ArrayList<>(fieldTypes.size());
-    List<SchemaRequest.Update> updates = new ArrayList<>(fieldTypes.size());
-    for (Map<String, Object> fieldType : fieldTypes) {
-        names.add(String.valueOf(fieldType.get("name")));
-        updates.add(new 
SchemaRequest.AddFieldType(toFieldTypeDefinition(fieldType)));
-    }
-
-    new SchemaRequest.MultiUpdate(updates).process(solrClient, collection);
-    return new SchemaUpdateResult(collection, true, names, new Date());
-}
-
-/**
- * Builds a {@link FieldTypeDefinition} from a flat input map matching the 
Solr Schema API
- * add-field-type JSON shape. SolrJ's {@code FieldTypeDefinition} stores 
name/class and
- * other scalar attributes inside an attributes {@link Map}, with analyzers 
pulled into
- * typed sub-objects — so we can't deserialize the flat input directly via 
Jackson.
- */
-private FieldTypeDefinition toFieldTypeDefinition(Map<String, Object> input) {
-    FieldTypeDefinition def = new FieldTypeDefinition();
-    Map<String, Object> attributes = new LinkedHashMap<>(input);
-    Object analyzer = attributes.remove("analyzer");
-    Object indexAnalyzer = attributes.remove("indexAnalyzer");
-    Object queryAnalyzer = attributes.remove("queryAnalyzer");
-    def.setAttributes(attributes);
-    if (analyzer != null) {
-        def.setAnalyzer(toAnalyzerDefinition(analyzer));
-    }
-    if (indexAnalyzer != null) {
-        def.setIndexAnalyzer(toAnalyzerDefinition(indexAnalyzer));
-    }
-    if (queryAnalyzer != null) {
-        def.setQueryAnalyzer(toAnalyzerDefinition(queryAnalyzer));
-    }
-    return def;
-}
-
-private AnalyzerDefinition toAnalyzerDefinition(Object raw) {
-    return objectMapper.convertValue(raw, AnalyzerDefinition.class);
-}
-```
-
-- [ ] **Step 4: Run tests, expect pass**
-
-```bash
-./gradlew test --tests SchemaServiceTest -i
-```
-
-Expected: all 9+ new tests PASS plus the pre-existing tests still pass.
-
-- [ ] **Step 5: Apply Spotless and commit**
-
-```bash
-./gradlew spotlessApply
-git add src/main/java/org/apache/solr/mcp/server/schema/SchemaService.java \
-        src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceTest.java
-git commit -s -m "$(cat <<'EOF'
-feat(schema): add add-field-types MCP tool with FieldTypeDefinition helper
-
-Supports single analyzer, separate index/query analyzers, and non-analyzer
-field types like DenseVectorField. Manual conversion from flat input map
-to SolrJ FieldTypeDefinition because name/class go into attributes map.
-
-Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
-EOF
-)"
-```
-
----
-
-## Task 5: Register `SchemaUpdateResult` for native image reflection
-
-**Files:**
-- Modify: 
`src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java`
-
-- [ ] **Step 1: Add `SchemaUpdateResult` to the `MCP_RESPONSE_RECORDS` list**
-
-Open `SolrNativeHints.java`. Find the `MCP_RESPONSE_RECORDS` list (around line 
63). Append the new entry. The list becomes:
-
-```java
-private static final List<String> MCP_RESPONSE_RECORDS = List.of(
-        "org.apache.solr.mcp.server.collection.CollectionCreationResult",
-        "org.apache.solr.mcp.server.collection.SolrHealthStatus",
-        "org.apache.solr.mcp.server.collection.SolrMetrics", 
"org.apache.solr.mcp.server.collection.IndexStats",
-        "org.apache.solr.mcp.server.collection.FieldStats", 
"org.apache.solr.mcp.server.collection.QueryStats",
-        "org.apache.solr.mcp.server.collection.CacheStats", 
"org.apache.solr.mcp.server.collection.CacheInfo",
-        "org.apache.solr.mcp.server.collection.HandlerStats", 
"org.apache.solr.mcp.server.collection.HandlerInfo",
-        "org.apache.solr.mcp.server.search.SearchResponse",
-        "org.apache.solr.mcp.server.schema.SchemaUpdateResult");
-```
-
-- [ ] **Step 2: Verify compilation**
-
-```bash
-./gradlew compileJava
-```
-
-Expected: BUILD SUCCESSFUL.
-
-- [ ] **Step 3: Apply Spotless and commit**
-
-```bash
-./gradlew spotlessApply
-git add src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java
-git commit -s -m "$(cat <<'EOF'
-feat(config): register SchemaUpdateResult for GraalVM native image reflection
-
-Same pattern as the other @McpTool response records — invisible to AOT
-because MCP dispatches via Object.
-
-Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
-EOF
-)"
-```
-
----
-
-## Task 6: Extend `SchemaServiceIntegrationTest` with real-Solr integration 
tests
-
-**Files:**
-- Modify: 
`src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceIntegrationTest.java`
-
-Reuse the existing `TEST_COLLECTION = "schema_test_collection"` setup. Use 
unique field/type names per test method to avoid collisions across test 
ordering.
-
-- [ ] **Step 1: Add the integration tests**
-
-Append after the existing tests. Add imports: `java.util.List`, 
`java.util.Map`, `org.apache.solr.client.solrj.SolrServerException`, 
`org.apache.solr.client.solrj.response.QueryResponse`, 
`org.apache.solr.client.solrj.request.SolrQuery`, 
`org.apache.solr.common.SolrInputDocument`.
-
-```java
-@Test
-void addFields_endToEnd_persistsToSchema() throws Exception {
-    List<Map<String, Object>> fields = List.of(
-            Map.of("name", "addf_title", "type", "text_general", "stored", 
true, "indexed", true),
-            Map.of("name", "addf_platform", "type", "string", "stored", true, 
"indexed", true,
-                    "docValues", true),
-            Map.of("name", "addf_year", "type", "pint", "stored", true, 
"indexed", true, "docValues", true));
-
-    SchemaUpdateResult result = schemaService.addFields(TEST_COLLECTION, 
fields);
-
-    assertTrue(result.success());
-    assertEquals(List.of("addf_title", "addf_platform", "addf_year"), 
result.addedNames());
-
-    SchemaRepresentation schema = schemaService.getSchema(TEST_COLLECTION);
-    Map<String, Object> title = schema.getFields().stream()
-            .filter(f -> 
"addf_title".equals(f.get("name"))).findFirst().orElseThrow();
-    Map<String, Object> platform = schema.getFields().stream()
-            .filter(f -> 
"addf_platform".equals(f.get("name"))).findFirst().orElseThrow();
-    Map<String, Object> year = schema.getFields().stream()
-            .filter(f -> 
"addf_year".equals(f.get("name"))).findFirst().orElseThrow();
-
-    assertEquals("text_general", title.get("type"));
-    assertEquals("string", platform.get("type"));
-    assertEquals(Boolean.TRUE, platform.get("docValues"));
-    assertEquals("pint", year.get("type"));
-}
-
-@Test
-void addFieldTypes_customAnalyzer_appliesAtIndexAndQuery() throws Exception {
-    schemaService.addFieldTypes(TEST_COLLECTION, List.of(Map.of(
-            "name", "aft_text_ci_keyword",
-            "class", "solr.TextField",
-            "analyzer", Map.of(
-                    "tokenizer", Map.of("class", 
"solr.KeywordTokenizerFactory"),
-                    "filters", List.of(Map.of("class", 
"solr.LowerCaseFilterFactory"))))));
-
-    schemaService.addFields(TEST_COLLECTION, List.of(Map.of(
-            "name", "aft_ci_platform",
-            "type", "aft_text_ci_keyword",
-            "stored", true, "indexed", true)));
-
-    SolrInputDocument doc = new SolrInputDocument();
-    doc.addField("id", "aft-doc-1");
-    doc.addField("aft_ci_platform", "NetFlix");
-    solrClient.add(TEST_COLLECTION, doc);
-    solrClient.commit(TEST_COLLECTION);
-
-    QueryResponse lowercase = solrClient.query(TEST_COLLECTION, new 
SolrQuery("aft_ci_platform:netflix"));
-    assertEquals(1L, lowercase.getResults().getNumFound());
-
-    QueryResponse uppercase = solrClient.query(TEST_COLLECTION, new 
SolrQuery("aft_ci_platform:NETFLIX"));
-    assertEquals(1L, uppercase.getResults().getNumFound());
-
-    QueryResponse partial = solrClient.query(TEST_COLLECTION, new 
SolrQuery("aft_ci_platform:net"));
-    assertEquals(0L, partial.getResults().getNumFound());
-}
-
-@Test
-void addFieldTypes_denseVectorField_schemaRoundTrip() throws Exception {
-    schemaService.addFieldTypes(TEST_COLLECTION, List.of(Map.of(
-            "name", "aft_test_vector",
-            "class", "solr.DenseVectorField",
-            "vectorDimension", 4,
-            "similarityFunction", "cosine")));
-
-    SchemaRepresentation schema = schemaService.getSchema(TEST_COLLECTION);
-    Map<String, Object> vt = schema.getFieldTypes().stream()
-            .filter(t -> 
"aft_test_vector".equals(t.getAttributes().get("name")))
-            .findFirst().orElseThrow().getAttributes();
-
-    assertEquals("solr.DenseVectorField", vt.get("class"));
-    assertEquals(4, ((Number) vt.get("vectorDimension")).intValue());
-    assertEquals("cosine", vt.get("similarityFunction"));
-}
-
-@Test
-void addFields_duplicateField_throws() throws Exception {
-    List<Map<String, Object>> field = List.of(
-            Map.of("name", "addf_dup_field", "type", "string", "stored", true, 
"indexed", true));
-    schemaService.addFields(TEST_COLLECTION, field);
-
-    // Second call with same name — Solr returns an error in the response body.
-    // Whether SolrJ throws or returns silently is part of what this test 
verifies.
-    Exception ex = assertThrows(Exception.class,
-            () -> schemaService.addFields(TEST_COLLECTION, field));
-    assertTrue(ex instanceof SolrServerException || ex instanceof 
RuntimeException,
-            "Expected SolrServerException or RuntimeException, got " + 
ex.getClass());
-}
-
-@Test
-void addFields_unknownType_throws() {
-    List<Map<String, Object>> field = List.of(
-            Map.of("name", "addf_broken", "type", "totally_not_a_real_type"));
-    assertThrows(Exception.class, () -> 
schemaService.addFields(TEST_COLLECTION, field));
-}
-```
-
-If the `addFields_duplicateField_throws` test FAILS (i.e., the second call 
returns normally because SolrJ doesn't throw on response-body errors), then 
`SchemaService.addFields` and `addFieldTypes` need to be updated to inspect the 
response and throw explicitly. This is the verification the spec called out. 
Add the inspection code:
-
-```java
-// Inside addFields/addFieldTypes after .process(...):
-SchemaResponse.UpdateResponse response =
-    new SchemaRequest.MultiUpdate(updates).process(solrClient, collection);
-@SuppressWarnings("unchecked")
-List<Object> errors = (List<Object>) response.getResponse().get("errors");
-if (errors != null && !errors.isEmpty()) {
-    throw new SolrServerException("Schema update returned errors: " + errors);
-}
-```
-
-Re-run the test and confirm it passes.
-
-- [ ] **Step 2: Run integration tests**
-
-```bash
-./gradlew test --tests SchemaServiceIntegrationTest -i
-```
-
-Expected: all integration tests PASS (existing + 5 new). If Docker isn't 
running, the test class is disabled automatically.
-
-- [ ] **Step 3: Apply Spotless and commit**
-
-```bash
-./gradlew spotlessApply
-git add 
src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceIntegrationTest.java
 \
-        src/main/java/org/apache/solr/mcp/server/schema/SchemaService.java
-git commit -s -m "$(cat <<'EOF'
-test(schema): integration tests for add-fields and add-field-types
-
-End-to-end against real Solr via Testcontainers. Verifies schema
-round-trip, custom analyzer behavior, vector field type registration,
-and error propagation on duplicate field / unknown type.
-
-Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
-EOF
-)"
-```
-
-Note: the commit may include `SchemaService.java` if the response-body 
inspection was added in Step 1.
-
----
-
-## Task 7: Extend `McpClientIntegrationTestBase` with MCP protocol tests
-
-**Files:**
-- Modify: 
`src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java`
-
-Reuse the existing `COLLECTION = "mcp-client-test"`. The new fields are added 
after the existing CSV indexing tests (order 14, 15). New tests are at orders 
16, 17, 18.
-
-- [ ] **Step 1: Add the ordered tests**
-
-Append after the `searchFindsAllDocumentsAfterCsvIndexing()` test (order 15), 
before the `protected static String extractText(...)` helpers.
-
-```java
-@Test
-@Order(16)
-void addFieldsToTestCollection() throws Exception {
-    List<Map<String, Object>> fields = List.of(
-            Map.of("name", "platform", "type", "string", "stored", true, 
"indexed", true, "docValues", true),
-            Map.of("name", "release_year", "type", "pint", "stored", true, 
"indexed", true, "docValues", true),
-            Map.of("name", "genres", "type", "strings", "stored", true, 
"indexed", true, "docValues", true));
-
-    CallToolResult result = mcpClient.callTool(new 
CallToolRequest("add-fields",
-            Map.of("collection", COLLECTION, "fields", fields)));
-
-    assertNotNull(result);
-    assertNotError(result);
-    String text = extractText(result);
-    assertTrue(text.contains("platform"), "Result should mention added 
'platform': " + text);
-    assertTrue(text.contains("release_year"), "Result should mention added 
'release_year': " + text);
-    assertTrue(text.contains("genres"), "Result should mention added 'genres': 
" + text);
-}
-
-@Test
-@Order(17)
-void indexDocumentWithNewFields() {
-    String json = """
-            [
-              {"id": "show-1", "title": "Breaking Bad", "author": "Vince 
Gilligan",
-               "category": "show", "platform": "Netflix",
-               "release_year": 2008, "genres": ["drama", "crime"]}
-            ]
-            """;
-
-    CallToolResult result = mcpClient.callTool(new 
CallToolRequest("index-json-documents",
-            Map.of("collection", COLLECTION, "json", json)));
-
-    assertNotNull(result);
-    assertNotError(result);
-}
-
-@Test
-@Order(18)
-void searchWithNewFieldFilters() throws Exception {
-    CallToolResult byPlatform = mcpClient.callTool(new 
CallToolRequest("search",
-            Map.of("collection", COLLECTION, "query", "*:*",
-                    "filterQueries", List.of("platform:Netflix"))));
-    Map<String, Object> r1 = OBJECT_MAPPER.readValue(extractText(byPlatform), 
new TypeReference<>() {
-    });
-    assertEquals(1, getNumFound(r1), "Should find exactly 1 doc with 
platform=Netflix");
-
-    CallToolResult byGenre = mcpClient.callTool(new CallToolRequest("search",
-            Map.of("collection", COLLECTION, "query", "*:*",
-                    "filterQueries", List.of("genres:crime"))));
-    Map<String, Object> r2 = OBJECT_MAPPER.readValue(extractText(byGenre), new 
TypeReference<>() {
-    });
-    assertEquals(1, getNumFound(r2), "Multi-valued 'genres' should match on 
'crime'");
-}
-```
-
-Also update the `listToolsReturnsExpectedTools` test (order 2) to assert the 
new tool names appear:
-
-```java
-assertTrue(toolNames.contains("add-fields"), "Should have add-fields tool");
-assertTrue(toolNames.contains("add-field-types"), "Should have add-field-types 
tool");
-```
-
-- [ ] **Step 2: Run both HTTP and stdio variants**
-
-```bash
-./gradlew test --tests McpClientIntegrationTest --tests 
McpClientStdioIntegrationTest -i
-```
-
-Expected: both subclasses PASS the full 18-test sequence. If the HTTP variant 
fails on `add-fields` with a 401/403, the test infrastructure for the HTTP 
transport isn't supplying auth correctly. In that case, inspect 
`McpClientIntegrationTest` to see how it authenticates other `@PreAuthorize` 
tool calls (`create-collection`, `list-collections`) — the same mechanism 
applies.
-
-- [ ] **Step 3: Apply Spotless and commit**
-
-```bash
-./gradlew spotlessApply
-git add 
src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java
-git commit -s -m "$(cat <<'EOF'
-test: extend MCP client integration tests for schema modification tools
-
-Adds ordered tests 16-18 exercising the add-fields → index → search
-workflow through the MCP protocol against both HTTP and stdio transports.
-Also asserts add-fields and add-field-types appear in listTools output.
-
-Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
-EOF
-)"
-```
-
----
-
-## Task 8: Update README
-
-**Files:**
-- Modify: `README.md`
-
-- [ ] **Step 1: Locate the MCP tools table or list**
-
-```bash
-grep -n -A 2 -B 2 "get-schema\|list-collections\|create-collection" README.md 
| head -40
-```
-
-Identify the section where existing tools are listed (likely a table or bullet 
list under an "MCP Tools" heading).
-
-- [ ] **Step 2: Add the two new tools**
-
-Add entries for `add-fields` and `add-field-types` in the same style as 
adjacent tools. Use these descriptions:
-
-- `add-fields` — Add one or more fields to a Solr collection schema (additive 
only; existing fields cannot be modified).
-- `add-field-types` — Add one or more field types to a Solr collection schema 
(supports custom analyzers, DenseVectorField for semantic search, etc.).
-
-If the README has a worked example section, add a small example showing the 
shows-collection workflow (create-collection → add-fields → 
index-json-documents → search). Keep it under ~15 lines.
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add README.md
-git commit -s -m "$(cat <<'EOF'
-docs: document add-fields and add-field-types MCP tools in README
-
-Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
-EOF
-)"
-```
-
----
-
-## Task 9: Update CLAUDE.md
-
-**Files:**
-- Modify: `CLAUDE.md`
-
-- [ ] **Step 1: Update the SchemaService entry**
-
-Find the `- **SchemaService**` line under "MCP Tools" → "Architecture". Change:
-
-```
-- **SchemaService** (`schema/`) - Schema introspection
-```
-
-to:
-
-```
-- **SchemaService** (`schema/`) - Schema introspection and additive 
modification (add-fields, add-field-types)
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-git add CLAUDE.md
-git commit -s -m "$(cat <<'EOF'
-docs: update CLAUDE.md SchemaService entry for new schema-modification tools
-
-Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
-EOF
-)"
-```
-
----
-
-## Task 10: Full build verification
-
-- [ ] **Step 1: Spotless check**
-
-```bash
-./gradlew spotlessCheck
-```
-
-Expected: BUILD SUCCESSFUL. If it fails, run `./gradlew spotlessApply`, 
inspect changes with `git diff`, commit them with `style: spotless` if 
non-trivial, otherwise amend the most recent commit.
-
-- [ ] **Step 2: Full build (JVM path)**
-
-```bash
-./gradlew build
-```
-
-Expected: BUILD SUCCESSFUL. All tests pass including the new unit + 
integration + MCP client tests. If any pre-existing test regresses, stop and 
investigate before continuing.
-
-- [ ] **Step 3: Native test (optional but recommended)**
-
-```bash
-./gradlew nativeTest -Pnative
-```
-
-Expected: BUILD SUCCESSFUL. Mockito-based unit tests are skipped via 
`@DisabledInNativeImage`; integration tests run. If the native test fails with 
a "missing reflection metadata" error for a SolrJ type involved in schema 
modification (e.g., `FieldTypeDefinition`, `AnalyzerDefinition`), add 
reflection registrations to `SolrNativeHints.Registrar.registerHints()`:
-
-```java
-hints.reflection().registerType(
-    org.apache.solr.client.solrj.request.schema.FieldTypeDefinition.class, 
categories);
-hints.reflection().registerType(
-    org.apache.solr.client.solrj.request.schema.AnalyzerDefinition.class, 
categories);
-```
-
-Then commit:
-
-```bash
-git add src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java
-git commit -s -m "$(cat <<'EOF'
-fix(native): register SolrJ schema-modification types for reflection
-
-Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
-EOF
-)"
-```
-
-- [ ] **Step 4: Docker integration test (optional)**
-
-```bash
-./gradlew dockerIntegrationTest
-```
-
-Expected: BUILD SUCCESSFUL. Exercises the new MCP tool tests via the Jib JVM 
image, end-to-end through the MCP protocol over both stdio and HTTP transports.
-
-Only run this if Docker is available. If it fails on the new tests but passes 
on master, investigate before moving on.
-
----
-
-## Task 11: Push branch and open PR
-
-- [ ] **Step 1: Confirm with user before pushing**
-
-Pushing the branch to `origin` and opening a PR are remote-visible actions. 
Stop and ask the user to confirm both before proceeding.
-
-- [ ] **Step 2: Push the branch**
-
-```bash
-git push -u origin schema-modification
-```
-
-- [ ] **Step 3: Open PR against `apache/solr-mcp:main`**
-
-```bash
-gh pr create --repo apache/solr-mcp --base main --head 
adityamparikh:schema-modification \
-    --title "feat: add schema modification MCP tools (add-fields, 
add-field-types)" \
-    --body "$(cat <<'EOF'
-## Summary
-- Adds `add-fields` and `add-field-types` MCP tools to extend a collection's 
schema additively from the MCP layer
-- Partially closes #30 — `replace-*`, `delete-*`, `add-copy-field`, 
`add-dynamic-field`, and `add-codec-factory` deferred (see spec for rationale)
-- Both tools take `List<Map<String, Object>>` matching Solr's Schema API JSON 
shape; batched via `SchemaRequest.MultiUpdate`
-- Both tools are `@PreAuthorize("isAuthenticated()")` — HTTP enforces auth, 
stdio bypasses (same pattern as existing tools)
-
-## Design and plan
-- Spec: `docs/superpowers/specs/2026-05-17-schema-modification-design.md`
-- Plan: `docs/superpowers/plans/2026-05-17-schema-modification.md`
-
-## Test plan
-- [x] Unit tests (Mockito, `@DisabledInNativeImage`) — validation, happy path, 
error propagation
-- [x] Integration tests (Testcontainers, real Solr) — schema round-trip, 
custom analyzer behavior, DenseVectorField, duplicate/unknown-type errors
-- [x] MCP protocol tests (`McpClientIntegrationTestBase`) — end-to-end 
add-fields → index → search via MCP tool calls over both HTTP and stdio 
transports
-- [x] Full `./gradlew build` passes with no regressions
-- [ ] `./gradlew nativeTest -Pnative` (verify in CI)
-- [ ] `./gradlew dockerIntegrationTest` (verify in CI)
-
-🤖 Generated with [Claude Code](https://claude.com/claude-code)
-EOF
-)"
-```
-
-Expected: PR is created and the URL is printed. Share the URL with the user.
-
----
-
-## Notes for the executor
-
-- **Conventional commits.** Every commit uses a Conventional Commits prefix 
(`feat`, `test`, `docs`, `fix`, `chore`, `style`) and the scope where 
applicable (`feat(schema):`, `test(schema):`, `docs:`).
-- **Signoffs.** Every commit uses `-s` (or includes `Signed-off-by:` 
manually). User's global CLAUDE.md requires this.
-- **Spotless.** Run `./gradlew spotlessApply` before each commit. The 
pre-commit hook or CI will reject unformatted code otherwise.
-- **No `--no-verify` or `--no-gpg-sign`.** Hard rule per the system prompt.
-- **Confirm before remote-affecting operations.** Step 3 of Task 1 (`git push 
origin main`), Steps 2–3 of Task 11 (push and PR creation).
-- **`SchemaResponse.UpdateResponse` shape verification.** Task 6 Step 1 
includes the verification — if the duplicate-field test passes without manual 
error inspection, SolrJ throws automatically and the code is fine as written. 
If it fails, add the response-body inspection shown in that step and re-run.
diff --git a/docs/superpowers/plans/2026-06-05-sbom-generation.md 
b/docs/superpowers/plans/2026-06-05-sbom-generation.md
deleted file mode 100644
index fd6eba6..0000000
--- a/docs/superpowers/plans/2026-06-05-sbom-generation.md
+++ /dev/null
@@ -1,673 +0,0 @@
-# SBOM generation — implementation plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use 
superpowers:subagent-driven-development (recommended) or 
superpowers:executing-plans to implement this plan task-by-task. Steps use 
checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Wire up CycloneDX SBOM generation so every JAR, Docker image, and 
GitHub release artifact ships a machine-readable Software Bill of Materials, 
and `/actuator/sbom` serves it at runtime.
-
-**Architecture:** Apply the `org.cyclonedx.bom` Gradle plugin (1.10.0). Spring 
Boot 3.5's bootJar task auto-detects and embeds 
`META-INF/sbom/application.cdx.json`; the actuator auto-discovers that resource 
and serves it at `/actuator/sbom`. Both the Jib JVM image and Paketo native 
images package the bootJar contents, so SBOM coverage is automatic for every 
artifact — no per-image wiring. CI workflows upload the SBOM as a workflow 
artifact and attach it to GitHub Releases.
-
-**Tech Stack:** Gradle Kotlin DSL with `libs.versions.toml`, Spring Boot 
3.5.14, CycloneDX Gradle Plugin 1.10.0, GitHub Actions.
-
-**Spec:** `docs/superpowers/specs/2026-06-05-sbom-generation-design.md`
-
----
-
-## Pre-flight context for the implementer
-
-Read these files before starting — they show what's already half-wired:
-
-- `gradle/libs.versions.toml` — version catalog; you'll add a new 
`cyclonedx-plugin` version key and plugin alias here.
-- `build.gradle.kts` — main build script; you'll add 
`alias(libs.plugins.cyclonedx)` in the `plugins { }` block and add a 
`cyclonedxBom { … }` configuration block.
-- `src/main/resources/application-http.properties` — `sbom` is already listed 
in `management.endpoints.web.exposure.include` (line near bottom). You'll add 
one explicit-enablement line.
-- `.github/workflows/build-and-publish.yml` — has existing `Upload JAR 
artifact` step pattern (around line 145); you'll add a parallel SBOM upload 
step.
-- `.github/workflows/release-publish.yml` — already contains a `Generate SBOM 
(Software Bill of Materials)` step (`./gradlew cyclonedxBom || echo "SBOM 
generation not configured"`). Today it's a no-op because the plugin isn't 
applied. You'll remove the `|| echo …` fallback (it would now mask a real 
failure) and add upload/attach steps after it.
-- `src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTest.java` — 
boots HTTP profile with random port; you'll add a focused test method (or 
sibling test class) that does an HTTP GET on `/actuator/sbom`.
-- `README.md` — sections are `## What's inside`, `## Get started`, `## 
Security`, `## Available MCP tools`, etc. (see `grep ^## README.md`). Add a new 
section before `## Documentation` (the last section).
-- `CLAUDE.md` (project, at repo root) — has a "Common Commands" section and an 
architecture section. Add a one-line note in Common Commands and a brief 
paragraph in the architecture section about SBOM.
-
----
-
-## File structure
-
-**Modify:**
-- `gradle/libs.versions.toml` — add CycloneDX plugin version + alias
-- `build.gradle.kts` — apply plugin, add `cyclonedxBom { }` configuration
-- `src/main/resources/application-http.properties` — add explicit endpoint 
enablement line
-- `.github/workflows/build-and-publish.yml` — add SBOM upload step in `build` 
job
-- `.github/workflows/release-publish.yml` — fix the existing SBOM step, add 
upload + GitHub Release attach
-- `README.md` — new "## Supply chain & SBOM" section
-- `CLAUDE.md` — short note in Common Commands + brief architecture paragraph
-
-**Create:**
-- 
`src/test/java/org/apache/solr/mcp/server/observability/SbomEndpointIntegrationTest.java`
 — focused HTTP integration test for `/actuator/sbom`
-
----
-
-## Task 1: Add CycloneDX plugin to the version catalog
-
-**Files:**
-- Modify: `gradle/libs.versions.toml`
-
-- [ ] **Step 1: Add plugin version**
-
-In the `[versions]` block, after the `graalvm-native = "0.10.6"` line, add:
-
-```toml
-cyclonedx-plugin = "1.10.0"
-```
-
-- [ ] **Step 2: Add plugin alias**
-
-In the `[plugins]` block, at the bottom (after the `graalvm-native = ...` 
line), add:
-
-```toml
-cyclonedx = { id = "org.cyclonedx.bom", version.ref = "cyclonedx-plugin" }
-```
-
-- [ ] **Step 3: Verify catalog parses**
-
-Run: `./gradlew help -q`
-Expected: succeeds with no output. If it prints `Invalid catalog definition`, 
fix the syntax.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add gradle/libs.versions.toml
-git commit -s -m "$(cat <<'EOF'
-chore(deps): add CycloneDX Gradle plugin 1.10.0 to version catalog
-
-Plugin will be applied in the next commit. Adding the catalog entry
-first keeps build.gradle.kts changes reviewable in isolation.
-
-Signed-off-by: Aditya Parikh <[email protected]>
-EOF
-)"
-```
-
----
-
-## Task 2: Apply and configure the plugin in build.gradle.kts
-
-**Files:**
-- Modify: `build.gradle.kts` (`plugins { }` block, and a new top-level config 
block near the existing `springBoot { buildInfo() }` block)
-
-- [ ] **Step 1: Apply the plugin**
-
-In `build.gradle.kts`, locate the `plugins { … }` block (top of file, around 
line 19-30). Add a new alias line after the `alias(libs.plugins.graalvm.native) 
apply false` line:
-
-```kotlin
-    alias(libs.plugins.cyclonedx)
-```
-
-The final block looks like:
-
-```kotlin
-plugins {
-    java
-    `maven-publish`
-    alias(libs.plugins.spring.boot)
-    alias(libs.plugins.spring.dependency.management)
-    jacoco
-    alias(libs.plugins.errorprone)
-    alias(libs.plugins.spotless)
-    alias(libs.plugins.jib)
-    alias(libs.plugins.graalvm.native) apply false
-    alias(libs.plugins.cyclonedx)
-}
-```
-
-- [ ] **Step 2: Add `cyclonedxBom` configuration block**
-
-Find the `springBoot { buildInfo() }` block (around line 195-197). Immediately 
AFTER it, insert:
-
-```kotlin
-// CycloneDX SBOM (Software Bill of Materials)
-// ==========================================
-// Spring Boot 3.3+ automatically embeds the generated SBOM into the bootable
-// JAR at META-INF/sbom/application.cdx.json when the file name matches
-// `application.cdx`. The actuator then serves it at /actuator/sbom (HTTP
-// profile only — see application-http.properties).
-//
-// One SBOM, three distribution channels:
-//   1. Embedded in the bootable JAR (META-INF/sbom/application.cdx.json)
-//   2. Embedded in every Docker image (Jib + Paketo both package bootJar 
contents)
-//   3. Surfaced at /actuator/sbom for live introspection (HTTP profile)
-//
-// The `bootJar` task automatically depends on `cyclonedxBom` once the plugin
-// is applied — no manual `dependsOn` wiring needed.
-tasks.cyclonedxBom {
-    outputName.set("application.cdx")
-    outputFormat.set("json")
-    schemaVersion.set("1.5")
-    projectType.set("application")
-    includeConfigs.set(listOf("runtimeClasspath"))
-    skipConfigs.set(listOf("testRuntimeClasspath", "errorprone", 
"annotationProcessor"))
-}
-```
-
-- [ ] **Step 3: Run formatter and build the SBOM**
-
-Run:
-```bash
-./gradlew spotlessApply
-./gradlew cyclonedxBom -q
-```
-Expected: both succeed. After the second command, 
`build/reports/application.cdx.json` exists.
-
-- [ ] **Step 4: Verify SBOM shape**
-
-Run:
-```bash
-test -f build/reports/application.cdx.json && \
-  grep -q '"bomFormat" : "CycloneDX"' build/reports/application.cdx.json && \
-  grep -q '"specVersion" : "1.5"' build/reports/application.cdx.json && \
-  echo "SBOM OK"
-```
-Expected: prints `SBOM OK`. If grep fails because the JSON is minified, swap 
`grep -q '"bomFormat":"CycloneDX"'` (no spaces).
-
-- [ ] **Step 5: Verify the SBOM is embedded in the bootJar**
-
-Run:
-```bash
-./gradlew bootJar -q
-unzip -l build/libs/solr-mcp-*.jar | grep -F 
'META-INF/sbom/application.cdx.json'
-```
-Expected: one line of output showing the path exists in the JAR.
-
-If the file is NOT present: the bootJar task didn't pick it up. Check that 
`outputName` is exactly `application.cdx` (not `application.cdx.json`) — Spring 
Boot appends the format extension itself.
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add build.gradle.kts
-git commit -s -m "$(cat <<'EOF'
-feat(build): wire CycloneDX plugin to generate and embed SBOM
-
-Spring Boot 3.5's bootJar auto-embeds META-INF/sbom/application.cdx.json
-when the file name matches `application.cdx`. The Jib JVM image and both
-Paketo native images package the bootJar contents, so every distribution
-artifact now carries an embedded CycloneDX 1.5 SBOM.
-
-Plugin config:
-- outputFormat=json (actuator only consumes JSON)
-- includeConfigs=runtimeClasspath only — test/errorprone deps excluded
-- schemaVersion=1.5
-
-Signed-off-by: Aditya Parikh <[email protected]>
-EOF
-)"
-```
-
----
-
-## Task 3: Enable the /actuator/sbom endpoint explicitly
-
-**Files:**
-- Modify: `src/main/resources/application-http.properties`
-
-`sbom` is already in `management.endpoints.web.exposure.include`. We're adding 
an explicit `enabled=true` line so the project's convention (be explicit about 
endpoint state) is satisfied and so any future scan reading just the properties 
file sees the intent.
-
-- [ ] **Step 1: Add the property**
-
-Find the line that begins with `# observability` (or 
`management.endpoints.web.exposure.include=...`). On a new line immediately 
after the `management.endpoints.web.exposure.include=...` line, add:
-
-```properties
-management.endpoint.sbom.enabled=true
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-git add src/main/resources/application-http.properties
-git commit -s -m "$(cat <<'EOF'
-feat(actuator): enable /actuator/sbom endpoint explicitly
-
-`sbom` was already in management.endpoints.web.exposure.include; this
-makes the endpoint enablement explicit so the file conveys intent
-without relying on Spring Boot defaults.
-
-Signed-off-by: Aditya Parikh <[email protected]>
-EOF
-)"
-```
-
----
-
-## Task 4: Add a focused HTTP integration test for /actuator/sbom
-
-**Files:**
-- Create: 
`src/test/java/org/apache/solr/mcp/server/observability/SbomEndpointIntegrationTest.java`
-
-The test boots the HTTP profile (which mirrors `McpClientIntegrationTest`'s 
setup), hits `/actuator/sbom` over HTTP, and asserts the response is valid 
CycloneDX JSON.
-
-- [ ] **Step 1: Write the failing test**
-
-Create 
`src/test/java/org/apache/solr/mcp/server/observability/SbomEndpointIntegrationTest.java`
 with:
-
-```java
-/*
- * 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 static org.assertj.core.api.Assertions.assertThat;
-
-import java.net.URI;
-import java.net.http.HttpClient;
-import java.net.http.HttpRequest;
-import java.net.http.HttpResponse;
-import org.apache.solr.mcp.server.TestcontainersConfiguration;
-import org.junit.jupiter.api.Tag;
-import org.junit.jupiter.api.Test;
-import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.boot.test.web.server.LocalServerPort;
-import org.springframework.context.annotation.Import;
-import org.springframework.test.context.ActiveProfiles;
-import org.testcontainers.junit.jupiter.Testcontainers;
-
-/**
- * Verifies the CycloneDX SBOM is served at /actuator/sbom in HTTP mode. The
- * SBOM is generated at build time by the cyclonedx Gradle plugin and embedded
- * in the bootJar at META-INF/sbom/application.cdx.json; the actuator
- * auto-discovers and serves it from there.
- */
-@SpringBootTest(
-               webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
-               properties = {"http.security.enabled=false", 
"spring.docker.compose.enabled=false"})
-@ActiveProfiles("http")
-@Import(TestcontainersConfiguration.class)
-@Tag("integration")
-@Testcontainers(disabledWithoutDocker = true)
-class SbomEndpointIntegrationTest {
-
-       @LocalServerPort
-       private int port;
-
-       @Test
-       void sbomEndpointReturnsCycloneDxJson() throws Exception {
-               HttpClient client = HttpClient.newHttpClient();
-               HttpRequest request = HttpRequest.newBuilder()
-                               .uri(URI.create("http://localhost:"; + port + 
"/actuator/sbom/application"))
-                               .GET()
-                               .build();
-
-               HttpResponse<String> response = client.send(request, 
HttpResponse.BodyHandlers.ofString());
-
-               assertThat(response.statusCode()).isEqualTo(200);
-               assertThat(response.headers().firstValue("Content-Type"))
-                               .hasValueSatisfying(ct -> 
assertThat(ct).contains("application/vnd.cyclonedx+json"));
-               
assertThat(response.body()).contains("\"bomFormat\"").contains("CycloneDX");
-       }
-}
-```
-
-**Note on the URL:** Spring Boot's SBOM actuator exposes each embedded SBOM 
under `/actuator/sbom/{id}`. The default id for the application SBOM is 
`application` (derived from the file basename `application.cdx`). If the test 
fails with 404 because the id differs, hit `/actuator/sbom` first (an index) to 
discover the right id, then update the URL.
-
-- [ ] **Step 2: Run the test to verify it passes**
-
-Run:
-```bash
-./gradlew test --tests 
org.apache.solr.mcp.server.observability.SbomEndpointIntegrationTest -i
-```
-Expected: PASS. If FAIL with status 404, see the note above and adjust the 
URL. If FAIL because of compilation, check that `assertj` is on the test 
classpath (it is — pulled in by `spring-boot-starter-test`).
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add 
src/test/java/org/apache/solr/mcp/server/observability/SbomEndpointIntegrationTest.java
-git commit -s -m "$(cat <<'EOF'
-test(observability): verify /actuator/sbom serves CycloneDX JSON
-
-Focused HTTP integration test that boots the http profile with the
-existing TestcontainersConfiguration and asserts the SBOM endpoint
-returns 200 with CycloneDX content.
-
-Signed-off-by: Aditya Parikh <[email protected]>
-EOF
-)"
-```
-
----
-
-## Task 5: Upload SBOM as workflow artifact in build-and-publish.yml
-
-**Files:**
-- Modify: `.github/workflows/build-and-publish.yml`
-
-- [ ] **Step 1: Add an upload step after the existing JAR upload**
-
-Find the step labeled `Upload JAR artifact` (around line 145-150). Immediately 
after it, add a new step:
-
-```yaml
-            # Upload the CycloneDX SBOM produced during the build
-            # build/reports/application.cdx.json is generated by the cyclonedx
-            # Gradle plugin and is also embedded in the bootable JAR
-            -   name: Upload SBOM artifact
-                if: always()
-                uses: actions/upload-artifact@v4
-                with:
-                    name: solr-mcp-sbom
-                    path: build/reports/application.cdx.json
-                    retention-days: 30
-```
-
-The `if: always()` mirrors the test-results pattern and ensures the SBOM is 
captured even if a downstream test fails (useful for debugging 
dependency-related test failures). Retention is 30 days (longer than the 7-day 
artifact retention) because SBOMs are useful for after-the-fact supply-chain 
investigation.
-
-- [ ] **Step 2: Commit**
-
-```bash
-git add .github/workflows/build-and-publish.yml
-git commit -s -m "$(cat <<'EOF'
-ci: upload CycloneDX SBOM as workflow artifact
-
-Mirrors the existing JAR/test-results/coverage upload pattern. Retains
-the SBOM for 30 days (vs the standard 7) since supply-chain
-investigations often happen well after a build.
-
-Signed-off-by: Aditya Parikh <[email protected]>
-EOF
-)"
-```
-
----
-
-## Task 6: Fix and extend the release-publish.yml SBOM step
-
-**Files:**
-- Modify: `.github/workflows/release-publish.yml`
-
-The workflow already has a `Generate SBOM (Software Bill of Materials)` step 
that runs `./gradlew cyclonedxBom || echo "SBOM generation not configured"`. 
With the plugin applied, that `|| echo` fallback would mask real failures. 
Replace it with a strict invocation and add an upload + GitHub Release 
attachment.
-
-- [ ] **Step 1: Locate the existing step**
-
-In `release-publish.yml`, search for `Generate SBOM`. You'll find:
-
-```yaml
-      - name: Generate SBOM (Software Bill of Materials)
-        run: |
-          # Generate SBOM for the release
-          # This helps with supply chain security
-          ./gradlew cyclonedxBom || echo "SBOM generation not configured"
-```
-
-- [ ] **Step 2: Replace it with the strict invocation + upload + attach**
-
-Replace the step above with:
-
-```yaml
-      - name: Generate SBOM (Software Bill of Materials)
-        run: ./gradlew cyclonedxBom
-
-      - name: Upload SBOM as workflow artifact
-        if: always()
-        uses: actions/upload-artifact@v4
-        with:
-          name: solr-mcp-sbom-${{ inputs.release_version }}
-          path: build/reports/application.cdx.json
-          retention-days: 90
-
-      - name: Attach SBOM to GitHub Release
-        env:
-          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          RELEASE_VERSION: ${{ inputs.release_version }}
-        run: |
-          # Rename to include the version so the asset is unambiguous on the 
release page
-          cp build/reports/application.cdx.json 
"solr-mcp-${RELEASE_VERSION}.cdx.json"
-          # --clobber lets re-runs of this workflow replace a previously 
uploaded SBOM
-          # If the v<version> GitHub Release does not exist yet, log and 
continue —
-          # the workflow artifact above is still captured.
-          if gh release view "v${RELEASE_VERSION}" >/dev/null 2>&1; then
-            gh release upload "v${RELEASE_VERSION}" 
"solr-mcp-${RELEASE_VERSION}.cdx.json" --clobber
-          else
-            echo "GitHub Release v${RELEASE_VERSION} does not exist yet; SBOM 
available as workflow artifact only."
-          fi
-```
-
-The 90-day retention is longer than build-and-publish (30 days) because 
release SBOMs have an asynchronous secondary consumer: PMC members downloading 
them weeks after a vote.
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add .github/workflows/release-publish.yml
-git commit -s -m "$(cat <<'EOF'
-ci(release): strict SBOM generation + upload + release attachment
-
-The existing Generate SBOM step swallowed errors with `|| echo "..."`,
-masking failures now that the plugin is wired. Removes the fallback,
-uploads the SBOM as a 90-day workflow artifact, and attaches it to the
-v<version> GitHub Release when one exists (graceful fallback otherwise
-since the source release of record lives at dist.apache.org, not GitHub).
-
-Signed-off-by: Aditya Parikh <[email protected]>
-EOF
-)"
-```
-
----
-
-## Task 7: Document SBOM in README.md
-
-**Files:**
-- Modify: `README.md`
-
-- [ ] **Step 1: Add a new section before `## Documentation`**
-
-Find the `## Documentation` section (last `##` heading in the file, around 
line 456). Immediately BEFORE it, insert:
-
-```markdown
-## Supply chain & SBOM
-
-Every released JAR and Docker image ships a [CycloneDX](https://cyclonedx.org/)
-1.5 Software Bill of Materials so downstream consumers can audit and scan the
-dependency graph.
-
-### Where the SBOM lives
-
-- **Inside every JAR and image:** `META-INF/sbom/application.cdx.json` —
-  embedded by the Spring Boot Gradle plugin at build time. The Jib JVM image
-  (`solr-mcp:<v>`) and both Paketo native images (`solr-mcp:<v>-native-stdio`,
-  `solr-mcp:<v>-native-http`) all package the bootJar contents, so the SBOM
-  ships with every distribution channel.
-- **HTTP endpoint** (`http` profile only): `GET /actuator/sbom/application`
-  returns the same SBOM as `application/vnd.cyclonedx+json`.
-- **GitHub Releases:** the release workflow attaches
-  `solr-mcp-<version>.cdx.json` to every official ASF release.
-- **CI artifacts:** every `Build and Publish` run uploads `solr-mcp-sbom`
-  (CycloneDX JSON) to the workflow run page; downloadable for 30 days.
-
-### Fetch the SBOM
-
-From a running HTTP-mode server:
-
-```bash
-curl -s http://localhost:8080/actuator/sbom/application > application.cdx.json
-```
-
-From the local build (no server required):
-
-```bash
-./gradlew cyclonedxBom
-cat build/reports/application.cdx.json
-```
-
-### Scan the SBOM
-
-```bash
-# Trivy
-trivy sbom application.cdx.json
-
-# Grype
-grype sbom:application.cdx.json
-```
-
-Both tools natively consume CycloneDX 1.5 and report CVEs against the
-listed components.
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-git add README.md
-git commit -s -m "$(cat <<'EOF'
-docs(readme): document SBOM location, retrieval, and scanning
-
-New 'Supply chain & SBOM' section covers all four distribution
-channels (embedded in JAR/image, /actuator/sbom endpoint, GitHub
-Release asset, CI workflow artifact) and shows trivy/grype usage.
-
-Signed-off-by: Aditya Parikh <[email protected]>
-EOF
-)"
-```
-
----
-
-## Task 8: Note SBOM in CLAUDE.md
-
-**Files:**
-- Modify: `CLAUDE.md`
-
-CLAUDE.md is project-level guidance for AI assistants. Two small additions: a 
build command and an architecture note.
-
-- [ ] **Step 1: Add a command line under "Common Commands"**
-
-Find the `## Common Commands` section. Within the fenced bash block, locate 
the `# Code formatting (REQUIRED before commit)` group. Immediately BEFORE that 
group, insert:
-
-```bash
-# SBOM (Software Bill of Materials)
-./gradlew cyclonedxBom                       # Generate 
build/reports/application.cdx.json
-
-```
-
-(Keep the trailing blank line so the existing groups stay visually separated.)
-
-- [ ] **Step 2: Add an architecture note**
-
-Find the `### Logging Architecture` section. Immediately BEFORE it, insert a 
new section:
-
-```markdown
-### SBOM Architecture
-
-CycloneDX SBOM generation is wired via `org.cyclonedx.bom` 
(`tasks.cyclonedxBom` in
-`build.gradle.kts`). The Spring Boot Gradle plugin embeds the generated file
-into the bootJar at `META-INF/sbom/application.cdx.json`; the actuator
-auto-discovers it and serves it at `/actuator/sbom/application` in the `http`
-profile (enabled in `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.
-
-Spec: 
[docs/superpowers/specs/2026-06-05-sbom-generation-design.md](docs/superpowers/specs/2026-06-05-sbom-generation-design.md)
-```
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add CLAUDE.md
-git commit -s -m "$(cat <<'EOF'
-docs(claude): note SBOM generation in commands + architecture
-
-Records the cyclonedxBom command and how the SBOM flows through
-bootJar → actuator → Docker images, so future agents have the
-mental model when working on related code.
-
-Signed-off-by: Aditya Parikh <[email protected]>
-EOF
-)"
-```
-
----
-
-## Task 9: Final verification
-
-**Files:** none (verification only)
-
-- [ ] **Step 1: Run the full build**
-
-Run:
-```bash
-./gradlew spotlessApply build
-```
-Expected: BUILD SUCCESSFUL. All tests pass.
-
-- [ ] **Step 2: Confirm SBOM artifacts present**
-
-Run:
-```bash
-ls -lh build/reports/application.cdx.json && \
-  unzip -l build/libs/solr-mcp-*.jar | grep -F 
'META-INF/sbom/application.cdx.json'
-```
-Expected: file exists; one line in JAR listing for the embedded SBOM.
-
-- [ ] **Step 3: Inspect SBOM head**
-
-Run:
-```bash
-head -20 build/reports/application.cdx.json
-```
-Expected: includes `"bomFormat" : "CycloneDX"` and `"specVersion" : "1.5"`.
-
-- [ ] **Step 4: Push the branch and open the PR**
-
-```bash
-git push -u origin worktree-add-sbom-generation
-gh pr create --title "feat(build): generate CycloneDX SBOM for every release 
artifact" --body "$(cat <<'EOF'
-## Summary
-
-- Wires CycloneDX 1.5 SBOM generation into every build, embeds it in the
-  bootJar at `META-INF/sbom/application.cdx.json`, and exposes it at
-  `/actuator/sbom/application` in HTTP mode.
-- Jib JVM image and both Paketo native images ship the SBOM for free via
-  bootJar packaging — no per-image wiring.
-- `build-and-publish.yml` uploads the SBOM as a 30-day workflow artifact;
-  `release-publish.yml` uploads as a 90-day artifact and attaches it to the
-  matching GitHub Release.
-- README documents location, retrieval, and scanning with trivy/grype.
-
-Spec: `docs/superpowers/specs/2026-06-05-sbom-generation-design.md`
-
-## Test plan
-
-- [x] `./gradlew build` is green
-- [x] `build/reports/application.cdx.json` produced (`bomFormat: CycloneDX`, 
`specVersion: 1.5`)
-- [x] SBOM is embedded in `build/libs/solr-mcp-*.jar` at 
`META-INF/sbom/application.cdx.json`
-- [x] `SbomEndpointIntegrationTest` passes (`/actuator/sbom/application` 
returns 200 + CycloneDX JSON)
-- [ ] CI green on this PR
-- [ ] Manual sanity-check after merge: pull the resulting Jib image, `docker 
run` it with `PROFILES=http`, `curl /actuator/sbom/application`
-EOF
-)"
-```
-
----
-
-## Self-review
-
-**Spec coverage check:**
-
-- ✅ CycloneDX Gradle plugin applied — Task 1, 2
-- ✅ `outputName=application.cdx`, `outputFormat=json`, `schemaVersion=1.5` — 
Task 2
-- ✅ `includeConfigs=runtimeClasspath`, exclude test/errorprone — Task 2
-- ✅ Embedded in bootJar at `META-INF/sbom/application.cdx.json` — verified in 
Task 2 step 5
-- ✅ `/actuator/sbom` exposed by default in HTTP profile — Task 3 (and 
pre-existing exposure list)
-- ✅ Workflow artifact in build-and-publish.yml — Task 5
-- ✅ Workflow artifact + GitHub Release asset in release-publish.yml — Task 6
-- ✅ README documents location, retrieval, scanning — Task 7
-- ✅ CLAUDE.md notes the plugin + endpoint — Task 8
-- ✅ One focused HTTP integration test asserting CycloneDX response — Task 4
-- ✅ Build green at end — Task 9
-
-**Placeholder scan:** No TBD / TODO / "implement later" found. Every 
code/config block is complete and copyable.
-
-**Type/name consistency:** `application.cdx` used consistently as 
`outputName`; the embedded path is consistently 
`META-INF/sbom/application.cdx.json`; endpoint URL `/actuator/sbom/application` 
consistent between Task 4 (test), Task 7 (README), Task 8 (CLAUDE.md). The 
plugin task name `cyclonedxBom` is consistent across Tasks 2, 6, and 9.
diff --git a/docs/superpowers/specs/2026-05-17-schema-modification-design.md 
b/docs/superpowers/specs/2026-05-17-schema-modification-design.md
deleted file mode 100644
index 76d5679..0000000
--- a/docs/superpowers/specs/2026-05-17-schema-modification-design.md
+++ /dev/null
@@ -1,400 +0,0 @@
-# Schema modification MCP tools — design
-
-**Status:** draft, pending user approval
-**Issue:** [apache/solr-mcp#30](https://github.com/apache/solr-mcp/issues/30) 
(partial — see Scope)
-**Branch:** `schema-modification`
-
-## Problem
-
-`SchemaService` exposes one MCP tool today: `get-schema`, which is read-only. 
AI assistants
-can inspect a Solr collection's schema but cannot extend it. The only way to 
add fields or
-field types is to drop out of the AI workflow and POST JSON to Solr's Schema 
API by hand:
-
-```bash
-curl -X POST -H 'Content-Type:application/json' \
-    http://localhost:8983/solr/shows/schema \
-    -d '{"add-field": [{"name":"title","type":"text_general", ...}, ...]}'
-```
-
-This breaks the end-to-end "set up a collection, define its shape, index data" 
workflow
-through an AI assistant.
-
-The motivating use case: ask an AI to create a `shows` collection and define a 
schema with
-fields like `title text_general`, `platform string` (for exact-match 
faceting), `release_year
-pint`, `genres strings` (multi-valued), and so on — properties that Solr's 
schemaless mode
-won't infer correctly.
-
-### Why not just rely on schemaless mode / dynamic fields?
-
-The `_default` configset has schemaless mode enabled and ships with 
dynamic-field patterns
-(`*_s`, `*_i`, `*_txt`, ...). For casual exploration these cover a lot. They 
fail for the
-motivating use case because:
-
-- Schemaless guesses `text_general` for strings, but several shows fields need 
`string` for
-  exact-match faceting (`platform`, `country`, `language`, `rating`).
-- Schemaless never sets `docValues=true`. The shows spec wants 
`docValues=true` on 11 of 16
-  fields for sorting/faceting/function-query efficiency.
-- Schemaless infers multi-valued from the first doc's value shape — fragile 
under data drift.
-- Schemaless cannot define `DenseVectorField` (vector search needs explicit 
`vectorDimension`,
-  `similarityFunction`, `knnAlgorithm` — the secondary issue-#30 motivation).
-
-Explicit `add-fields` / `add-field-types` is therefore necessary for any 
non-casual workflow.
-
-## Scope
-
-In scope:
-
-- `add-fields` MCP tool — add one or more fields to an existing collection's 
schema.
-- `add-field-types` MCP tool — add one or more field types (including custom 
analyzer chains
-  and `DenseVectorField` for vector search) to an existing collection's schema.
-
-Out of scope (issue #30 still partially open after merge):
-
-- `replace-field` / `replace-field-type` — silently breaks existing indexed 
data without
-  reindex. AI-driven workflows are the wrong place to expose that footgun 
without a
-  guardrail design.
-- `delete-field` / `delete-field-type` — same risk; orphan data; cascading 
effects on
-  field types used by multiple fields.
-- `add-copy-field` / `add-dynamic-field` — useful but not motivating; defer to 
follow-up.
-- `add-codec-factory` (issue #30 third bullet) — uses Config API, not Schema 
API; different
-  code path; different risk profile.
-
-## Decisions (from brainstorming)
-
-| Decision | Choice | Rationale |
-|---|---|---|
-| Operations | Add-only; no replace/delete | Replace/delete silently corrupt 
indexed data without explicit reindex. Add-only is safe; orphan fields/types 
are harmless. |
-| Batching | Batch per call (list of definitions) | Matches Solr Schema API 
wire format; one round-trip. SolrJ's `SchemaRequest.MultiUpdate` is built for 
this. |
-| Parameter shape | `List<Map<String, Object>>` | Maps 1:1 to Solr's JSON. 
Records can't cleanly express analyzer nesting + arbitrary per-factory params. 
Matches SolrJ's `AddField(Map)` constructor — zero transformation. |
-| One tool vs two | Two separate tools | LLM tool-use guidance favors 
single-purpose tools. Two single-list-parameter tools eliminate cross-wire risk 
vs a combined tool with two optional lists. |
-| Failure mode | Throw on any Solr error | Matches `createCollection`. 
Partial-failure case is rare in practice; when it does happen the LLM can call 
`get-schema` to inspect. Avoids inventing a `failures` shape for a rare case. |
-
-## Architecture
-
-Add the two tools as methods on the existing `SchemaService`
-(`src/main/java/org/apache/solr/mcp/server/schema/SchemaService.java`). Same 
package,
-same constructor dependencies (`SolrClient`, `ObjectMapper`), same annotations 
as
-`getSchema`. No new service class.
-
-```
-SchemaService
-├── getSchema(String collection)               — existing
-├── addFields(collection, fields)              — NEW
-└── addFieldTypes(collection, fieldTypes)      — NEW
-```
-
-### Method signatures
-
-Tool descriptions are deliberately long and include inline recipes 
(case-insensitive exact
-match, dense vector, autocomplete). LLMs use these recipes as the 
diagnostic-to-fix bridge:
-the user describes a symptom ("my filter doesn't match Netflix"), the LLM 
matches the
-symptom to a recipe in the description, and the recipe gives the LLM the exact 
analyzer
-chain to construct. Generic shape-only descriptions are not sufficient — a 
strong model
-might still produce a working chain from training data, but inline recipes 
improve
-reliability and reduce variance across model capabilities.
-
-```java
-@PreAuthorize("isAuthenticated()")
-@McpTool(
-    name = "add-fields",
-    description = "Add one or more fields to a Solr collection schema. " +
-        "Call get-schema first to inspect existing field configuration before 
adding. " +
-        "Each field map follows the Solr Schema API add-field shape: required 
keys " +
-        "'name' and 'type', plus optional 'stored', 'indexed', 'docValues', " +
-        "'multiValued', 'required', 'omitNorms', etc. " +
-        "Example: 
{\"name\":\"platform\",\"type\":\"string\",\"stored\":true,\"indexed\":true,\"docValues\":true}.
 " +
-        "Use 'strings' (not 'string') for multi-valued string fields. " +
-        "Note: this only adds new fields; existing fields cannot be modified. 
" +
-        "Solr's Schema API is transactional — if any command in the batch 
fails, " +
-        "none are applied. On failure, fix the invalid field(s) and retry the 
whole batch."
-)
-public SchemaUpdateResult addFields(
-    @McpToolParam(description = "Solr collection name") String collection,
-    @McpToolParam(description = "List of field definitions (Solr add-field 
JSON shape)")
-        List<Map<String, Object>> fields
-) throws SolrServerException, IOException;
-
-@PreAuthorize("isAuthenticated()")
-@McpTool(
-    name = "add-field-types",
-    description = "Add one or more field types to a Solr collection schema. " +
-        "Call get-schema first to inspect existing field types before adding. 
" +
-        "Each map follows the Solr Schema API add-field-type shape: required 
keys " +
-        "'name' and 'class', optional 'analyzer' (or 
'indexAnalyzer'+'queryAnalyzer'), " +
-        "and class-specific attributes. " +
-        "Common recipes: " +
-        "(1) case-insensitive exact match: class=solr.TextField with analyzer 
" +
-        "{tokenizer:{class:solr.KeywordTokenizerFactory}, 
filters:[{class:solr.LowerCaseFilterFactory}]}; " +
-        "(2) dense vector for semantic search: class=solr.DenseVectorField 
with " +
-        "vectorDimension, similarityFunction (cosine/dot_product/euclidean), 
and knnAlgorithm=hnsw; " +
-        "(3) autocomplete: class=solr.TextField with separate indexAnalyzer 
using EdgeNGramFilterFactory " +
-        "and queryAnalyzer without it. " +
-        "After adding a type, use add-fields to create fields of that type. " +
-        "Solr's Schema API is transactional — if any command in the batch 
fails, none are applied."
-)
-public SchemaUpdateResult addFieldTypes(
-    @McpToolParam(description = "Solr collection name") String collection,
-    @McpToolParam(description = "List of field type definitions (Solr 
add-field-type JSON shape)")
-        List<Map<String, Object>> fieldTypes
-) throws SolrServerException, IOException;
-```
-
-### Result type
-
-New record `SchemaUpdateResult` in a new file
-`src/main/java/org/apache/solr/mcp/server/schema/SchemaUpdateResult.java`:
-
-```java
-public record SchemaUpdateResult(String collection, List<String> addedNames) {}
-```
-
-Failures throw and never produce this result, so no `success` flag is needed.
-`addedNames` echoes the `name` from each input definition in input order so the
-caller can confirm what landed. No `timestamp` — sub-second operation; the MCP
-host records call timing already.
-
-### Implementation skeleton
-
-```java
-public SchemaUpdateResult addFields(String collection, List<Map<String, 
Object>> fields)
-        throws SolrServerException, IOException {
-    if (collection == null || collection.isBlank()) {
-        throw new IllegalArgumentException("Collection name must not be 
blank");
-    }
-    if (fields == null || fields.isEmpty()) {
-        throw new IllegalArgumentException("fields must not be empty");
-    }
-    List<String> names = new ArrayList<>(fields.size());
-    List<SchemaRequest.Update> updates = new ArrayList<>(fields.size());
-    for (Map<String, Object> field : fields) {
-        names.add(String.valueOf(field.get("name")));
-        updates.add(new SchemaRequest.AddField(field));
-    }
-    new SchemaRequest.MultiUpdate(updates).process(solrClient, collection);
-    return new SchemaUpdateResult(collection, true, names, new Date());
-}
-```
-
-`addFieldTypes` is the same shape but each map needs conversion to 
`FieldTypeDefinition`
-(see below).
-
-### `FieldTypeDefinition` conversion helper
-
-`FieldTypeDefinition` (SolrJ) doesn't accept `name`/`class` as top-level 
setters — those go
-into the attributes map. So a flat input map doesn't deserialize directly via 
Jackson. A
-small private helper builds it manually:
-
-```java
-private FieldTypeDefinition toFieldTypeDefinition(Map<String, Object> input) {
-    FieldTypeDefinition def = new FieldTypeDefinition();
-    Map<String, Object> attributes = new LinkedHashMap<>(input);
-    Object analyzer = attributes.remove("analyzer");
-    Object indexAnalyzer = attributes.remove("indexAnalyzer");
-    Object queryAnalyzer = attributes.remove("queryAnalyzer");
-    def.setAttributes(attributes);
-    if (analyzer != null)      def.setAnalyzer(toAnalyzerDefinition(analyzer));
-    if (indexAnalyzer != null) 
def.setIndexAnalyzer(toAnalyzerDefinition(indexAnalyzer));
-    if (queryAnalyzer != null) 
def.setQueryAnalyzer(toAnalyzerDefinition(queryAnalyzer));
-    return def;
-}
-
-private AnalyzerDefinition toAnalyzerDefinition(Object raw) {
-    return objectMapper.convertValue(raw, AnalyzerDefinition.class);
-}
-```
-
-`AnalyzerDefinition` (with its nested `charFilters`, `tokenizer`, `filters` 
lists of
-`{class, params...}` maps) is structurally amenable to Jackson `convertValue`. 
Verify by
-integration test.
-
-### Validation
-
-Minimal — match `createCollection` style:
-
-- `collection` not null, not blank → `IllegalArgumentException`
-- `fields` / `fieldTypes` not null, not empty → `IllegalArgumentException`
-
-No per-map key validation. Solr returns clear errors for missing/invalid keys; 
pre-validating
-duplicates that work and adds maintenance.
-
-### Failure mode
-
-- Bad input (blank collection, empty list) → `IllegalArgumentException` 
(Spring AI MCP
-  converts to tool error)
-- Solr transport failure → `SolrServerException` / `IOException` propagate
-- Solr-side command failure → `SolrServerException` propagates from
-  `MultiUpdate.process()` (verify exact behavior in integration test — Solr 
returns errors
-  in the response body; SolrJ may or may not throw automatically. If it 
doesn't, inspect
-  `response.getResponse().get("errors")` and throw explicitly. **This API 
shape needs
-  integration-test verification before relying on it.**)
-
-`MultiUpdate` is **transactional** — per Solr's Schema API reference guide and 
SolrJ's
-`SchemaRequest.MultiUpdate` Javadoc, all commands in a single call either 
succeed or
-fail together. Solr returns HTTP 400 with an `errors` array on failure and 
rolls back
-any partially-applied state. SolrJ then throws (verified by the
-`addFields_duplicateField_throws` integration test, which passes without 
needing manual
-response-body inspection).
-
-### Native image hints
-
-Add to `src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java`:
-
-- `SchemaUpdateResult` — invisible to AOT (MCP dispatches via `Object`), same 
pattern as
-  `CollectionCreationResult`
-
-For SolrJ types (`SchemaRequest.AddField`, `AddFieldType`, `MultiUpdate`,
-`FieldTypeDefinition`, `AnalyzerDefinition`): verify by running
-`./gradlew nativeTest -Pnative` after the implementation pass. Add reflection 
registrations
-only if tests fail.
-
-Resource hints: none new.
-
-## Testing
-
-### `SchemaServiceTest` (unit, Mockito, `@DisabledInNativeImage`)
-
-New file at 
`src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceTest.java`
-(or extend if exists). Cases:
-
-- `addFields_blankCollection_throws()` — null and blank collection
-- `addFields_emptyList_throws()` — null and empty list
-- `addFields_happyPath_buildsMultiUpdate()` — capture `SolrRequest` argument to
-  `solrClient.request(...)`, assert it is a `MultiUpdate` carrying the 
expected `AddField`s
-  in input order
-- `addFields_solrThrows_propagates()` — mock SolrClient to throw 
`SolrServerException`,
-  assert it surfaces unchanged
-- Same four cases for `addFieldTypes`, plus:
-  - `addFieldTypes_withAnalyzer_buildsFieldTypeDefinition()` — input has 
nested analyzer,
-    assert the `FieldTypeDefinition` passed to `AddFieldType` has its analyzer 
set with the
-    expected tokenizer/filters
-
-### `SchemaServiceIntegrationTest` (Testcontainers, real Solr)
-
-New file 
`src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceIntegrationTest.java`.
-Pattern follows existing `*IntegrationTest` classes (real `SolrContainer`, 
real `SolrClient`).
-
-- `addFields_endToEnd_persistsToSchema()` — create collection via 
`CollectionService`, call
-  `addFields` with 3 fields covering `string`/`text_general`/`pint`, then call 
`getSchema`
-  and assert all 3 appear with the right types and properties (including 
`docValues=true`
-  where set)
-- `addFieldTypes_endToEnd_persistsToSchema()` — add a custom field type with 
an analyzer
-  (e.g., `text_lowercase` with `KeywordTokenizerFactory` + 
`LowerCaseFilterFactory`), then
-  add a field using that type, then index a doc and assert the lowercase 
analyzer was
-  applied (query for the field with mixed-case input matches)
-- `addFields_duplicateField_throws()` — add a field, then call again with the 
same name;
-  assert exception (Solr returns "Field 'X' already exists")
-- `addFields_unknownType_throws()` — try to add a field with `type: 
"nonexistent_type"`;
-  assert exception
-
-The duplicate-field and unknown-type tests also serve to **verify the response 
error
-shape** assumption noted under Failure mode.
-
-### `McpClientIntegrationTestBase`
-
-Append ordered tests to the existing `mcp-client-test` collection (reuse — by 
test 16 the
-prior assertions are done, and adding fields doesn't disturb them):
-
-```java
-@Test @Order(16)
-void addFieldsToTestCollection() {
-    // call add-fields with a subset of the shows-style schema:
-    //   {name: "platform", type: "string", stored: true, indexed: true, 
docValues: true}
-    //   {name: "release_year", type: "pint", stored: true, indexed: true, 
docValues: true}
-    //   {name: "genres", type: "strings", stored: true, indexed: true, 
docValues: true}
-    // assert result has success=true and addedNames in expected order
-}
-
-@Test @Order(17)
-void indexDocumentWithNewFields() {
-    // index-json-documents with one doc using the new fields:
-    //   {id: "show-1", title: "Breaking Bad", platform: "Netflix",
-    //    release_year: 2008, genres: ["drama","crime"]}
-    // assert no error
-}
-
-@Test @Order(18)
-void searchWithNewFieldFilter() {
-    // search with filterQueries=["platform:Netflix"]
-    // assert numFound=1 and the returned doc has title="Breaking Bad"
-}
-```
-
-Skip `add-field-types` in `McpClientIntegrationTestBase` — covered in
-`SchemaServiceIntegrationTest`. Keeps the MCP-protocol-level test focused on 
the user's
-motivating workflow.
-
-### Docker / native test coverage
-
-No changes to `dockerIntegrationTest` or `nativeTest` configuration. The new 
methods are
-exercised by the existing test runs:
-
-- JVM unit + integration: `./gradlew build`
-- Native: `./gradlew nativeTest -Pnative` (unit Mockito tests stay 
`@DisabledInNativeImage`)
-- Docker MCP protocol: `./gradlew dockerIntegrationTest` (runs 
`McpClientIntegrationTestBase`
-  subclasses against the Jib image, including the new ordered tests)
-
-## Docs
-
-- **`README.md`** — append rows for `add-fields` and `add-field-types` to the 
existing MCP
-  tools list, one line each, matching the style of `get-schema`.
-- **`CLAUDE.md`** — under "MCP Tools" → SchemaService entry, change from 
"Schema
-  introspection" to "Schema introspection and additive modification". One 
sentence.
-- **No new doc files.**
-
-## Git workflow
-
-```bash
-# Sync local main with upstream main (will confirm with user before push)
-git checkout main
-git fetch upstream
-git reset --hard upstream/main
-git push origin main
-
-# Branch off updated main
-git checkout -b schema-modification
-```
-
-**Spec file handling.** This spec is written on the `docs-restructure` branch 
but the
-implementation work happens on `schema-modification` (off main). To avoid 
cherry-picking:
-
-1. Do not commit the spec on `docs-restructure`.
-2. After `git checkout -b schema-modification`, the unstaged spec file in
-   `docs/superpowers/specs/` carries over to the new branch automatically.
-3. First commit on `schema-modification` includes the spec.
-
-Untracked `.DS_Store` and the rest of `docs/superpowers/` stay alone.
-
-## Commit conventions
-
-Per project + user CLAUDE.md:
-
-- Conventional Commits: `feat(schema): add add-fields and add-field-types MCP 
tools`
-- `Signed-off-by:` in every commit (user's global instruction; `git commit -s`)
-- `Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>`
-
-## Open questions resolved during brainstorming
-
-- **Why not include modification ops (replace/delete)?** Replace/delete 
silently break
-  indexed data without reindex. AI-driven workflows are exactly the wrong 
place to expose
-  that footgun. Defer until we design a guardrail (e.g. mandatory
-  `acknowledgeReindexRequired: true`).
-- **Why not include codec factory?** Different API (Config API vs Schema API), 
different
-  risk profile (wrong codec choice can break an index), and the motivating use 
case
-  doesn't need it.
-- **Why two tools instead of one combined?** LLM tool-use guidance favors 
single-purpose
-  tools. Combined tool's two-optional-list shape risks LLMs cross-wiring field 
defs and
-  type defs. Orphan-field-type cost of separation is harmless.
-- **Why `List<Map<String, Object>>` instead of strongly-typed records?** Solr 
field-type
-  shape includes analyzers/tokenizers/filters with class-specific param bags; 
records
-  collapse to `Map<String, Object>` at the leaves anyway. Map shape matches 
SolrJ's
-  `AddField(Map)` constructor — zero transformation.
-- **Why not skip this and rely on schemaless mode?** Schemaless gets `string` 
vs
-  `text_general` wrong for the motivating use case, never sets `docValues`, 
infers
-  multi-valued fragilely from doc 1, and cannot define vector fields at all.
-- **Why throw on failure instead of returning a partial-result type?** Most 
failures fail
-  fast at command #1 (already-exists, unknown-type). Mid-batch partial failure 
is rare;
-  modeling it with a `List<SchemaUpdateError>` adds a type for an edge case. 
Caller can
-  call `get-schema` after a failure to see what landed.
-- **Why no per-key map validation?** Solr returns clear errors for 
missing/invalid keys.
-  Pre-validating duplicates Solr's work and adds maintenance burden. Matches
-  `createCollection` style (only validates collection name).
diff --git a/docs/superpowers/specs/2026-06-05-sbom-generation-design.md 
b/docs/superpowers/specs/2026-06-05-sbom-generation-design.md
deleted file mode 100644
index 68fb7e1..0000000
--- a/docs/superpowers/specs/2026-06-05-sbom-generation-design.md
+++ /dev/null
@@ -1,160 +0,0 @@
-# SBOM generation — design
-
-**Status:** draft, pending user approval
-**Branch:** `worktree-add-sbom-generation`
-
-## Problem
-
-The Solr MCP server ships as a JAR, a Jib JVM Docker image, and two Paketo 
native
-images, but produces no Software Bill of Materials. Downstream consumers — 
Apache
-release reviewers, supply-chain scanners (Trivy, Grype, Dependency-Track),
-container-registry attestation tooling — have no machine-readable inventory of
-what dependencies ship inside the binary. SBOM coverage is increasingly an
-Apache release-policy expectation and a precondition for SLSA / CycloneDX VEX
-workflows downstream.
-
-Curiously, `application-http.properties` already lists `sbom` in
-`management.endpoints.web.exposure.include`. The endpoint config is half-wired
-already; today the actuator returns 404 because no SBOM is generated.
-
-## Scope
-
-In scope:
-
-- Generate a CycloneDX 1.6 SBOM (`application.cdx.json`) on every `./gradlew 
build`.
-- Embed the SBOM in the bootable JAR at `META-INF/sbom/application.cdx.json` so
-  it ships with every distribution (JAR, Jib JVM image, both Paketo native
-  images).
-- Expose `GET /actuator/sbom` in the HTTP profile (config already partially in
-  place; finish the wiring).
-- Attach the SBOM as a release artifact in `build-and-publish.yml` and
-  `release-publish.yml` (workflow artifact + GitHub Release asset).
-- Document the SBOM in `README.md` (location, endpoint, scanning) and in
-  `CLAUDE.md` (build-system and native-image notes).
-- One small HTTP integration-test assertion that `/actuator/sbom` returns
-  200 + a CycloneDX-shaped body.
-
-Out of scope (intentional, can be follow-ups):
-
-- SPDX format alongside CycloneDX — the plugin supports
-  `outputFormat = "all"`; can layer on without redesign.
-- Cosign / SLSA provenance signing — separate concern; would add another moving
-  part to maintain.
-- Dependency-Track upload from CI — requires an externally-hosted server.
-- SBOM for transitive native-image runtime libraries that GraalVM links in —
-  the CycloneDX plugin reports Gradle dependencies, which already covers what
-  ends up in the binary.
-
-## Tool choice: CycloneDX Gradle plugin
-
-The project is on Spring Boot 3.5.14, which has first-class CycloneDX
-integration since 3.3.0:
-
-- Applying `org.cyclonedx.bom` makes the Spring Boot Gradle plugin 
automatically
-  embed the generated `application.cdx.json` into the bootable JAR at
-  `META-INF/sbom/application.cdx.json`.
-- Spring Boot's actuator auto-discovers that resource and serves it at
-  `/actuator/sbom` (CycloneDX-format) when the endpoint is exposed.
-- The Jib JVM image and both Paketo native images package the bootJar contents,
-  so the SBOM ships with every artifact for free — no per-image wiring.
-
-CycloneDX (vs SPDX) is the de-facto Apache ecosystem standard, what Spring Boot
-natively integrates with, and what Trivy/Grype/Dependency-Track ingest 
natively.
-
-**Plugin version: 2.4.1** — the version Spring Initializr ships for Spring
-Boot 3.5.14 when you select the `sbom-cyclone-dx` dependency.
-
-## Architecture
-
-### Build wiring
-
-```
-gradle/libs.versions.toml             ← new version key + plugin alias
-build.gradle.kts                      ← apply alias(libs.plugins.cyclonedx)
-                                      ← cyclonedxBom { … } configuration block
-```
-
-No custom `cyclonedxBom { ... }` block is needed. Spring Boot's
-`CycloneDxPluginAction` auto-configures `outputName = "application.cdx"`,
-`outputFormat = "json"`, and `projectType = "application"` via Property
-conventions, and `bootJar` automatically depends on `cyclonedxBom`. The plugin
-default schema version (CycloneDX 1.6) is used as-is. This matches what
-Spring Initializr generates for the same dependency set.
-
-### Runtime wiring
-
-`application-http.properties` already exposes `sbom` via
-`management.endpoints.web.exposure.include`. The remaining work:
-
-- Add `management.endpoint.sbom.enabled=true` (explicit, even though it
-  defaults true, because the project's convention is to be explicit about
-  endpoint enablement for the LGTM stack to discover).
-- No change to `application-stdio.properties` — actuator HTTP endpoints don't
-  apply in stdio mode.
-
-### CI wiring
-
-`build-and-publish.yml`: after `./gradlew build`, add an 
`actions/upload-artifact`
-step that uploads `build/reports/application.cdx.json`. Retained 30 days
-(default), accessible from the run page.
-
-`release-publish.yml`: same upload step, plus `gh release upload <tag>
-build/reports/application.cdx.json`. The SBOM appears alongside source tarballs
-on the GitHub Release page.
-
-`native.yml`: no change. The native-image build inherits the SBOM via the 
bootJar
-input.
-
-### Documentation
-
-`README.md`: new "## Supply chain & SBOM" section near the bottom, covering:
-
-- Where the SBOM lives (`META-INF/sbom/application.cdx.json` inside every JAR
-  and image).
-- How to fetch it from a running server: `curl 
http://localhost:8080/actuator/sbom`.
-- How to extract it from a Docker image:
-  `docker run --rm --entrypoint cat solr-mcp:latest 
/workspace/META-INF/sbom/application.cdx.json`
-  (Jib path) or via the release asset.
-- How to scan: `trivy sbom application.cdx.json` and
-  `grype sbom:application.cdx.json` examples.
-
-`CLAUDE.md`: brief note in the build-system section that CycloneDX is wired and
-the SBOM ships embedded; reference the spec.
-
-## Testing
-
-- `./gradlew build` produces `build/reports/application.cdx.json`. Verify
-  manually post-merge.
-- No new integration test. `/actuator/sbom` is stock Spring Boot
-  functionality; the only project-specific configuration is two lines in
-  `application-http.properties`. A Spring Boot integration test that boots a
-  full context + Testcontainers Solr just to assert an actuator returns 200
-  tests Spring Boot, not us. The plugin wiring is already implicitly verified:
-  Spring Boot's bootJar task auto-depends on `cyclonedxBom`, so if the plugin
-  ever breaks, `./gradlew build` fails. The remaining question — "is the SBOM
-  actually inside the JAR?" — is handled by the build itself succeeding and is
-  re-verifiable any time via `unzip -l build/libs/*.jar | grep sbom`.
-- Existing Docker integration tests already verify image startup. The SBOM
-  being present in the image is implicit via the bootJar packaging — no new
-  Docker test added.
-
-## Risks and mitigations
-
-| Risk | Mitigation |
-|------|-----------|
-| Plugin adds significant build time | CycloneDX plugin runs once at 
JAR-assembly, typically <2s on this dependency graph. Measure before/after; 
report in PR. |
-| Native-image build fails because of SBOM resource | Spring Boot already 
registers `META-INF/sbom/*` as a runtime resource hint; the existing native 
build should work unchanged. Verify with `./gradlew nativeCompile -Pnative` 
post-merge. |
-| Actuator endpoint leaks info in production | SBOM contents are public (every 
dependency name + version is already in the JAR's manifest). Endpoint exposure 
is opt-in by being in the explicit `include` list. Documented. |
-| Plugin version drift | Pinned in `libs.versions.toml`; Renovate / Dependabot 
will surface upgrades on schedule. |
-
-## Acceptance criteria
-
-1. `./gradlew build` produces `build/reports/application.cdx.json` with
-   `bomFormat: CycloneDX`, `specVersion: 1.5`.
-2. `./gradlew bootJar` produces a JAR containing
-   `META-INF/sbom/application.cdx.json`.
-3. `GET /actuator/sbom` returns 200 + valid CycloneDX JSON in HTTP profile.
-4. `build-and-publish.yml` uploads the SBOM as a workflow artifact.
-5. `release-publish.yml` attaches the SBOM to the GitHub Release.
-6. `README.md` documents the SBOM under a clearly named section.
-7. `./gradlew spotlessCheck build` is green.
\ No newline at end of file
diff --git a/scripts/benchmark-native.sh b/scripts/benchmark-native.sh
index 13064f0..5c25c49 100755
--- a/scripts/benchmark-native.sh
+++ b/scripts/benchmark-native.sh
@@ -41,6 +41,7 @@ VERSION="$(grep '^version = ' build.gradle.kts | sed 
's/version = "\(.*\)"/\1/')
 JVM_IMAGE="solr-mcp:${VERSION}"
 NATIVE_IMAGE="solr-mcp:${VERSION}-native-stdio"
 RESULT_FILE="docs/specs/benchmark-results.md"
+mkdir -p "$(dirname "${RESULT_FILE}")"
 # Require this many consecutive RSS samples within 1 MB to declare startup 
complete
 STABLE_THRESHOLD="${STABLE_THRESHOLD:-3}"
 


Reply via email to