PR #115 · AIML-757 · PR 1 of 3

# Migrate MCP Server to Gradle Modules

First of three PRs for AIML-757 — establishes the Gradle multi-module skeleton and Java 21 baseline before publishing (S3B) and standards alignment (S3C) follow

Branch: AIML-757-gradle-migration Base: main Files: 251 changed Lines: +2,580 / −1,160
1

## 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.

This is a tracer bullet, not the final state The core module in this PR is intentionally minimal — this is the first of three AIML-757 PRs.
What's in core today:

- Validation, hints, and filter helpers — that's it

What's not in core yet (and why):

- 12+ production tools, `BaseTool` pipeline, `ContrastApiClient`, shared DTOs
- Blocked by coupling to local SDK factories, SDK helpers, and Guava caches

This first PR proves the Gradle multi-module build works end-to-end (CI, release, Docker, tests). The next two PRs — S3B (publish & consume) and S3C (standards alignment) — complete the migration before later slices begin filling the module with production code.
AIML-757 is three PRs, not one This is PR 1 of 3 for AIML-757 (Slice 3 of the AIML-110 delivery plan). Each PR lands independently:

- 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.

Without the multi-module split, the hosted server team would need to fork or duplicate ~3,000 lines of tool infrastructure. Everything after AIML-757 — `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)

This PR — Tracer Bullet (S3A)
Gradle multi-module — two subprojects with centralized versions
Minimal core — validation, hints, filter helpers only (intermediate)
Java 21 — toolchain, CI, Docker all upgraded
Boundary enforcement — `CoreBoundaryTest` blocks local-only deps
Zero behavior change — all existing tests pass, all renames
Remaining AIML-757 PRs (S3B & S3C)
S3B: `maven-publish` → hosted server consumes core via composite build
S3B: Classpath boundary proof — no stdio classes in hosted service
S3C: Align README, AGENTS.md, Dependabot with Gradle split
S3C: Document composite-build local dev for cross-repo workflow
After AIML-757: `ContrastApiClient` (S4A), tool migration (S5+)
2

## 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.

settings.gradle NEW

    +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.

gradle.properties NEW

    +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

The release workflow reads and writes the `version` line via `printVersion`/`setVersion` tasks, replacing Maven's `release:prepare` plugin. Dependabot updates land as single-line changes to this file.
3

## 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`.

build.gradle NEW

    +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}

Plugins are declared with `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`.
build.gradle — subprojects block NEW

    +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    }

Both `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.
build.gradle — shared dependencies NEW

    +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.
Note The `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.
4

## 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` — `StringUtils` for validation
- `gson` — hint generation
- `slf4j-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.

contrast-mcp-core/build.gradle NEW

    +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.

This module will grow substantially The eventual contents of `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` / `CursorPaginatedTool` pipeline
- `ContrastApiClient` interface
- Shared DTOs and result types

Why they can't move yet: they import `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`:

- `McpContrastApplication` and local credential/config wiring
- `SdkApiClient` and SDK helper/cache implementations
- The deprecated `get_scan_results`

5

## 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.

contrast-mcp-stdio-app/build.gradle — dependencies NEW

    +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}

Line 10 is the BOM precedence decision. Spring AI's BOM is imported as a regular `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.

contrast-mcp-stdio-app/build.gradle — integrationTest task NEW

    +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}

Lines 67–68 replicate what Maven Failsafe did via XML config. Gradle's `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.
Design note The default parallelism for integration tests is `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).
6

## 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

contrast-mcp-core/src/test/java/.../CoreBoundaryTest.java NEW

    +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"));

This list represents the current minimum, not the final contract. It will grow significantly as tools are decoupled from local SDK factories and migrated into core. Today it's 5 support types; the final state includes 12+ tools, the `BaseTool` pipeline, `ContrastApiClient`, and shared DTOs. If any of these files get accidentally removed from core, the test fails immediately.
CoreBoundaryTest.java — prohibited patterns NEW

    +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");

Most of these are permanently forbidden patterns — local credential wiring, config classes, Guava caches, SARIF code, and direct Contrast SDK imports must never enter core. One exception to watch: `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`.

Why the test is shaped this way The boundary test blocks local-only concerns, not shared tool categories.
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.
7

## 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`.

.github/workflows/build.yml MODIFIED

    - 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

The wrapper validation step is a supply chain security guard. It verifies the `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.
gradle-release.yml — version management (excerpt) RENAMED

    -           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"

The release prepare step now runs the full verification suite at the release version before tagging. Maven's `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.
8

## 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
Every `$(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
This script reduces token consumption when AI agents run tests by suppressing verbose Gradle output and emitting only a compact summary (test count, failures, elapsed time). Maven printed test counts inline (`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
Builder stage: `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
All Maven command references updated to Gradle equivalents. `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)
All source files under `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
The Maven POM, wrapper scripts, and wrapper properties are deleted. Their Gradle equivalents are `build.gradle`, `settings.gradle`, `gradle.properties`, `gradlew`, `gradlew.bat`, and `gradle/wrapper/*`. The Gradle wrapper JAR is added (43KB binary).
9

## 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 Skeleton PR 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

After AIML-757 completes Once all three S3 PRs have merged, later slices begin to fill the core module with production code:

- S4A (AIML-758) — introduces `ContrastApiClient` and the auth strategy hook, decoupling tools from local SDK factories
- S5+ (AIML-759) — migrates 12 shared tools, the `BaseTool` pipeline, and DTOs into core. App shrinks to: local config, `SdkApiClient`, `get_scan_results`

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