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.
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.
Scope
This PR is Slice 4A of a three-part sequence that proves the shared client pattern before migrating all tools.
list_vulnerability_types) to ContrastApiClientget_user_info| Bead | Slice | Description |
|---|---|---|
| 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 |
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.
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.
+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}
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.
contrast-mcp-core, it will never receive local API keys or org IDs from core classes — the interface physically cannot carry them.
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.
+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}
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.
+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 }
@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.
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.
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 ...
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().
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.
+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}
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.
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.
+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}
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:
-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> {
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
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)
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.
Verification
Three new test classes and an E2E gate script prove the abstraction is correct, the auth lifecycle works, and the boundary holds.
+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}
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.
+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}
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.
+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'
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
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.
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.
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 |