PR #116 · AIML-757

# Publish contrast-mcp-core Artifact

Make the core module consumable by the hosted MCP server — with two layers of verification proving the published artifact carries the right dependencies and excludes local-only classes.

Branch: AIML-757-s3b-publish-core Base: AIML-757-gradle-migration (PR #115) Files: 7 changed Lines: +217 / −12
1

## Context

Contrast is building a hosted (remote) MCP server alongside the existing public stdio-based one, and the two servers need to share ~6,000 lines of tool logic without duplicating code. ADR-011 evaluated six options for code sharing and chose Option A: two repos, shared core library on Artifactory. The alternative most seriously considered — a single-codebase dual-mode model (Option F) — was rejected because it would create a "snowflake service" that drifts from Contrast's internal platform conventions (ArgoCD GitOps, `contrast.aiml-service-conventions` Gradle plugin, `dp-observation-api-client`, and the upcoming Spring Boot 4 upgrade). Option A keeps the hosted server inside the ecosystem it deploys into.

This PR is the second of three steps in AIML-757 (Slice 3) to make that code-sharing strategy real. PR #115 (S3A) created the Gradle multi-module skeleton with `:contrast-mcp-core` and `:contrast-mcp-stdio-app`. But the core module couldn't yet be published or consumed. This PR (S3B) adds the publication configuration and verification to prove the boundary holds. The core is intentionally minimal right now — just DTOs, validation, and hints — because the production tools and base classes (`BaseTool`, `SingleTool`, `PaginatedTool`) still have hard dependencies on local SDK factories. They'll migrate iteratively: Slice 4 introduces `ContrastApiClient` to decouple tools from local SDK wiring, Slice 5 ports the first shared tool, and subsequent slices continue until all 12 remote-eligible tools live in core.

Companion PR This PR must be merged together with aiml-services#203, which wires up the hosted server's consumption of `contrast-mcp-core` via composite build. That PR adds the `includeBuild` in `settings.gradle.kts`, the hosted classpath boundary verification task, and the unit test proving forbidden classes are absent. Neither PR is complete without the other — this one publishes, that one consumes.

Bead | Scope | Description
--- | --- | ---
S3A | Gradle Migration | Convert Maven → Gradle multi-module, Java 21 compile target, module skeleton (PR #115)
S3B | Core Publication This PR | Publish `contrast-mcp-core`, verify POM/metadata, prove classpath boundary
S3C | Workflow Alignment | Update CI, Makefile, contributor docs, Dependabot for Gradle (not started)

2

## Scope

This PR (S3B)
maven-publish — POM metadata, Artifactory repository config
BOM scoping — `platform()` for core, `enforcedPlatform()` for app
Metadata verification — Gradle task validates POM + module JSON
E2E boundary script — temporary tracer bullet for JAR content validation
Future Work
S3C: CI/Makefile/contributor doc alignment
S4: Extract `ContrastApiClient` and auth strategy hook
S5: Port first shared tool to hosted server via core
Artifactory publishing approval (currently local-only)
Why this matters This PR is the gate between "the modules exist" and "they can actually be consumed." Downstream slices (S4 and S5) depend on the hosted server being able to compile against `contrast-mcp-core`. Without this PR's publication config and boundary verification, that consumption path is unproven and could silently leak local-only classes into the hosted classpath.
3

## Spring Boot BOM Split

Published libraries must not dictate their consumers' dependency versions — but deployable apps want exact reproducibility. Before this change, every subproject used `enforcedPlatform()` for the Spring Boot BOM, which forces all transitive dependency versions to match the BOM. That's ideal for the stdio app (you want the exact tested dependency set), but if the published core library carries `enforcedPlatform()`, it silently overrides the hosted server's own Spring Boot version management. Gradle doesn't reject the conflict — it just picks the enforced version. So if this repo pins Spring Boot 3.5.7 but `aiml-services` upgrades to 3.6.x via its convention plugin, the core library would drag shared transitive versions back to 3.5.7 while the hosted server's own dependencies stay at 3.6.x. The result: a Frankenstein dependency set with no warning. The fix: core uses `platform()` (recommendation, not enforcement), everything else keeps `enforcedPlatform()`.

build.gradle MODIFIED

     87    dependencies {
    -88        implementation enforcedPlatform("org.springframework.boot:spring-boot-dependencies:${springBootVersion}")
    +88        if (project.name == 'contrast-mcp-core') {
    +89            // Published libraries should not force Spring-managed dependency versions on consumers.
    +90            // The tested baseline is the Spring Boot BOM version pinned by springBootVersion.
    +91            implementation platform("org.springframework.boot:spring-boot-dependencies:${springBootVersion}")
    +92        } else {
    +93            // Deployable modules run with the exact Spring Boot dependency set tested by this repo.
    +94            implementation enforcedPlatform("org.springframework.boot:spring-boot-dependencies:${springBootVersion}")
    +95        }

The `project.name` conditional is the simplest way to express this without restructuring the build. If more published modules appear later, this could become a set check, but for now it's just core.
4

## Publishing Configuration

The core module can now publish to Maven Local (for composite builds) and Contrast Artifactory (for engineers without a local checkout). The `maven-publish` plugin generates a POM with proper metadata — name, description, Apache 2.0 license, and the repository URL. The Artifactory repository selector automatically targets snapshot vs. release based on the version suffix.

contrast-mcp-core/build.gradle MODIFIED

    +6plugins {
    +7    id 'maven-publish'
    +8}
    +
     10dependencies {
     11    api 'org.springframework:spring-core'
     12    api 'com.google.code.gson:gson'
     13
     14    implementation 'org.slf4j:slf4j-api'
     15}
    +
    +17publishing {
    +18    publications {
    +19        mavenJava(MavenPublication) {
    +20            from components.java
    +21            pom {
    +22                name = 'contrast-mcp-core'
    +23                description = 'Transport-neutral shared support library for Contrast MCP servers.'
    +24                url = 'https://github.com/Contrast-Security-Inc/mcp-contrast'
    +25                licenses {
    +26                    license {
    +27                        name = 'Apache License, Version 2.0'
    +28                        url = 'https://www.apache.org/licenses/LICENSE-2.0'
    +29                    }
    +30                }
    +31            }
    +32        }
    +33    }
    +34    repositories {
    +35        maven {
    +36            name = version.toString().endsWith('-SNAPSHOT')
    +37                ? 'contrastInternalSnapshot'
    +38                : 'contrastInternalRelease'
    +39            url = uri(version.toString().endsWith('-SNAPSHOT')
    +40                ? 'https://pkg.contrastsecurity.com/artifactory/maven-snapshot-local'
    +41                : 'https://pkg.contrastsecurity.com/artifactory/maven-release-local')
    +42            credentials {
    +43                username = findProperty('contrastArtifactoryUser') ?: ''
    +44                password = findProperty('contrastArtifactoryPassword') ?: ''
    +45            }
    +46        }
    +47    }
    +48}

Credentials resolve from `~/.gradle/gradle.properties` via `findProperty`. The empty-string fallback means local `publishToMavenLocal` works without any Artifactory config. Engineers who need to push to Artifactory configure `contrastArtifactoryUser` and `contrastArtifactoryPassword` in their user-level Gradle properties. The snapshot/release selector is evaluated at configuration time from the project version.
Note Artifactory publishing is not yet approved for this public repo. The configuration is in place so the path is ready when approval comes, but the primary consumption path today is local composite builds where `aiml-services/settings.gradle.kts` conditionally includes `../mcp-contrast`.
5

## Publication Metadata Verification

The `verifyCorePublicationMetadata` task is the build-time guardian of the published artifact's dependency surface. It generates the POM and Gradle module metadata, then parses both to assert two things: (1) the expected dependencies are present with correct Maven scopes, and (2) forbidden dependencies never leak into the published metadata. This runs as part of `check`, so every `make check-test` validates the publication contract.

contrast-mcp-core/build.gradle — verifyCorePublicationMetadata NEW

    +50tasks.register('verifyCorePublicationMetadata') {
    +51    description = 'Validates published contrast-mcp-core POM and Gradle module metadata.'
    +52    group = 'verification'
    +53    dependsOn 'generateMetadataFileForMavenJavaPublication'
    +54    dependsOn 'generatePomFileForMavenJavaPublication'
    +55
    +56    doLast {
    +57        def pomFile = layout.buildDirectory.file('publications/mavenJava/pom-default.xml').get().asFile
    +58        def moduleFile = layout.buildDirectory.file('publications/mavenJava/module.json').get().asFile

The task depends on Gradle's own POM and module generation tasks. By reading the generated files rather than the Gradle model directly, we verify the exact artifacts that would be published — catching issues like Gradle silently upgrading a scope or including a transitive.
contrast-mcp-core/build.gradle — POM expected dependencies NEW

    +76        [
    +77            'org.springframework:spring-core:compile',
    +78            'com.google.code.gson:gson:compile',
    +79            'org.slf4j:slf4j-api:runtime'
    +80        ].each { expectedDependency ->
    +81            if (!dependencies.contains(expectedDependency)) {
    +82                throw new GradleException(
    +83                    "Missing expected POM dependency ${expectedDependency}; found ${dependencies}")
    +84            }
    +85        }

Note the scope assertions: `spring-core` and `gson` are `compile` (because they're declared as `api`), while `slf4j-api` is `runtime` (because it's `implementation`). This catches the Gradle-to-Maven scope mapping bug where `implementation` dependencies sometimes appear as `compile` in the POM, which would force them onto consumers' compile classpaths.
contrast-mcp-core/build.gradle — forbidden dependencies NEW

    +87        [
    +88            'org.projectlombok:lombok',
    +89            'com.contrast.labs.ai.mcp:contrast-mcp-stdio-app',
    +90            'com.contrastsecurity:contrast-sdk-java',
    +91            'org.springframework.ai:spring-ai-starter-mcp-server'
    +92        ].each { forbiddenDependency ->
    +93            if (dependencies.any { it.startsWith("${forbiddenDependency}:") }) {
    +94                throw new GradleException("POM must not publish ${forbiddenDependency}")
    +95            }
    +96        }

Four categories of dependency are forbidden in the published core today: (1) Lombok — compile-only annotation processor, must not appear in published metadata. (2) The stdio app module — core must not depend on its consumer. (3) The Contrast SDK — the core doesn't directly use the SDK yet; whether it becomes a core dependency in Slice 4 depends on whether the SDK can support OAuth tokens for the hosted auth path (still being discussed with the security team). (4) The Spring AI MCP server starter — that's a transport concern belonging to the stdio app. This forbidden list will evolve as the core boundary expands.
contrast-mcp-core/build.gradle — wired into check NEW

    +123check.dependsOn tasks.named('verifyCorePublicationMetadata')

This single line is what makes the verification automatic. Every `make check-test` (and every CI run) validates the publication contract. No one has to remember to run a separate task.
6

## E2E Boundary Verification Script

Tracer bullet This script is a temporary tracer bullet, not permanent infrastructure. It was written to validate assumptions and mitigate unknowns early in the Gradle migration — specifically, to prove the publication pipeline works end-to-end before downstream slices depend on it. The `verifyCorePublicationMetadata` Gradle task (Chapter 5) is the durable verification layer; this script will be removed once the publication path is proven in CI. Don't invest review time in its error handling or logging conventions.

The Gradle task validates metadata; the E2E script validates the actual JAR contents. They cover complementary failure modes: the Gradle task catches wrong dependency scopes or leaked transitives in the POM/module.json. The shell script catches the case where the JAR itself contains classes that shouldn't be there — for example, if someone accidentally puts a local-only source file under `contrast-mcp-core/src`.

hack/verify-core-publication.sh NEW

    +1#!/usr/bin/env bash
    +2set -euo pipefail
    +3
    +4ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
    +5
    +6log() {
    +7  printf '[core-publication] %s\n' "$*"
    +8}
    +9
    +10cd "${ROOT_DIR}"
    +11
    +12version="$(./gradlew -q printVersion)"
    +13jar_path="${ROOT_DIR}/contrast-mcp-core/build/libs/contrast-mcp-core-${version}.jar"
    +14
    +15log "gate=S3B-CORE-BOUNDARY-SMOKE step=publishToMavenLocal version=${version}"
    +16./gradlew --no-daemon :contrast-mcp-core:publishToMavenLocal :contrast-mcp-core:verifyCorePublicationMetadata

The script runs both `publishToMavenLocal` and the metadata verification in one Gradle invocation. After this step, the JAR exists on disk and the POM/module.json have been validated. The structured log format (`gate=`, `step=`, `assertion=`) follows the project's E2E script convention for machine-parseable diagnostics.
hack/verify-core-publication.sh — required & forbidden classes NEW

    +24required_classes=(
    +25  "com/contrast/labs/ai/mcp/contrast/tool/validation/ToolValidationContext.class"
    +26  "com/contrast/labs/ai/mcp/contrast/tool/base/ToolParams.class"
    +27  "com/contrast/labs/ai/mcp/contrast/hints/HintGenerator.class"
    +28)
    +29
    +39forbidden_classes=(
    +40  "com/contrast/labs/ai/mcp/contrast/McpContrastApplication.class"
    +41  "com/contrast/labs/ai/mcp/contrast/config/ContrastProperties.class"
    +42  "com/contrast/labs/ai/mcp/contrast/config/ContrastSDKFactory.class"
    +43  "com/contrast/labs/ai/mcp/contrast/config/SDKExtensionFactory.class"
    +44  "com/contrast/labs/ai/mcp/contrast/client/SdkApiClient.class"
    +45  "com/contrast/labs/ai/mcp/contrast/sdkextension/SDKHelper.class"
    +46  "com/contrast/labs/ai/mcp/contrast/sdkextension/SDKExtension.class"
    +47  "com/contrast/labs/ai/mcp/contrast/tool/sast/GetSastResultsTool.class"
    +48)

These two lists encode the boundary contract from the bead's acceptance criteria. Required classes (`ToolValidationContext`, `ToolParams`, `HintGenerator`) are the transport-neutral support types that the hosted server needs. Forbidden classes are local-only runtime concerns: the Spring Boot application entry point, credential/SDK factory wiring, extension caches, and the local-only SAST tool.
Security consideration The forbidden class list includes `ContrastProperties` and `SdkApiClient`. These classes handle Contrast API credentials. Their presence in the hosted server's classpath would mean the local credential-handling code could accidentally be instantiated in a shared cloud environment — a security boundary violation. The E2E script enforces this at the class level, not just at the dependency level.
7

## CI Hardening & Housekeeping

The release workflow's Docker content trust secrets were leaked as workflow-level environment variables, visible to every step in the job. This was caught during code review of the S3A Gradle migration work and fixed opportunistically here rather than as a separate PR. The change scopes secrets to only the three steps that actually use them: key loading, trust loading, and the final push-and-sign step. It also quotes the `docker push` argument to prevent word splitting on tags with spaces (unlikely but defensive), and adds `.env*` to `.dockerignore` so credential files can't accidentally end up in the Docker build context.

.github/workflows/gradle-release.yml MODIFIED

    -6env:
    -7  DOCKER_CONTENT_TRUST_REPOSITORY_PASSPHRASE: "${{secrets.DIGICERT_PKEY_PASSPHRASE}}"
    -8  DOCKER_CONTENT_TRUST_KEY_FILENAME: "${{secrets.DIGICERT_PKEY_FILENAME}}"
    -9  DOCKER_CONTENT_TRUST_PKEY_ROLE: "${{secrets.DIGICERT_PKEY_ROLE}}"
    -10  DOCKER_CONTENT_TRUST: 1

These four workflow-level env vars made Docker content trust secrets available to every step in the job — including the Java build, Git config, and GitHub Release steps that don't need them. The scoped version exposes each secret only where it's consumed.
.github/workflows/gradle-release.yml — Push and sign MODIFIED

     151      - name: Push and sign Docker image with DCT
    +152        env:
    +153          DOCKER_CONTENT_TRUST: 1
    +154          DOCKER_CONTENT_TRUST_REPOSITORY_PASSPHRASE: ${{ secrets.DIGICERT_PKEY_PASSPHRASE }}
     155        run: |
     156          echo "${{ steps.meta.outputs.tags }}" | while read -r tag; do
     157            echo "Pushing and signing $tag"
    -158            docker push $tag
    +158            docker push "$tag"
     159          done

Only two secrets are needed at push time: `DOCKER_CONTENT_TRUST=1` and the repository passphrase. The key filename and role were already consumed in the earlier key-loading steps and aren't needed here.
Documentation & Config Updates Standard Pattern .dockerignore, CLAUDE.md, README.md
Three files with minor documentation and config updates. `.dockerignore` adds `.env*` to prevent credential files from entering the Docker build context. `CLAUDE.md` adds a project status note and clarifies integration test skip behavior. `README.md` documents the Spring Boot dependency baseline for downstream consumers.
.dockerignore MODIFIED

     1.git
     2.gradle
    +3.env*
     4**/.gradle

README.md — Build Compatibility section MODIFIED

    +176### Build Compatibility
    +177
    +178This repository builds and tests against Java 21 and the Spring Boot dependency-management
    +179version pinned by `springBootVersion` in `gradle.properties` (currently Spring Boot 3.5.7).
    +180
    +181The `contrast-mcp-core` module imports the Spring Boot BOM as a regular Gradle `platform()`
    +182so published-library consumers can keep control of their dependency graph. The deployable
    +183`contrast-mcp-stdio-app` module uses `enforcedPlatform()` so the shipped application runs
    +184with the exact Spring-managed dependency set tested by this repository.

8

## File Summary

Suggested review order: start with the BOM split in `build.gradle`, then the core publishing + verification in `contrast-mcp-core/build.gradle`, then the E2E script, then the CI hardening. The documentation files are safe to skim last.

File | Purpose | Lines | Novelty
--- | --- | --- | ---
contrast-mcp-core/build.gradle | maven-publish config + `verifyCorePublicationMetadata` task | +121 | Novel
hack/verify-core-publication.sh | E2E boundary verification: required/forbidden class validation | +58 | Novel
build.gradle | Split BOM binding: `platform()` for core, `enforcedPlatform()` for app | +9 / −1 | Pattern
.github/workflows/gradle-release.yml | Scope DCT secrets to consuming steps; quote `docker push` | +17 / −8 | Pattern
README.md | Spring Boot dependency baseline for consumers | +10 | Standard
CLAUDE.md | Project status note, integration test clarification | +4 / −1 | Standard
.dockerignore | Exclude `.env*` from Docker build context | +1 | Standard

Future work The core module will grow iteratively as SDK coupling is broken slice by slice. S3C aligns CI, Makefile, and contributor docs. S4 introduces `ContrastApiClient` as the abstraction that decouples tools from local SDK wiring (the exact shape depends on whether the Contrast SDK can support OAuth tokens — still being discussed with the security team). S5 ports the first shared vulnerability tool. Subsequent slices continue until all 12 remote-eligible tools, base classes, models, SDK extensions, and hints live in core — the ~6,000-line shared surface described in ADR-011.