Why This Migration Exists
Contrast is building a hosted MCP server that needs to share tool logic with the existing public local server — and Maven single-module can't produce a consumable library artifact.
The AIML-110 initiative requires extracting transport-neutral tool infrastructure into a shared core that both servers consume. Maven's single-module layout made this impossible. This PR creates the Gradle multi-module structure that unblocks all shared tool work.
Java 21 is a co-requisite, not a nice-to-have.
The hosted server runs on Java 21 in the aiml-services monorepo. The public repo must compile at the same target so the shared core artifact is binary-compatible. This PR upgrades from Java 17 to 21 across CI, Docker, and the build toolchain in one pass.
What's in core today:
- Validation, hints, and filter helpers — that's it
- 12+ production tools,
BaseToolpipeline,ContrastApiClient, shared DTOs - Blocked by coupling to local SDK factories, SDK helpers, and Guava caches
- S3A (this PR) — Gradle multi-module skeleton, Java 21, CI, boundary test. Proves the build works.
- S3B (next) — Add
maven-publish, prove hosted server consumes core via composite-build substitution, validate classpath boundary. - S3C (final) — Align contributor docs (README, AGENTS.md), Dependabot, Makefile. Document composite-build local dev for cross-repo workflow.
ContrastApiClient extraction (S4A), tool migration (S5+), BearerToken transport — depends on all three PRs being in place.
Module evolution: Maven → This PR → Final State
Package redistribution: what moved where (214 files)
CoreBoundaryTest blocks local-only depsmaven-publish → hosted server consumes core via composite buildContrastApiClient (S4A), tool migration (S5+)Module Structure
Two modules, one clear boundary: core holds transport-neutral support types, the app holds everything that touches the Contrast SDK or Spring Boot.
The settings.gradle defines the project topology. FAIL_ON_PROJECT_REPOS ensures no subproject can declare its own repository — all dependency resolution goes through the centralized mavenCentral() declaration.
+1pluginManagement { +2 repositories { +3 gradlePluginPortal() +4 mavenCentral() +5 } +6} +7 +8dependencyResolutionManagement { +9 repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) +10 repositories { +11 mavenCentral() +12 } +13} +14 +15rootProject.name = 'mcp-contrast' +16 +17include 'contrast-mcp-core' +18include 'contrast-mcp-stdio-app'
FAIL_ON_PROJECT_REPOS prevents subprojects from declaring their own repositories, ensuring all dependency resolution is centralized. This prevents a future contributor from accidentally pulling from an unapproved source inside a subproject build.gradle.
All dependency versions live in a single gradle.properties file.
No version string appears inline in any build.gradle. This satisfies the project's dependency policy (no dynamic versions) and makes version bumps atomic single-line changes — exactly what Dependabot needs to submit clean PRs.
+1version=1.0.1-SNAPSHOT +2 +3springBootVersion=3.5.7 +4springAiVersion=1.1.4 +5spotlessGradlePluginVersion=7.0.4 +6googleJavaFormatVersion=1.26.0 +7guavaVersion=33.5.0-jre +8contrastSdkVersion=3.7.0 +9commonsValidatorVersion=1.10.1 +10mockitoInlineVersion=5.2.0 +11assertjVersion=3.27.7 +12checkstyleVersion=13.4.1 +13lombokVersion=1.18.38
version line via printVersion/setVersion tasks, replacing Maven's release:prepare plugin. Dependabot updates land as single-line changes to this file.
The Root Build
The root build.gradle is the single source of truth for shared configuration — Java toolchain, Spotless, Checkstyle, Lombok, and test dependencies are configured once and inherited by both modules.
This eliminates the duplication that would otherwise creep in as modules are added. The root also provides two custom tasks for the release workflow: printVersion and setVersion.
+1import org.gradle.api.plugins.quality.Checkstyle +2 +3plugins { +4 id 'base' +5 id 'com.diffplug.spotless' version "${spotlessGradlePluginVersion}" apply false +6 id 'org.springframework.boot' version "${springBootVersion}" apply false +7}
apply false at the root so they're resolved once (pinning the version) but only applied in the subprojects that need them. Spring Boot is applied only in contrast-mcp-stdio-app.
+45subprojects { +46 apply plugin: 'java-library' +47 apply plugin: 'checkstyle' +48 apply plugin: 'com.diffplug.spotless' +49 +50 java { +51 toolchain { +52 languageVersion = JavaLanguageVersion.of(21) +53 } +54 } +55 +56 tasks.withType(JavaCompile).configureEach { +57 options.encoding = 'UTF-8' +58 options.release = 21 +59 }
toolchain and options.release are set to 21. The toolchain controls which JDK Gradle uses to compile. The release flag is a javac cross-compilation guard — it ensures the compiled bytecode only uses APIs available in Java 21, even if a newer JDK is on the path.
+87 dependencies { +88 implementation enforcedPlatform("org.springframework.boot:spring-boot-dependencies:${springBootVersion}") +89 +90 compileOnly "org.projectlombok:lombok:${lombokVersion}" +91 annotationProcessor "org.projectlombok:lombok:${lombokVersion}" +92 +93 testCompileOnly "org.projectlombok:lombok:${lombokVersion}" +94 testAnnotationProcessor "org.projectlombok:lombok:${lombokVersion}" +95 testImplementation 'org.junit.jupiter:junit-jupiter' +96 testImplementation "org.assertj:assertj-core:${assertjVersion}" +97 testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +98 }
enforcedPlatform() is the key to BOM precedence — and it's resolving real conflicts today. Unlike a regular platform(), an enforced platform overrides any transitive version that disagrees. This build has concrete examples:
Jackson — Spring AI wants 2.19.4, Boot pins 2.19.2
Reactor — Spring AI wants 3.7.16, Boot pins 3.7.12
Without
enforcedPlatform(), the higher Spring AI versions would silently win at resolve time, producing a classpath that Boot's integration testing never validated. The enforced BOM ensures we run on the exact dependency set that Spring Boot was tested against.
positiveIntProperty helper (lines 14–25) validates integer Gradle properties like testMaxParallelForks and integrationTestMaxParallelForks. It exists because Gradle properties are untyped strings — without validation, a typo like -PtestMaxParallelForks=abc would produce a confusing NumberFormatException deep inside a task configuration.
The Core Module
The core module's build file is intentionally minimal right now — this is an intermediate state, not the final boundary.
Today it has three dependencies and only validation/hints/filter helpers:
spring-core—StringUtilsfor validationgson— hint generationslf4j-api— logging
The production tools, BaseTool pipeline, ContrastApiClient, and shared DTOs aren't here yet — they still depend directly on local SDK factories and SDK helpers that must be decoupled first. Once ContrastApiClient is introduced (S4A, after AIML-757), tools migrate here incrementally.
+1dependencies { +2 api 'org.springframework:spring-core' +3 api 'com.google.code.gson:gson' +4 +5 implementation 'org.slf4j:slf4j-api' +6}
api vs implementation matters here. spring-core and gson use api because the core module's public types expose these in their signatures (e.g., StringUtils in validation, Gson types in hints). slf4j-api uses implementation because it's an internal detail — consumers shouldn't see it on their compile classpath.
No version numbers appear here because the root build.gradle applies Spring Boot's enforcedPlatform to all subprojects.
The Boot BOM manages spring-core, gson, and slf4j-api versions. This is how the core module gets version alignment for free without importing Boot itself.
contrast-mcp-core go far beyond what's here today.
What will move into core (after AIML-757):
- All 12 remote-eligible shared tools
BaseTool/PaginatedTool/SingleTool/CursorPaginatedToolpipelineContrastApiClientinterface- Shared DTOs and result types
ContrastSDKFactory, SDKHelper, Guava caches, and other local implementation details. S4A introduces the ContrastApiClient abstraction that breaks this coupling.
What stays permanently in
contrast-mcp-stdio-app:
McpContrastApplicationand local credential/config wiringSdkApiClientand SDK helper/cache implementations- The deprecated
get_scan_results
The App Module
The app module is where the interesting dependency management decisions live — particularly how Spring Boot's BOM and Spring AI's BOM coexist without stepping on each other.
This module also defines a custom integrationTest task that solves the credential forwarding problem that plagued the Maven build.
+7dependencies { +8 implementation project(':contrast-mcp-core') +9 // Keep Spring Boot's enforced dependency management authoritative; Spring AI supplies only its own coordinates. +10 implementation platform("org.springframework.ai:spring-ai-bom:${springAiVersion}") +11 +12 implementation 'com.google.code.gson:gson' +13 implementation "com.google.guava:guava:${guavaVersion}" +14 implementation "com.contrastsecurity:contrast-sdk-java:${contrastSdkVersion}" +15 implementation "commons-validator:commons-validator:${commonsValidatorVersion}" +16 implementation 'org.springframework.ai:spring-ai-starter-mcp-server' +17 +18 testImplementation 'org.springframework.boot:spring-boot-starter-test' +19 testImplementation "org.mockito:mockito-inline:${mockitoInlineVersion}" +20}
platform() — not enforcedPlatform(). The root already applies Spring Boot's BOM as enforced. The practical effect: when both BOMs manage the same dependency (Jackson, Reactor, etc.), Boot's version wins. Spring AI's BOM only contributes versions for coordinates it uniquely owns, like spring-ai-starter-mcp-server. If someone changed this to enforcedPlatform(), Spring AI would override Boot's pinned versions — producing an untested dependency combination.
The integration test task reproduces Maven Failsafe's dual credential forwarding in Gradle's idiom.
Maven's <systemPropertyVariables> and <environmentVariables> blocks both forwarded Contrast credentials to the forked test JVM. Gradle's Test task doesn't inherit the parent process's environment by default, so the same dual forwarding must be wired explicitly via systemProperty and environment calls.
+48test { +49 exclude '**/*IT.class' +50 exclude '**/*IntegrationTest.class' +51} +52 +53tasks.register('integrationTest', Test) { +54 description = 'Runs integration tests.' +55 group = 'verification' +56 testClassesDirs = sourceSets.test.output.classesDirs +57 classpath = sourceSets.test.runtimeClasspath +58 maxParallelForks = rootProject.ext.positiveIntProperty( +59 'integrationTestMaxParallelForks', +60 defaultIntegrationTestMaxParallelForks) +61 shouldRunAfter test +62 include '**/*IT.class' +63 include '**/*IntegrationTest.class' +64 contrastIntegrationTestCredentialEnvVars.each { credentialEnvVar -> +65 def credentialValue = System.getenv(credentialEnvVar) +66 if (credentialValue) { +67 systemProperty credentialEnvVar, credentialValue +68 environment credentialEnvVar, credentialValue +69 } +70 } +71}
Test task forks a clean JVM that doesn't inherit environment variables. systemProperty makes credentials available via System.getProperty(); environment makes them available via System.getenv(). Both paths are needed because Spring Boot's @Value resolution and @EnabledIfEnvironmentVariable test guards use different lookup mechanisms.
min(5, availableProcessors / 2). This balances speed against the Contrast API rate limits that integration tests hit. Unit tests default to 1 fork (safe for tests that share in-memory state).
The Boundary Guard
CoreBoundaryTest guards the boundary by blocking local-only dependencies — but deliberately does not block shared tool abstractions that belong in core's future.
What the test forbids (permanently local-only concerns):
- SDK factories (
ContrastSDKFactory,SDKExtensionFactory,SdkApiClient) - Local config classes, Guava caches, SARIF code
What the test does not forbid:
BaseTool,SingleTool,PaginatedTool, shared tool implementations- These aren't in core yet only because they're coupled to local SDK factories — not because they don't belong here
+29class CoreBoundaryTest { +30 +31 private static final Path CORE_MAIN = Path.of("src/main/java"); +32 private static final List<Path> REQUIRED_SUPPORT_TYPES = +33 List.of( +34 sourcePath("com/contrast/.../tool/base/FilterHelper.java"), +35 sourcePath("com/contrast/.../tool/base/LoggingKeys.java"), +36 sourcePath("com/contrast/.../tool/base/ToolParams.java"), +37 sourcePath("com/contrast/.../tool/validation/ToolValidationContext.java"), +38 sourcePath("com/contrast/.../hints/HintGenerator.java"));
BaseTool pipeline, ContrastApiClient, and shared DTOs. If any of these files get accidentally removed from core, the test fails immediately.
+50 private static final List<String> LOCAL_ONLY_TEXT = +51 List.of( +52 "com.contrast.labs.ai.mcp.contrast.config", +53 "com.contrastsecurity", +54 "org.springframework.ai", +55 "spring-ai", +56 "@Configuration", +57 "ContrastSDKFactory", +58 "SDKExtensionFactory", +59 "SdkApiClient", +60 "SDKHelper", +61 "McpContrastApplication", +62 "com.google.common.cache", +63 "get_scan_results", +64 "sarif", +65 "SARIF");
org.springframework.ai is forbidden today because core has no Spring AI dependency, but when @Tool-annotated classes migrate to core in S5+, this pattern will need to be narrowed (e.g., allow @Tool but still block MCP server internals). That's a deliberate future refinement, not an oversight. BaseTool, PaginatedTool, and shared tool names are deliberately absent from this list — they belong in core's future.
The test uses two complementary strategies: structural and textual.
The first test method checks file presence (required types exist) and absence (local-only types aren't here). The second walks every .java file and does substring matching against the prohibited patterns. Together they form a fast, zero-dependency boundary check that runs on every ./gradlew test.
An earlier iteration explicitly forbade
BaseTool, SingleTool, PaginatedTool, and tool implementation names from appearing in core. That was wrong — it would have encoded the temporary S3A minimal state as a permanent rule, blocking the very migrations that are the whole point of this split.
The corrected test asks one question: does this file reference local SDK factories, credential wiring, or SARIF code? If a class can be decoupled from those concerns, it's welcome in core.
CI & Release Pipeline
The CI workflow consolidates what were separate Maven steps into a single Gradle invocation, and adds wrapper validation as a supply chain security measure.
The release workflow replaces Maven's release:prepare plugin with explicit setVersion/printVersion tasks that read and write gradle.properties.
- name: Java CI with Maven +1name: Java CI with Gradle ... +23 - name: Validate Gradle wrapper +24 uses: gradle/actions/wrapper-validation@0b6dd653... # v4 ... - java-version: '17' +30 java-version: '21' ... - cache: maven +32 cache: gradle - - name: Build with Maven - run: ./mvnw clean package -DskipTests - - name: Run tests - run: ./mvnw test +33 - name: Run checks, tests, and build +34 run: ./gradlew spotlessCheck checkstyleMain checkstyleTest test :contrast-mcp-stdio-app:bootJar --no-daemon
gradle-wrapper.jar checksum against known Gradle distributions, preventing an attacker from slipping a malicious wrapper JAR into the repo. The single consolidated ./gradlew invocation replaces two separate Maven commands, running formatting checks, Checkstyle, tests, and the boot JAR build in one pass.
- CURRENT=$(./mvnw help:evaluate -Dexpression=project.version -q -DforceStdout) +51 CURRENT=$(./gradlew -q printVersion --no-daemon) ... - ./mvnw release:prepare \ - -DreleaseVersion=${{ steps.versions.outputs.release }} \ - -DdevelopmentVersion=${{ steps.versions.outputs.next }} \ - -Dtag=v${{ steps.versions.outputs.release }} \ - -DpushChanges=false \ - -B +69 ./gradlew setVersion -PnewVersion=${{ steps.versions.outputs.release }} --no-daemon +70 ./gradlew clean spotlessCheck check :contrast-mcp-stdio-app:integrationTest :contrast-mcp-stdio-app:bootJar --no-daemon +71 git add gradle.properties +72 git commit -m "[gradle-release] prepare release v${{ steps.versions.outputs.release }}" +73 git tag v${{ steps.versions.outputs.release }} +74 ./gradlew setVersion -PnewVersion=${{ steps.versions.outputs.next }} --no-daemon +75 git add gradle.properties +76 git commit -m "[gradle-release] prepare next development iteration"
release:prepare was a black box. The Gradle equivalent is explicit: set version, verify everything (including integration tests), commit, tag, advance to next snapshot, commit. Each step is visible and auditable.
Developer Experience
The Makefile, Docker build, and test output formatting all got updated to speak Gradle instead of Maven — but the developer-facing interface (make check-test, make verify, etc.) is unchanged.
A developer who never reads this PR will notice nothing different about their workflow.
Makefile — Maven-to-Gradle command substitution Standard Pattern Makefile (87 lines) Mechanical: mvn → ./gradlew
$(MVN) reference becomes $(GRADLE) (defaulting to ./gradlew). Target names and the make help output are unchanged. The verify target now runs test :contrast-mcp-stdio-app:integrationTest instead of mvn verify.
run_silent.sh — token-optimization helper rewritten for Gradle Standard Pattern hack/run_silent.sh Dev tooling, not production code
Tests run: 45, Failures: 0); Gradle doesn't. The rewrite parses JUnit XML reports from build/test-results via a Perl one-liner, using a marker-file timestamp to exclude stale results from prior runs.
Dockerfile — Java 21 Alpine base images Standard Pattern Dockerfile (24 lines) Pattern: multi-stage build
maven:3.9-eclipse-temurin-17 → eclipse-temurin:21-jdk-alpine (with digest pin). Runtime stage: eclipse-temurin:25-jre-alpine → eclipse-temurin:21-jre-alpine. The runtime "downgrade" from JRE 25 to 21 is intentional — the old builder compiled Java 17 bytecode that happened to run on a JRE 25 runtime; now builder and runtime are aligned at 21. Build command: mvn clean package → ./gradlew :contrast-mcp-stdio-app:bootJar. JAR path: target/ → contrast-mcp-stdio-app/build/libs/. A .dockerignore was added to exclude .git, .gradle, and build/ directories from the build context.
Documentation updates — Maven→Gradle references across 9 files Standard Pattern CLAUDE.md, RELEASING.md, INTEGRATION_TESTS.md, MCP_STANDARDS.md, SECURITY.md, WORKFLOW.md, README.md, 6 install guides Mechanical text substitution
RELEASING.md was substantially rewritten to document the new gradle-release.yml workflow. Install guides update the JAR path from target/mcp-contrast-*.jar to contrast-mcp-stdio-app/build/libs/mcp-contrast-*.jar. dependabot.yml changed package-ecosystem from maven to gradle.
Source file renames — 214 files moved to module paths Standard Pattern 214 renamed files (202 identical, 12 with Javadoc edits) Pure renames (R100 in git)
src/ were moved to either contrast-mcp-core/src/ or contrast-mcp-stdio-app/src/. 202 of 214 renames are R100 (100% identical). The remaining 12 have minor Javadoc edits updating mvn verify references to Gradle commands — no logic changes. The only package that split across both modules is tool/base/: 3 transport-neutral helpers (FilterHelper, LoggingKeys, ToolParams) went to core; the 9 SDK-coupled pipeline classes (BaseTool, PaginatedTool, SingleTool, etc.) stayed in the app module.
Deleted Maven artifacts — pom.xml, mvnw, .mvn/ Standard Pattern pom.xml (274 lines), mvnw, mvnw.cmd, .mvn/wrapper/ Replaced by Gradle equivalents
build.gradle, settings.gradle, gradle.properties, gradlew, gradlew.bat, and gradle/wrapper/*. The Gradle wrapper JAR is added (43KB binary).
What’s Next
This is PR 1 of 3 for AIML-757. Two more PRs follow to complete the Gradle migration:
- S3B — adds publishing and proves the hosted server can consume the core artifact
- S3C — aligns contributor docs and workflow tooling
Only after all three merge do later slices (S4A, S5+) begin decoupling and migrating tools.
Suggested review order: settings.gradle → gradle.properties → root build.gradle → module build files → CoreBoundaryTest. CI workflows are worth a look. Everything else is mechanical.
| Slice | Scope | What Happens to Core |
|---|---|---|
| S3A | Gradle SkeletonPR 1 of 3 | Core gets validation, hints, filter helpers — proves the build works |
| S3B | Publish & Consume | maven-publish added; hosted server consumes core via composite build; classpath boundary proven |
| S3C | Standards & Docs | README, AGENTS.md, Dependabot, Makefile aligned with Gradle split; composite-build dev documented |
| File | Purpose | Lines | Novelty |
|---|---|---|---|
| settings.gradle | Module topology + centralized repo config | +18 | Novel |
| gradle.properties | Centralized dependency versions | +13 | Novel |
| build.gradle | Shared Java 21 toolchain, Spotless, Checkstyle, Lombok, BOM | +106 | Novel |
| contrast-mcp-core/build.gradle | Core module: spring-core + gson + slf4j | +6 | Novel |
| contrast-mcp-stdio-app/build.gradle | App module: Boot, SDK, Spring AI, integration tests | +71 | Novel |
| CoreBoundaryTest.java | Boundary enforcement: required types + prohibited patterns | +114 | Novel |
| .github/workflows/build.yml | CI: wrapper validation, Java 21, consolidated Gradle step | +10/−8 | Pattern |
| .github/workflows/gradle-release.yml | Release: setVersion/printVersion, explicit prepare steps | +16/−9 | Pattern |
| hack/run_silent.sh | Token-optimization helper rewritten for Gradle test reports | +120/−80 | Pattern |
| Makefile | mvn → ./gradlew command substitution | +13/−13 | Standard |
| Dockerfile | Java 21 Alpine images, Gradle build command | +4/−4 | Standard |
| .dockerignore | Exclude .git, .gradle, build/ from Docker context | +5 | Standard |
| checkstyle.xml | Added suppressions file config property | +3 | Standard |
| CLAUDE.md + 8 doc files | Maven→Gradle command updates across all docs | +44/−44 | Standard |
| ~200 .java files | Pure renames into module src/ paths (R100) | — | Standard |
| pom.xml + Maven wrapper | Deleted (replaced by Gradle equivalents) | −701 | Standard |