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.

Core Publication & Consumption Flow mcp-contrast (this repo) :contrast-mcp-core DTOs, validation, hints :stdio-app Local server, SDK wiring publishToMavenLocal Verification Layer verifyCorePublicationMetadata (Gradle) + verify-core-publication.sh (E2E) aiml-services (companion repo) aiml-hosted-mcp-server Remote MCP server composite build substitution verify-hosted-mcp-core-classpath.sh
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.
BeadScopeDescription
S3A Gradle Migration Convert Maven → Gradle multi-module, Java 21 compile target, module skeleton (PR #115)
S3B Core PublicationThis 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 scopingplatform() 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.

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