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
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
After: shared tool in :contrast-mcp-core, local bridge in :contrast-mcp-stdio-app
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-base — `SingleTool` + `ContrastApiClient` injection
ToolContext forwarding — added to `@Tool` method
Tests rewritten — `ContrastApiClient` 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

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 | S4B This 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

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.

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