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.

Before & After: How Tools Access the Contrast API BEFORE (coupled to local) MCP Tool ContrastSDKFactory + SDKExtensionFactory env vars: API_KEY, ORG_ID AFTER (abstracted) MCP Tool ContrastApiClient (interface — domain only) SdkApiClient local: SDK + env vars RemoteApiClient hosted: OAuth + gateway AuthenticationStrategy before doExecute()

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
BeadSliceDescription
mcp-mj2g S4AThis 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 PaginatedToolLocalSdkPaginatedTool or SingleToolLocalSdkSingleTool. 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.
FilePurposeLinesNovelty
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