PR #124 · AIML-758 · Slice 4B

Move First Shared Tool into contrast-mcp-core

The first production tool crosses the module boundary — proving that ContrastApiClient and ToolContext forwarding work end-to-end before migrating the remaining 11 tools.

Branch: AIML-758-s4b-move-first-shared-tool Stacked on: PR #123 (S4A) Files: 8 changed Lines: +231 / −51
1

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.

Slice 4 delivery sequence
PR #123 · S4A ContrastApiClient + AuthStrategy + SdkApiClient bridge PR #124 · S4B THIS PR Move list_vulnerability_types into :contrast-mcp-core S4C (next) Local parity proof + auth strategy error test
Why this matters Until this PR, :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.
2

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.

Before: everything in :contrast-mcp-stdio-app
:contrast-mcp-stdio-app tool.base BaseTool + mapHttpErrorCode(int) SingleTool<P, R> # executePipeline(Supplier<P>) LocalSdkSingleTool<P, R> @Autowired sdkFactory: ContrastSDKFactory @Autowired sdkExtensionFactory: SDKExtensionFactory + getContrastSDK() + getOrgId() + getSDKExtension() config ContrastSDKFactory SDKExtensionFactory tool.vulnerability ListVulnerabilityTypesTool @Tool listVulnerabilityTypes() # doExecute() → sdk.getRules(getOrgId()) (inherits SDK access from LocalSdkSingleTool) External: contrast-sdk-java ContrastSDK PACKAGE COLORS tool.base tool.vulnerability config client local-only (cannot share) Problem: ListVulnerabilityTypesTool inherits local SDK access — cannot be shared with the hosted server The entire chain lives in :contrast-mcp-stdio-app with hardcoded ContrastSDKFactory / SDKExtensionFactory dependencies
After: shared tool in :contrast-mcp-core, local bridge in :contrast-mcp-stdio-app
:contrast-mcp-core (shared — published JAR) client «interface» ContrastApiClient + getRules(): Rules tool.base «interface» AuthenticationStrategy + authenticate(ToolContext): AutoCloseable BaseTool @Autowired(required=false) strategy # authenticate(ToolContext): AutoCloseable SingleTool<P, R> # executePipeline(Supplier<P>, ToolContext) # abstract doExecute(P, WarningCollector) tool.vulnerability ListVulnerabilityTypesTool - contrastApiClient: ContrastApiClient @Tool listVulnerabilityTypes(ToolContext) # doExecute() → contrastApiClient.getRules() (no SDK access — pure domain call through interface) :contrast-mcp-stdio-app (local) client SdkApiClient implements ContrastApiClient tool.base LocalSdkSingleTool (11 unmigrated) config ContrastSDKFactory SDKExtensionFactory :aiml-hosted-mcp-server (Slice 5) BearerTokenApiClient implements ContrastApiClient ToolAuthenticationHelper implements AuthStrategy Shared tool accesses Contrast data exclusively through ContrastApiClient — no local SDK, no org ID, no credentials Local mode: SdkApiClient wraps SDK factories • Hosted mode (Slice 5): BearerTokenApiClient relays bearer token through CAG
Security consideration The moved tool must not leak authentication details into the shared module. The boundary gate script and 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.
3

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.

This PR (S4B)
Rename-move — tool + params from stdio-app to core
Re-baseSingleTool + ContrastApiClient injection
ToolContext forwarding — added to @Tool method
Tests rewrittenContrastApiClient mocks, 3 new tests
Boundary gates — exactly-one-tool assertion + new gate script
Publication gate — tool class added to required-classes list
Deferred
S4C: byte-equivalent local parity proof
S4C: auth strategy error-path test
Slice 5: first remote shared tool via CAG
Slice 8: remaining 11 tool family migrations
Slice 8: broad ReflectionTestUtils cleanup
PRSub-sliceDescription
#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
4

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.

File rename-moves in this PR
:contrast-mcp-stdio-app tool/vulnerability/ListVulnerabilityTypesTool.java tool/vulnerability/params/ListVulnerabilityTypesParams.java test: ListVulnerabilityTypesToolTest.java test: ListVulnerabilityTypesParamsTest.java :contrast-mcp-core tool/vulnerability/ListVulnerabilityTypesTool.java tool/vulnerability/params/ListVulnerabilityTypesParams.java test: ListVulnerabilityTypesToolTest.java test: ListVulnerabilityTypesParamsTest.java MODIFIED UNCHANGED REWRITTEN UNCHANGED

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

contrast-mcp-core/.../vulnerability/ListVulnerabilityTypesTool.java RENAMED + MODIFIED
 // 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;
Three import changes tell the whole story. 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  }
Constructor injection replaces field-level @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);
The 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();
Two lines become one, and the org ID disappears. Previously the tool called 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.
5

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.

contrast-mcp-core/.../CoreBoundaryTest.java MODIFIED
 // 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"));
This list is the gatekeeper. Any future tool added to core must be added to this list explicitly, making accidental tool migration impossible without a deliberate test update. The list is expected to grow from 1 to 12 as Slice 8 migrates each tool family.
 // Removed: @Tool annotation was in the local-only blocklist
-62          new LocalOnlyPattern(
-63              "Spring AI transport/runtime dependency", "org.springframework.ai.tool.annotation"),
Before S4B, core had no production tools, so @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  }
This test scans all Java source files in :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  }
Refactor: 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.
hack/verify-s4b-first-tool-boundary.sh NEW
+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}"
The gate script is a defense-in-depth companion to 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
Adds 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.
hack/verify-core-publication.sh MODIFIED
 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"
6

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.

contrast-mcp-core/.../ListVulnerabilityTypesToolTest.java RENAMED + REWRITTEN
 // 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);
Seven lines of reflection-based setup become two lines of constructor injection. The old approach used 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.

New test: ToolContext forwarding NEW
+  @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);
+  }
Proves the 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.
New test: method signature verification NEW
+  @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);
+  }
Reflection-based guard ensuring the @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.
New test: source-level dependency audit NEW
+  @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");
+  }
Reads the tool's own source file and asserts it contains no local SDK references. This is a belt-and-suspenders check alongside 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.
contrast-mcp-stdio-app/.../ListVulnerabilityTypesToolIT.java MODIFIED
 // All 9 call sites updated: listVulnerabilityTypes() → listVulnerabilityTypes(null)
-    var response = listVulnerabilityTypesTool.listVulnerabilityTypes();
+    var response = listVulnerabilityTypesTool.listVulnerabilityTypes(null);
Mechanical: every integration test call site passes 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.
7

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.

Suggested review order Start with the tool move itself (Chapter 4) to understand the structural change, then read the boundary enforcement (Chapter 5) to see how the invariants are maintained. Finish with the tests (Chapter 6) to verify coverage. The three new tests — ToolContext forwarding, method signature verification, and source-level dependency audit — are the ones most worth reading carefully, as they establish the pattern for all future tool migrations.
FilePurposeLinesNovelty
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