PR #123 · AIML-758 · Slice 4A

# Shared Client Boundary & Auth Strategy Hook

The abstraction that makes shared tools possible — a transport-neutral API contract and per-request authentication hook so the same tool code runs in both the local stdio and hosted remote servers.

Branch: AIML-758-s4a-client-auth-hook Base: main S4A files: 17 changed (+60 renames) S4A lines: +711 / −112
1

## Context

Imagine building a second MCP server that serves the same vulnerability tools but authenticates completely differently — that’s the problem this PR solves. The Contrast MCP project currently ships a local stdio server: you give it API keys at startup, and every tool call uses those credentials for the duration of the process. The upcoming hosted remote server will live behind an API gateway where each request carries an OAuth bearer token from a different user. The tools need to work identically in both worlds.

Without a shared abstraction, every tool would need to be duplicated. Today, tools call `sdkFactory.getSDK()` and `sdkExtensionFactory.getSDKExtension()` directly — classes that only exist in the local stdio app. They resolve credentials from environment variables, cache SDK instances, and hardcode the organization ID. None of that can exist in the hosted server, which must resolve auth per-request from the gateway token.

This PR introduces the left-to-right transformation shown above. Tools stop calling local factories directly. Instead, they go through `ContrastApiClient` — an interface that speaks only in domain concepts (get vulnerability rules, search applications) with no credential parameters. Each server provides its own implementation. An `AuthenticationStrategy` hook runs before every tool execution to bind per-request auth context.

2

## Approach

Three design decisions shaped this PR: interface-first, strategy pattern for auth, and bridge classes for incremental migration.

Interface-first. `ContrastApiClient` is a pure Java interface — no default methods, no static methods, no abstract class with half an implementation. Each method signature uses domain-level parameters only: application IDs, filter bodies, page offsets. The organization ID, bearer tokens, API keys, and credential objects are resolved inside each server’s implementation. This was validated with a reflection-based contract test that scans the source for forbidden auth tokens.

Strategy pattern for auth. Rather than modifying every tool to accept auth context, the auth hook lives in the base pipeline. `BaseTool.authenticate(ToolContext)` is called inside `executePipeline()` before `doExecute()`. When no strategy is injected (local stdio mode), it returns a no-op `AutoCloseable`. The hosted server will inject its strategy bean to bind request-scoped OAuth context. Tools never see the difference.

Bridge classes for migration. Rewriting all 13 tools in one shot would be risky and hard to review. Instead, `LocalSdkPaginatedTool` and `LocalSdkSingleTool` sit between the core base classes and the existing tools, restoring the SDK accessors that tools currently depend on. Each tool changes one line — its `extends` clause. Future slices (S4B, S4C) migrate tools off the bridge one at a time.

Why not rewrite everything at once? The bridge approach lets this PR prove the abstraction is sound without touching 13 tool implementations simultaneously. Each future migration is a small, independently testable change. If a tool migration breaks something, the blast radius is one tool, not the entire server.
3

## Scope

This PR is Slice 4A of a three-part sequence that proves the shared client pattern before migrating all tools.

This PR (S4A)
ContrastApiClient — shared domain API interface
AuthenticationStrategy — per-request auth hook in BaseTool
SdkApiClient — local implementation wrapping SDK factories
Bridge classes — temporary shim for un-migrated tools
Core dependencies — Contrast SDK + Spring AI model in core
Boundary tests — contract, auth strategy, core boundary
Future Work
S4B — migrate one tool (`list_vulnerability_types`) to ContrastApiClient
S4C — prove local parity, replace reflection-based tests with mocks
S5 — consume core in hosted server, adapt `get_user_info`
S8 — migrate remaining 11 shared tools

Bead | Slice | Description
--- | --- | ---
mcp-mj2g | S4A This PR | Introduce ContrastApiClient, auth strategy hook, SdkApiClient bridge
mcp-gto0 | S4B | Move `list_vulnerability_types` to core via ContrastApiClient
mcp-6sjx | S4C | Prove byte-equivalent local parity, replace reflection-based tests

Note `get_scan_results` is deliberately excluded from `ContrastApiClient`. It uses raw SARIF data that the hosted server will handle differently — it stays local-only. The boundary test enforces this.
4

## The Shared API Contract

The centerpiece of this PR is a 20-method interface that defines every domain operation both servers need — with zero auth parameters. Every method signature takes only domain inputs (application IDs, filter bodies, pagination offsets) and returns domain outputs (traces, rules, libraries). The organization ID, bearer token, API key, and any other credential detail are the implementation’s problem, not the caller’s.

contrast-mcp-core/.../client/ContrastApiClient.java NEW

    +44public interface ContrastApiClient {
    +45
    +46  ApplicationsResponse searchApplications(
    +47      String name, String[] tags, List<AppMetadataFilter> metadataFilters, int limit, int offset)
    +48      throws Exception;
    +49
    +50  List<AppMetadataField> getApplicationMetadataFields() throws Exception;
    +51
    +52  MetadataFilterResponse getSessionMetadata(String appId) throws Exception;
    +53
    +54  SessionMetadataResponse getLatestSessionMetadata(String appId) throws Exception;
    +55
    +56  Traces searchVulnerabilities(
    +57      TraceFilterBody filters, int limit, int offset, EnumSet<TraceExpandValue> expand)
    +58      throws Exception;
    +59
    +60  // ... 12 more domain methods: getVulnerability, getRules, getLibraryPage,
    +61  //     searchAttacks, getProtectRules, getRouteCoverage, getScanProject...
    +62
    +77  Rules getRules() throws Exception;
    +78
    +96  ScanProject getScanProject(String projectName) throws Exception;
    +97}

Notice what’s absent: no `orgId`, no `bearerToken`, no `apiKey`. Every method uses domain parameters only. The contract test (`ContrastApiClientContractTest`) scans the source file for forbidden auth tokens to enforce this boundary permanently. The interface is deliberately plain — no default methods, no static methods — so implementations can’t hide behavior.
Security consideration The credential-free interface is a security boundary, not just a design preference. It prevents accidental credential leakage across module boundaries. When the hosted server consumes `contrast-mcp-core`, it will never receive local API keys or org IDs from core classes — the interface physically cannot carry them.
5

## The Auth Strategy Hook

The hosted server needs to bind OAuth context before every tool execution and clean it up afterward — but the local server needs to do nothing at all. The `AuthenticationStrategy` functional interface and the refactored `BaseTool` solve this with a single injection point.

contrast-mcp-core/.../tool/base/AuthenticationStrategy.java NEW

    +22/** Binds transport-specific authentication context for one tool execution. */
    +23@FunctionalInterface
    +24public interface AuthenticationStrategy {
    +25
    +26  AutoCloseable authenticate(@Nullable ToolContext toolContext) throws Exception;
    +27}

Returns `AutoCloseable` so the auth scope is automatically cleaned up via try-with-resources. The hosted server will use this to bind a request-scoped OAuth context (e.g., setting a `ThreadLocal` with the bearer token) and clear it when the tool completes. The `@Nullable ToolContext` allows local mode and tests to pass `null`.
contrast-mcp-core/.../tool/base/BaseTool.java NEW

    +26public abstract class BaseTool {
    +27
    +28  private static final AutoCloseable NOOP_AUTHENTICATION_SCOPE = () -> {};
    +29
    +30  private AuthenticationStrategy authenticationStrategy;
    +31
    +32  @Autowired(required = false)
    +33  public final void setAuthenticationStrategy(AuthenticationStrategy authenticationStrategy) {
    +34    this.authenticationStrategy = authenticationStrategy;
    +35  }
    +36
    +42  public final boolean isAuthenticationStrategyConfigured() {
    +43    return authenticationStrategy != null;
    +44  }
    +45
    +46  protected final AutoCloseable authenticate(@Nullable ToolContext toolContext) throws Exception {
    +47    if (authenticationStrategy == null) {
    +48      return NOOP_AUTHENTICATION_SCOPE;
    +49    }
    +50    var scope = authenticationStrategy.authenticate(toolContext);
    +51    return scope != null ? scope : NOOP_AUTHENTICATION_SCOPE;
    +52  }

Three deliberate design choices here. First, `@Autowired(required = false)` means the local stdio server — which has no `AuthenticationStrategy` bean — starts up without error. Second, the `authenticate()` method has a double null guard: it handles both “no strategy configured” and “strategy returned null scope” by falling back to the no-op. Third, both the setter and the boolean accessor are `final` — subclasses can’t override the auth lifecycle, only the tool logic in `doExecute()`.

What was removed from `BaseTool` is as important as what was added. The previous version had `ContrastSDKFactory` and `SDKExtensionFactory` fields directly on the base class. Those local-only types are gone from core entirely — they live only in the stdio-app bridge classes now. This is what makes `BaseTool` sharable across servers.

6

## Pipeline Integration

Both `PaginatedTool` and `SingleTool` now accept an optional `ToolContext` and wrap `doExecute()` in a try-with-resources block that calls `authenticate()`. The change is surgical: a new overload of `executePipeline()` threads the context through, and the existing zero-arg overload passes `null` for backwards compatibility.

contrast-mcp-core/.../tool/base/PaginatedTool.java MOVED + MODIFIED

     73  protected final PaginatedToolResponse<R> executePipeline(
     74      Integer page, Integer pageSize, Supplier<P> paramsSupplier) {
    +75    return executePipeline(page, pageSize, paramsSupplier, null);
    +76  }
    +77
    +78  protected final PaginatedToolResponse<R> executePipeline(
    +79      Integer page, Integer pageSize,
    +80      Supplier<P> paramsSupplier, @Nullable ToolContext toolContext) {
          // ... validation steps unchanged ...

          // 5. Execute with auth scope
    +106    try (var ignored = authenticate(toolContext)) {
     107      var result = doExecute(pagination, params, collector);
            // ... success response building ...
    +     } catch (UnauthorizedException e) { // ... exception handlers ...

The try-with-resources ensures the auth scope is always closed, even if `doExecute()` throws. In local mode, `authenticate(null)` returns the static `NOOP_AUTHENTICATION_SCOPE` lambda — zero allocation, zero side effects. In hosted mode, the scope’s `close()` will clear the request-scoped auth context. The same pattern appears in `SingleTool.executePipeline()`.
7

## Local Implementation

`SdkApiClient` is the local server’s implementation of `ContrastApiClient` — it wraps the existing SDK factories and resolves the organization ID from local config. Every method delegates to `contrastSDKFactory.getSDK()` or `sdkExtensionFactory.getSDKExtension()`, injecting the org ID from `localOrganization()`. No new behavior, just adaptation.

contrast-mcp-stdio-app/.../client/SdkApiClient.java NEW

    +49@Service
    +50@RequiredArgsConstructor
    +51public class SdkApiClient implements ContrastApiClient {
    +52
    +53  private final ContrastSDKFactory contrastSDKFactory;
    +54  private final SDKExtensionFactory sdkExtensionFactory;
    +55
    +56  @Override
    +57  public ApplicationsResponse searchApplications(
    +58      String name, String[] tags, List<AppMetadataFilter> metadataFilters, int limit, int offset)
    +59      throws Exception {
    +60    return sdkExtensionFactory.getSDKExtension()
    +61        .getApplicationsFiltered(localOrganization(), name, tags, metadataFilters, limit, offset);
    +62  }
    +63
    +   // ... 19 more methods, each following the same pattern:
    +   //   delegate to SDK/SDKExtension, inject localOrganization()
    +
    +130  @Override
    +131  public Rules getRules() throws Exception {
    +132    return contrastSDKFactory.getSDK().getRules(localOrganization());
    +133  }
    +134
    +186  private String localOrganization() {
    +187    return contrastSDKFactory.getOrgId();
    +188  }
    +189}

This class is pure plumbing — every method is a one-to-three-line delegation. The `localOrganization()` helper centralizes the org ID resolution that was previously scattered across every tool’s `doExecute()`. The hosted server will have its own `RemoteApiClient` that resolves the org from the gateway token instead.
8

## The Bridge Pattern

Thirteen production tools need to keep working while the core base classes no longer provide SDK accessors. The bridge classes `LocalSdkPaginatedTool` and `LocalSdkSingleTool` restore the `sdkFactory` / `sdkExtensionFactory` fields that `BaseTool` used to carry, so existing tools change only their `extends` clause.

contrast-mcp-stdio-app/.../tool/base/LocalSdkPaginatedTool.java NEW

    +25/** Temporary stdio-only bridge for tools that have not migrated to ContrastApiClient yet. */
    +26public abstract class LocalSdkPaginatedTool<P extends ToolParams, R> extends PaginatedTool<P, R> {
    +27
    +28  @Autowired protected ContrastSDKFactory sdkFactory;
    +29  @Autowired protected SDKExtensionFactory sdkExtensionFactory;
    +30
    +31  protected ContrastSDK getContrastSDK()   { return sdkFactory.getSDK(); }
    +34  protected String getOrgId()             { return sdkFactory.getOrgId(); }
    +38  protected SDKExtension getSDKExtension() { return sdkExtensionFactory.getSDKExtension(); }
    +41}

These bridge classes are explicitly temporary scaffolding. S4B will migrate `list_vulnerability_types` off the bridge. S4C will prove local parity. Once all tools migrate to `ContrastApiClient` (Slice 8), the bridge classes and their local SDK accessors are deleted. `LocalSdkSingleTool` is structurally identical.

Each of the 13 tools changes exactly one line — the `extends` clause. Here’s a representative example:

contrast-mcp-stdio-app/.../vulnerability/SearchVulnerabilitiesTool.java MODIFIED

    -20import com.contrast.labs.ai.mcp.contrast.tool.base.PaginatedTool;
    +20import com.contrast.labs.ai.mcp.contrast.tool.base.LocalSdkPaginatedTool;
      ...
    -38public class SearchVulnerabilitiesTool extends PaginatedTool<VulnerabilityFilterParams, VulnLight> {
    +38public class SearchVulnerabilitiesTool
    +39    extends LocalSdkPaginatedTool<VulnerabilityFilterParams, VulnLight> {

This is the entire change for each tool: one import swap, one extends swap. The other 12 tools follow the same pattern. `doExecute()` bodies are completely untouched — they still call `getContrastSDK()`, `getOrgId()`, and `getSDKExtension()` exactly as before. The bridge classes provide those same accessors.
Tool extends-clause migration (12 more tools) Standard Pattern 12 files, 1 line each Same as: SearchVulnerabilitiesTool above
All 12 remaining tools apply the identical one-line change: swap `PaginatedTool` → `LocalSdkPaginatedTool` or `SingleTool` → `LocalSdkSingleTool`. The paginated tools are: `SearchApplicationsTool`, `SearchAttacksTool`, `ListApplicationLibrariesTool`, `ListApplicationsByCveTool`, `SearchAppVulnerabilitiesTool`, `GetSastResultsTool`. The single tools are: `GetSessionMetadataTool`, `GetProtectRulesTool`, `GetRouteCoverageTool`, `GetSastProjectTool`, `GetVulnerabilityTool`, `ListVulnerabilityTypesTool`. No `doExecute()` bodies changed.
Data model renames (stdio-app → core) Standard Pattern ~60 files, 0 content changes Follows: S3B core module structure (PR #116)
Approximately 60 data model classes, SDK extension classes, and their tests were moved from `contrast-mcp-stdio-app` to `contrast-mcp-core` with zero content changes. These are the `result/`, `sdkextension/`, `tool/base/`, `tool/validation/`, and `hints/` packages. The move was necessary because `ContrastApiClient` method signatures reference these types, so they must live in the core module. Git shows these as renames with 100% similarity.
9

## Verification

Three new test classes and an E2E gate script prove the abstraction is correct, the auth lifecycle works, and the boundary holds.

contrast-mcp-core/.../tool/base/BaseToolAuthenticationStrategyTest.java NEW

    +28@Test
    +29void executePipeline_should_remain_noop_when_authentication_strategy_is_not_configured() {
    +30  var events = new ArrayList<String>();
    +31  var tool = new RecordingSingleTool(events);
    +32  var result = tool.executePipeline(TestParams::valid, null);
    +33  assertThat(result.isSuccess()).isTrue();
    +34  assertThat(tool.isAuthenticationStrategyConfigured()).isFalse();
    +35  assertThat(events).containsExactly("doExecute");
    +36}
    +37
    +41@Test
    +42void executePipeline_should_bind_configured_strategy_before_doExecute_and_close_afterward() {
    +43  var events = new ArrayList<String>();
    +44  var tool = new RecordingSingleTool(events);
    +45  var context = new ToolContext(Map.of("requestId", "req-123"));
    +46  tool.setAuthenticationStrategy(tc -> {
    +47    events.add("authenticate");
    +48    assertThat(tc).isSameAs(context);
    +49    return () -> events.add("close");
    +50  });
    +51  var result = tool.executePipeline(TestParams::valid, context);
    +52  assertThat(result.isSuccess()).isTrue();
    +53  assertThat(events).containsExactly("authenticate", "doExecute", "close");
    +54}

The event-recording pattern proves the exact lifecycle ordering: authenticate → doExecute → close. The `RecordingSingleTool` uses a shared `List<String>` to capture the call sequence. The `containsExactly` assertion would fail if the order changed. A third test verifies that `isAuthenticationStrategyConfigured()` transitions from false to true after injection.
contrast-mcp-core/.../client/ContrastApiClientContractTest.java NEW

    +44@Test
    +45void contrastApiClient_should_be_pure_interface_without_default_or_static_methods() {
    +46  assertThat(ContrastApiClient.class.isInterface()).isTrue();
    +47  assertThat(ContrastApiClient.class.getDeclaredMethods())
    +48      .allSatisfy(method -> {
    +49        assertThat(method.isDefault()).isFalse();
    +50        assertThat(Modifier.isStatic(method.getModifiers())).isFalse();
    +51      });
    +52}
    +53
    +56@Test
    +57void contrastApiClient_method_signatures_should_not_expose_auth_or_org_parameters() {
    +58  var source = Files.readString(CLIENT_SOURCE, StandardCharsets.UTF_8);
    +59  assertThat(FORBIDDEN_AUTH_PARAMETER_TOKENS)
    +60      .allSatisfy(token -> assertThat(source).doesNotContain(token));
    +61}

Two complementary guards on the API boundary. The first test uses reflection to ensure no one sneaks behavior into the interface via default or static methods. The second reads the source file and asserts that forbidden tokens (`orgId`, `bearer`, `token`, `apiKey`, `serviceKey`, `credential`, etc.) never appear anywhere in the file — not in parameter names, comments, or string literals. Together, they make the boundary self-enforcing.

The `CoreBoundaryTest` was updated to require the new types (`ContrastApiClient`, `AuthenticationStrategy`, `BaseTool`, `PaginatedTool`, `SingleTool`) in core and to relax the Contrast SDK prohibition — core now legitimately depends on SDK types because the `ContrastApiClient` method signatures use them. The local-only text scan was narrowed to check for specific Spring AI transport annotations rather than blanket-banning the `org.springframework.ai` package, since `spring-ai-model` is now a core dependency.

hack/verify-s4a-client-boundary.sh NEW

    +1#!/usr/bin/env bash
    +2# Temporary S4A slice gate — remove once S4 is proven and boundary checks are covered by CI.
    +3set -euo pipefail
    + // ... setup and logging ...
    +33./gradlew --no-daemon :contrast-mcp-core:test :contrast-mcp-stdio-app:compileJava
    +34
    +37assert_not_contains "client_has_no_hidden_auth_or_org_parameters" "${CLIENT_SOURCE}" \
    +38  'orgId|organizationId|organizationUuid|bearer|token|apiKey|serviceKey|credential'
    +39
    +40# For each Java file in core tool/base, assert no local SDK leakage
    +41assert_not_contains "core_base_has_no_local_sdk_factory_access" ... \
    +42  'ContrastSDKFactory|SDKExtensionFactory|SDKHelper|SdkApiClient|get_scan_results|sarif|SARIF'

The E2E gate runs core tests, compiles stdio-app against the new core, and then greps every base class for leaked local types. This catches issues that unit tests miss — like a developer accidentally importing `ContrastSDKFactory` in a core base class. The script is marked as temporary; once CI fully covers these assertions, it will be removed alongside the other slice gate scripts.
Core build.gradle dependency updates Standard Pattern build.gradle (+14/-7) Follows: PR #116 core build.gradle structure
Added `contrast-sdk-java`, `spring-ai-model`, `jackson-annotations`, and `spring-ai-bom` as `api` dependencies. Updated the `verifyCorePublicationMetadata` task to require these new dependencies in POM/module metadata and removed `contrast-sdk-java` from the forbidden list (it’s now a legitimate core dependency). Also added `mockito-inline` for test mocking.
BaseToolTest + integration test import updates Standard Pattern BaseToolTest.java, 11 IT files Follows: existing test patterns in the repo
`BaseToolTest` was moved to core and simplified — it tests only `mapHttpErrorCode()` and the default auth state. SDK factory tests that were on the old `BaseToolTest` are no longer relevant since `BaseTool` no longer has those fields. The 11 integration test files received minor import path updates to reflect the stdio-app module move, with no behavioral changes.
10

## What’s Next

This PR proves the abstraction; the next two PRs prove it works with a real tool. S4B (`mcp-gto0`) will migrate `list_vulnerability_types` to use `ContrastApiClient` directly — no bridge class, no SDK factories. S4C (`mcp-6sjx`) will verify byte-equivalent output and replace reflection-based test patterns with `ContrastApiClient` mocks. After that, S5 consumes the core module in the hosted server.

Suggested review order 1. ContrastApiClient — the contract everything else depends on.
2. AuthenticationStrategy + BaseTool — the lifecycle hook.
3. PaginatedTool / SingleTool — where the hook integrates.
4. SdkApiClient — the local implementation (should be boring).
5. Bridge classes — verify they only restore the old accessors.
6. Tests — verify coverage of the new abstractions.
7. Tool changes — spot-check that they’re truly one-line `extends` swaps.

File | Purpose | Lines | Novelty
--- | --- | --- | ---
client/ContrastApiClient.java | Shared domain API interface (20 methods, zero auth params) | +97 | Novel
tool/base/AuthenticationStrategy.java | Per-request auth hook (functional interface) | +34 | Novel
tool/base/BaseTool.java | Refactored: removed SDK factories, added auth strategy | +72 | Novel
tool/base/PaginatedTool.java | Moved to core; auth try-with-resources added to pipeline | +242 | Pattern
tool/base/SingleTool.java | Moved to core; auth try-with-resources added to pipeline | +183 | Pattern
client/SdkApiClient.java | Local implementation wrapping SDK factories | +189 | Novel
tool/base/LocalSdkPaginatedTool.java | Temporary bridge for un-migrated paginated tools | +41 | Novel
tool/base/LocalSdkSingleTool.java | Temporary bridge for un-migrated single tools | +41 | Novel
BaseToolAuthenticationStrategyTest.java | Auth lifecycle: no-op, ordering, boolean state | +106 | Novel
ContrastApiClientContractTest.java | Reflection + source scan for credential-free boundary | +64 | Novel
CoreBoundaryTest.java | Updated to require new core types, narrowed Spring AI ban | +11 / -6 | Pattern
BaseToolTest.java | Moved to core; tests mapHttpErrorCode + default auth state | +61 | Pattern
verify-s4a-client-boundary.sh | E2E gate: core tests + auth leak grep assertions | +43 | Novel
contrast-mcp-core/build.gradle | Added SDK, Spring AI model, Jackson dependencies | +14 / -7 | Pattern
13 tool classes | One-line extends clause swap to bridge classes | +13 / -13 | Standard
~60 data model renames | Moved from stdio-app to core (zero content changes) | 0 | Standard
11 integration test files | Import path updates for module move | +11 / -11 | Standard