Context
Contrast is building a hosted MCP server that shares tool logic with the existing open-source local server — and this PR is the first time a production tool actually crosses that boundary.
The AIML-110 initiative (PRD v4) produces two MCP server modes: a local stdio server authenticated with API keys, and a hosted remote server authenticated with OAuth bearer tokens. Twelve of the thirteen existing tools must work identically in both servers. The architecture (ADR-011, Option A) places shared tool code in a :contrast-mcp-core Gradle module published as a library artifact, consumed by both the local :contrast-mcp-stdio-app and the private hosted service in aiml-services.
The PRD mandates a tracer-bullet delivery model: prove each boundary with one thin vertical slice before expanding.
Slice 3 (PRs #115 and #116, both merged) established the Gradle multi-module build and published the empty-of-tools :contrast-mcp-core artifact. Slice 4 proves the client abstraction by migrating exactly one tool. This PR is Slice 4B — the sub-slice that physically moves the tool, stacked on PR #123 (S4A) which introduced ContrastApiClient, the BaseTool auth strategy hook, and the local SdkApiClient bridge.
:contrast-mcp-core contained base classes and data models but zero production tools. The hosted server cannot serve shared tools until at least one tool physically lives in core and routes Contrast data through ContrastApiClient instead of local SDK factories. S4B is the proof that the abstractions introduced in S4A (PR #123) actually work — a tool can extend SingleTool, inject ContrastApiClient, forward ToolContext, and produce identical results whether the client is backed by local API keys or remote bearer tokens.
Approach
list_vulnerability_types was chosen deliberately as the lowest-risk first mover among the 12 remote-eligible tools.
It maps cleanly to a single ContrastApiClient.getRules() call, has no pagination, no SARIF, no SCA enrichment, no route-coverage queries, and no product-gated behavior. If the migration pattern has a flaw, this tool will expose it with minimal blast radius. The remaining 11 tools — some with high fan-out like get_vulnerability (which calls getAllLibraries and getLibraryObservations for SCA enrichment) — wait for Slice 8 after the hosted CAG tracer proves the remote path.
The move follows a three-step recipe: re-base the class hierarchy, swap the data source, and add context forwarding.
The tool previously extended LocalSdkSingleTool (a temporary stdio bridge that injects ContrastSDKFactory and SDKExtensionFactory via @Autowired). After the move, it extends the shared SingleTool directly and receives ContrastApiClient through its constructor. Its @Tool method now accepts ToolContext and forwards it to executePipeline(), enabling the auth strategy hook for remote execution.
CoreBoundaryTest both assert that the tool source contains no references to orgId, bearer, token, apiKey, serviceKey, credential, or any local SDK factory/cache type. The ContrastApiClient interface is pure domain — it declares getRules() with no auth parameters. Authentication is handled entirely by the AuthenticationStrategy hook in the base pipeline, which runs before doExecute() and cleans up via try-with-resources.
Scope
This PR does exactly one thing: move list_vulnerability_types from :contrast-mcp-stdio-app into :contrast-mcp-core and prove the move with tests and boundary gates.
It deliberately does not touch the remaining 11 remote-eligible tools, does not move get_scan_results (which stays local-only forever), and does not rewrite unrelated test families to use ContrastApiClient mocks. Those are separate concerns for S4C and Slice 8.
SingleTool + ContrastApiClient injection@Tool methodContrastApiClient mocks, 3 new testsReflectionTestUtils cleanup| PR | Sub-slice | Description |
|---|---|---|
| #115 | S3A | Gradle multi-module migration (Java 21) |
| #116 | S3B | Publish :contrast-mcp-core artifact |
| #120 | S3C | Align public CI with Gradle standards |
| #123 | S4A | ContrastApiClient + AuthStrategy hook + SdkApiClient |
| #124 | S4BThis PR | Move first shared tool into core |
| — | S4C | Local parity proof + test rewiring |
| — | S5 | First remote shared tool via CAG bearer relay |
The Tool Move
The core change: ListVulnerabilityTypesTool is renamed-moved from :contrast-mcp-stdio-app to :contrast-mcp-core and rewired from local SDK access to the shared ContrastApiClient interface.
Git tracks this as a rename with 82% similarity. The structural changes are small but architecturally significant: the tool's entire data path now goes through the transport-neutral client abstraction that both the local and hosted servers will implement.
The tool class itself changes in four precise ways. Each one is visible in the diff below: the import swap from LocalSdkSingleTool to SingleTool + ContrastApiClient, the constructor injection, the ToolContext parameter on the @Tool method, and the replacement of sdk.getRules(getOrgId()) with contrastApiClient.getRules().
// Imports: the critical swap -18import com.contrast.labs.ai.mcp.contrast.tool.base.LocalSdkSingleTool; +18import com.contrast.labs.ai.mcp.contrast.client.ContrastApiClient; +19import com.contrast.labs.ai.mcp.contrast.tool.base.SingleTool; ... +27import org.springframework.ai.chat.model.ToolContext;
LocalSdkSingleTool (the stdio bridge carrying ContrastSDKFactory and SDKExtensionFactory) is gone, replaced by the shared SingleTool base and the transport-neutral ContrastApiClient. The ToolContext import enables the auth forwarding path.
// Class declaration and dependency injection -34 extends LocalSdkSingleTool<ListVulnerabilityTypesParams, List<String>> { +36 extends SingleTool<ListVulnerabilityTypesParams, List<String>> { +37 +38 private final ContrastApiClient contrastApiClient; +39 +40 public ListVulnerabilityTypesTool(ContrastApiClient contrastApiClient) { +41 this.contrastApiClient = contrastApiClient; +42 }
@Autowired. LocalSdkSingleTool used @Autowired protected ContrastSDKFactory sdkFactory which made the dependency invisible to callers and required ReflectionTestUtils for testing. The explicit constructor makes the dependency tree visible and testable.
// @Tool method: ToolContext forwarding -53 public SingleToolResponse<List<String>> listVulnerabilityTypes() { -54 return executePipeline(ListVulnerabilityTypesParams::of); +61 public SingleToolResponse<List<String>> listVulnerabilityTypes(ToolContext toolContext) { +62 return executePipeline(ListVulnerabilityTypesParams::of, toolContext);
ToolContext parameter is how the hosted server will relay bearer tokens. Spring AI passes the ToolContext to @Tool methods automatically. The tool forwards it to executePipeline(), which calls authenticate(toolContext) on the base class. In local mode (no AuthenticationStrategy configured), this is a no-op. In hosted mode, the strategy extracts the bearer token from the context and binds it for the downstream API call.
// doExecute: data access through ContrastApiClient -60 var sdk = getContrastSDK(); -61 var rules = sdk.getRules(getOrgId()); +68 var rules = contrastApiClient.getRules();
getContrastSDK() and getOrgId() — methods inherited from LocalSdkSingleTool that reached into the local SDK factory. Now it calls contrastApiClient.getRules() which takes no parameters. The ContrastApiClient interface is pure domain: the org ID is resolved by the implementation (SdkApiClient locally, BearerTokenApiClient remotely) and never leaks into tool code.
Boundary Enforcement
Moving a tool into core is easy; keeping exactly one tool there (and keeping it clean) requires enforcement at two levels.
The PR adds a new Java test assertion in CoreBoundaryTest and a new shell gate script, and extends the existing publication gate. Together, these three mechanisms ensure that exactly one tool lives in core, it has no local dependencies, and it appears in the published JAR.
// New: expected tool list for S4B invariant +45 private static final List<Path> EXPECTED_CORE_PRODUCTION_TOOLS = +46 List.of( +47 sourcePath( +48 "com/contrast/labs/ai/mcp/contrast/tool/vulnerability/" +49 + "ListVulnerabilityTypesTool.java"));
// Removed: @Tool annotation was in the local-only blocklist -62 new LocalOnlyPattern( -63 "Spring AI transport/runtime dependency", "org.springframework.ai.tool.annotation"),
@Tool annotations were in the local-only blocklist. Now that core legitimately contains a tool class, this pattern is removed from the blocklist. The other spring-ai patterns (spring-ai dependency, org.springframework.ai.support) remain blocked because core should not depend on Spring AI runtime/transport.
// New test: exactly-one-tool invariant +107 @Test +108 void core_should_include_exactly_one_s4b_production_tool() throws IOException { +109 var productionToolSources = +110 javaSources() +111 .filter( +112 path -> sourceText(path).contains("org.springframework.ai.tool.annotation.Tool")) +113 .map(CORE_MAIN::relativize) +114 .toList(); +115 +116 assertThat(productionToolSources) +117 .as("S4B moves exactly one shared production tool into core") +118 .containsExactlyElementsOf(EXPECTED_CORE_PRODUCTION_TOOLS); +119 }
:contrast-mcp-core for the @Tool annotation import string. It then asserts the set of files found matches exactly EXPECTED_CORE_PRODUCTION_TOOLS. If someone accidentally moves a second tool into core before the list is updated, this test fails immediately.
// Extracted helper: sourceText() now used by both tests +141 private static String sourceText(Path path) { +142 try { +143 return Files.readString(path, StandardCharsets.UTF_8); +144 } catch (IOException e) { +145 throw new IllegalStateException("Failed to read " + path, e); +146 } +147 }
sourceText() was extracted from localOnlyMatches() for reuse. The new core_should_include_exactly_one_s4b_production_tool test needs to read file contents to check for the @Tool annotation import, and the existing localOnlyMatches() method already had this logic inlined. Now both use the same helper.
+1#!/usr/bin/env bash +2# Temporary S4B slice gate — remove once the first-tool move is covered by permanent CI gates. ... +87assert_file_exists "moved_tool_lives_in_core" "${CORE_TOOL}" +88assert_file_absent "moved_tool_removed_from_stdio_app" "${STDIO_TOOL}" +89assert_file_absent "get_scan_results_remains_out_of_core" "${CORE_SCAN_RESULTS}" +90assert_contains "moved_tool_uses_contrast_api_client" "${CORE_TOOL}" 'ContrastApiClient' +91assert_contains "moved_tool_calls_rules_through_client" "${CORE_TOOL}" ... +92assert_contains "tool_method_declares_tool_context" "${CORE_TOOL}" ... +93assert_contains "tool_context_forwarded_to_pipeline" "${CORE_TOOL}" ... +94assert_not_contains "moved_tool_has_no_local_sdk_factory_cache_or_raw_sarif_access" ... +95assert_not_contains "moved_tool_has_no_hidden_auth_or_org_parameters" ... ... +101assert_equals "core_contains_exactly_one_production_tool" "1" "${CORE_TOOL_COUNT}"
CoreBoundaryTest. It runs Gradle tests and compilation first, then performs 10 file-level assertions: the tool lives in core, was removed from stdio-app, get_scan_results stays out, the tool uses ContrastApiClient and ToolContext, and it has no local SDK/auth/credential references. The final assertion confirms exactly one tool class exists in core source. All log output follows the bead sanitized logging standard with <redacted> placeholders.
Publication Gate Update Standard Pattern verify-core-publication.sh (+1 line) Follows: existing required_classes pattern
ListVulnerabilityTypesTool.class to the required-classes array in hack/verify-core-publication.sh. This script verifies the published JAR contains the expected classes. The pattern was established in PR #116 (S3B) for support types like ToolValidationContext and HintGenerator. Adding the first tool class follows exactly the same pattern.
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/tool/vulnerability/ListVulnerabilityTypesTool.class" 28 "com/contrast/labs/ai/mcp/contrast/hints/HintGenerator.class"
Tests
The unit test file was rewritten to test through ContrastApiClient mocks instead of reflected SDK factory fields, and three new tests were added to verify the migration contract.
The existing six tests (happy-path, filtering, null-handling, empty-rules) were updated mechanically: sdk.getRules(eq(ORG_ID)) becomes contrastApiClient.getRules(), and every call site passes null as the ToolContext. The three new tests — ToolContext forwarding, method signature verification, and source-level dependency audit — prove the migration contract holds.
// Setup: ContrastApiClient replaces reflected SDK factory - private ContrastSDKFactory sdkFactory; - private ContrastSDK sdk; + private ContrastApiClient contrastApiClient; - sdk = mock(); - sdkFactory = mock(); - when(sdkFactory.getSDK()).thenReturn(sdk); - when(sdkFactory.getOrgId()).thenReturn(ORG_ID); - tool = new ListVulnerabilityTypesTool(); - ReflectionTestUtils.setField(tool, "sdkFactory", sdkFactory); + contrastApiClient = mock(); + tool = new ListVulnerabilityTypesTool(contrastApiClient);
ReflectionTestUtils.setField(tool, "sdkFactory", sdkFactory) because LocalSdkSingleTool used @Autowired field injection. The new approach passes ContrastApiClient through the constructor, which is the pattern all 12 tools will follow after Slice 8 migration.
Three new tests verify properties that only matter after the tool crosses the module boundary.
+ @Test + void listVulnerabilityTypes_should_forward_tool_context_to_base_pipeline() { + // ... setup with rules mock ... + var toolContext = new ToolContext(Map.of("requestId", "req-123")); + var capturedContext = new AtomicReference<ToolContext>(); + tool.setAuthenticationStrategy(context -> { + capturedContext.set(context); + return () -> {}; + }); + tool.listVulnerabilityTypes(toolContext); + assertThat(capturedContext.get()).isSameAs(toolContext); + }
ToolContext passed to the @Tool method reaches the AuthenticationStrategy. This is the contract that the hosted server relies on: when a bearer token arrives via ToolContext, it must reach the strategy so BearerTokenApiClient can relay it to CAG. The test installs a spy strategy that captures the context and asserts identity (isSameAs), not just equality.
+ @Test + void tool_method_should_declare_tool_context_for_remote_auth_forwarding() { + Method toolMethod = ListVulnerabilityTypesTool.class + .getDeclaredMethod("listVulnerabilityTypes", ToolContext.class); + assertThat(toolMethod.getAnnotation(Tool.class)).isNotNull(); + assertThat(toolMethod.getParameterTypes()).containsExactly(ToolContext.class); + }
@Tool method signature doesn't regress. If someone accidentally removes the ToolContext parameter (reverting to the pre-S4B no-arg signature), this test catches it immediately. This is the pattern all 12 migrated tools will carry.
+ @Test + void tool_source_should_use_only_contrast_api_client_for_contrast_data_access() { + var source = Files.readString(TOOL_SOURCE, StandardCharsets.UTF_8); + assertThat(source).contains("ContrastApiClient"); + assertThat(source).contains("contrastApiClient.getRules()"); + assertThat(source) + .doesNotContain("ContrastSDKFactory") + .doesNotContain("SDKExtensionFactory") + .doesNotContain("SDKHelper") + .doesNotContain("getContrastSDK") + .doesNotContain("getSDKExtension") + .doesNotContain("getOrgId") + .doesNotContain("new ContrastSDK"); + }
CoreBoundaryTest and the gate script. The difference: this test is scoped to exactly this tool file, while CoreBoundaryTest scans all core source files. Together they catch both accidental local-dependency introduction and accidental tool-level regression.
// All 9 call sites updated: listVulnerabilityTypes() → listVulnerabilityTypes(null) - var response = listVulnerabilityTypesTool.listVulnerabilityTypes(); + var response = listVulnerabilityTypesTool.listVulnerabilityTypes(null);
null ToolContext for the local no-auth path. Integration tests run against a real Contrast instance using API key auth, which doesn't use ToolContext. Passing null triggers the no-op path in BaseTool.authenticate(). These tests confirm that the tool's behavior is unchanged after the migration — the S4C sub-slice will strengthen this with byte-equivalent output comparison.
What’s Next
S4C completes the Slice 4 proof by verifying byte-equivalent local parity and adding the auth-strategy error-path test.
The S4C bead (mcp-6sjx) will confirm that the moved tool produces identical JSON output through the local stdio path as it did before the migration. It will also add executePipeline_should_return_error_when_authentication_strategy_throws to BaseToolAuthenticationStrategyTest, proving that auth failures surface as tool errors rather than being swallowed — a contract the hosted server's ToolAuthenticationHelper depends on.
After Slice 4 closes, Slice 5 proves the remote path end-to-end.
The hosted aiml-services repo will consume :contrast-mcp-core (initially via Gradle composite build, later via Artifactory), implement BearerTokenApiClient, and route list_vulnerability_types through CAG with a bearer token relay. If that tracer passes, Slice 8 migrates the remaining 11 tools family-by-family.
| File | Purpose | Lines | Novelty |
|---|---|---|---|
| core/.../ListVulnerabilityTypesTool.java | Renamed-moved + rewired to ContrastApiClient | +8 / −5 | Novel |
| core/.../ListVulnerabilityTypesParams.java | Renamed-moved, unchanged | 0 | Standard |
| core/.../CoreBoundaryTest.java | Exactly-one-tool assertion + extracted helper | +26 / −13 | Novel |
| core/.../ListVulnerabilityTypesToolTest.java | Rewritten: ContrastApiClient mocks + 3 new tests | +67 / −37 | Novel |
| core/.../ListVulnerabilityTypesParamsTest.java | Renamed-moved, unchanged | 0 | Standard |
| stdio-app/.../ListVulnerabilityTypesToolIT.java | Null ToolContext at 9 call sites | +9 / −9 | Pattern |
| hack/verify-core-publication.sh | Add tool class to required-classes list | +1 | Standard |
| hack/verify-s4b-first-tool-boundary.sh | New S4B boundary gate script (103 lines) | +103 | Novel |