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.
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 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) |
Scope
platform() for core, enforcedPlatform() for appContrastApiClient and auth strategy hookcontrast-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.
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().
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 }
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.
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.
+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}
~/.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.
aiml-services/settings.gradle.kts conditionally includes ../mcp-contrast.
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.
+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
+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 }
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.
+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 }
+123check.dependsOn tasks.named('verifyCorePublicationMetadata')
make check-test (and every CI run) validates the publication contract. No one has to remember to run a separate task.
E2E Boundary Verification Script
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.
+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
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.
+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)
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.
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.
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.
-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
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
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
.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.
1.git 2.gradle +3.env* 4**/.gradle
+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.
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 |
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.