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 6688090 build(docker): per-profile native images + Jib JVM, plus
matrix CI + bootJar dep + HTTP/1.1 SolrJ (#129)
6688090 is described below
commit 6688090bb31a222dccf9c78e3b7aa4e13a88ec98
Author: Aditya Parikh <[email protected]>
AuthorDate: Fri May 8 16:36:27 2026 -0400
build(docker): per-profile native images + Jib JVM, plus matrix CI +
bootJar dep + HTTP/1.1 SolrJ (#129)
* test(native): allow Testcontainers integration tests to run in native
image
Removes @DisabledInNativeImage from IndexingServiceIntegrationTest and
SearchServiceIntegrationTest. These tests use real Solr via Testcontainers,
not Mockito, and have no fundamental incompatibility with the native image
runtime. They were marked disabled defensively when GraalVM support was
added, but Mockito (ByteBuddy) is the actual native-image blocker — and
Testcontainers integration tests don't depend on it.
Adds --initialize-at-build-time entries for org.junit.platform.launcher,
org.junit.platform.engine, and org.junit.jupiter.engine.descriptor so the
nativeTest image can build with these tests included. Without these,
nativeTest fails with "object of type 'org.junit.platform.engine.TestTag'
was found in the image heap" because the JUnit launcher embeds discovery
results (TestPlan, descriptors, TestTag) at build time.
After this change, @DisabledInNativeImage remains on the truly incompatible
Mockito-based tests (CollectionServiceTest, IndexingServiceTest,
SchemaServiceTest, SearchServiceTest, MainTest).
* ci(native): drop redundant -Pnative from nativeTest invocation
-Pnative has no effect on nativeTest — it only gates processAot profile
pinning (production binary) and dockerIntegrationTest behavior. The
graalvm.native plugin registers nativeTest unconditionally.
Signed-off-by: adityamparikh <[email protected]>
* test(mcp-client): run MCP client workflow tests against both stdio and
http
Extracts shared MCP tool-call assertions (create-collection, index,
search, facets, pagination, schema) into McpClientIntegrationTestBase.
McpClientIntegrationTest (http) extends the base and boots the full
Spring application with @SpringBootTest + HttpClientStreamableHttpTransport.
McpClientStdioIntegrationTest (stdio) extends the base without a Spring
context: spawns the assembled jar as a subprocess via StdioClientTransport
and connects a Testcontainers-managed SolrContainer through the env var.
Both transports exercise the same 15-test workflow, verified on JDK and
GraalVM nativeTest.
Signed-off-by: adityamparikh <[email protected]>
* build(docker): add per-profile native images alongside Jib JVM image
Adds two Paketo native image variants alongside the existing Jib JVM image,
giving us full coverage of the transport × runtime matrix:
./gradlew jibDockerBuild # solr-mcp:<v>
— both stdio + http
./gradlew bootBuildImage -Pnative #
solr-mcp:<v>-native-stdio — stdio only
./gradlew bootBuildImage -Pnative -Pprofile=http #
solr-mcp:<v>-native-http — http only
Why three images, not one or two:
- Paketo's libjvm helpers (memory calculator, NMT, ca-certificates) write to
stdout before the JVM starts, breaking MCP STDIO. Verified protocol-level
via the new DockerImageMcpClientStdioIntegrationTest, filed upstream as
paketo-buildpacks/libjvm#482. Jib uses a clean java -jar entrypoint with
no launcher script, so the JVM image stays dual-mode.
- Spring AOT bakes spring.main.web-application-type into the binary at AOT
time. Activating both profiles picks servlet (http overrides stdio),
forcing Tomcat to start regardless of runtime PROFILES — breaks stdio.
So one native image per profile, AOT-pinned via -Pprofile=stdio|http.
Implementation:
- graalvm-native plugin is applied conditionally on -Pnative so the JVM Jib
path doesn't accidentally trigger Spring Boot's bootBuildImage
auto-configuration for native.
- A `nativeProfile` project property selects which Spring profile is active
during AOT and tags the resulting image accordingly.
- dockerIntegrationTest gates the test subset by image type:
Jib JVM runs all three docker tests; native stdio runs MCP stdio + smoke;
native http runs HTTP endpoint test only.
- DockerImageMcpClientStdioIntegrationTest is the new protocol-level
verification — spawns docker run -i as a subprocess and drives the full
MCP tool-call workflow over real JSON-RPC stdio.
Verified end-to-end:
- ./gradlew dockerIntegrationTest → 23/23 (Jib JVM)
- ./gradlew dockerIntegrationTest -Pnative → 19/19 (native
stdio)
- ./gradlew dockerIntegrationTest -Pnative -Pprofile=http → 6/6 (native
http)
Docs updated in AGENTS.md / CLAUDE.md and README.md to reflect the matrix.
docs/specs/graalvm-native-image.md gets a superseded notice. The old
native.yml change to drop -Pnative from nativeTest is reverted because
-Pnative is now the single switch that applies the graalvm-native plugin.
Publishing workflows (build-and-publish, nightly-build, release-publish)
still publish only the Jib JVM image — adding native publish jobs (with
multi-arch matrix and Paketo auth) is the next step.
Signed-off-by: adityamparikh <[email protected]>
* docs: add Image × Mode test coverage matrix to Testing Structure
The previous Testing Structure section was three lines and didn't explain
which tests verify which image × mode combinations. Two related tests have
similar names but very different semantics — McpClientStdioIntegrationTest
spawns `java -jar` (JVM-process layer), while
DockerImageMcpClientStdioIntegrationTest spawns `docker run -i <image>`
(protocol-level Docker verification).
Adds:
- Layer distinction (unit / integration / docker) with notes on what runs
where (./gradlew build vs nativeTest vs dockerIntegrationTest)
- Explicit comparison of MCP-protocol tests vs container smoke tests
- Test coverage matrix mapping each Gradle invocation to the image it
builds and the test classes that run against it
- CI coverage gap note: -Pprofile=http has no CI coverage yet
Signed-off-by: adityamparikh <[email protected]>
* ci(native): cover -Pnative -Pprofile=http via matrix
native.yml's native-image job ran only -Pnative (stdio), leaving the
native-http variant uncovered in CI — flagged in AGENTS.md as a known
gap. Convert the job to a profile=[stdio, http] matrix so both Paketo
native variants are built and integration-tested on every PR touching
native-related files.
Also fixes the now-broken commands that the previous refactor (making
graalvm-native plugin conditional on -Pnative) silently broke:
- native.yml previously ran `bootBuildImage --no-daemon` (no -Pnative),
which would now produce a Paketo JVM image with stdout pollution
instead of a native image. Add -Pnative to both build and test steps.
- benchmark-native.sh used `bootBuildImage` (no flag) and the old
`-native` tag suffix. Updated to `-Pnative` and `-native-stdio`.
Updates AGENTS.md to remove the "CI coverage gap" callout since it is
now closed.
Signed-off-by: adityamparikh <[email protected]>
* ci(release): publish multi-arch native image variants alongside JVM
Extends release-publish.yml so that an ASF release publishes all three
image artifacts, not just the Jib JVM image:
apache/solr-mcp:<v> (JVM, multi-arch — existing)
ghcr.io/<owner>/solr-mcp:<v>-native-stdio (multi-arch manifest list, NEW)
ghcr.io/<owner>/solr-mcp:<v>-native-http (multi-arch manifest list, NEW)
Two new jobs:
- publish-native: matrix over [profile × arch]. Each cell runs
`bootBuildImage -Pnative -Pprofile=<p> --publishImage` on the matching
arch runner (ubuntu-latest for amd64, ubuntu-24.04-arm for arm64) and
pushes an arch-suffixed tag. Refuses to overwrite an existing
per-version tag (release immutability).
- publish-native-manifests: assembles per-profile manifest lists from the
four arch-specific tags via `docker buildx imagetools create`.
Scope of this PR is intentionally narrow:
- Pushes only to ghcr.io/${{ github.repository_owner }}/solr-mcp so it
works for fork validation (adityamparikh/solr-mcp) and apache/solr-mcp
identically. DockerHub apache/solr-mcp publishing for native variants
is deferred to a follow-up PR once the multi-arch mechanics are
validated end-to-end.
- publish-mcp-registry now waits for both publish-docker (JVM) and
publish-native-manifests so the registry entry references a fully
published release.
Also fixes two pre-existing YAML indentation bugs in publish-docker
(line 188) and publish-mcp-registry (line 525) that strict YAML parsers
rejected. Switches the version-substitution sed step to env-var
interpolation to satisfy the workflow-injection security check.
Signed-off-by: adityamparikh <[email protected]>
* ci(release): wire bootBuildImage publishRegistry credentials from env
Native publish jobs failed with `unauthorized: access token has
insufficient scopes` because `-PdockerPublishUsername=...` style flags
were not recognized — bootBuildImage reads credentials from the task's
`docker.publishRegistry` block, not arbitrary project properties.
Fix:
- Configure `bootBuildImage.docker.publishRegistry` to read URL,
username, and password from DOCKER_PUBLISH_{URL,USERNAME,PASSWORD}
environment variables (with empty defaults for local builds).
- Set those env vars at the workflow step level instead of passing
unrecognized `-P` flags.
The token already has packages: write scope via job permissions; the
auth was just never reaching bootBuildImage. With this change the
4-cell publish matrix can complete its push step.
Signed-off-by: adityamparikh <[email protected]>
* ci(release): drop https:// prefix from publishRegistry URL
Spring Boot's bootBuildImage publishRegistry.url expects a bare
hostname, not a URL with protocol. Previous "https://ghcr.io" caused
GHCR to reject the credentials with "incorrect username or password"
even though the same credentials succeeded for docker login earlier in
the same job (verified in the workflow log).
Use "ghcr.io" so bootBuildImage assembles the correct registry endpoint.
Signed-off-by: adityamparikh <[email protected]>
* ci(release): split bootBuildImage build from docker push
Spring Boot's bootBuildImage publishRegistry credentials are rejected by
GHCR ("incorrect username or password") even when the same credentials
succeed for `docker login` in the same job (verified across 3 test runs
with both `https://ghcr.io` and bare `ghcr.io` URL formats).
Workaround: build to the local Docker daemon (no --publishImage), then
`docker push` separately. The push reuses the working docker login
session, sidestepping bootBuildImage's auth path entirely.
Drops the now-unused publishRegistry block from build.gradle.kts.
Signed-off-by: adityamparikh <[email protected]>
* build(docker): drop unused bootBuildImage publishRegistry block
The release-publish workflow no longer uses bootBuildImage to push
(see d2f9c1a). Push happens via `docker push` after a local-daemon
build, so the publishRegistry credentials block is dead code.
Signed-off-by: adityamparikh <[email protected]>
* build: make tasks of type Test depend on bootJar
McpClientStdioIntegrationTest spawns `java -jar build/libs/<bootJar>` as
a subprocess. Without an explicit dependency, when `:test` runs
transitively (e.g., via `nativeTest`'s task graph), `:bootJar` hasn't
executed yet — `build/libs/` is empty, the subprocess silently fails to
start, and the MCP client times out on initialize() with a 20s Reactor
timeout.
Symptom: `./gradlew nativeTest -Pnative` fails the regular `:test` phase
with `McpClientStdioIntegrationTest > initializationError FAILED` →
`java.util.concurrent.TimeoutException`. Same hidden bug also affected
the parent native.yml workflow.
Worked when invoked via `./gradlew build nativeTest` because `:build`
chains `:bootJar` → `:test` in the right order, but the dedicated CI
commands skip `:build`.
Excludes `dockerIntegrationTest` from the new dependency since the
docker-integration test classes use the Docker image, not the bootJar
directly, and the dockerIntegrationTest task already gets the image via
`dependsOn(jibDockerBuild)` or `bootBuildImage`.
Signed-off-by: adityamparikh <[email protected]>
* fix(solr): force HTTP/1.1 in SolrJ client to avoid flaky H2 EOF
The JDK 25 HttpClient's HTTP/2 transport intermittently closes
reused connections with java.io.EOFException against Solr/Jetty,
causing test flakiness (observed in
SearchServiceIntegrationTest.testSpecialCharactersInQuery on CI).
HTTP/2 multiplexing is not needed for our usage; force HTTP/1.1
on HttpJdkSolrClient via useHttp1_1(true) for deterministic
behavior.
---------
Signed-off-by: adityamparikh <[email protected]>
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
---
.github/workflows/native.yml | 19 +-
.github/workflows/release-publish.yml | 184 ++++++++--
AGENTS.md | 181 ++++++++--
README.md | 68 +++-
build.gradle.kts | 374 ++++++++-------------
docs/specs/graalvm-native-image.md | 16 +-
scripts/benchmark-native.sh | 6 +-
.../solr/mcp/server/McpClientIntegrationTest.java | 290 +---------------
...Test.java => McpClientIntegrationTestBase.java} | 46 +--
.../mcp/server/McpClientStdioIntegrationTest.java | 56 +++
.../DockerImageHttpIntegrationTest.java | 15 +-
.../DockerImageMcpClientStdioIntegrationTest.java | 74 ++++
.../DockerImageStdioIntegrationTest.java | 3 +-
.../indexing/IndexingServiceIntegrationTest.java | 2 -
.../search/SearchServiceIntegrationTest.java | 2 -
15 files changed, 699 insertions(+), 637 deletions(-)
diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml
index 1a05342..8888dbc 100644
--- a/.github/workflows/native.yml
+++ b/.github/workflows/native.yml
@@ -26,7 +26,8 @@
#
# Jobs:
# - native-test: ./gradlew nativeTest against Solr via Testcontainers
-# - native-image: build the native Docker image + run STDIO integration test
+# - native-image: build the native Docker image + run dockerIntegrationTest
+# (matrix over profile=[stdio, http] so both native variants are covered)
# - benchmark: compare JVM vs native image size / startup / RSS
name: Native Image
@@ -71,9 +72,13 @@ jobs:
run: ./gradlew nativeTest -Pnative --no-daemon
native-image:
- name: native Docker image + STDIO integration
+ name: native Docker image (${{ matrix.profile }}) + integration
runs-on: ubuntu-latest
timeout-minutes: 60
+ strategy:
+ fail-fast: false
+ matrix:
+ profile: [stdio, http]
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -89,17 +94,17 @@ jobs:
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- - name: Build native Docker image
- run: ./gradlew bootBuildImage --no-daemon
+ - name: Build native Docker image (${{ matrix.profile }})
+ run: ./gradlew bootBuildImage -Pnative -Pprofile=${{
matrix.profile }} --no-daemon
- - name: Run STDIO integration test against native image
- run: ./gradlew dockerIntegrationTest -Pnative --no-daemon
+ - name: Run integration tests against native ${{ matrix.profile
}} image
+ run: ./gradlew dockerIntegrationTest -Pnative -Pprofile=${{
matrix.profile }} --no-daemon
- name: Upload integration test results
if: always()
uses: actions/upload-artifact@v4
with:
- name: native-integration-results
+ name: native-integration-results-${{ matrix.profile }}
path: build/reports/dockerIntegrationTest
retention-days: 7
diff --git a/.github/workflows/release-publish.yml
b/.github/workflows/release-publish.yml
index 9cc7863..a89f0ba 100644
--- a/.github/workflows/release-publish.yml
+++ b/.github/workflows/release-publish.yml
@@ -186,10 +186,12 @@ jobs:
uses: ./.github/actions/setup-java
- name: Update version in build.gradle.kts
+ env:
+ RELEASE_VERSION: ${{ inputs.release_version }}
run: |
- # Ensure the Gradle project version matches the GA version
(removes any -SNAPSHOT)
- # This keeps image tags and any generated artifacts consistent
with the voted release
- sed -i 's/version = ".*"/version = "${{ inputs.release_version }}"/'
build.gradle.kts
+ # Ensure the Gradle project version matches the GA version (removes
any -SNAPSHOT)
+ # This keeps image tags and any generated artifacts consistent with
the voted release
+ sed -i "s/version = \".*\"/version = \"${RELEASE_VERSION}\"/"
build.gradle.kts
- name: Build project
run: ./gradlew build
@@ -306,22 +308,160 @@ jobs:
fi
- name: Publish release summary
+ env:
+ RELEASE_VERSION: ${{ inputs.release_version }}
+ RELEASE_CANDIDATE: ${{ inputs.release_candidate }}
+ REPO_OWNER: ${{ github.repository_owner }}
run: |
- echo "### Release Published Successfully! 🎉" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "**Version**: ${{ inputs.release_version }}" >>
$GITHUB_STEP_SUMMARY
- echo "**Release Candidate**: ${{ inputs.release_candidate }}" >>
$GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "#### Docker Images Published" >> $GITHUB_STEP_SUMMARY
- echo "- \`apache/solr-mcp:${{ inputs.release_version }}\`" >>
$GITHUB_STEP_SUMMARY
- echo "- \`apache/solr-mcp:latest\`" >> $GITHUB_STEP_SUMMARY
- echo "- \`ghcr.io/${{ github.repository_owner }}/solr-mcp:${{
inputs.release_version }}\`" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "#### Next Steps" >> $GITHUB_STEP_SUMMARY
- echo "1. Announce the release on the mailing list" >>
$GITHUB_STEP_SUMMARY
- echo "2. Update the documentation" >> $GITHUB_STEP_SUMMARY
- echo "3. Close the release milestone in GitHub" >>
$GITHUB_STEP_SUMMARY
- echo "4. Tweet about the release (optional)" >> $GITHUB_STEP_SUMMARY
+ {
+ echo "### JVM Image Published 🎉"
+ echo ""
+ echo "**Version**: ${RELEASE_VERSION}"
+ echo "**Release Candidate**: ${RELEASE_CANDIDATE}"
+ echo ""
+ echo "#### JVM Docker Images"
+ echo "- \`apache/solr-mcp:${RELEASE_VERSION}\`"
+ echo "- \`apache/solr-mcp:latest\`"
+ echo "- \`ghcr.io/${REPO_OWNER}/solr-mcp:${RELEASE_VERSION}\`"
+ } >> "$GITHUB_STEP_SUMMARY"
+
+ # Build and publish the two GraalVM native image variants (stdio + http).
+ # Paketo's bootBuildImage builds a single architecture per invocation, so we
+ # use a profile × arch matrix and assemble a manifest list per profile in the
+ # follow-up job.
+ #
+ # Scope of this PR: pushes ONLY to ghcr.io/<repo_owner>/solr-mcp (works for
+ # both fork validation and apache org once permissions are set up). DockerHub
+ # apache/solr-mcp publishing for native images is intentionally deferred to a
+ # follow-up PR once the mechanics are validated end-to-end.
+ publish-native:
+ name: Publish native (${{ matrix.profile }}, ${{ matrix.arch.name }})
+ runs-on: ${{ matrix.arch.runs-on }}
+ needs: validate-release
+ if: ${{ needs.validate-release.outputs.proceed == 'true' }}
+ timeout-minutes: 60
+ permissions:
+ contents: read
+ packages: write
+ strategy:
+ fail-fast: false
+ matrix:
+ profile: [stdio, http]
+ arch:
+ - { name: amd64, runs-on: ubuntu-latest }
+ - { name: arm64, runs-on: ubuntu-24.04-arm }
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ ref: "v${{ inputs.release_version }}-${{ inputs.release_candidate }}"
+
+ - name: Set up GraalVM JDK 25
+ uses: graalvm/setup-graalvm@v1
+ with:
+ java-version: '25'
+ distribution: 'graalvm'
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ cache: 'gradle'
+
+ - name: Update version in build.gradle.kts
+ env:
+ RELEASE_VERSION: ${{ inputs.release_version }}
+ run: |
+ sed -i "s/version = \".*\"/version = \"${RELEASE_VERSION}\"/"
build.gradle.kts
+
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ # Refuse to overwrite an already-published version tag. Moving tags
+ # (latest-*) are mutable and assembled later; per-version tags are not.
+ - name: Verify target tag does not already exist
+ env:
+ TARGET_TAG: ghcr.io/${{ github.repository_owner }}/solr-mcp:${{
inputs.release_version }}-native-${{ matrix.profile }}-${{ matrix.arch.name }}
+ run: |
+ if docker manifest inspect "${TARGET_TAG}" >/dev/null 2>&1; then
+ echo "ERROR: ${TARGET_TAG} already exists. Refusing to overwrite a
published release tag."
+ exit 1
+ fi
+
+ # Build to the local Docker daemon (no --publishImage), then docker push.
+ # Spring Boot's bootBuildImage publishRegistry auth doesn't interoperate
+ # with GHCR — same credentials succeed for `docker login` (above) but
+ # fail for bootBuildImage's push, regardless of URL format. Splitting
+ # build and push lets us reuse the working docker login session.
+ - name: Build native image (local daemon)
+ env:
+ IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/solr-mcp:${{
inputs.release_version }}-native-${{ matrix.profile }}-${{ matrix.arch.name }}
+ PROFILE: ${{ matrix.profile }}
+ run: |
+ ./gradlew bootBuildImage -Pnative "-Pprofile=${PROFILE}" \
+ "--imageName=${IMAGE_NAME}" \
+ --no-daemon
+
+ - name: Push native image to GHCR
+ env:
+ IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/solr-mcp:${{
inputs.release_version }}-native-${{ matrix.profile }}-${{ matrix.arch.name }}
+ run: docker push "${IMAGE_NAME}"
+
+ # Assemble per-profile manifest lists from the arch-specific tags pushed
+ # above. Consumers pull `:<v>-native-<profile>` (or
`:latest-native-<profile>`)
+ # and Docker selects the right arch automatically.
+ publish-native-manifests:
+ name: Assemble native manifest lists
+ runs-on: ubuntu-latest
+ needs: publish-native
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Create version manifest lists
+ env:
+ REPO_OWNER: ${{ github.repository_owner }}
+ RELEASE_VERSION: ${{ inputs.release_version }}
+ run: |
+ for PROFILE in stdio http; do
+ BASE="ghcr.io/${REPO_OWNER}/solr-mcp"
+ docker buildx imagetools create \
+ --tag "${BASE}:${RELEASE_VERSION}-native-${PROFILE}" \
+ "${BASE}:${RELEASE_VERSION}-native-${PROFILE}-amd64" \
+ "${BASE}:${RELEASE_VERSION}-native-${PROFILE}-arm64"
+ docker buildx imagetools create \
+ --tag "${BASE}:latest-native-${PROFILE}" \
+ "${BASE}:${RELEASE_VERSION}-native-${PROFILE}-amd64" \
+ "${BASE}:${RELEASE_VERSION}-native-${PROFILE}-arm64"
+ done
+
+ - name: Native release summary
+ env:
+ REPO_OWNER: ${{ github.repository_owner }}
+ RELEASE_VERSION: ${{ inputs.release_version }}
+ run: |
+ {
+ echo "### Native Images Published 🚀"
+ echo ""
+ echo "#### Native Docker Images (multi-arch)"
+ echo "-
\`ghcr.io/${REPO_OWNER}/solr-mcp:${RELEASE_VERSION}-native-stdio\`"
+ echo "-
\`ghcr.io/${REPO_OWNER}/solr-mcp:${RELEASE_VERSION}-native-http\`"
+ echo "- \`ghcr.io/${REPO_OWNER}/solr-mcp:latest-native-stdio\`"
+ echo "- \`ghcr.io/${REPO_OWNER}/solr-mcp:latest-native-http\`"
+ echo ""
+ echo "Each tag points to a manifest list covering linux/amd64 +
linux/arm64."
+ } >> "$GITHUB_STEP_SUMMARY"
publish-mcp-registry:
# Job: Publish to MCP Registry so MCP clients can discover the server
version released above
@@ -336,7 +476,9 @@ jobs:
# publishing registry entries that don't correspond to an approved
release.
name: Publish to MCP Registry
runs-on: ubuntu-latest
- needs: publish-docker # Wait for Docker images to be published
successfully
+ # Wait for both JVM (publish-docker) and native (publish-native-manifests)
+ # images so the MCP Registry entry references a fully-published release.
+ needs: [publish-docker, publish-native-manifests]
if: ${{ needs.validate-release.outputs.proceed == 'true' }}
# Permissions required for OIDC-based auth to the MCP Registry and read
access
@@ -384,8 +526,8 @@ jobs:
# Publish this server to the MCP Registry
- name: Publish to MCP Registry
- run: |
- ./mcp-publisher publish
+ run: |
+ ./mcp-publisher publish
# Verify publication by querying the public registry API
- name: Verify publication
diff --git a/AGENTS.md b/AGENTS.md
index 494d274..8e0d31b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -22,27 +22,73 @@ Solr MCP Server is a Spring AI Model Context Protocol (MCP)
server that enables
./gradlew test # Run all tests
./gradlew test --tests SearchServiceTest # Run specific test class
./gradlew test --tests "*IntegrationTest" # Run integration tests
-./gradlew dockerIntegrationTest # Run Docker image tests
(requires jibDockerBuild first)
./gradlew test jacocoTestReport # Tests with coverage report
# Code formatting (REQUIRED before commit)
./gradlew spotlessApply # Apply formatting
./gradlew spotlessCheck # Check formatting
-# Docker
-./gradlew jibDockerBuild # Build Docker image locally
+# Docker images
+./gradlew jibDockerBuild # JVM (Jib):
solr-mcp:<v> (both stdio + http)
+./gradlew bootBuildImage -Pnative # Native stdio:
solr-mcp:<v>-native-stdio
+./gradlew bootBuildImage -Pnative -Pprofile=http # Native http:
solr-mcp:<v>-native-http
+./gradlew dockerIntegrationTest # Test JVM Jib
(stdio + http + MCP stdio)
+./gradlew dockerIntegrationTest -Pnative # Test native stdio
+./gradlew dockerIntegrationTest -Pnative -Pprofile=http # Test native http
-# Native image (experimental, requires GraalVM JDK 25)
-./gradlew nativeCompile -Pnative # Compile native binary (host OS
only)
-./gradlew bootBuildImage # Build native Docker image (any
OS/arch)
-./gradlew nativeTest # Run tests as native image
-./gradlew dockerIntegrationTest -Pnative # Docker integration tests
(native)
+# Native image (requires GraalVM JDK 25; -Pnative applies the graalvm-native
plugin)
+./gradlew nativeCompile -Pnative # Compile native binary (host OS
only)
+./gradlew nativeTest -Pnative # Run tests as native image
# Run locally (requires `docker compose up -d` for Solr)
./gradlew bootRun # STDIO mode (default)
PROFILES=http ./gradlew bootRun # HTTP mode
```
+## Image × Mode matrix
+
+Three published image artifacts cover the full transport × runtime matrix:
+
+| Image | Toolchain | Build command
| STDIO | HTTP |
+|--------------------------------|-----------|----------------------------------------------------------|-------|------|
+| `solr-mcp:<v>` | Jib | `./gradlew jibDockerBuild`
| ✅ | ✅ |
+| `solr-mcp:<v>-native-stdio` | Paketo | `./gradlew bootBuildImage
-Pnative` | ✅ | ❌ |
+| `solr-mcp:<v>-native-http` | Paketo | `./gradlew bootBuildImage
-Pnative -Pprofile=http` | ❌ | ✅ |
+
+Why three images:
+
+- **Jib's JVM image is dual-mode** because Jib uses a clean `java -jar`
+ entrypoint with no launcher script. Stdout stays clean for MCP STDIO, and
+ runtime `PROFILES=http` switches to web mode.
+- **Paketo's JVM image is unsuitable for stdio** — its `libjvm` helpers
+ (memory calculator, NMT, ca-certificates) write 6 lines to stdout before
+ the JVM, breaking MCP's JSON-RPC stream. Verified via
+ `DockerImageMcpClientStdioIntegrationTest`. Filed upstream as
+
[paketo-buildpacks/libjvm#482](https://github.com/paketo-buildpacks/libjvm/issues/482).
+ We use Jib for the JVM image instead.
+- **Native images must AOT-pin to one profile** because Spring AOT bakes
+ `spring.main.web-application-type` into the binary; activating both profiles
+ picks `servlet` (http overrides stdio) and forces Tomcat to start regardless
+ of runtime `PROFILES`. So one native image per profile.
+
+Run examples:
+
+```bash
+# STDIO — Jib JVM (default profile is stdio)
+docker run -i --rm -e SOLR_URL=http://host.docker.internal:8983/solr/
solr-mcp:latest
+
+# STDIO — native (faster startup, smaller image)
+docker run -i --rm -e SOLR_URL=http://host.docker.internal:8983/solr/
solr-mcp:latest-native-stdio
+
+# HTTP — Jib JVM
+docker run -p 8080:8080 --rm -e PROFILES=http \
+ -e SOLR_URL=http://host.docker.internal:8983/solr/ solr-mcp:latest
+
+# HTTP — native
+docker run -p 8080:8080 --rm -e PROFILES=http \
+ -e SOLR_URL=http://host.docker.internal:8983/solr/
solr-mcp:latest-native-http
+```
+
## Architecture
### MCP Tools (src/main/java/org/apache/solr/mcp/server/)
@@ -90,21 +136,55 @@ corrupts the protocol. Logging is configured in two layers:
**Init order**: logback.xml → Spring Boot starts → logback-spring.xml →
application-{profile}.properties
-### Why Jib Instead of Spring Boot Buildpacks
-
-Spring Boot Buildpacks output logs to stdout, breaking MCP's STDIO protocol.
Jib produces clean images with no stdout pollution, plus faster builds and
multi-platform support (amd64/arm64).
-
-### GraalVM Native Image (Opt-In)
-
-An opt-in native image build is available via `-Pnative`, targeting the STDIO
profile only.
-The native binary is compiled by `org.graalvm.buildtools.native`
(`nativeCompile`) and packaged
-into a Docker image via `bootBuildImage` (Paketo buildpacks). Key
configuration:
-
-- **Opt-in flag:** `val nativeBuild = project.hasProperty("native")` in
`build.gradle.kts`
-- **Cross-platform:** `bootBuildImage` compiles inside a Linux builder
container, so it works on any host OS (macOS, Linux, Windows).
-- **AOT profile:** `processAot` runs with `--spring.profiles.active=stdio`
under `-Pnative`
- so security autoconfig exclusions from `application-stdio.properties` are
applied during
- hint generation. The `@SpringBootApplication` annotation is **not** modified.
+### Docker image strategy
+
+Two toolchains, three image artifacts. See **Image × Mode matrix** above
+for the published-artifact summary.
+
+- **Jib** builds the JVM image (`solr-mcp:<v>`). Plain `java -jar` entrypoint,
+ multi-arch (amd64 + arm64), clean stdout. One image serves both stdio and
+ http; runtime `PROFILES` env var selects.
+- **Paketo `bootBuildImage`** builds two native-image variants:
+ - `solr-mcp:<v>-native-stdio` via `./gradlew bootBuildImage -Pnative`
+ - `solr-mcp:<v>-native-http` via `./gradlew bootBuildImage -Pnative
-Pprofile=http`
+
+**Why not Paketo for the JVM image:** Paketo's `libjvm` runtime helpers
+(memory calculator, NMT, ca-certificates) write 6 status lines to stdout
+before the JVM starts. This breaks MCP STDIO at the protocol level;
+verified end-to-end via `DockerImageMcpClientStdioIntegrationTest` (Spring
+AI MCP client times out on `initialize()`). Filed upstream as
+[paketo-buildpacks/libjvm#482](https://github.com/paketo-buildpacks/libjvm/issues/482).
+Jib doesn't have this problem because it writes a plain `java -jar`
+entrypoint — no launcher script, no stdout pollution.
+
+**Why two native images instead of one:** Spring AOT bakes
+`spring.main.web-application-type` into the binary at AOT time. Activating
+both `stdio` and `http` profiles during AOT picks `servlet` (http overrides
+stdio), which forces Tomcat to start regardless of the runtime `PROFILES`
+value — breaking stdio. So we AOT-pin per profile and produce one native
+image per transport: stdio binary excludes web servlet beans; http binary
+includes them.
+
+### GraalVM Native Image
+
+Native image is enabled via `-Pnative`. The native binary is compiled by the
+`org.graalvm.buildtools.native` plugin (`nativeCompile`) or via Paketo
+buildpacks (`bootBuildImage -Pnative`). Key configuration:
+
+- **Opt-in flag:** `val nativeBuild = project.hasProperty("native")` in
+ `build.gradle.kts`. The `graalvm-native` plugin is applied conditionally on
+ this flag, and the entire `graalvmNative { ... }` configuration block runs
+ only under `-Pnative`. `nativeCompile`, `nativeTest`, and the native variant
+ of `bootBuildImage` all require `-Pnative`.
+- **Per-profile AOT pin:** `processAot` runs with
+ `--spring.profiles.active=$nativeProfile` where `nativeProfile` is `stdio`
+ by default or `http` if `-Pprofile=http` is passed. Activating both profiles
+ during AOT picks `servlet` and forces Tomcat regardless of runtime
+ `PROFILES`, breaking stdio — hence one native image per profile.
+- **Cross-platform builds:** `bootBuildImage` compiles inside a Linux builder
+ container, so it works on any host OS (macOS, Linux, Windows). Multi-arch
+ (amd64+arm64) is handled in CI via a GitHub Actions matrix; local builds
+ produce a single image for the host architecture.
- **OTel build-time init:** OTel instrumentation BOM 2.11.0 lacks native
metadata;
`--initialize-at-build-time` is set for `io.opentelemetry.api`,
`io.opentelemetry.context`,
`io.opentelemetry.instrumentation.api`, and
`io.opentelemetry.instrumentation.logback`.
@@ -122,15 +202,62 @@ into a Docker image via `bootBuildImage` (Paketo
buildpacks). Key configuration:
- **Wire format:** `SolrConfig` uses `XMLRequestWriter` instead of the default
`JavaBinRequestWriter`. The JavaBin binary codec uses deep reflection that
would
require extensive additional native image hints.
-- **Docker tags:** JVM image = `solr-mcp:<version>` (Jib), native image =
`solr-mcp:<version>-native` (bootBuildImage)
+- **Docker tags:** Jib JVM = `solr-mcp:<version>` (`solr-mcp:latest`).
+ 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)
## Testing Structure
-- **Unit tests** (`*Test.java`): Mocked dependencies, fast execution
-- **Integration tests** (`*IntegrationTest.java`, `*DirectTest.java`): Real
Solr via Testcontainers
-- **Docker tests** (`containerization/`): Tagged `@Tag("docker-integration")`,
run separately
+- **Unit tests** (`*Test.java`): Mocked dependencies, fast execution.
Mockito-based
+ unit tests are `@DisabledInNativeImage` because ByteBuddy proxies don't
survive
+ GraalVM's closed-world assumption.
+- **Integration tests** (`*IntegrationTest.java`, `*DirectTest.java`): Real
Solr via
+ Testcontainers. Run as part of `./gradlew build` (JVM) and `./gradlew
nativeTest -Pnative`
+ (native test binary).
+- **Docker tests** (`containerization/`): Tagged `@Tag("docker-integration")`,
only
+ run via `./gradlew dockerIntegrationTest`. They drive a built Docker image
as a
+ black-box subject under test.
+
+### MCP-protocol vs container smoke tests
+
+Two different layers verify stdio behavior — easy to confuse:
+
+- `McpClientStdioIntegrationTest` (top-level package) spawns the raw `java
-jar`
+ JAR as a subprocess and runs the full MCP tool-call workflow. Verifies the
+ application's stdio JSON-RPC at the JVM-process layer. Runs in `./gradlew
build`
+ and `./gradlew nativeTest -Pnative`. It does **not** test any Docker image.
+- `DockerImageMcpClientStdioIntegrationTest` (`containerization/`) does the
same
+ workflow but spawns `docker run -i <image>` instead of `java -jar`. This is
+ the protocol-level Docker image verification. Runs only in
+ `dockerIntegrationTest`.
+- `DockerImageStdioIntegrationTest` is a container **smoke** test (starts,
stays
+ alive, no errors in logs). It does not exercise MCP at all.
+- `DockerImageHttpIntegrationTest` exercises the HTTP transport via real HTTP
+ calls to `/actuator/health`, etc.
+
+### Image × Mode test coverage
+
+Each Gradle invocation builds a different image and runs the appropriate test
+subset. Together the three invocations cover all four image × mode
combinations:
+
+| Gradle invocation | Image built
| Tests that run
|
+|------------------------------------------------------------|-----------------------------------|-------------------------------------------------------------------------------------------------------------|
+| `./gradlew dockerIntegrationTest` | Jib JVM
`solr-mcp:<v>` | `DockerImageStdioIntegrationTest` (smoke) +
`DockerImageMcpClientStdioIntegrationTest` (MCP STDIO protocol) +
`DockerImageHttpIntegrationTest` (HTTP endpoint) |
+| `./gradlew dockerIntegrationTest -Pnative` | Paketo
`solr-mcp:<v>-native-stdio` | `DockerImageStdioIntegrationTest` +
`DockerImageMcpClientStdioIntegrationTest` |
+| `./gradlew dockerIntegrationTest -Pnative -Pprofile=http` | Paketo
`solr-mcp:<v>-native-http` | `DockerImageHttpIntegrationTest`
|
+
+The native-stdio image excludes the HTTP test (no servlet beans in the closed
+world). The native-http image excludes the stdio tests (no MCP STDIO transport
+in that profile). The Jib JVM image runs all three because it serves both
+modes.
+
+### CI coverage
+
+`native.yml` runs `dockerIntegrationTest` over a `[stdio, http]` matrix on
+every PR that touches native-related files, so both Paketo native variants
+are exercised. The Jib JVM path runs in `build-and-publish.yml`.
### Solr Version Compatibility Testing
diff --git a/README.md b/README.md
index f30239e..7bc5740 100644
--- a/README.md
+++ b/README.md
@@ -177,7 +177,7 @@ Then add to your `claude_desktop_config.json`:
}
```
-More configuration options: docs/DEPLOYMENT.md#docker-images-with-jib
+More configuration options: see the **Building Docker images** section below.
### Claude Code
@@ -376,22 +376,63 @@ The `solr://{collection}/schema` resource supports
autocompletion for the `{coll

-## Native image (experimental)
+## Building Docker images
-An opt-in GraalVM native image build is available for the STDIO profile. The
-native binary starts faster and uses less memory than the JVM image.
+Three image artifacts cover the full transport × runtime matrix. The JVM
+image is built with Jib (clean stdout, multi-arch); the native variants are
+built with Paketo Cloud Native Buildpacks.
-```bash
-# Build the native Docker image (works on any OS — compiles inside a Linux
builder container)
-./gradlew bootBuildImage
-# Produces: solr-mcp:<version>-native (also tagged :latest-native)
+| Image | Toolchain | Build command
| STDIO | HTTP |
+|------------------------------------|-----------|----------------------------------------------------------|-------|------|
+| `solr-mcp:<version>` | Jib | `./gradlew jibDockerBuild`
| ✅ | ✅ |
+| `solr-mcp:<version>-native-stdio` | Paketo | `./gradlew bootBuildImage
-Pnative` | ✅ | ❌ |
+| `solr-mcp:<version>-native-http` | Paketo | `./gradlew bootBuildImage
-Pnative -Pprofile=http` | ❌ | ✅ |
+
+### Run commands
-# Run it
-docker run -i --rm -e SOLR_URL=http://host.docker.internal:8983/solr/ \
- solr-mcp:latest-native
+```bash
+# STDIO — Jib JVM (default profile is stdio)
+docker run -i --rm \
+ -e SOLR_URL=http://host.docker.internal:8983/solr/ \
+ solr-mcp:latest
+
+# STDIO — native (faster startup, smaller image)
+docker run -i --rm \
+ -e SOLR_URL=http://host.docker.internal:8983/solr/ \
+ solr-mcp:latest-native-stdio
+
+# HTTP — Jib JVM
+docker run -p 8080:8080 --rm \
+ -e PROFILES=http \
+ -e SOLR_URL=http://host.docker.internal:8983/solr/ \
+ solr-mcp:latest
+
+# HTTP — native
+docker run -p 8080:8080 --rm \
+ -e PROFILES=http \
+ -e SOLR_URL=http://host.docker.internal:8983/solr/ \
+ solr-mcp:latest-native-http
```
-### Claude Desktop (native)
+### Why three images
+
+- **Jib's JVM image is dual-mode** because Jib uses a clean `java -jar`
+ entrypoint with no launcher script. Stdout stays clean for MCP STDIO,
+ and runtime `PROFILES=http` switches to web mode.
+- **Paketo's JVM image is unsuitable for stdio** — its `libjvm` helpers
+ (memory calculator, NMT, ca-certificates) write 6 lines to stdout before
+ the JVM, breaking MCP's JSON-RPC stream. Verified end-to-end by
+ `DockerImageMcpClientStdioIntegrationTest` (Spring AI MCP client times
+ out on `initialize()`). Filed upstream as
+
[paketo-buildpacks/libjvm#482](https://github.com/paketo-buildpacks/libjvm/issues/482).
+ We use Jib for the JVM image instead.
+- **Native images must AOT-pin to one profile.** Spring AOT bakes
+ `spring.main.web-application-type` into the binary at AOT time. Activating
+ both profiles picks `servlet` (http overrides stdio), which forces Tomcat
+ to start regardless of the runtime `PROFILES` value, breaking stdio. So
+ we ship one native image per transport.
+
+### Claude Desktop (native, STDIO)
```json
{
@@ -408,7 +449,7 @@ docker run -i --rm -e
SOLR_URL=http://host.docker.internal:8983/solr/ \
}
```
-See [docs/specs/graalvm-native-image.md](docs/specs/graalvm-native-image.md)
for the design and known risks.
+See [docs/specs/graalvm-native-image.md](docs/specs/graalvm-native-image.md)
for the native image design and known risks.
## Documentation
@@ -437,5 +478,6 @@ Built with:
- Spring AI MCP — https://spring.io/projects/spring-ai
- Apache Solr — https://solr.apache.org/
- Jib — https://github.com/GoogleContainerTools/jib
+- Paketo Cloud Native Buildpacks — https://paketo.io/
- Testcontainers — https://www.testcontainers.org/
- Spring AI MCP Security — https://github.com/spring-ai-community/mcp-security
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index dc878d9..dc5417b 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -26,21 +26,32 @@ plugins {
alias(libs.plugins.errorprone)
alias(libs.plugins.spotless)
alias(libs.plugins.jib)
- alias(libs.plugins.graalvm.native)
+ alias(libs.plugins.graalvm.native) apply false
}
// GraalVM Native Image (Opt-In)
// =============================
-// Invoked via `./gradlew ... -Pnative`. See
docs/specs/graalvm-native-image.md.
-// When true:
-// - AOT tasks (processAot) run with spring.profiles.active=stdio so that
-// security autoconfig exclusions from application-stdio.properties are
-// applied during hint generation.
-// - graalvmNative plugin configures nativeCompile / nativeTest tasks.
-// - Native Docker images use bootBuildImage (see separate configuration).
-// Jib is always JVM-only; it is NOT used for native images.
+// `-Pnative` is the single switch that controls all native-related behavior:
+// - applies the graalvm-native plugin (registers nativeCompile / nativeTest)
+// - Spring Boot's bootBuildImage auto-configures for native (Paketo
native-image
+// buildpack) when graalvm-native is on the classpath
+// - dockerIntegrationTest tags the image accordingly
+// Without `-Pnative`, the graalvm-native plugin is not applied and
bootBuildImage
+// produces a plain JVM Paketo image.
val nativeBuild = project.hasProperty("native")
+// Native image profile selector: -Pprofile=stdio (default) or -Pprofile=http.
+// Determines the Spring profile active during AOT, which decides whether the
+// resulting native binary serves stdio or http transport.
+val nativeProfile: String = (project.findProperty("profile") as String?) ?:
"stdio"
+
+if (nativeBuild) {
+ apply(plugin = "org.graalvm.buildtools.native")
+ require(nativeProfile == "stdio" || nativeProfile == "http") {
+ "Invalid -Pprofile=$nativeProfile; expected 'stdio' or 'http'"
+ }
+}
+
// Shared GraalVM native-image arguments used by both graalvmNative (local
builds)
// and bootBuildImage (Docker builds via Paketo buildpacks).
val nativeImageBuildArgs =
@@ -181,6 +192,13 @@ tasks.withType<Test> {
excludeTags("docker-integration")
}
}
+ // McpClientStdioIntegrationTest spawns `java -jar build/libs/<bootJar>`
as a
+ // subprocess. Without an explicit dependency, `:test` runs before
`:bootJar`
+ // (e.g., when invoked transitively by `nativeTest`), the jar is missing,
the
+ // subprocess silently fails, and the MCP client times out on initialize().
+ if (name != "dockerIntegrationTest") {
+ dependsOn(tasks.bootJar)
+ }
// Forward solr.test.image system property to test JVMs for Solr version
compatibility testing
systemProperty("solr.test.image", System.getProperty("solr.test.image",
"solr:9.9-slim"))
if (name != "dockerIntegrationTest") {
@@ -284,35 +302,23 @@ spotless {
// Docker Integration Test Task
// =============================
-// This task runs integration tests for the Docker image produced by Jib.
-// It is separate from the regular test task and must be explicitly invoked.
+// Runs integration tests against the appropriate Docker image:
//
-// Usage:
-// ./gradlew dockerIntegrationTest
+// ./gradlew dockerIntegrationTest # Jib JVM
image — both stdio + http
+// ./gradlew dockerIntegrationTest -Pnative # Paketo
native-stdio image
+// ./gradlew dockerIntegrationTest -Pnative -Pprofile=http # Paketo
native-http image
//
-// Prerequisites:
-// - Docker must be installed and running
-// - The task will automatically build the Docker image using jibDockerBuild
-//
-// The task:
-// - Checks if Docker is available
-// - Builds the Docker image using Jib (if Docker is available)
-// - Runs tests tagged with "docker-integration"
-// - Uses the same test configuration as regular tests
-//
-// Notes:
-// - If Docker is not available, the task will fail with a helpful error
message
-// - The test will verify the Docker image starts correctly and remains
stable
-// - Tests run in isolation from regular unit tests
+// Test selection per image mode (Image × Mode matrix in CLAUDE.md):
+// Jib JVM: stdio smoke + http endpoint + MCP stdio (Jib has clean stdout)
+// Native stdio: stdio smoke + MCP stdio (no http servlet beans)
+// Native http: http endpoint test (AOT'd for servlet)
tasks.register<Test>("dockerIntegrationTest") {
description = "Runs integration tests for the Docker image"
group = "verification"
// Always run this task, don't use Gradle's up-to-date checking
- // Docker images can change without Gradle knowing
outputs.upToDateWhen { false }
- // Check if Docker is available
val dockerAvailable =
try {
val process = ProcessBuilder("docker", "info").start()
@@ -329,7 +335,6 @@ tasks.register<Test>("dockerIntegrationTest") {
}
}
- // Depend on building the Docker image first (only if Docker is available)
if (dockerAvailable) {
if (nativeBuild) {
dependsOn(tasks.named("bootBuildImage"))
@@ -338,138 +343,85 @@ tasks.register<Test>("dockerIntegrationTest") {
}
}
- // Configure test task to only run docker integration tests
useJUnitPlatform {
includeTags("docker-integration")
}
- // The native image is AOT-compiled for the STDIO profile only.
- // Exclude the HTTP integration test when testing the native image.
- if (nativeBuild) {
- exclude("**/DockerImageHttpIntegrationTest*")
- }
-
- // Use the same test classpath and configuration as regular tests
testClassesDirs = sourceSets["test"].output.classesDirs
classpath = sourceSets["test"].runtimeClasspath
- // Ensure this doesn't trigger the regular test task or jacocoTestReport
mustRunAfter(tasks.test)
-
- // Set longer timeout for Docker tests
systemProperty("junit.jupiter.execution.timeout.default", "5m")
- // When invoked as `./gradlew dockerIntegrationTest -Pnative`, the Jib
- // image produced is `solr-mcp:<version>-native`. BuildInfoReader only
- // knows the plain version, so pass the tag override through a system
- // property that the integration test can read.
if (nativeBuild) {
- systemProperty("solr.mcp.docker.image.tag.suffix", "-native")
+ // Native images are tagged solr-mcp:<v>-native-<profile>; tests append
+ // this suffix to BuildInfoReader.getDockerImageName().
+ systemProperty("solr.mcp.docker.image.tag.suffix",
"-native-$nativeProfile")
+ if (nativeProfile == "stdio") {
+ // stdio binary has no servlet beans → HTTP test would fail.
+ exclude("**/DockerImageHttpIntegrationTest*")
+ } else {
+ // http binary has no stdio MCP transport → stdio MCP test would
fail.
+ // Smoke-only stdio test (DockerImageStdioIntegrationTest) is also
+ // skipped because it spawns the container expecting stdin to stay
open.
+ exclude("**/DockerImageMcpClientStdioIntegrationTest*")
+ exclude("**/DockerImageStdioIntegrationTest*")
+ }
}
+ // For Jib JVM (no -Pnative): no exclusions — all three test classes run.
- // Output test results
testLogging {
events("passed", "skipped", "failed", "standardOut", "standardError")
exceptionFormat =
org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
showStandardStreams = true
}
- // Generate separate test report in a different directory
reports {
html.outputLocation.set(layout.buildDirectory.dir("reports/dockerIntegrationTest"))
junitXml.outputLocation.set(layout.buildDirectory.dir("test-results/dockerIntegrationTest"))
}
}
-// Jib Plugin Configuration
-// =========================
-// Jib is a Gradle plugin that builds optimized Docker images without
requiring Docker installed.
-// It creates layered images for faster rebuilds and smaller image sizes.
-//
-// Key features:
-// - Multi-platform support (amd64 and arm64)
-// - No Docker daemon required
-// - Reproducible builds
-// - Optimized layering for faster deployments
-//
-// Building Images:
-// ----------------
-// 1. Build to Docker daemon (requires Docker installed):
-// ./gradlew jibDockerBuild
-// Creates image: solr-mcp:1.0.0-SNAPSHOT
-//
-// 2. Push to Docker Hub (requires authentication):
-// docker login
-// ./gradlew jib -Djib.to.image=dockerhub-username/solr-mcp:1.0.0-SNAPSHOT
-//
-// 3. Push to GitHub Container Registry (requires authentication):
-// echo $GITHUB_TOKEN | docker login ghcr.io -u GITHUB_USERNAME
--password-stdin
-// ./gradlew jib
-Djib.to.image=ghcr.io/github-username/solr-mcp:1.0.0-SNAPSHOT
-//
-// Authentication:
-// ---------------
-// For Docker Hub:
-// docker login
-//
-// For GitHub Container Registry:
-// Create a Personal Access Token (classic) with write:packages scope at:
-// https://github.com/settings/tokens
-// Then authenticate:
-// echo YOUR_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin
+// Docker images: Jib (JVM) + Paketo bootBuildImage (native, per-profile)
+// ======================================================================
+// Three image artifacts cover the full stdio/http × jvm/native matrix:
//
-// Alternative: Set credentials in ~/.gradle/gradle.properties:
-// jib.to.auth.username=YOUR_USERNAME
-// jib.to.auth.password=YOUR_TOKEN_OR_PASSWORD
+// ./gradlew jibDockerBuild # JVM:
solr-mcp:<v> (both stdio + http via PROFILES)
+// ./gradlew bootBuildImage -Pnative # Native:
solr-mcp:<v>-native-stdio (stdio only, AOT pinned)
+// ./gradlew bootBuildImage -Pnative -Pprofile=http # Native:
solr-mcp:<v>-native-http (http only, AOT pinned)
//
-// Docker Executable Configuration:
-// ---------------------------------
-// Jib needs to find the Docker executable to build images. By default, it
uses these paths:
-// - macOS: /usr/local/bin/docker
-// - Linux: /usr/bin/docker
-// - Windows: C:\Program Files\Docker\Docker\resources\bin\docker.exe
+// Why three images:
+// - Jib's JVM image has clean stdout (java -jar entrypoint, no launcher
script),
+// so a single image serves both stdio and http via runtime PROFILES.
+// - Paketo's JVM image is unsuitable for stdio (libjvm helpers pollute stdout
—
+// see https://github.com/paketo-buildpacks/libjvm/issues/482).
+// - Native images must AOT-pin to one profile because Spring AOT bakes
+// spring.main.web-application-type into the binary; activating both profiles
+// picks `servlet` (http overrides stdio) and forces Tomcat to start
regardless
+// of runtime PROFILES, breaking stdio. Hence one native image per profile.
//
-// If Docker is installed in a different location, set the DOCKER_EXECUTABLE
environment variable:
-// export DOCKER_EXECUTABLE=/custom/path/to/docker
-// ./gradlew jibDockerBuild
-//
-// Or in gradle.properties:
-// systemProp.DOCKER_EXECUTABLE=/custom/path/to/docker
-//
-// Environment Variables:
-// ----------------------
-// The container is pre-configured with:
-// - SPRING_DOCKER_COMPOSE_ENABLED=false (Docker Compose disabled in container)
-// - SOLR_URL=http://host.docker.internal:8983/solr/ (default Solr connection)
-//
-// These can be overridden at runtime:
-// docker run -e SOLR_URL=http://custom-solr:8983/solr/
solr-mcp:1.0.0-SNAPSHOT
+// Multi-arch (amd64 + arm64) is handled in CI via a GitHub Actions matrix.
+
+// Jib JVM image — clean stdout, multi-arch, both stdio and http modes.
jib {
- // Configure Docker client executable path
- // This ensures Jib can find Docker even if it's not in Gradle's PATH
- // Can be overridden with environment variable:
DOCKER_EXECUTABLE=/path/to/docker
dockerClient {
- executable = System.getenv("DOCKER_EXECUTABLE") ?: when {
- // macOS with Docker Desktop
- org.gradle.internal.os.OperatingSystem
- .current()
- .isMacOsX -> "/usr/local/bin/docker"
- // Linux (most distributions)
- org.gradle.internal.os.OperatingSystem
- .current()
- .isLinux -> "/usr/bin/docker"
- // Windows with Docker Desktop
- org.gradle.internal.os.OperatingSystem
- .current()
- .isWindows -> "C:\\Program
Files\\Docker\\Docker\\resources\\bin\\docker.exe"
- // Fallback to PATH lookup
- else -> "docker"
- }
+ executable = System.getenv("DOCKER_EXECUTABLE")
+ ?: when {
+ org.gradle.internal.os.OperatingSystem
+ .current()
+ .isMacOsX -> "/usr/local/bin/docker"
+ org.gradle.internal.os.OperatingSystem
+ .current()
+ .isLinux -> "/usr/bin/docker"
+ org.gradle.internal.os.OperatingSystem
+ .current()
+ .isWindows ->
+ "C:\\Program
Files\\Docker\\Docker\\resources\\bin\\docker.exe"
+ else -> "docker"
+ }
}
-
from {
image = "eclipse-temurin:25-jre"
-
- // Multi-arch: build for both amd64 and arm64
platforms {
platform {
architecture = "amd64"
@@ -481,39 +433,19 @@ jib {
}
}
}
-
to {
image = "solr-mcp:$version"
tags = setOf("latest")
}
-
container {
- // Disable Spring Boot Docker Compose support when running in
container.
- // Docker Compose integration is disabled in the container image.
- // It is only useful for local development (HTTP profile) where
- // the host has Docker and a compose.yaml. Inside a container,
- // Docker Compose cannot start sibling containers without a
- // Docker socket mount, so it must be turned off.
- // The application-stdio.properties also disables it for STDIO mode.
- environment = mapOf("SPRING_DOCKER_COMPOSE_ENABLED" to "false")
-
- jvmFlags =
- listOf(
- // Use container-aware memory settings
- "-XX:+UseContainerSupport",
- // Set max RAM percentage (default 75%)
- "-XX:MaxRAMPercentage=75.0",
+ environment =
+ mapOf(
+ "PROFILES" to "stdio",
+ "SPRING_DOCKER_COMPOSE_ENABLED" to "false",
)
-
- // Explicitly set main class to avoid ASM scanning issues with newer
Java versions
+ jvmFlags = listOf("-XX:+UseContainerSupport",
"-XX:MaxRAMPercentage=75.0")
mainClass = "org.apache.solr.mcp.server.Main"
-
- // Port exposures (for documentation purposes)
- // The application doesn't expose ports by default (STDIO mode)
- // If running in HTTP mode, the port would be 8080
ports = listOf("8080")
-
- // Labels for image metadata
labels.set(
mapOf(
"org.opencontainers.image.title" to "Solr MCP Server",
@@ -521,99 +453,75 @@ jib {
"org.opencontainers.image.version" to version.toString(),
"org.opencontainers.image.vendor" to "Apache Software
Foundation",
"org.opencontainers.image.licenses" to "Apache-2.0",
- // MCP Registry annotation for server discovery
"io.modelcontextprotocol.server.name" to
"io.github.apache/solr-mcp",
),
)
}
}
-// Native Docker image via Spring Boot Buildpacks
-// ===============================================
-// `bootBuildImage` compiles the native binary inside a Paketo builder
-// container, so it works on any host OS and CPU architecture (macOS
-// Apple Silicon, Linux x86_64, etc.).
-//
-// Always configured for native builds (BP_NATIVE_IMAGE=true).
-//
-// Usage:
-// ./gradlew bootBuildImage # Build native Docker image
-// ./gradlew dockerIntegrationTest -Pnative # Test the native image
tasks.named<org.springframework.boot.gradle.tasks.bundling.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",
- // The Paketo builder runs Spring AOT processing inside the
- // builder container. Set the STDIO profile so security
- // autoconfig exclusions from application-stdio.properties are
- // applied during AOT hint generation (same effect as the
- // processAot args block for local nativeCompile).
- "SPRING_PROFILES_ACTIVE" to "stdio",
- // BPE_DEFAULT_* sets default runtime environment variables in
- // the resulting container image.
- "BPE_DEFAULT_SPRING_PROFILES_ACTIVE" to "stdio",
- "BPE_DEFAULT_SPRING_DOCKER_COMPOSE_ENABLED" to "false",
- ),
- )
+ if (nativeBuild) {
+ imageName.set("solr-mcp:$version-native-$nativeProfile")
+ tags.set(listOf("solr-mcp:latest-native-$nativeProfile"))
+ environment.set(
+ mapOf(
+ "BP_JVM_VERSION" to "25",
+ "BP_NATIVE_IMAGE_BUILD_ARGUMENTS" to
nativeImageBuildArgs.joinToString(" "),
+ "SPRING_PROFILES_ACTIVE" to nativeProfile,
+ "BPE_DEFAULT_PROFILES" to nativeProfile,
+ "BPE_DEFAULT_SPRING_DOCKER_COMPOSE_ENABLED" to "false",
+ ),
+ )
+ }
+ // When -Pnative is not set, this task is unreachable (graalvm-native
plugin
+ // not applied → Spring Boot's auto-config doesn't extend bootBuildImage
for
+ // native, but the task still exists). We use Jib for JVM images, so this
+ // branch is intentionally a no-op rather than producing a confusing image.
}
//
─────────────────────────────────────────────────────────────────────────────
-// GraalVM Native Image configuration
+// GraalVM Native Image configuration (only applied when -Pnative is set)
//
─────────────────────────────────────────────────────────────────────────────
// The `org.graalvm.buildtools.native` plugin registers `nativeCompile` and
-// `nativeTest` tasks. They are only useful when a GraalVM toolchain is
-// available; the plugin's presence does not affect regular JVM builds.
-graalvmNative {
- binaries {
- named("main") {
- imageName.set("solr-mcp")
- // Uses shared nativeImageBuildArgs which include --no-fallback,
- // -H:+ReportExceptionStackTraces, and OTel
--initialize-at-build-time entries.
- // OTel instrumentation BOM 2.11.0 does not ship native-image
metadata.
- // NOTE: do NOT use io.opentelemetry.instrumentation (too broad) —
that
- // would catch Spring autoconfigure CGLIB proxies which cannot be
build-time
- // initialized.
- buildArgs.addAll(nativeImageBuildArgs)
- }
- }
- // Tests that are incompatible with native image are annotated
- // @DisabledInNativeImage and skipped during nativeTest:
- // - Mockito-based tests (ByteBuddy cannot generate classes at run time)
- // - SolrJ indexing/query integration tests (response parsing uses
- // reflection that lacks native-image metadata)
- // Collection management, schema, and observability integration tests
- // run normally in the native binary.
- binaries {
- named("test") {
- // The test binary inherits the OTel --initialize-at-build-time
entries
- // from the shared args (filtering out --no-fallback and
- // -H:+ReportExceptionStackTraces), plus test-specific SDK entries.
- buildArgs.addAll(
- nativeImageBuildArgs.filter {
it.startsWith("--initialize-at-build-time=") },
- )
- buildArgs.addAll(
- // opentelemetry-sdk-testing on the test classpath adds a
ServiceLoader
- // provider (SettableContextStorageProvider) that is loaded at
build time.
- "--initialize-at-build-time=io.opentelemetry.sdk",
- // AndroidFriendlyRandomHolder creates a java.util.Random in
<clinit>,
- // which GraalVM forbids in the image heap (stale seed).
-
"--initialize-at-run-time=io.opentelemetry.sdk.internal.AndroidFriendlyRandomHolder",
- )
+// `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.
+if (nativeBuild) {
+
extensions.configure<org.graalvm.buildtools.gradle.dsl.GraalVMExtension>("graalvmNative")
{
+ binaries {
+ named("main") {
+ imageName.set("solr-mcp")
+ buildArgs.addAll(nativeImageBuildArgs)
+ }
+ named("test") {
+ // Test binary inherits OTel --initialize-at-build-time
entries from the
+ // shared args (filtering out --no-fallback and
-H:+ReportExceptionStackTraces),
+ // plus test-specific SDK entries.
+ buildArgs.addAll(
+ nativeImageBuildArgs.filter {
it.startsWith("--initialize-at-build-time=") },
+ )
+ buildArgs.addAll(
+ // opentelemetry-sdk-testing adds a ServiceLoader provider
+ // (SettableContextStorageProvider) loaded at build time.
+ "--initialize-at-build-time=io.opentelemetry.sdk",
+ // AndroidFriendlyRandomHolder creates a java.util.Random
in <clinit>,
+ // which GraalVM forbids in the image heap (stale seed).
+
"--initialize-at-run-time=io.opentelemetry.sdk.internal.AndroidFriendlyRandomHolder",
+ // The GraalVM native JUnit launcher embeds test discovery
results
+ // (InternalTestPlan, descriptors, TestTag, etc.) in the
image heap.
+ "--initialize-at-build-time=org.junit.platform.launcher",
+ "--initialize-at-build-time=org.junit.platform.engine",
+
"--initialize-at-build-time=org.junit.jupiter.engine.descriptor",
+ )
+ }
}
}
-}
-
-// When -Pnative is present, pin spring.profiles.active=stdio on the AOT
-// processor so application-stdio.properties (which excludes
-// SecurityAutoConfiguration + ManagementWebSecurityAutoConfiguration) is
-// applied while hint generation runs. Test AOT is intentionally left alone
-// because individual @SpringBootTest classes set their own profiles.
-if (nativeBuild) {
tasks.named<JavaExec>("processAot") {
- args("--spring.profiles.active=stdio")
+ args("--spring.profiles.active=$nativeProfile")
}
}
diff --git a/docs/specs/graalvm-native-image.md
b/docs/specs/graalvm-native-image.md
index 97fb2b5..d46aaba 100644
--- a/docs/specs/graalvm-native-image.md
+++ b/docs/specs/graalvm-native-image.md
@@ -1,6 +1,20 @@
# Spec: GraalVM Native Image Support (Opt-In, bootBuildImage, STDIO)
-Status: Draft
+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)
diff --git a/scripts/benchmark-native.sh b/scripts/benchmark-native.sh
index 34774ee..13064f0 100755
--- a/scripts/benchmark-native.sh
+++ b/scripts/benchmark-native.sh
@@ -39,7 +39,7 @@ RUNS="${RUNS:-5}"
SOLR_URL="${SOLR_URL:-http://host.docker.internal:8983/solr/}"
VERSION="$(grep '^version = ' build.gradle.kts | sed 's/version =
"\(.*\)"/\1/')"
JVM_IMAGE="solr-mcp:${VERSION}"
-NATIVE_IMAGE="solr-mcp:${VERSION}-native"
+NATIVE_IMAGE="solr-mcp:${VERSION}-native-stdio"
RESULT_FILE="docs/specs/benchmark-results.md"
# Require this many consecutive RSS samples within 1 MB to declare startup
complete
STABLE_THRESHOLD="${STABLE_THRESHOLD:-3}"
@@ -54,8 +54,8 @@ log() { printf '▶ %s\n' "$*" >&2; }
build_images() {
log "Building JVM image…"
./gradlew jibDockerBuild
- log "Building native image (this can take several minutes)…"
- ./gradlew bootBuildImage
+ log "Building native stdio image (this can take several minutes)…"
+ ./gradlew bootBuildImage -Pnative
}
image_size_mb() {
diff --git
a/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTest.java
b/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTest.java
index 79c4735..486417c 100644
--- a/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTest.java
+++ b/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTest.java
@@ -16,26 +16,10 @@
*/
package org.apache.solr.mcp.server;
-import static org.junit.jupiter.api.Assertions.*;
-
-import com.fasterxml.jackson.core.type.TypeReference;
-import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import
io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
-import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
-import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
-import io.modelcontextprotocol.spec.McpSchema.TextContent;
-import java.util.List;
-import java.util.Map;
-import org.junit.jupiter.api.AfterAll;
-import org.junit.jupiter.api.BeforeAll;
-import org.junit.jupiter.api.MethodOrderer;
-import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Tag;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.TestInstance;
-import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.context.annotation.Import;
@@ -43,11 +27,9 @@ import org.springframework.test.context.ActiveProfiles;
import org.testcontainers.junit.jupiter.Testcontainers;
/**
- * Integration test that exercises the MCP server through a real MCP client.
- *
- * <p>
- * Boots the application in HTTP mode, connects an MCP client, and tests the
- * full create-collection → index → search workflow via MCP tool calls.
+ * MCP client integration test running against the server in HTTP mode. Boots
+ * the full application with a real Solr container and exercises all MCP tools
+ * via an HTTP transport.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {"http.security.enabled=false",
"spring.docker.compose.enabled=false"})
@@ -55,273 +37,15 @@ import org.testcontainers.junit.jupiter.Testcontainers;
@Import(TestcontainersConfiguration.class)
@Tag("integration")
@Testcontainers(disabledWithoutDocker = true)
-@TestInstance(TestInstance.Lifecycle.PER_CLASS)
-@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
-class McpClientIntegrationTest {
-
- private static final String COLLECTION = "mcp-client-test";
-
- private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+class McpClientIntegrationTest extends McpClientIntegrationTestBase {
@LocalServerPort
private int port;
- private McpSyncClient mcpClient;
-
- @BeforeAll
- void setupClient() {
+ @Override
+ protected McpSyncClient createClient() {
var transport =
HttpClientStreamableHttpTransport.builder("http://localhost:" + port).build();
- mcpClient = McpClient.sync(transport).build();
- mcpClient.initialize();
- }
-
- @AfterAll
- void tearDown() {
- if (mcpClient != null) {
- mcpClient.close();
- }
- }
-
- @Test
- @Order(1)
- void pingServer() {
- assertDoesNotThrow(() -> mcpClient.ping(), "MCP ping should
succeed");
- }
-
- @Test
- @Order(2)
- void listToolsReturnsExpectedTools() {
- var toolsResult = mcpClient.listTools();
- assertNotNull(toolsResult);
- List<String> toolNames = toolsResult.tools().stream().map(t ->
t.name()).toList();
-
- assertTrue(toolNames.contains("create-collection"), "Should
have create-collection tool");
- assertTrue(toolNames.contains("index-json-documents"), "Should
have index-json-documents tool");
- assertTrue(toolNames.contains("search"), "Should have search
tool");
- assertTrue(toolNames.contains("list-collections"), "Should have
list-collections tool");
- assertTrue(toolNames.contains("check-health"), "Should have
check-health tool");
- assertTrue(toolNames.contains("get-collection-stats"), "Should
have get-collection-stats tool");
- assertTrue(toolNames.contains("get-schema"), "Should have
get-schema tool");
- }
-
- @Test
- @Order(3)
- void createCollection() {
- CallToolResult result = mcpClient
- .callTool(new
CallToolRequest("create-collection", Map.of("name", COLLECTION)));
-
- assertNotNull(result);
- assertNotError(result);
- String text = extractText(result);
- assertTrue(text.contains("success") || text.contains("true"),
"Collection creation should succeed: " + text);
- }
-
- @Test
- @Order(4)
- void listCollectionsContainsCreatedCollection() {
- CallToolResult result = mcpClient.callTool(new
CallToolRequest("list-collections", Map.of()));
-
- assertNotNull(result);
- assertNotError(result);
- String text = extractText(result);
- assertTrue(text.contains(COLLECTION), "Created collection
should appear in list: " + text);
- }
-
- @Test
- @Order(5)
- void indexJsonDocuments() {
- String json = """
- [
- {"id": "1", "title": "Introduction to Solr",
"author": "Alice", "category": "search"},
- {"id": "2", "title": "MCP Protocol Guide",
"author": "Bob", "category": "protocol"},
- {"id": "3", "title": "Spring Boot in Action",
"author": "Charlie", "category": "framework"},
- {"id": "4", "title": "Advanced Solr
Techniques", "author": "Alice", "category": "search"},
- {"id": "5", "title": "Building MCP Servers",
"author": "Diana", "category": "protocol"}
- ]
- """;
-
- CallToolResult result = mcpClient
- .callTool(new
CallToolRequest("index-json-documents", Map.of("collection", COLLECTION,
"json", json)));
-
- assertNotNull(result);
- assertNotError(result);
- }
-
- @Test
- @Order(6)
- void checkHealthShowsIndexedDocuments() {
- CallToolResult result = mcpClient
- .callTool(new CallToolRequest("check-health",
Map.of("collection", COLLECTION)));
-
- assertNotNull(result);
- assertNotError(result);
- String text = extractText(result);
- assertTrue(text.contains("true") || text.contains("healthy"),
"Collection should be healthy: " + text);
- assertTrue(text.contains("5"), "Should report 5 documents: " +
text);
- }
-
- @Test
- @Order(7)
- void searchAllDocuments() throws Exception {
- CallToolResult result = mcpClient
- .callTool(new CallToolRequest("search",
Map.of("collection", COLLECTION, "query", "*:*", "rows", 10)));
-
- assertNotNull(result);
- assertNotError(result);
- String text = extractText(result);
-
- Map<String, Object> response = OBJECT_MAPPER.readValue(text,
new TypeReference<>() {
- });
- assertEquals(5, getNumFound(response), "Should find all 5
documents");
- }
-
- @Test
- @Order(8)
- void searchWithFilterQuery() throws Exception {
- CallToolResult result = mcpClient.callTool(new
CallToolRequest("search",
- Map.of("collection", COLLECTION, "query",
"*:*", "filterQueries", List.of("category:search"))));
-
- assertNotNull(result);
- assertNotError(result);
- String text = extractText(result);
-
- Map<String, Object> response = OBJECT_MAPPER.readValue(text,
new TypeReference<>() {
- });
- assertEquals(2, getNumFound(response), "Should find 2
search-category documents");
- }
-
- @Test
- @Order(9)
- void searchWithKeyword() throws Exception {
- CallToolResult result = mcpClient
- .callTool(new CallToolRequest("search",
Map.of("collection", COLLECTION, "query", "title:Solr")));
-
- assertNotNull(result);
- assertNotError(result);
- String text = extractText(result);
-
- Map<String, Object> response = OBJECT_MAPPER.readValue(text,
new TypeReference<>() {
- });
- int numFound = getNumFound(response);
- assertTrue(numFound >= 1, "Should find at least 1 document with
'Solr' in title: " + numFound);
- }
-
- @Test
- @Order(10)
- void searchWithPagination() throws Exception {
- CallToolResult page1 = mcpClient.callTool(
- new CallToolRequest("search",
Map.of("collection", COLLECTION, "query", "*:*", "start", 0, "rows", 2)));
- CallToolResult page2 = mcpClient.callTool(
- new CallToolRequest("search",
Map.of("collection", COLLECTION, "query", "*:*", "start", 2, "rows", 2)));
-
- Map<String, Object> response1 =
OBJECT_MAPPER.readValue(extractText(page1), new TypeReference<>() {
- });
- Map<String, Object> response2 =
OBJECT_MAPPER.readValue(extractText(page2), new TypeReference<>() {
- });
-
- List<Map<String, Object>> docs1 = getDocuments(response1);
- List<Map<String, Object>> docs2 = getDocuments(response2);
-
- assertEquals(2, docs1.size(), "Page 1 should have 2 documents");
- assertEquals(2, docs2.size(), "Page 2 should have 2 documents");
- assertNotEquals(docs1.get(0).get("id"), docs2.get(0).get("id"),
"Pages should return different documents");
- }
-
- @Test
- @Order(11)
- void getCollectionStats() {
- CallToolResult result = mcpClient
- .callTool(new
CallToolRequest("get-collection-stats", Map.of("collection", COLLECTION)));
-
- assertNotNull(result);
- assertNotError(result);
- String text = extractText(result);
- assertTrue(text.contains("5") || text.contains("numDocs"),
"Stats should reference indexed documents: " + text);
- }
-
- @Test
- @Order(12)
- void getSchema() {
- CallToolResult result = mcpClient.callTool(new
CallToolRequest("get-schema", Map.of("collection", COLLECTION)));
-
- assertNotNull(result);
- assertNotError(result);
- String text = extractText(result);
- assertFalse(text.isEmpty(), "Schema response should not be
empty");
- }
-
- @Test
- @Order(13)
- void searchWithFacets() throws Exception {
- CallToolResult result = mcpClient.callTool(new
CallToolRequest("search",
- Map.of("collection", COLLECTION, "query",
"*:*", "facetFields", List.of("id"), "rows", 0)));
-
- assertNotNull(result);
- assertNotError(result);
- String text = extractText(result);
-
- Map<String, Object> response = OBJECT_MAPPER.readValue(text,
new TypeReference<>() {
- });
- @SuppressWarnings("unchecked")
- Map<String, Object> facets = (Map<String, Object>)
response.get("facets");
- assertNotNull(facets, "Should have facets in response");
- assertTrue(facets.containsKey("id"), "Should have id facet");
- }
-
- @Test
- @Order(14)
- void indexCsvDocuments() {
- String csv = """
- id,title,author,category
- 6,CSV Document One,Eve,csv-test
- 7,CSV Document Two,Frank,csv-test
- """;
-
- CallToolResult result = mcpClient
- .callTool(new
CallToolRequest("index-csv-documents", Map.of("collection", COLLECTION, "csv",
csv)));
-
- assertNotNull(result);
- assertNotError(result);
- }
-
- @Test
- @Order(15)
- void searchFindsAllDocumentsAfterCsvIndexing() throws Exception {
- CallToolResult result = mcpClient
- .callTool(new CallToolRequest("search",
Map.of("collection", COLLECTION, "query", "*:*", "rows", 0)));
-
- Map<String, Object> response =
OBJECT_MAPPER.readValue(extractText(result), new TypeReference<>() {
- });
- assertEquals(7, getNumFound(response), "Should find 7 documents
(5 JSON + 2 CSV)");
- }
-
- private static String extractText(CallToolResult result) {
- assertNotNull(result.content(), "Result content should not be
null");
- assertFalse(result.content().isEmpty(), "Result content should
not be empty");
- assertInstanceOf(TextContent.class, result.content().get(0),
"Content should be TextContent");
- return ((TextContent) result.content().get(0)).text();
- }
-
- private static void assertNotError(CallToolResult result) {
- if (Boolean.TRUE.equals(result.isError())) {
- String errorText = result.content().isEmpty()
- ? "unknown error"
- : ((TextContent)
result.content().get(0)).text();
- fail("MCP tool call returned error: " + errorText);
- }
- }
-
- private static int getNumFound(Map<String, Object> response) {
- Object value = response.get("numFound");
- assertNotNull(value, "numFound should be present in response");
- return ((Number) value).intValue();
- }
-
- @SuppressWarnings("unchecked")
- private static List<Map<String, Object>> getDocuments(Map<String,
Object> response) {
- Object value = response.get("documents");
- assertNotNull(value, "documents should be present in response");
- return (List<Map<String, Object>>) value;
+ return McpClient.sync(transport).build();
}
}
diff --git
a/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTest.java
b/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java
similarity index 86%
copy from src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTest.java
copy to
src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java
index 79c4735..776d52a 100644
--- a/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTest.java
+++ b/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java
@@ -20,9 +20,7 @@ import static org.junit.jupiter.api.Assertions.*;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
-import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
-import
io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
@@ -36,47 +34,33 @@ import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.TestMethodOrder;
-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;
/**
- * Integration test that exercises the MCP server through a real MCP client.
- *
- * <p>
- * Boots the application in HTTP mode, connects an MCP client, and tests the
- * full create-collection → index → search workflow via MCP tool calls.
+ * Base class for MCP client integration tests. Exercises the full
+ * create-collection → index → search workflow via MCP tool calls. Subclasses
+ * provide the transport (HTTP or stdio).
*/
-@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)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
-class McpClientIntegrationTest {
+public abstract class McpClientIntegrationTestBase {
- private static final String COLLECTION = "mcp-client-test";
+ protected static final String COLLECTION = "mcp-client-test";
- private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+ protected static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
- @LocalServerPort
- private int port;
+ protected McpSyncClient mcpClient;
- private McpSyncClient mcpClient;
+ protected abstract McpSyncClient createClient() throws Exception;
@BeforeAll
- void setupClient() {
- var transport =
HttpClientStreamableHttpTransport.builder("http://localhost:" + port).build();
- mcpClient = McpClient.sync(transport).build();
+ void beforeAll() throws Exception {
+ mcpClient = createClient();
mcpClient.initialize();
}
@AfterAll
- void tearDown() {
+ void afterAll() {
if (mcpClient != null) {
mcpClient.close();
}
@@ -295,14 +279,14 @@ class McpClientIntegrationTest {
assertEquals(7, getNumFound(response), "Should find 7 documents
(5 JSON + 2 CSV)");
}
- private static String extractText(CallToolResult result) {
+ protected static String extractText(CallToolResult result) {
assertNotNull(result.content(), "Result content should not be
null");
assertFalse(result.content().isEmpty(), "Result content should
not be empty");
assertInstanceOf(TextContent.class, result.content().get(0),
"Content should be TextContent");
return ((TextContent) result.content().get(0)).text();
}
- private static void assertNotError(CallToolResult result) {
+ protected static void assertNotError(CallToolResult result) {
if (Boolean.TRUE.equals(result.isError())) {
String errorText = result.content().isEmpty()
? "unknown error"
@@ -311,14 +295,14 @@ class McpClientIntegrationTest {
}
}
- private static int getNumFound(Map<String, Object> response) {
+ protected static int getNumFound(Map<String, Object> response) {
Object value = response.get("numFound");
assertNotNull(value, "numFound should be present in response");
return ((Number) value).intValue();
}
@SuppressWarnings("unchecked")
- private static List<Map<String, Object>> getDocuments(Map<String,
Object> response) {
+ protected static List<Map<String, Object>> getDocuments(Map<String,
Object> response) {
Object value = response.get("documents");
assertNotNull(value, "documents should be present in response");
return (List<Map<String, Object>>) value;
diff --git
a/src/test/java/org/apache/solr/mcp/server/McpClientStdioIntegrationTest.java
b/src/test/java/org/apache/solr/mcp/server/McpClientStdioIntegrationTest.java
new file mode 100644
index 0000000..c6120a7
--- /dev/null
+++
b/src/test/java/org/apache/solr/mcp/server/McpClientStdioIntegrationTest.java
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.solr.mcp.server;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.modelcontextprotocol.client.McpClient;
+import io.modelcontextprotocol.client.McpSyncClient;
+import io.modelcontextprotocol.client.transport.ServerParameters;
+import io.modelcontextprotocol.client.transport.StdioClientTransport;
+import io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapper;
+import org.junit.jupiter.api.Tag;
+import org.testcontainers.containers.SolrContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.utility.DockerImageName;
+
+/**
+ * MCP client integration test running against the server in STDIO mode. Spawns
+ * the application jar as a subprocess using {@link StdioClientTransport} and
+ * exercises all MCP tools via the stdio JSON-RPC protocol.
+ */
+@Tag("integration")
+@Testcontainers(disabledWithoutDocker = true)
+class McpClientStdioIntegrationTest extends McpClientIntegrationTestBase {
+
+ @Container
+ static final SolrContainer solrContainer = new SolrContainer(
+
DockerImageName.parse(System.getProperty("solr.test.image", "solr:9.9-slim")));
+
+ @Override
+ protected McpSyncClient createClient() {
+ String solrUrl = "http://" + solrContainer.getHost() + ":" +
solrContainer.getMappedPort(8983) + "/solr/";
+ String jarPath = "build/libs/" +
BuildInfoReader.getJarFileName();
+
+ var params = ServerParameters.builder("java").args("-jar",
jarPath).addEnvVar("SOLR_URL", solrUrl)
+ .addEnvVar("SPRING_DOCKER_COMPOSE_ENABLED",
"false").build();
+
+ var transport = new StdioClientTransport(params, new
JacksonMcpJsonMapper(new ObjectMapper()));
+ return McpClient.sync(transport).build();
+ }
+
+}
diff --git
a/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageHttpIntegrationTest.java
b/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageHttpIntegrationTest.java
index 224f53d..9b3cd84 100644
---
a/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageHttpIntegrationTest.java
+++
b/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageHttpIntegrationTest.java
@@ -94,7 +94,8 @@ class DockerImageHttpIntegrationTest {
private static final Logger log =
LoggerFactory.getLogger(DockerImageHttpIntegrationTest.class);
// Docker image name and tag from build-info.properties
- private static final String DOCKER_IMAGE =
BuildInfoReader.getDockerImageName();
+ private static final String DOCKER_IMAGE =
BuildInfoReader.getDockerImageName()
+ +
System.getProperty("solr.mcp.docker.image.tag.suffix", "");
private static final String SOLR_IMAGE =
System.getProperty("solr.test.image");
private static final int HTTP_PORT = 8080;
@@ -206,18 +207,6 @@ class DockerImageHttpIntegrationTest {
log.info("Container can connect to Solr without errors");
}
- @Test
- void testHttpModeConfiguration() {
- String logs = mcpServerContainer.getLogs();
-
- // Verify HTTP mode is active by checking for typical Spring
Boot web server
- // logs
- assertTrue(logs.contains("Tomcat started on port") ||
logs.contains("Netty started on port"),
- "Logs should indicate web server started on a
port");
-
- log.info("HTTP mode configuration verified");
- }
-
@Test
void testPortExposure() {
// Verify the port is exposed and mapped
diff --git
a/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageMcpClientStdioIntegrationTest.java
b/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageMcpClientStdioIntegrationTest.java
new file mode 100644
index 0000000..392d397
--- /dev/null
+++
b/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageMcpClientStdioIntegrationTest.java
@@ -0,0 +1,74 @@
+/*
+ * 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.containerization;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.modelcontextprotocol.client.McpClient;
+import io.modelcontextprotocol.client.McpSyncClient;
+import io.modelcontextprotocol.client.transport.ServerParameters;
+import io.modelcontextprotocol.client.transport.StdioClientTransport;
+import io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapper;
+import org.apache.solr.mcp.server.BuildInfoReader;
+import org.apache.solr.mcp.server.McpClientIntegrationTestBase;
+import org.junit.jupiter.api.Tag;
+import org.testcontainers.containers.SolrContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.utility.DockerImageName;
+
+/**
+ * End-to-end MCP STDIO test against the Paketo Docker image built by
+ * {@code bootBuildImage}. Spawns {@code docker run -i} as the MCP server
+ * subprocess and drives the full MCP tool-call workflow over the JSON-RPC
stdio
+ * protocol — exactly how a client like Claude Desktop would use it.
+ *
+ * <p>
+ * This is the protocol-level verification of stdout cleanliness. If Paketo's
+ * libjvm helpers (memory calculator, NMT, ca-certificates) write to stdout
+ * before the JVM starts, the MCP client's JSON-RPC parser will choke on those
+ * lines and {@code initialize()} will fail.
+ *
+ * <p>
+ * Image under test: {@link BuildInfoReader#getDockerImageName()} plus the
+ * {@code solr.mcp.docker.image.tag.suffix} system property (set to
+ * {@code -native} for {@code dockerIntegrationTest -Pnative}, empty
otherwise).
+ */
+@Tag("docker-integration")
+@Testcontainers(disabledWithoutDocker = true)
+class DockerImageMcpClientStdioIntegrationTest extends
McpClientIntegrationTestBase {
+
+ private static final String DOCKER_IMAGE =
BuildInfoReader.getDockerImageName()
+ +
System.getProperty("solr.mcp.docker.image.tag.suffix", "");
+
+ @Container
+ static final SolrContainer solrContainer = new SolrContainer(
+
DockerImageName.parse(System.getProperty("solr.test.image", "solr:9.9-slim")));
+
+ @Override
+ protected McpSyncClient createClient() {
+ String solrUrl = "http://host.docker.internal:" +
solrContainer.getMappedPort(8983) + "/solr/";
+
+ var params = ServerParameters.builder("docker")
+ .args("run", "-i", "--rm",
"--add-host=host.docker.internal:host-gateway", "-e", "SOLR_URL=" + solrUrl,
+ "-e",
"SPRING_DOCKER_COMPOSE_ENABLED=false", DOCKER_IMAGE)
+ .build();
+
+ var transport = new StdioClientTransport(params, new
JacksonMcpJsonMapper(new ObjectMapper()));
+ return McpClient.sync(transport).build();
+ }
+
+}
diff --git
a/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageStdioIntegrationTest.java
b/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageStdioIntegrationTest.java
index 7efb11e..8336d68 100644
---
a/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageStdioIntegrationTest.java
+++
b/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageStdioIntegrationTest.java
@@ -84,7 +84,8 @@ class DockerImageStdioIntegrationTest {
private static final Logger log =
LoggerFactory.getLogger(DockerImageStdioIntegrationTest.class);
// Docker image name and tag from build-info.properties
- private static final String DOCKER_IMAGE =
BuildInfoReader.getDockerImageName();
+ private static final String DOCKER_IMAGE =
BuildInfoReader.getDockerImageName()
+ +
System.getProperty("solr.mcp.docker.image.tag.suffix", "");
private static final String SOLR_IMAGE =
System.getProperty("solr.test.image");
// Network for container communication
diff --git
a/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceIntegrationTest.java
b/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceIntegrationTest.java
index 57a544b..93a5b98 100644
---
a/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceIntegrationTest.java
+++
b/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceIntegrationTest.java
@@ -33,7 +33,6 @@ import org.apache.solr.mcp.server.search.SearchService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.condition.DisabledInNativeImage;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
@@ -48,7 +47,6 @@ import org.testcontainers.junit.jupiter.Testcontainers;
@Import(TestcontainersConfiguration.class)
@Tag("integration")
@Testcontainers(disabledWithoutDocker = true)
-@DisabledInNativeImage
class IndexingServiceIntegrationTest {
private static boolean initialized = false;
diff --git
a/src/test/java/org/apache/solr/mcp/server/search/SearchServiceIntegrationTest.java
b/src/test/java/org/apache/solr/mcp/server/search/SearchServiceIntegrationTest.java
index 9f5ff90..35e273c 100644
---
a/src/test/java/org/apache/solr/mcp/server/search/SearchServiceIntegrationTest.java
+++
b/src/test/java/org/apache/solr/mcp/server/search/SearchServiceIntegrationTest.java
@@ -31,7 +31,6 @@ import org.apache.solr.mcp.server.indexing.IndexingService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.condition.DisabledInNativeImage;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
@@ -45,7 +44,6 @@ import org.testcontainers.junit.jupiter.Testcontainers;
@Import(TestcontainersConfiguration.class)
@Tag("integration")
@Testcontainers(disabledWithoutDocker = true)
-@DisabledInNativeImage
class SearchServiceIntegrationTest {
private static final String COLLECTION_NAME = "search_test_" +
System.currentTimeMillis();