Write a data-source adapter
This tutorial goes from an empty module to a pseudonymised MCP tool call,
using the same seven steps a real integration takes. Every code, YAML, pom
and Dockerfile block below is pulled at build time from a marked region in
data-prism-quickstart-extension or docker/ — a real module in this
repository that Maven compiles and QuickstartSmokeIT exercises end to end
on every build — never hand-copied. If a region or file ever went missing or
was renamed, the site build would fail, not silently show stale code.
This is the shorter, guided walk. docs/extending.md is
the full reference for everything below, including the parts this tutorial
only links to rather than repeats. If your API is flat or nests JSON at most
one level deep, docs/protect-your-own-api.md
gets you the same result with no Java at all.
1. Model the response and classify its fields
A DataSourceAdapter returns one Java type. Every field on a type exposed
through MCP must carry a classification annotation — @LlmExposedModel
requires one of @InternalIdentifier, @SensitiveData, @NonSensitive or
@SubjectIdentifier on every field, and the annotation processor fails the
build if one is missing:
@LlmExposedModel
public record CustomerModel(
@InternalIdentifier
String customerId,
@SensitiveData(
classifications = DataClassification.PII,
namespace = PrivacyNamespace.PERSON_NAME,
suggestedAction = PrivacyAction.SYNTHESIZE)
String customerName,
@SensitiveData(
classifications = DataClassification.CONTACT,
namespace = PrivacyNamespace.EMAIL,
suggestedAction = PrivacyAction.REDACT)
String email,
@NonSensitive(reason = "Enumerated lifecycle state; no free text and no bearing on identity")
String status) {
}
@InternalIdentifier marks the record's own correlation id; @SensitiveData
classifies a field and suggests what should happen to it (here, synthesizing
customerName and redacting email) — the privacy engine's own profile
rule, or the stricter of the two if both apply, has the final say;
@NonSensitive asserts a field is safe to emit unchanged and requires a
reason(). Full reference:
Classify the model with @LlmExposedModel,
including what the processor rejects and why.
2. Implement DataSourceAdapter
The adapter itself is small: name the source, state the response type, fetch one record.
final class QuickstartCustomerAdapter implements DataSourceAdapter<CustomerModel> {
private static final Logger LOG = LoggerFactory.getLogger(QuickstartCustomerAdapter.class);
/** Must equal the {@code dataprism.sources.*} key this adapter is configured under. */
static final String SOURCE_NAME = "customer";
private final RestClient client;
QuickstartCustomerAdapter(RestClient client) {
this.client = Objects.requireNonNull(client, "client");
}
@Override
public String sourceName() {
return SOURCE_NAME;
}
@Override
public Class<CustomerModel> responseType() {
return CustomerModel.class;
}
@Override
public CustomerModel fetch(DataRequest request) {
Objects.requireNonNull(request, "request");
try {
return client.get()
.uri("/customers/{id}", request.subjectId())
.retrieve()
.body(CustomerModel.class);
} catch (HttpClientErrorException.NotFound absent) {
LOG.debug("source {} holds no record for the requested subject", SOURCE_NAME);
return null;
}
}
}
Two details worth carrying into your own adapter even though nothing enforces
them: the subject id is a URI template variable ("/customers/{id}"), so
RestClient encodes it rather than letting it steer the path; and a 404 is
treated as data, not failure — returning null lets the orchestrator record
this source's outcome as NO_DATA instead of tripping its circuit breaker.
Full reference: Implement DataSourceAdapter.
3. Wire it up with auto-configuration
Nothing on a -Dloader.path jar is component-scanned, so Spring Boot has to
be told which @AutoConfiguration class to load. That class supplies the
DataSourceAdapter bean the platform refuses to start without, plus a
fallback IdentityResolver bean — one is only mandatory because
DataPrismAutoConfiguration.dataPrismIdentityResolverPreflight refuses to
start without some IdentityResolver bean, which dataprism.identity.resolver:
pass-through or an application-supplied bean can equally satisfy:
@AutoConfiguration
public class QuickstartExtensionAutoConfiguration {
@Bean
@ConditionalOnMissingBean
IdentityResolver quickstartIdentityResolver() {
// Every source the quickstart configures already keys on the same
// subjectId, so the honest default resolver is the correct one — see
// PassThroughIdentityResolver's own Javadoc.
return new PassThroughIdentityResolver();
}
@Bean
@ConditionalOnMissingBean
DataSourceAdapter<CustomerModel> quickstartCustomerAdapter(
@Value("${dataprism.sources.customer.base-url}") String baseUrl,
@Value("${dataprism.sources.customer.timeout}") String timeout) {
Duration readTimeout = DurationStyle.detectAndParse(timeout);
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout((int) readTimeout.toMillis());
requestFactory.setReadTimeout((int) readTimeout.toMillis());
RestClient client = RestClient.builder()
.baseUrl(baseUrl)
.requestFactory(requestFactory)
.build();
return new QuickstartCustomerAdapter(client);
}
}
quickstartIdentityResolver() returns the shipped PassThroughIdentityResolver
— correct here because the quickstart's one source already keys on the
subject id it was asked for; tutorial 2 covers writing your own. Registration
is one line, in one file:
io.github.aindriub.dataprism.quickstart.extension.QuickstartExtensionAutoConfiguration
Full reference: Register the extension.
4. Shape the pom
Inside this repository, the dependencies are unversioned because the reactor
parent pins them, and all four are scoped provided (test-scope entries
omitted below):
<dependencies>
<dependency>
<groupId>io.github.aindriub</groupId>
<artifactId>data-prism-core</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.github.aindriub</groupId>
<artifactId>data-prism-annotations</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
<scope>provided</scope>
</dependency>
This is not a standalone pom. Outside this reactor, every dependency
above needs an explicit <version> element, and the annotation processor
below needs a real version rather than ${project.version} — see
The pom shape for the version-complete
equivalent that stands alone, verified by building it against a clean local
repository. The annotation processor itself goes on the processor path, never
as a <dependency>:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<!-- On the processor path, not the compile classpath: this module
depends on the @LlmExposedModel classification check running,
not on the checker's own classes. -->
<annotationProcessorPaths>
<path>
<groupId>io.github.aindriub</groupId>
<artifactId>data-prism-processor</artifactId>
<version>${project.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
5. Load the extension
The packaged server adds a -Dloader.path jar's own classes and resources to
its classpath — never its dependencies, which is why the pom above marks
every dependency provided. Read
The -Dloader.path trap before
relying on this. The quickstart's own server image loads this module's jar
exactly that way:
ENTRYPOINT ["java", "-Dloader.path=/app/data-prism-quickstart-extension.jar", "-jar", "/app/data-prism-server.jar", "--server.port=8080"]
An operator deploying the distribution image uses the same mechanism, but as
a directory of jars rather than a single one — LOADER_PATH, read by Spring
Boot's PropertiesLauncher:
ENV LOADER_PATH=/app/adapters
Full reference: Load the extension.
6. Configure the source
QuickstartExtensionAutoConfiguration's @Value bindings above read
dataprism.sources.customer.*.
The quickstart's own deployment configuration sets it:
sources:
customer:
# fixture-only: "fixtures" is this Compose network's own synthetic
# source API — see compose.yaml and data-prism-quickstart-fixtures. A
# real deployment points this at its own reviewed enterprise API.
base-url: https://fixtures:8543
timeout: 5s
Binding is by the adapter's own sourceName() return value, not by bean or
class name — configure a name no adapter returns, and the server refuses to
start with UNRESOLVED_SOURCE_ADAPTER. Full reference:
Bind dataprism.sources.<name> to your adapter.
7. Run it and see the pseudonymised response
docker compose up pulls published images, which do not contain the code
above unless it has already been released. To actually exercise what this
page shows, build from source instead:
docker compose -f compose.yaml -f compose.build.yaml up --build
This builds and starts four services: a throwaway certificate generator, the
local token issuer, the synthetic customer fixture API, and the standalone
server with this module's jar loaded through the -Dloader.path entrypoint
above. The first run takes a few minutes — a Maven reactor build inside three
of the four images — later runs are fast. The server's own startup does not
wait on the issuer or the fixture API being fully ready, so if the very first
command below fails, wait a few seconds and retry it; see
docs/quickstart.md for the full walkthrough of this same
stack, including what each service is and how they trust each other.
Once it settles, mint a token and call get_entity_context — the same
handshake docs/quickstart.md walks through in full —
using the runnable script this repository ships. The up --build above runs
in the foreground and blocks its terminal, so run this in a second one:
examples/quickstart-demo/run.sh
Real output, from an actual run, 2026-09-24:
PASS: get_entity_context for CUSTOMER 1001 returned a pseudonymised response.
field real fixture value pseudonymised response
------------ ------------------------------------- --------------------------
subjectId 1001 SUBJ-KNSYWNZ9
customerName Fixture Person One Rowan Okafor (D1B5CR19)
email fixture.person.one@example.invalid [REDACTED]
customerName is a stable synthetic name — the module's own code, from step
1, suggested SYNTHESIZE, and the deployment's privacy profile agreed — never
the fixture's own Fixture Person One. email is redacted outright: the
model suggested REDACT, and the privacy engine, which always has the final
say, did not relax it. Neither
is anonymisation in any strict, re-identification-proof sense: pseudonymised
output is still personal data, recoverable by whoever holds the deployment's
HMAC key, and this tutorial does not claim otherwise. status, classified
@NonSensitive in step 1, passes through unchanged (not shown in the table
above, but present in the raw MCP response) — a decision the model states
explicitly, never a platform default: an unclassified field would have
refused the whole response, not disclosed it (see
the developer guide overview for where AuditSink and the
classification annotations are covered in more depth).
When you are done:
docker compose down -v